Compare SQL and Datastore objects in SQL->DS replay testing (#1291)

Add double-replay to the Host*Flow tests to show how this works. The
only change to the double replay itself is that now we store the
Datastore entity in the TransactionEntity object -- this is because we
use Objectify to serialize the objects into bytes and we need it to know
about the entity in question.
This commit is contained in:
gbrodman 2021-08-23 09:05:14 -06:00 committed by GitHub
parent 603a95d719
commit 5c33286056
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 131 additions and 57 deletions

View file

@ -22,6 +22,7 @@ import static google.registry.request.Action.Method.GET;
import com.google.common.annotations.VisibleForTesting; import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.flogger.FluentLogger; import com.google.common.flogger.FluentLogger;
import google.registry.model.UpdateAutoTimestamp;
import google.registry.model.common.DatabaseMigrationStateSchedule; import google.registry.model.common.DatabaseMigrationStateSchedule;
import google.registry.model.common.DatabaseMigrationStateSchedule.MigrationState; import google.registry.model.common.DatabaseMigrationStateSchedule.MigrationState;
import google.registry.model.common.DatabaseMigrationStateSchedule.ReplayDirection; import google.registry.model.common.DatabaseMigrationStateSchedule.ReplayDirection;
@ -64,7 +65,7 @@ public class ReplicateToDatastoreAction implements Runnable {
@VisibleForTesting @VisibleForTesting
public List<TransactionEntity> getTransactionBatch() { public List<TransactionEntity> getTransactionBatch() {
// Get the next batch of transactions that we haven't replicated. // Get the next batch of transactions that we haven't replicated.
LastSqlTransaction lastSqlTxnBeforeBatch = ofyTm().transact(() -> LastSqlTransaction.load()); LastSqlTransaction lastSqlTxnBeforeBatch = ofyTm().transact(LastSqlTransaction::load);
try { try {
return jpaTm() return jpaTm()
.transactWithoutBackup( .transactWithoutBackup(
@ -85,10 +86,15 @@ public class ReplicateToDatastoreAction implements Runnable {
/** /**
* Apply a transaction to Datastore, returns true if there was a fatal error and the batch should * Apply a transaction to Datastore, returns true if there was a fatal error and the batch should
* be aborted. * be aborted.
*
* <p>TODO(gbrodman): this should throw an exception on error instead since it gives more
* information and we can't rely on the caller checking the boolean result.
*/ */
@VisibleForTesting @VisibleForTesting
public boolean applyTransaction(TransactionEntity txnEntity) { public boolean applyTransaction(TransactionEntity txnEntity) {
logger.atInfo().log("Applying a single transaction Cloud SQL -> Cloud Datastore"); logger.atInfo().log("Applying a single transaction Cloud SQL -> Cloud Datastore");
try (UpdateAutoTimestamp.DisableAutoUpdateResource disabler =
UpdateAutoTimestamp.disableAutoUpdate()) {
return ofyTm() return ofyTm()
.transact( .transact(
() -> { () -> {
@ -115,7 +121,8 @@ public class ReplicateToDatastoreAction implements Runnable {
return false; return false;
} }
logger.atInfo().log("Applying transaction %s to Cloud Datastore", txnEntity.getId()); logger.atInfo().log(
"Applying transaction %s to Cloud Datastore", txnEntity.getId());
// At this point, we know txnEntity is the correct next transaction, so write it // At this point, we know txnEntity is the correct next transaction, so write it
// to datastore. // to datastore.
@ -133,6 +140,7 @@ public class ReplicateToDatastoreAction implements Runnable {
return false; return false;
}); });
} }
}
@Override @Override
public void run() { public void run() {

View file

@ -21,10 +21,12 @@ import static google.registry.persistence.transaction.TransactionManagerFactory.
import com.google.appengine.api.datastore.Entity; import com.google.appengine.api.datastore.Entity;
import com.google.appengine.api.datastore.EntityTranslator; import com.google.appengine.api.datastore.EntityTranslator;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.storage.onestore.v3.OnestoreEntity.EntityProto; import com.google.storage.onestore.v3.OnestoreEntity.EntityProto;
import google.registry.model.Buildable; import google.registry.model.Buildable;
import google.registry.model.ImmutableObject; import google.registry.model.ImmutableObject;
import google.registry.model.replay.SqlEntity;
import google.registry.persistence.VKey; import google.registry.persistence.VKey;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
@ -57,6 +59,11 @@ public class Transaction extends ImmutableObject implements Buildable {
private transient ImmutableList<Mutation> mutations; private transient ImmutableList<Mutation> mutations;
@VisibleForTesting
public ImmutableList<Mutation> getMutations() {
return mutations;
}
/** Write the entire transaction to the datastore in a datastore transaction. */ /** Write the entire transaction to the datastore in a datastore transaction. */
public void writeToDatastore() { public void writeToDatastore() {
ofyTm() ofyTm()
@ -128,7 +135,7 @@ public class Transaction extends ImmutableObject implements Buildable {
* Returns true if we are serializing a transaction in the current thread. * Returns true if we are serializing a transaction in the current thread.
* *
* <p>This should be checked by any Ofy translators prior to making any changes to an entity's * <p>This should be checked by any Ofy translators prior to making any changes to an entity's
* state representation based on the assumption that we are currently pseristing the entity to * state representation based on the assumption that we are currently persisting the entity to
* datastore. * datastore.
*/ */
public static boolean inSerializationMode() { public static boolean inSerializationMode() {
@ -146,7 +153,7 @@ public class Transaction extends ImmutableObject implements Buildable {
public static class Builder extends GenericBuilder<Transaction, Builder> { public static class Builder extends GenericBuilder<Transaction, Builder> {
ImmutableList.Builder listBuilder = new ImmutableList.Builder(); ImmutableList.Builder<Mutation> listBuilder = new ImmutableList.Builder<>();
Builder() {} Builder() {}
@ -224,7 +231,8 @@ public class Transaction extends ImmutableObject implements Buildable {
private Object entity; private Object entity;
Update(Object entity) { Update(Object entity) {
this.entity = entity; this.entity =
(entity instanceof SqlEntity) ? ((SqlEntity) entity).toDatastoreEntity().get() : entity;
} }
@Override @Override
@ -241,6 +249,11 @@ public class Transaction extends ImmutableObject implements Buildable {
proto.writeDelimitedTo(out); proto.writeDelimitedTo(out);
} }
@VisibleForTesting
public Object getEntity() {
return entity;
}
public static Update deserializeFrom(ObjectInputStream in) throws IOException { public static Update deserializeFrom(ObjectInputStream in) throws IOException {
EntityProto proto = new EntityProto(); EntityProto proto = new EntityProto();
proto.parseDelimitedFrom(in); proto.parseDelimitedFrom(in);
@ -273,9 +286,14 @@ public class Transaction extends ImmutableObject implements Buildable {
out.writeObject(key); out.writeObject(key);
} }
@VisibleForTesting
public VKey<?> getKey() {
return key;
}
public static Delete deserializeFrom(ObjectInputStream in) throws IOException { public static Delete deserializeFrom(ObjectInputStream in) throws IOException {
try { try {
return new Delete((VKey) in.readObject()); return new Delete((VKey<?>) in.readObject());
} catch (ClassNotFoundException e) { } catch (ClassNotFoundException e) {
throw new IllegalArgumentException(e); throw new IllegalArgumentException(e);
} }

View file

@ -14,6 +14,7 @@
package google.registry.persistence.transaction; package google.registry.persistence.transaction;
import google.registry.model.ImmutableObject;
import google.registry.model.replay.SqlOnlyEntity; import google.registry.model.replay.SqlOnlyEntity;
import javax.persistence.Entity; import javax.persistence.Entity;
import javax.persistence.GeneratedValue; import javax.persistence.GeneratedValue;
@ -28,7 +29,7 @@ import javax.persistence.Table;
*/ */
@Entity @Entity
@Table(name = "Transaction") @Table(name = "Transaction")
public class TransactionEntity implements SqlOnlyEntity { public class TransactionEntity extends ImmutableObject implements SqlOnlyEntity {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)

View file

@ -36,7 +36,7 @@ class HostCheckFlowTest extends ResourceCheckFlowTestCase<HostCheckFlow, HostRes
@Order(value = Order.DEFAULT - 2) @Order(value = Order.DEFAULT - 2)
@RegisterExtension @RegisterExtension
final ReplayExtension replayExtension = ReplayExtension.createWithCompare(clock); final ReplayExtension replayExtension = ReplayExtension.createWithDoubleReplay(clock);
HostCheckFlowTest() { HostCheckFlowTest() {
setEppInput("host_check.xml"); setEppInput("host_check.xml");

View file

@ -67,7 +67,7 @@ class HostCreateFlowTest extends ResourceFlowTestCase<HostCreateFlow, HostResour
@Order(value = Order.DEFAULT - 2) @Order(value = Order.DEFAULT - 2)
@RegisterExtension @RegisterExtension
final ReplayExtension replayExtension = ReplayExtension.createWithCompare(clock); final ReplayExtension replayExtension = ReplayExtension.createWithDoubleReplay(clock);
private void setEppHostCreateInput(String hostName, String hostAddrs) { private void setEppHostCreateInput(String hostName, String hostAddrs) {
setEppInput( setEppInput(

View file

@ -65,7 +65,7 @@ class HostDeleteFlowTest extends ResourceFlowTestCase<HostDeleteFlow, HostResour
@Order(value = Order.DEFAULT - 2) @Order(value = Order.DEFAULT - 2)
@RegisterExtension @RegisterExtension
final ReplayExtension replayExtension = ReplayExtension.createWithCompare(clock); final ReplayExtension replayExtension = ReplayExtension.createWithDoubleReplay(clock);
@BeforeEach @BeforeEach
void initFlowTest() { void initFlowTest() {

View file

@ -93,7 +93,7 @@ class HostUpdateFlowTest extends ResourceFlowTestCase<HostUpdateFlow, HostResour
@Order(value = Order.DEFAULT - 2) @Order(value = Order.DEFAULT - 2)
@RegisterExtension @RegisterExtension
final ReplayExtension replayExtension = ReplayExtension.createWithCompare(clock); final ReplayExtension replayExtension = ReplayExtension.createWithDoubleReplay(clock);
private void setEppHostUpdateInput( private void setEppHostUpdateInput(
String oldHostName, String newHostName, String ipOrStatusToAdd, String ipOrStatusToRem) { String oldHostName, String newHostName, String ipOrStatusToAdd, String ipOrStatusToRem) {

View file

@ -15,8 +15,10 @@
package google.registry.testing; package google.registry.testing;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static google.registry.model.ImmutableObjectSubject.assertAboutImmutableObjects; import static google.registry.model.ImmutableObjectSubject.assertAboutImmutableObjects;
import static google.registry.persistence.transaction.TransactionManagerFactory.jpaTm; import static google.registry.persistence.transaction.TransactionManagerFactory.jpaTm;
import static google.registry.persistence.transaction.TransactionManagerFactory.ofyTm;
import com.google.common.base.Suppliers; import com.google.common.base.Suppliers;
import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMap;
@ -28,9 +30,16 @@ import google.registry.model.ofy.ReplayQueue;
import google.registry.model.ofy.TransactionInfo; import google.registry.model.ofy.TransactionInfo;
import google.registry.model.replay.DatastoreEntity; import google.registry.model.replay.DatastoreEntity;
import google.registry.model.replay.ReplicateToDatastoreAction; import google.registry.model.replay.ReplicateToDatastoreAction;
import google.registry.model.replay.SqlEntity;
import google.registry.persistence.VKey; import google.registry.persistence.VKey;
import google.registry.persistence.transaction.JpaTransactionManagerImpl; import google.registry.persistence.transaction.JpaTransactionManagerImpl;
import google.registry.persistence.transaction.Transaction;
import google.registry.persistence.transaction.Transaction.Delete;
import google.registry.persistence.transaction.Transaction.Mutation;
import google.registry.persistence.transaction.Transaction.Update;
import google.registry.persistence.transaction.TransactionEntity; import google.registry.persistence.transaction.TransactionEntity;
import java.io.IOException;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.AfterEachCallback;
@ -186,12 +195,50 @@ public class ReplayExtension implements BeforeEachCallback, AfterEachCallback {
return; return;
} }
// TODO(mmuller): Verify that all entities are the same across both databases. List<TransactionEntity> transactionBatch;
for (TransactionEntity txn : sqlToDsReplicator.getTransactionBatch()) { do {
transactionBatch = sqlToDsReplicator.getTransactionBatch();
for (TransactionEntity txn : transactionBatch) {
if (sqlToDsReplicator.applyTransaction(txn)) { if (sqlToDsReplicator.applyTransaction(txn)) {
break; throw new RuntimeException(
"Error when replaying to Datastore in tests; see logs for more details");
}
if (compare) {
ofyTm().transact(() -> compareSqlTransaction(txn));
} }
clock.advanceOneMilli(); clock.advanceOneMilli();
} }
} while (!transactionBatch.isEmpty());
}
/** Verifies that the replaying the SQL transaction created the same entities in Datastore. */
private void compareSqlTransaction(TransactionEntity transactionEntity) {
Transaction transaction;
try {
transaction = Transaction.deserialize(transactionEntity.getContents());
} catch (IOException e) {
throw new RuntimeException("Error during transaction deserialization.", e);
}
for (Mutation mutation : transaction.getMutations()) {
if (mutation instanceof Update) {
Update update = (Update) mutation;
ImmutableObject fromTransactionEntity = (ImmutableObject) update.getEntity();
ImmutableObject fromDatastore = ofyTm().loadByEntity(fromTransactionEntity);
if (fromDatastore instanceof SqlEntity) {
// We store the Datastore entity in the transaction, so use that if necessary
fromDatastore = (ImmutableObject) ((SqlEntity) fromDatastore).toDatastoreEntity().get();
}
assertAboutImmutableObjects().that(fromDatastore).hasCorrectHashValue();
assertAboutImmutableObjects()
.that(fromDatastore)
.isEqualAcrossDatabases(fromTransactionEntity);
} else {
Delete delete = (Delete) mutation;
VKey<?> key = delete.getKey();
assertWithMessage(String.format("Expected key %s to not exist in Datastore", key))
.that(ofyTm().exists(key))
.isFalse();
}
}
} }
} }