diff --git a/core/src/main/java/google/registry/config/RegistryConfig.java b/core/src/main/java/google/registry/config/RegistryConfig.java
index a876daee5..b0e2e68e3 100644
--- a/core/src/main/java/google/registry/config/RegistryConfig.java
+++ b/core/src/main/java/google/registry/config/RegistryConfig.java
@@ -1527,6 +1527,21 @@ public final class RegistryConfig {
return CONFIG_SETTINGS.get().hibernate.hikariIdleTimeout;
}
+ /**
+ * Returns whether to replicate cloud SQL transactions to datastore.
+ *
+ *
If true, all cloud SQL transactions will be persisted as TransactionEntity objects in the
+ * Transaction table and replayed against datastore in a cron job.
+ */
+ public static boolean getCloudSqlReplicateTransactions() {
+ return CONFIG_SETTINGS.get().cloudSql.replicateTransactions;
+ }
+
+ @VisibleForTesting
+ public static void overrideCloudSqlReplicateTransactions(boolean replicateTransactions) {
+ CONFIG_SETTINGS.get().cloudSql.replicateTransactions = replicateTransactions;
+ }
+
/** Returns the roid suffix to be used for the roids of all contacts and hosts. */
public static String getContactAndHostRoidSuffix() {
return CONFIG_SETTINGS.get().registryPolicy.contactAndHostRoidSuffix;
diff --git a/core/src/main/java/google/registry/config/RegistryConfigSettings.java b/core/src/main/java/google/registry/config/RegistryConfigSettings.java
index 4c4405e12..6936da4b2 100644
--- a/core/src/main/java/google/registry/config/RegistryConfigSettings.java
+++ b/core/src/main/java/google/registry/config/RegistryConfigSettings.java
@@ -122,6 +122,7 @@ public class RegistryConfigSettings {
public String jdbcUrl;
public String username;
public String instanceConnectionName;
+ public boolean replicateTransactions;
}
/** Configuration for Apache Beam (Cloud Dataflow). */
diff --git a/core/src/main/java/google/registry/config/files/default-config.yaml b/core/src/main/java/google/registry/config/files/default-config.yaml
index 2485ed28a..98145bb12 100644
--- a/core/src/main/java/google/registry/config/files/default-config.yaml
+++ b/core/src/main/java/google/registry/config/files/default-config.yaml
@@ -230,6 +230,9 @@ cloudSql:
username: username
# This name is used by Cloud SQL when connecting to the database.
instanceConnectionName: project-id:region:instance-id
+ # Set this to true to replicate cloud SQL transactions to datastore in the
+ # background.
+ replicateTransactions: false
cloudDns:
# Set both properties to null in Production.
diff --git a/core/src/main/java/google/registry/persistence/transaction/JpaTransactionManagerImpl.java b/core/src/main/java/google/registry/persistence/transaction/JpaTransactionManagerImpl.java
index a1307210d..7766aad5d 100644
--- a/core/src/main/java/google/registry/persistence/transaction/JpaTransactionManagerImpl.java
+++ b/core/src/main/java/google/registry/persistence/transaction/JpaTransactionManagerImpl.java
@@ -26,6 +26,7 @@ import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.flogger.FluentLogger;
+import google.registry.config.RegistryConfig;
import google.registry.persistence.VKey;
import google.registry.util.Clock;
import java.lang.reflect.Field;
@@ -101,9 +102,9 @@ public class JpaTransactionManagerImpl implements JpaTransactionManager {
EntityTransaction txn = txnInfo.entityManager.getTransaction();
try {
txn.begin();
- txnInfo.inTransaction = true;
- txnInfo.transactionTime = clock.nowUtc();
+ txnInfo.start(clock);
T result = work.get();
+ txnInfo.recordTransaction();
txn.commit();
return result;
} catch (RuntimeException | Error e) {
@@ -177,6 +178,7 @@ public class JpaTransactionManagerImpl implements JpaTransactionManager {
checkArgumentNotNull(entity, "entity must be specified");
assertInTransaction();
getEntityManager().persist(entity);
+ transactionInfo.get().addUpdate(entity);
}
@Override
@@ -191,6 +193,7 @@ public class JpaTransactionManagerImpl implements JpaTransactionManager {
checkArgumentNotNull(entity, "entity must be specified");
assertInTransaction();
getEntityManager().merge(entity);
+ transactionInfo.get().addUpdate(entity);
}
@Override
@@ -206,6 +209,7 @@ public class JpaTransactionManagerImpl implements JpaTransactionManager {
assertInTransaction();
checkArgument(checkExists(entity), "Given entity does not exist");
getEntityManager().merge(entity);
+ transactionInfo.get().addUpdate(entity);
}
@Override
@@ -297,6 +301,7 @@ public class JpaTransactionManagerImpl implements JpaTransactionManager {
String.format("DELETE FROM %s WHERE %s", entityType.getName(), getAndClause(entityIds));
Query query = getEntityManager().createQuery(sql);
entityIds.forEach(entityId -> query.setParameter(entityId.name, entityId.value));
+ transactionInfo.get().addDelete(key);
return query.executeUpdate();
}
@@ -387,9 +392,23 @@ public class JpaTransactionManagerImpl implements JpaTransactionManager {
boolean inTransaction = false;
DateTime transactionTime;
+ // Serializable representation of the transaction to be persisted in the Transaction table.
+ Transaction.Builder contentsBuilder;
+
+ /** Start a new transaction. */
+ private void start(Clock clock) {
+ checkArgumentNotNull(clock);
+ inTransaction = true;
+ transactionTime = clock.nowUtc();
+ if (RegistryConfig.getCloudSqlReplicateTransactions()) {
+ contentsBuilder = new Transaction.Builder();
+ }
+ }
+
private void clear() {
inTransaction = false;
transactionTime = null;
+ contentsBuilder = null;
if (entityManager != null) {
// Close this EntityManager just let the connection pool be able to reuse it, it doesn't
// close the underlying database connection.
@@ -397,5 +416,26 @@ public class JpaTransactionManagerImpl implements JpaTransactionManager {
entityManager = null;
}
}
+
+ private void addUpdate(Object entity) {
+ if (contentsBuilder != null) {
+ contentsBuilder.addUpdate(entity);
+ }
+ }
+
+ private void addDelete(VKey> key) {
+ if (contentsBuilder != null) {
+ contentsBuilder.addDelete(key);
+ }
+ }
+
+ private void recordTransaction() {
+ if (contentsBuilder != null) {
+ Transaction persistedTxn = contentsBuilder.build();
+ if (!persistedTxn.isEmpty()) {
+ entityManager.persist(persistedTxn.toEntity());
+ }
+ }
+ }
}
}
diff --git a/core/src/main/java/google/registry/persistence/transaction/Transaction.java b/core/src/main/java/google/registry/persistence/transaction/Transaction.java
index 176ae6610..8c66d6d95 100644
--- a/core/src/main/java/google/registry/persistence/transaction/Transaction.java
+++ b/core/src/main/java/google/registry/persistence/transaction/Transaction.java
@@ -109,11 +109,20 @@ public class Transaction extends ImmutableObject implements Buildable {
return builder.build();
}
+ /** Returns true if the transaction contains no mutations. */
+ public boolean isEmpty() {
+ return mutations.isEmpty();
+ }
+
@Override
public Builder asBuilder() {
return new Builder(clone(this));
}
+ public final TransactionEntity toEntity() {
+ return new TransactionEntity(serialize());
+ }
+
public static class Builder extends GenericBuilder {
ImmutableList.Builder listBuilder = new ImmutableList.Builder();
diff --git a/core/src/main/java/google/registry/persistence/transaction/TransactionEntity.java b/core/src/main/java/google/registry/persistence/transaction/TransactionEntity.java
new file mode 100644
index 000000000..5fdf47c20
--- /dev/null
+++ b/core/src/main/java/google/registry/persistence/transaction/TransactionEntity.java
@@ -0,0 +1,43 @@
+// Copyright 2020 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.transaction;
+
+import javax.persistence.Entity;
+import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
+import javax.persistence.Id;
+import javax.persistence.Table;
+
+/**
+ * Object to be stored in the transaction table.
+ *
+ * This consists of a sequential identifier and a serialized {@code Tranaction} object.
+ */
+@Entity
+@Table(name = "Transaction")
+public class TransactionEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ long id;
+
+ byte[] contents;
+
+ TransactionEntity() {}
+
+ TransactionEntity(byte[] contents) {
+ this.contents = contents;
+ }
+}
diff --git a/core/src/main/resources/META-INF/persistence.xml b/core/src/main/resources/META-INF/persistence.xml
index 3d8df0bc6..7ddc6cb5c 100644
--- a/core/src/main/resources/META-INF/persistence.xml
+++ b/core/src/main/resources/META-INF/persistence.xml
@@ -31,6 +31,7 @@
google.registry.model.registrar.RegistrarContact
google.registry.model.registry.label.PremiumList
google.registry.model.reporting.Spec11ThreatMatch
+ google.registry.persistence.transaction.TransactionEntity
google.registry.schema.domain.RegistryLock
google.registry.schema.tmch.ClaimsList
google.registry.schema.cursor.Cursor
diff --git a/core/src/test/java/google/registry/persistence/transaction/JpaEntityCoverageExtension.java b/core/src/test/java/google/registry/persistence/transaction/JpaEntityCoverageExtension.java
index 8993dddc4..71c2b7dfc 100644
--- a/core/src/test/java/google/registry/persistence/transaction/JpaEntityCoverageExtension.java
+++ b/core/src/test/java/google/registry/persistence/transaction/JpaEntityCoverageExtension.java
@@ -42,7 +42,13 @@ public class JpaEntityCoverageExtension implements BeforeEachCallback, AfterEach
// TODO(weiminyu): update this set when entities written to Cloud SQL and tests are added.
private static final ImmutableSet IGNORE_ENTITIES =
ImmutableSet.of(
- "DelegationSignerData", "DesignatedContact", "GracePeriod", "RegistrarContact");
+ "DelegationSignerData",
+ "DesignatedContact",
+ "GracePeriod",
+ "RegistrarContact",
+
+ // TransactionEntity is trivial, its persistence is tested in TransactionTest.
+ "TransactionEntity");
private static final ImmutableSet ALL_JPA_ENTITIES =
PersistenceXmlUtility.getManagedClasses().stream()
diff --git a/core/src/test/java/google/registry/persistence/transaction/JpaTransactionManagerExtension.java b/core/src/test/java/google/registry/persistence/transaction/JpaTransactionManagerExtension.java
index e92c664d3..40d63e4d1 100644
--- a/core/src/test/java/google/registry/persistence/transaction/JpaTransactionManagerExtension.java
+++ b/core/src/test/java/google/registry/persistence/transaction/JpaTransactionManagerExtension.java
@@ -165,10 +165,10 @@ abstract class JpaTransactionManagerExtension implements BeforeEachCallback, Aft
}
executeSql(readSqlInClassPath(DB_CLEANUP_SQL_PATH));
initScriptPath.ifPresent(path -> executeSql(readSqlInClassPath(path)));
- if (!extraEntityClasses.isEmpty()) {
+ if (!includeNomulusSchema) {
File tempSqlFile = File.createTempFile("tempSqlFile", ".sql");
tempSqlFile.deleteOnExit();
- exporter.export(extraEntityClasses, tempSqlFile);
+ exporter.export(getTestEntities(), tempSqlFile);
executeSql(new String(Files.readAllBytes(tempSqlFile.toPath()), StandardCharsets.UTF_8));
}
@@ -187,11 +187,7 @@ abstract class JpaTransactionManagerExtension implements BeforeEachCallback, Aft
assertReasonableNumDbConnections();
emf =
createEntityManagerFactory(
- getJdbcUrl(),
- database.getUsername(),
- database.getPassword(),
- properties,
- extraEntityClasses);
+ getJdbcUrl(), database.getUsername(), database.getPassword(), properties);
emfEntityHash = entityHash;
}
@@ -309,11 +305,7 @@ abstract class JpaTransactionManagerExtension implements BeforeEachCallback, Aft
/** Constructs the {@link EntityManagerFactory} instance. */
private EntityManagerFactory createEntityManagerFactory(
- String jdbcUrl,
- String username,
- String password,
- ImmutableMap configs,
- ImmutableList extraEntityClasses) {
+ String jdbcUrl, String username, String password, ImmutableMap configs) {
HashMap properties = Maps.newHashMap(configs);
properties.put(Environment.URL, jdbcUrl);
properties.put(Environment.USER, username);
@@ -342,7 +334,14 @@ abstract class JpaTransactionManagerExtension implements BeforeEachCallback, Aft
descriptor.getManagedClassNames().addAll(nonEntityClasses);
}
- extraEntityClasses.stream().map(Class::getName).forEach(descriptor::addClasses);
+ getTestEntities().stream().map(Class::getName).forEach(descriptor::addClasses);
return Bootstrap.getEntityManagerFactoryBuilder(descriptor, properties).build();
}
+
+ private ImmutableList getTestEntities() {
+ // We have to add the TransactionEntity to extra entities, as this is required by the
+ // transaction replication mechanism.
+ return Stream.concat(extraEntityClasses.stream(), Stream.of(TransactionEntity.class))
+ .collect(toImmutableList());
+ }
}
diff --git a/core/src/test/java/google/registry/persistence/transaction/TransactionTest.java b/core/src/test/java/google/registry/persistence/transaction/TransactionTest.java
index 66c3a8805..c3a6e9e3b 100644
--- a/core/src/test/java/google/registry/persistence/transaction/TransactionTest.java
+++ b/core/src/test/java/google/registry/persistence/transaction/TransactionTest.java
@@ -15,16 +15,19 @@
package google.registry.persistence.transaction;
import static com.google.common.truth.Truth.assertThat;
+import static google.registry.persistence.transaction.TransactionManagerFactory.jpaTm;
import static google.registry.persistence.transaction.TransactionManagerFactory.ofyTm;
import static org.junit.Assert.assertThrows;
import com.googlecode.objectify.Key;
import com.googlecode.objectify.annotation.Entity;
import com.googlecode.objectify.annotation.Id;
+import google.registry.config.RegistryConfig;
import google.registry.model.ImmutableObject;
import google.registry.persistence.VKey;
import google.registry.testing.AppEngineExtension;
import java.io.ByteArrayOutputStream;
+import java.io.IOException;
import java.io.ObjectOutputStream;
import java.io.StreamCorruptedException;
import org.junit.jupiter.api.BeforeEach;
@@ -99,6 +102,51 @@ public class TransactionTest {
StreamCorruptedException.class, () -> Transaction.deserialize(new byte[] {1, 2, 3, 4}));
}
+ @Test
+ public void testTransactionSerialization() throws IOException {
+ RegistryConfig.overrideCloudSqlReplicateTransactions(true);
+ try {
+ jpaTm()
+ .transact(
+ () -> {
+ jpaTm().saveNew(fooEntity);
+ jpaTm().saveNew(barEntity);
+ });
+ TransactionEntity txnEnt =
+ jpaTm().transact(() -> jpaTm().load(VKey.createSql(TransactionEntity.class, 1L)));
+ Transaction txn = Transaction.deserialize(txnEnt.contents);
+ txn.writeToDatastore();
+ ofyTm()
+ .transact(
+ () -> {
+ assertThat(ofyTm().load(fooEntity.key())).isEqualTo(fooEntity);
+ assertThat(ofyTm().load(barEntity.key())).isEqualTo(barEntity);
+ });
+
+ // Verify that no transaction was persisted for the load transaction.
+ assertThat(
+ jpaTm()
+ .transact(() -> jpaTm().checkExists(VKey.createSql(TransactionEntity.class, 2L))))
+ .isFalse();
+ } finally {
+ RegistryConfig.overrideCloudSqlReplicateTransactions(false);
+ }
+ }
+
+ @Test
+ public void testTransactionSerializationDisabledByDefault() {
+ jpaTm()
+ .transact(
+ () -> {
+ jpaTm().saveNew(fooEntity);
+ jpaTm().saveNew(barEntity);
+ });
+ assertThat(
+ jpaTm()
+ .transact(() -> jpaTm().checkExists(VKey.createSql(TransactionEntity.class, 1L))))
+ .isFalse();
+ }
+
@Entity(name = "TxnTestEntity")
@javax.persistence.Entity(name = "TestEntity")
private static class TestEntity extends ImmutableObject {
diff --git a/db/src/main/resources/sql/flyway/V42__add_txn_table.sql b/db/src/main/resources/sql/flyway/V42__add_txn_table.sql
new file mode 100644
index 000000000..f678fef0c
--- /dev/null
+++ b/db/src/main/resources/sql/flyway/V42__add_txn_table.sql
@@ -0,0 +1,19 @@
+-- Copyright 2020 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.
+
+create table "Transaction" (
+ id bigserial not null,
+ contents bytea,
+ primary key (id)
+);
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 288e25348..fced5c322 100644
--- a/db/src/main/resources/sql/schema/db-schema.sql.generated
+++ b/db/src/main/resources/sql/schema/db-schema.sql.generated
@@ -494,6 +494,12 @@ create sequence history_id_sequence start 1 increment 1;
tld text not null,
primary key (id)
);
+
+ create table "Transaction" (
+ id bigserial not null,
+ contents bytea,
+ primary key (id)
+ );
create index IDXih4b2tea127p5rb61gje6e1y2 on "BillingCancellation" (registrar_id);
create index IDX2exdfbx6oiiwnhr8j6gjpqt2j on "BillingCancellation" (event_time);
create index IDXqa3g92jc17e8dtiaviy4fet4x on "BillingCancellation" (billing_time);
diff --git a/db/src/main/resources/sql/schema/nomulus.golden.sql b/db/src/main/resources/sql/schema/nomulus.golden.sql
index a3fcb0827..9d1aa2191 100644
--- a/db/src/main/resources/sql/schema/nomulus.golden.sql
+++ b/db/src/main/resources/sql/schema/nomulus.golden.sql
@@ -770,6 +770,35 @@ CREATE SEQUENCE public."SafeBrowsingThreat_id_seq"
ALTER SEQUENCE public."SafeBrowsingThreat_id_seq" OWNED BY public."Spec11ThreatMatch".id;
+--
+-- Name: Transaction; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public."Transaction" (
+ id bigint NOT NULL,
+ contents bytea
+);
+
+
+--
+-- Name: Transaction_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public."Transaction_id_seq"
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: Transaction_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public."Transaction_id_seq" OWNED BY public."Transaction".id;
+
+
--
-- Name: BillingCancellation billing_cancellation_id; Type: DEFAULT; Schema: public; Owner: -
--
@@ -833,6 +862,13 @@ ALTER TABLE ONLY public."ReservedList" ALTER COLUMN revision_id SET DEFAULT next
ALTER TABLE ONLY public."Spec11ThreatMatch" ALTER COLUMN id SET DEFAULT nextval('public."SafeBrowsingThreat_id_seq"'::regclass);
+--
+-- Name: Transaction id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public."Transaction" ALTER COLUMN id SET DEFAULT nextval('public."Transaction_id_seq"'::regclass);
+
+
--
-- Name: BillingCancellation BillingCancellation_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -1001,6 +1037,14 @@ ALTER TABLE ONLY public."Spec11ThreatMatch"
ADD CONSTRAINT "SafeBrowsingThreat_pkey" PRIMARY KEY (id);
+--
+-- Name: Transaction Transaction_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public."Transaction"
+ ADD CONSTRAINT "Transaction_pkey" PRIMARY KEY (id);
+
+
--
-- Name: RegistryLock idx_registry_lock_repo_id_revision_id; Type: CONSTRAINT; Schema: public; Owner: -
--