diff --git a/core/src/main/java/google/registry/model/EntityClasses.java b/core/src/main/java/google/registry/model/EntityClasses.java
index 1a0213d7a..8aa0fc1f7 100644
--- a/core/src/main/java/google/registry/model/EntityClasses.java
+++ b/core/src/main/java/google/registry/model/EntityClasses.java
@@ -17,6 +17,7 @@ package google.registry.model;
import com.google.common.collect.ImmutableSet;
import google.registry.model.billing.BillingEvent;
import google.registry.model.common.Cursor;
+import google.registry.model.common.DatabaseMigrationStateWrapper;
import google.registry.model.common.DatabaseTransitionSchedule;
import google.registry.model.common.EntityGroupRoot;
import google.registry.model.common.GaeUserIdConverter;
@@ -75,6 +76,7 @@ public final class EntityClasses {
ContactHistory.class,
ContactResource.class,
Cursor.class,
+ DatabaseMigrationStateWrapper.class,
DatabaseTransitionSchedule.class,
DomainBase.class,
DomainHistory.class,
diff --git a/core/src/main/java/google/registry/model/common/DatabaseMigrationStateWrapper.java b/core/src/main/java/google/registry/model/common/DatabaseMigrationStateWrapper.java
new file mode 100644
index 000000000..1063651c9
--- /dev/null
+++ b/core/src/main/java/google/registry/model/common/DatabaseMigrationStateWrapper.java
@@ -0,0 +1,122 @@
+// Copyright 2021 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.base.Preconditions.checkArgument;
+import static google.registry.persistence.transaction.TransactionManagerFactory.ofyTm;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.googlecode.objectify.annotation.Entity;
+import google.registry.model.annotations.InCrossTld;
+import google.registry.model.common.DatabaseTransitionSchedule.PrimaryDatabase;
+import google.registry.schema.replay.DatastoreOnlyEntity;
+
+/**
+ * A wrapper object representing the current stage of the Registry 3.0 Cloud SQL migration.
+ *
+ *
The entity is stored in Datastore throughout the entire migration so as to have a single point
+ * of access (avoiding a two-phase commit problem).
+ */
+@Entity
+@InCrossTld
+public class DatabaseMigrationStateWrapper extends CrossTldSingleton
+ implements DatastoreOnlyEntity {
+
+ /**
+ * The current phase of the migration plus information about which database to use and whether or
+ * not the phase is read-only.
+ */
+ public enum MigrationState {
+ DATASTORE_ONLY(PrimaryDatabase.DATASTORE, false),
+ DATASTORE_PRIMARY(PrimaryDatabase.DATASTORE, false),
+ DATASTORE_PRIMARY_READ_ONLY(PrimaryDatabase.DATASTORE, true),
+ SQL_PRIMARY(PrimaryDatabase.CLOUD_SQL, false),
+ SQL_ONLY(PrimaryDatabase.CLOUD_SQL, false);
+
+ private final PrimaryDatabase primaryDatabase;
+ private final boolean readOnly;
+
+ public PrimaryDatabase getPrimaryDatabase() {
+ return primaryDatabase;
+ }
+
+ public boolean isReadOnly() {
+ return readOnly;
+ }
+
+ MigrationState(PrimaryDatabase primaryDatabase, boolean readOnly) {
+ this.primaryDatabase = primaryDatabase;
+ this.readOnly = readOnly;
+ }
+ }
+
+ private MigrationState migrationState;
+
+ // Required for Objectify initialization
+ private DatabaseMigrationStateWrapper() {}
+
+ DatabaseMigrationStateWrapper(MigrationState migrationState) {
+ this.migrationState = migrationState;
+ }
+
+ // The valid state transitions. Basically, at state N, state N+1 is valid as well as all previous
+ // states, with one type of exception: when in either of the SQL states, we can only move back
+ // one step so that we can make sure that any modifications have been replayed back to Datastore.
+ private static final ImmutableMap>
+ VALID_STATE_TRANSITIONS =
+ ImmutableMap.of(
+ MigrationState.DATASTORE_ONLY,
+ ImmutableSet.of(MigrationState.DATASTORE_PRIMARY),
+ MigrationState.DATASTORE_PRIMARY,
+ ImmutableSet.of(
+ MigrationState.DATASTORE_ONLY, MigrationState.DATASTORE_PRIMARY_READ_ONLY),
+ MigrationState.DATASTORE_PRIMARY_READ_ONLY,
+ ImmutableSet.of(
+ MigrationState.DATASTORE_ONLY,
+ MigrationState.DATASTORE_PRIMARY,
+ MigrationState.SQL_PRIMARY),
+ MigrationState.SQL_PRIMARY,
+ ImmutableSet.of(MigrationState.DATASTORE_PRIMARY_READ_ONLY, MigrationState.SQL_ONLY),
+ MigrationState.SQL_ONLY,
+ ImmutableSet.of(MigrationState.SQL_PRIMARY));
+
+ private static boolean isValidStateTransition(MigrationState from, MigrationState to) {
+ return VALID_STATE_TRANSITIONS.get(from).contains(to);
+ }
+
+ /** Sets and persists to Datastore the current database migration state. */
+ public static void set(MigrationState newState) {
+ MigrationState currentState = get();
+ checkArgument(
+ isValidStateTransition(currentState, newState),
+ "Moving from migration state %s to %s is not a valid transition",
+ currentState,
+ newState);
+ DatabaseMigrationStateWrapper wrapper = new DatabaseMigrationStateWrapper(newState);
+ ofyTm().transact(() -> ofyTm().put(wrapper));
+ }
+
+ /** Retrieves the current state of the migration (or DATASTORE_ONLY if it hasn't started). */
+ public static MigrationState get() {
+ return ofyTm()
+ .transact(
+ () ->
+ ofyTm()
+ .loadSingleton(DatabaseMigrationStateWrapper.class)
+ .map(s -> s.migrationState)
+ .orElse(MigrationState.DATASTORE_ONLY));
+ }
+}
diff --git a/core/src/test/java/google/registry/model/common/DatabaseMigrationStateWrapperTest.java b/core/src/test/java/google/registry/model/common/DatabaseMigrationStateWrapperTest.java
new file mode 100644
index 000000000..006b81c2f
--- /dev/null
+++ b/core/src/test/java/google/registry/model/common/DatabaseMigrationStateWrapperTest.java
@@ -0,0 +1,111 @@
+// Copyright 2021 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.DatabaseMigrationStateWrapper.MigrationState.DATASTORE_ONLY;
+import static google.registry.model.common.DatabaseMigrationStateWrapper.MigrationState.DATASTORE_PRIMARY;
+import static google.registry.model.common.DatabaseMigrationStateWrapper.MigrationState.DATASTORE_PRIMARY_READ_ONLY;
+import static google.registry.model.common.DatabaseMigrationStateWrapper.MigrationState.SQL_ONLY;
+import static google.registry.model.common.DatabaseMigrationStateWrapper.MigrationState.SQL_PRIMARY;
+import static google.registry.persistence.transaction.TransactionManagerFactory.ofyTm;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import google.registry.model.common.DatabaseMigrationStateWrapper.MigrationState;
+import google.registry.testing.AppEngineExtension;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+public class DatabaseMigrationStateWrapperTest {
+
+ @RegisterExtension
+ public final AppEngineExtension appEngine =
+ AppEngineExtension.builder().withDatastoreAndCloudSql().build();
+
+ @Test
+ void testEmpty_returnsDatastore() {
+ assertThat(DatabaseMigrationStateWrapper.get()).isEqualTo(DATASTORE_ONLY);
+ }
+
+ @Test
+ void testEmpty_canChangeToDatastorePrimary() {
+ DatabaseMigrationStateWrapper.set(DATASTORE_PRIMARY);
+ assertThat(DatabaseMigrationStateWrapper.get()).isEqualTo(DATASTORE_PRIMARY);
+ }
+
+ @Test
+ void testValidTransitions() {
+ runValidTransition(DATASTORE_ONLY, DATASTORE_PRIMARY);
+
+ runValidTransition(DATASTORE_PRIMARY, DATASTORE_ONLY);
+ runValidTransition(DATASTORE_PRIMARY, DATASTORE_PRIMARY_READ_ONLY);
+
+ runValidTransition(DATASTORE_PRIMARY_READ_ONLY, DATASTORE_ONLY);
+ runValidTransition(DATASTORE_PRIMARY_READ_ONLY, DATASTORE_PRIMARY);
+ runValidTransition(DATASTORE_PRIMARY_READ_ONLY, SQL_PRIMARY);
+
+ runValidTransition(SQL_PRIMARY, DATASTORE_PRIMARY_READ_ONLY);
+ runValidTransition(SQL_PRIMARY, SQL_ONLY);
+
+ runValidTransition(SQL_ONLY, SQL_PRIMARY);
+ }
+
+ @Test
+ void testInvalidTransitions() {
+ runInvalidTransition(DATASTORE_ONLY, DATASTORE_ONLY);
+ runInvalidTransition(DATASTORE_ONLY, DATASTORE_PRIMARY_READ_ONLY);
+ runInvalidTransition(DATASTORE_ONLY, SQL_PRIMARY);
+ runInvalidTransition(DATASTORE_ONLY, SQL_ONLY);
+
+ runInvalidTransition(DATASTORE_PRIMARY, DATASTORE_PRIMARY);
+ runInvalidTransition(DATASTORE_PRIMARY, SQL_PRIMARY);
+ runInvalidTransition(DATASTORE_PRIMARY, SQL_ONLY);
+
+ runInvalidTransition(DATASTORE_PRIMARY_READ_ONLY, DATASTORE_PRIMARY_READ_ONLY);
+ runInvalidTransition(DATASTORE_PRIMARY_READ_ONLY, SQL_ONLY);
+
+ runInvalidTransition(SQL_PRIMARY, DATASTORE_ONLY);
+ runInvalidTransition(SQL_PRIMARY, DATASTORE_PRIMARY);
+ runInvalidTransition(SQL_PRIMARY, SQL_PRIMARY);
+
+ runInvalidTransition(SQL_ONLY, DATASTORE_ONLY);
+ runInvalidTransition(SQL_ONLY, DATASTORE_PRIMARY);
+ runInvalidTransition(SQL_ONLY, DATASTORE_PRIMARY_READ_ONLY);
+ runInvalidTransition(SQL_ONLY, SQL_ONLY);
+ }
+
+ private static void runValidTransition(MigrationState from, MigrationState to) {
+ setStateForced(from);
+ DatabaseMigrationStateWrapper.set(to);
+ assertThat(DatabaseMigrationStateWrapper.get()).isEqualTo(to);
+ }
+
+ private static void runInvalidTransition(MigrationState from, MigrationState to) {
+ setStateForced(from);
+ assertThat(
+ assertThrows(
+ IllegalArgumentException.class, () -> DatabaseMigrationStateWrapper.set(to)))
+ .hasMessageThat()
+ .isEqualTo(
+ String.format(
+ "Moving from migration state %s to %s is not a valid transition", from, to));
+ }
+
+ private static void setStateForced(MigrationState migrationState) {
+ DatabaseMigrationStateWrapper wrapper = new DatabaseMigrationStateWrapper(migrationState);
+ ofyTm().transact(() -> ofyTm().put(wrapper));
+ assertThat(DatabaseMigrationStateWrapper.get()).isEqualTo(migrationState);
+ }
+}
diff --git a/core/src/test/resources/google/registry/export/backup_kinds.txt b/core/src/test/resources/google/registry/export/backup_kinds.txt
index ca5d12ad2..c6464177e 100644
--- a/core/src/test/resources/google/registry/export/backup_kinds.txt
+++ b/core/src/test/resources/google/registry/export/backup_kinds.txt
@@ -2,6 +2,7 @@ AllocationToken
Cancellation
ContactResource
Cursor
+DatabaseMigrationStateWrapper
DatabaseTransitionSchedule
DomainBase
EntityGroupRoot
diff --git a/core/src/test/resources/google/registry/export/crosstld_kinds.txt b/core/src/test/resources/google/registry/export/crosstld_kinds.txt
index 244a406d5..de709a0e0 100644
--- a/core/src/test/resources/google/registry/export/crosstld_kinds.txt
+++ b/core/src/test/resources/google/registry/export/crosstld_kinds.txt
@@ -1,6 +1,7 @@
ClaimsList
ClaimsListSingleton
Cursor
+DatabaseMigrationStateWrapper
DatabaseTransitionSchedule
KmsSecret
KmsSecretRevision
diff --git a/core/src/test/resources/google/registry/model/schema.txt b/core/src/test/resources/google/registry/model/schema.txt
index 33c3100d4..003d7863d 100644
--- a/core/src/test/resources/google/registry/model/schema.txt
+++ b/core/src/test/resources/google/registry/model/schema.txt
@@ -78,6 +78,18 @@ class google.registry.model.common.Cursor {
google.registry.model.UpdateAutoTimestamp lastUpdateTime;
org.joda.time.DateTime cursorTime;
}
+class google.registry.model.common.DatabaseMigrationStateWrapper {
+ @Id long id;
+ @Parent com.googlecode.objectify.Key parent;
+ google.registry.model.common.DatabaseMigrationStateWrapper$MigrationState migrationState;
+}
+enum google.registry.model.common.DatabaseMigrationStateWrapper$MigrationState {
+ DATASTORE_ONLY;
+ DATASTORE_PRIMARY;
+ DATASTORE_PRIMARY_READ_ONLY;
+ SQL_ONLY;
+ SQL_PRIMARY;
+}
class google.registry.model.common.DatabaseTransitionSchedule {
@Id java.lang.String transitionId;
@Parent com.googlecode.objectify.Key parent;