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 extends String, ? extends FeatureFlag> loadAll(
+ Set extends String> 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,