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: - --