diff --git a/core/src/main/java/google/registry/model/common/FeatureFlag.java b/core/src/main/java/google/registry/model/common/FeatureFlag.java new file mode 100644 index 000000000..0e5bc7e6c --- /dev/null +++ b/core/src/main/java/google/registry/model/common/FeatureFlag.java @@ -0,0 +1,177 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.model.common; + +import static com.google.api.client.util.Preconditions.checkState; +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.collect.ImmutableMap.toImmutableMap; +import static com.google.common.collect.ImmutableSet.toImmutableSet; +import static google.registry.config.RegistryConfig.getSingletonCacheRefreshDuration; +import static google.registry.persistence.transaction.TransactionManagerFactory.tm; + +import com.github.benmanes.caffeine.cache.CacheLoader; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.google.common.base.Joiner; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSortedMap; +import com.google.common.collect.Sets; +import google.registry.model.Buildable; +import google.registry.model.CacheUtils; +import google.registry.model.ImmutableObject; +import google.registry.persistence.VKey; +import java.util.Map; +import java.util.Set; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import org.joda.time.DateTime; + +@Entity +public class FeatureFlag extends ImmutableObject implements Buildable { + + /** + * The current status of the feature the flag represents. + * + *

Currently, there is no enforced ordering of these status values, but that may change in the + * future should new statuses be added to this enum that require it. + */ + public enum FeatureStatus { + ACTIVE, + INACTIVE + } + + /** The name of the flag/feature. */ + @Id String featureName; + + /** A map of times for each {@link FeatureStatus} the FeatureFlag should hold. */ + @Column(nullable = false) + TimedTransitionProperty status = + TimedTransitionProperty.withInitialValue(FeatureStatus.INACTIVE); + + public static FeatureFlag get(String featureName) { + FeatureFlag maybeFeatureFlag = CACHE.get(featureName); + if (maybeFeatureFlag == null) { + throw new FeatureFlagNotFoundException(featureName); + } else { + return maybeFeatureFlag; + } + } + + public static ImmutableSet get(Set featureNames) { + Map featureFlags = CACHE.getAll(featureNames); + ImmutableSet missingFlags = + Sets.difference(featureNames, featureFlags.keySet()).immutableCopy(); + if (missingFlags.isEmpty()) { + return featureFlags.values().stream().collect(toImmutableSet()); + } else { + throw new FeatureFlagNotFoundException(missingFlags); + } + } + + /** A cache that loads the {@link FeatureFlag} for a given featureName. */ + private static final LoadingCache CACHE = + CacheUtils.newCacheBuilder(getSingletonCacheRefreshDuration()) + .build( + new CacheLoader<>() { + @Override + public FeatureFlag load(final String featureName) { + return tm().reTransact(() -> tm().loadByKeyIfPresent(createVKey(featureName))) + .orElse(null); + } + + @Override + public Map loadAll( + Set featureFlagNames) { + ImmutableMap> keysMap = + featureFlagNames.stream() + .collect( + toImmutableMap(featureName -> featureName, FeatureFlag::createVKey)); + Map, FeatureFlag> entities = + tm().reTransact(() -> tm().loadByKeysIfPresent(keysMap.values())); + return entities.values().stream() + .collect(toImmutableMap(flag -> flag.featureName, flag -> flag)); + } + }); + + public static VKey createVKey(String featureName) { + return VKey.create(FeatureFlag.class, featureName); + } + + @Override + public VKey createVKey() { + return createVKey(featureName); + } + + public String getFeatureName() { + return featureName; + } + + public TimedTransitionProperty getStatusMap() { + return status; + } + + public FeatureStatus getStatus(DateTime time) { + return status.getValueAtTime(time); + } + + @Override + public FeatureFlag.Builder asBuilder() { + return new FeatureFlag.Builder(clone(this)); + } + + /** A builder for constructing {@link FeatureFlag} objects, since they are immutable. */ + public static class Builder extends Buildable.Builder { + + public Builder() {} + + private Builder(FeatureFlag instance) { + super(instance); + } + + @Override + public FeatureFlag build() { + checkArgument( + !Strings.isNullOrEmpty(getInstance().featureName), + "Feature name must not be null or empty"); + getInstance().status.checkValidity(); + return super.build(); + } + + public Builder setFeatureName(String featureName) { + checkState(getInstance().featureName == null, "Feature name can only be set once"); + getInstance().featureName = featureName; + return this; + } + + public Builder setStatus(ImmutableSortedMap statusMap) { + getInstance().status = TimedTransitionProperty.fromValueMap(statusMap); + return this; + } + } + + /** Exception to throw when no FeatureFlag entity is found for given FeatureName string(s). */ + public static class FeatureFlagNotFoundException extends RuntimeException { + + FeatureFlagNotFoundException(ImmutableSet featureNames) { + super("No feature flag object(s) found for " + Joiner.on(", ").join(featureNames)); + } + + FeatureFlagNotFoundException(String featureName) { + this(ImmutableSet.of(featureName)); + } + } +} diff --git a/core/src/main/java/google/registry/model/tld/Tld.java b/core/src/main/java/google/registry/model/tld/Tld.java index fc9ad2009..dbed42170 100644 --- a/core/src/main/java/google/registry/model/tld/Tld.java +++ b/core/src/main/java/google/registry/model/tld/Tld.java @@ -41,9 +41,9 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSortedMap; -import com.google.common.collect.Maps; import com.google.common.collect.Ordering; import com.google.common.collect.Range; +import com.google.common.collect.Sets; import com.google.common.net.InternetDomainName; import google.registry.model.Buildable; import google.registry.model.CacheUtils; @@ -204,10 +204,7 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl public static ImmutableSet get(Set tlds) { Map registries = CACHE.getAll(tlds); ImmutableSet missingRegistries = - registries.entrySet().stream() - .filter(e -> e.getValue() == null) - .map(Map.Entry::getKey) - .collect(toImmutableSet()); + Sets.difference(tlds, registries.keySet()).immutableCopy(); if (missingRegistries.isEmpty()) { return registries.values().stream().collect(toImmutableSet()); } else { @@ -243,7 +240,8 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl tlds.stream().collect(toImmutableMap(tld -> tld, Tld::createVKey)); Map, Tld> entities = tm().reTransact(() -> tm().loadByKeysIfPresent(keysMap.values())); - return Maps.transformEntries(keysMap, (k, v) -> entities.getOrDefault(v, null)); + return entities.values().stream() + .collect(toImmutableMap(tld -> tld.tldStr, tld -> tld)); } }); diff --git a/core/src/main/java/google/registry/persistence/converter/FeatureFlagStatusConverter.java b/core/src/main/java/google/registry/persistence/converter/FeatureFlagStatusConverter.java new file mode 100644 index 000000000..4904b947c --- /dev/null +++ b/core/src/main/java/google/registry/persistence/converter/FeatureFlagStatusConverter.java @@ -0,0 +1,34 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.persistence.converter; + +import google.registry.model.common.FeatureFlag.FeatureStatus; +import javax.persistence.Converter; + +/** JPA converter for {@link google.registry.model.common.FeatureFlag} status transitions. */ +@Converter(autoApply = true) +public class FeatureFlagStatusConverter + extends TimedTransitionPropertyConverterBase { + + @Override + protected String convertValueToString(FeatureStatus value) { + return value.toString(); + } + + @Override + protected FeatureStatus convertStringToValue(String string) { + return FeatureStatus.valueOf(string); + } +} diff --git a/core/src/main/resources/META-INF/persistence.xml b/core/src/main/resources/META-INF/persistence.xml index 7df4384d6..248055610 100644 --- a/core/src/main/resources/META-INF/persistence.xml +++ b/core/src/main/resources/META-INF/persistence.xml @@ -56,6 +56,7 @@ google.registry.model.contact.Contact google.registry.model.domain.Domain google.registry.model.domain.DomainHistory + google.registry.model.common.FeatureFlag google.registry.model.domain.GracePeriod google.registry.model.domain.GracePeriod$GracePeriodHistory google.registry.model.domain.secdns.DomainDsData @@ -97,6 +98,7 @@ google.registry.persistence.converter.CurrencyUnitConverter google.registry.persistence.converter.DateTimeConverter google.registry.persistence.converter.DurationConverter + google.registry.persistence.converter.FeatureFlagStatusConverter google.registry.persistence.converter.IdnTableEnumSetConverter google.registry.persistence.converter.InetAddressSetConverter google.registry.persistence.converter.LocalDateConverter diff --git a/core/src/test/java/google/registry/model/common/FeatureFlagTest.java b/core/src/test/java/google/registry/model/common/FeatureFlagTest.java new file mode 100644 index 000000000..73489ce91 --- /dev/null +++ b/core/src/test/java/google/registry/model/common/FeatureFlagTest.java @@ -0,0 +1,207 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.model.common; + +import static com.google.common.truth.Truth.assertThat; +import static google.registry.model.common.FeatureFlag.FeatureStatus.ACTIVE; +import static google.registry.model.common.FeatureFlag.FeatureStatus.INACTIVE; +import static google.registry.testing.DatabaseHelper.loadByEntity; +import static google.registry.testing.DatabaseHelper.persistResource; +import static google.registry.util.DateTimeUtils.START_OF_TIME; +import static org.joda.time.DateTimeZone.UTC; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSortedMap; +import google.registry.model.EntityTestCase; +import google.registry.model.common.FeatureFlag.FeatureFlagNotFoundException; +import google.registry.model.common.FeatureFlag.FeatureStatus; +import org.joda.time.DateTime; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link FeatureFlag}. */ +public class FeatureFlagTest extends EntityTestCase { + + public FeatureFlagTest() { + super(JpaEntityCoverageCheck.ENABLED); + } + + @Test + void testSuccess_persistence() { + FeatureFlag featureFlag = + new FeatureFlag.Builder() + .setFeatureName("testFlag") + .setStatus( + ImmutableSortedMap.naturalOrder() + .put(START_OF_TIME, INACTIVE) + .put(DateTime.now(UTC).plusWeeks(8), ACTIVE) + .build()) + .build(); + persistResource(featureFlag); + FeatureFlag flagFromDb = loadByEntity(featureFlag); + assertThat(featureFlag).isEqualTo(flagFromDb); + } + + @Test + void testSuccess_getSingleFlag() { + FeatureFlag featureFlag = + new FeatureFlag.Builder() + .setFeatureName("testFlag") + .setStatus( + ImmutableSortedMap.naturalOrder() + .put(START_OF_TIME, INACTIVE) + .put(DateTime.now(UTC).plusWeeks(8), ACTIVE) + .build()) + .build(); + persistResource(featureFlag); + assertThat(FeatureFlag.get("testFlag")).isEqualTo(featureFlag); + } + + @Test + void testSuccess_getMultipleFlags() { + FeatureFlag featureFlag1 = + persistResource( + new FeatureFlag.Builder() + .setFeatureName("testFlag1") + .setStatus( + ImmutableSortedMap.naturalOrder() + .put(START_OF_TIME, INACTIVE) + .put(DateTime.now(UTC).plusWeeks(8), ACTIVE) + .build()) + .build()); + FeatureFlag featureFlag2 = + persistResource( + new FeatureFlag.Builder() + .setFeatureName("testFlag2") + .setStatus( + ImmutableSortedMap.naturalOrder() + .put(START_OF_TIME, INACTIVE) + .put(DateTime.now(UTC).plusWeeks(3), INACTIVE) + .build()) + .build()); + FeatureFlag featureFlag3 = + persistResource( + new FeatureFlag.Builder() + .setFeatureName("testFlag3") + .setStatus( + ImmutableSortedMap.naturalOrder() + .put(START_OF_TIME, INACTIVE) + .build()) + .build()); + ImmutableSet featureFlags = + FeatureFlag.get(ImmutableSet.of("testFlag1", "testFlag2", "testFlag3")); + assertThat(featureFlags.size()).isEqualTo(3); + assertThat(featureFlags).containsExactly(featureFlag1, featureFlag2, featureFlag3); + } + + @Test + void testFailure_getMultipleFlagsOneMissing() { + persistResource( + new FeatureFlag.Builder() + .setFeatureName("testFlag1") + .setStatus( + ImmutableSortedMap.naturalOrder() + .put(START_OF_TIME, INACTIVE) + .put(DateTime.now(UTC).plusWeeks(8), ACTIVE) + .build()) + .build()); + persistResource( + new FeatureFlag.Builder() + .setFeatureName("testFlag2") + .setStatus( + ImmutableSortedMap.naturalOrder() + .put(START_OF_TIME, INACTIVE) + .put(DateTime.now(UTC).plusWeeks(3), INACTIVE) + .build()) + .build()); + FeatureFlagNotFoundException thrown = + assertThrows( + FeatureFlagNotFoundException.class, + () -> FeatureFlag.get(ImmutableSet.of("missingFlag", "testFlag1", "testFlag2"))); + assertThat(thrown) + .hasMessageThat() + .isEqualTo("No feature flag object(s) found for missingFlag"); + } + + @Test + void testFailure_featureFlagNotPresent() { + FeatureFlagNotFoundException thrown = + assertThrows(FeatureFlagNotFoundException.class, () -> FeatureFlag.get("fakeFlag")); + assertThat(thrown).hasMessageThat().isEqualTo("No feature flag object(s) found for fakeFlag"); + } + + @Test + void testFailure_resetFeatureName() { + FeatureFlag featureFlag = + new FeatureFlag.Builder() + .setFeatureName("testFlag") + .setStatus( + ImmutableSortedMap.naturalOrder() + .put(START_OF_TIME, INACTIVE) + .put(DateTime.now(UTC).plusWeeks(8), ACTIVE) + .build()) + .build(); + IllegalStateException thrown = + assertThrows( + IllegalStateException.class, + () -> featureFlag.asBuilder().setFeatureName("differentName")); + assertThat(thrown).hasMessageThat().isEqualTo("Feature name can only be set once"); + } + + @Test + void testFailure_nullFeatureName() { + FeatureFlag.Builder featureFlagBuilder = + new FeatureFlag.Builder() + .setStatus( + ImmutableSortedMap.naturalOrder() + .put(START_OF_TIME, INACTIVE) + .put(DateTime.now(UTC).plusWeeks(8), ACTIVE) + .build()); + IllegalArgumentException thrown = + assertThrows(IllegalArgumentException.class, () -> featureFlagBuilder.build()); + assertThat(thrown).hasMessageThat().isEqualTo("Feature name must not be null or empty"); + } + + @Test + void testFailure_emptyFeatureName() { + FeatureFlag.Builder featureFlagBuilder = + new FeatureFlag.Builder() + .setFeatureName("") + .setStatus( + ImmutableSortedMap.naturalOrder() + .put(START_OF_TIME, INACTIVE) + .put(DateTime.now(UTC).plusWeeks(8), ACTIVE) + .build()); + IllegalArgumentException thrown = + assertThrows(IllegalArgumentException.class, () -> featureFlagBuilder.build()); + assertThat(thrown).hasMessageThat().isEqualTo("Feature name must not be null or empty"); + } + + @Test + void testFailure_invalidStatusMap() { + FeatureFlag.Builder featureFlagBuilder = new FeatureFlag.Builder().setFeatureName("testFlag"); + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> + featureFlagBuilder.setStatus( + ImmutableSortedMap.naturalOrder() + .put(DateTime.now(UTC).plusWeeks(8), ACTIVE) + .build())); + assertThat(thrown) + .hasMessageThat() + .isEqualTo("Must provide transition entry for the start of time (Unix Epoch)"); + } +} diff --git a/core/src/test/java/google/registry/model/tld/TldTest.java b/core/src/test/java/google/registry/model/tld/TldTest.java index ae82bf98d..feb0ff2d2 100644 --- a/core/src/test/java/google/registry/model/tld/TldTest.java +++ b/core/src/test/java/google/registry/model/tld/TldTest.java @@ -325,6 +325,16 @@ public final class TldTest extends EntityTestCase { .values()); } + @Test + void testFailure_testGetAllMissingTld() { + createTld("foo"); + TldNotFoundException thrown = + assertThrows( + TldNotFoundException.class, + () -> assertThat(Tld.get(ImmutableSet.of("foo", "tld", "missing")))); + assertThat(thrown).hasMessageThat().isEqualTo("No TLD object(s) found for missing"); + } + @Test void testSetReservedLists() { ReservedList rl5 = diff --git a/core/src/test/java/google/registry/schema/integration/SqlIntegrationTestSuite.java b/core/src/test/java/google/registry/schema/integration/SqlIntegrationTestSuite.java index 36be06fac..ccf1aceeb 100644 --- a/core/src/test/java/google/registry/schema/integration/SqlIntegrationTestSuite.java +++ b/core/src/test/java/google/registry/schema/integration/SqlIntegrationTestSuite.java @@ -23,6 +23,7 @@ import google.registry.bsa.persistence.BsaUnblockableDomainTest; import google.registry.model.billing.BillingBaseTest; import google.registry.model.common.CursorTest; import google.registry.model.common.DnsRefreshRequestTest; +import google.registry.model.common.FeatureFlagTest; import google.registry.model.console.ConsoleEppActionHistoryTest; import google.registry.model.console.RegistrarPocUpdateHistoryTest; import google.registry.model.console.RegistrarUpdateHistoryTest; @@ -103,6 +104,7 @@ import org.junit.runner.RunWith; DnsRefreshRequestTest.class, DomainSqlTest.class, DomainHistoryTest.class, + FeatureFlagTest.class, HostHistoryTest.class, LockTest.class, PollMessageTest.class, diff --git a/db/src/main/resources/sql/schema/db-schema.sql.generated b/db/src/main/resources/sql/schema/db-schema.sql.generated index 093b0bd0f..6427ac29e 100644 --- a/db/src/main/resources/sql/schema/db-schema.sql.generated +++ b/db/src/main/resources/sql/schema/db-schema.sql.generated @@ -463,6 +463,12 @@ primary key (id) ); + create table "FeatureFlag" ( + feature_name text not null, + status hstore not null, + primary key (feature_name) + ); + create table "GracePeriod" ( grace_period_id int8 not null, billing_event_id int8,