Refactor transact() related methods. (#2195)

This PR makes a few changes to make it possible to turn on
per-transaction isolation level with minimal disruption:

1) Changed the signatures of transact() and reTransact() methods to allow
passing in lambdas that throw checked exceptions. Previously one has
always to wrap such lambdas in try-and-retrow blocks, which wasn't a
big issue when one can liberally open nested transactions around small
lambdas and keeps the "throwing" part outside the lambda. This becomes a
much bigger hassle when the goal is to eliminate nested transactions and
put as much code as possible within the top-level lambda. As a result,
the transactNoRetry() method now handles checked exceptions by re-throwing
them as runtime exceptions.

2) Changed the name and meaning of the config file field that used to
indicate if per-transaction isolation level is enabled or not. Now it
decides if transact() is called within a transaction, whether to
throw or to log, regardless whether the transaction could have
succeeded based on the isolation override level (if provided). The
flag will initially be set to false and would help us identify all
instances of nested calls and either refactor them or use reTransact()
instead. Once we are fairly certain that no nested calls to transact()
exists, we flip the flag to true and start enforcing this logic.
Eventually the flag will go away and nested calls to transact() will
always throw.

3) Per-transaction isolation level will now always be applied, if an
override is provided. Because currently there should be no actual
use of such feature (except for places where we explicitly use an
override and have ensured no nested transactions exist, like in
RefreshDnsForAllDomainsAction), we do not expect any issues with
conflicting isolation levels, which would resulted in failure.

3) transactNoRetry() is made package private and removed from the
exposed API of JpaTransactionManager. This saves a lot of redundant
methods that do not have a practical use. The only instances where this
method was called outside the package was in the reader of
RegistryJpaIO, which should have no problem with retrying.
This commit is contained in:
Lai Jiang 2023-11-03 17:43:27 -04:00 committed by GitHub
parent cd23fea698
commit 08471242df
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 270 additions and 329 deletions

View file

@ -209,7 +209,7 @@ public final class RegistryJpaIO {
@ProcessElement @ProcessElement
public void processElement(OutputReceiver<T> outputReceiver) { public void processElement(OutputReceiver<T> outputReceiver) {
tm().transactNoRetry( tm().transact(
() -> { () -> {
query.stream().map(resultMapper::apply).forEach(outputReceiver::output); query.stream().map(resultMapper::apply).forEach(outputReceiver::output);
}); });

View file

@ -1548,9 +1548,9 @@ public final class RegistryConfig {
return CONFIG_SETTINGS.get().hibernate.connectionIsolation; return CONFIG_SETTINGS.get().hibernate.connectionIsolation;
} }
/** Returns true if per-transaction isolation level is enabled. */ /** Returns true if nested calls to {@code tm().transact()} are allowed. */
public static boolean getHibernatePerTransactionIsolationEnabled() { public static boolean getHibernateAllowNestedTransactions() {
return CONFIG_SETTINGS.get().hibernate.perTransactionIsolation; return CONFIG_SETTINGS.get().hibernate.allowNestedTransactions;
} }
/** Returns true if hibernate.show_sql is enabled. */ /** Returns true if hibernate.show_sql is enabled. */

View file

@ -113,7 +113,7 @@ public class RegistryConfigSettings {
/** Configuration for Hibernate. */ /** Configuration for Hibernate. */
public static class Hibernate { public static class Hibernate {
public boolean perTransactionIsolation; public boolean allowNestedTransactions;
public String connectionIsolation; public String connectionIsolation;
public String logSqlQueries; public String logSqlQueries;
public String hikariConnectionTimeout; public String hikariConnectionTimeout;

View file

@ -189,11 +189,13 @@ registryPolicy:
sunriseDomainCreateDiscount: 0.15 sunriseDomainCreateDiscount: 0.15
hibernate: hibernate:
# Make it possible to specify the isolation level for each transaction. If set # If set to false, calls to tm().transact() cannot be nested. If set to true,
# to true, nested transactions will throw an exception. If set to false, a # nested calls to tm().transact() are allowed, as long as they do not specify
# transaction with the isolation override specified will still execute at the # a transaction isolation level override. These nested transactions should
# default level (specified below). # either be refactored to non-nested transactions, or changed to
perTransactionIsolation: true # tm().reTransact(), which explicitly allows nested transactions, but does not
# allow setting an isolation level override.
allowNestedTransactions: true
# Make 'SERIALIZABLE' the default isolation level to ensure correctness. # Make 'SERIALIZABLE' the default isolation level to ensure correctness.
# #

View file

@ -25,9 +25,10 @@ import com.google.common.base.Strings;
import com.google.common.flogger.FluentLogger; import com.google.common.flogger.FluentLogger;
import google.registry.model.ImmutableObject; import google.registry.model.ImmutableObject;
import google.registry.persistence.VKey; import google.registry.persistence.VKey;
import google.registry.persistence.transaction.TransactionManager.ThrowingRunnable;
import java.io.Serializable; import java.io.Serializable;
import java.util.Optional; import java.util.Optional;
import java.util.function.Supplier; import java.util.concurrent.Callable;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import javax.persistence.Column; import javax.persistence.Column;
import javax.persistence.Entity; import javax.persistence.Entity;
@ -184,7 +185,7 @@ public class Lock extends ImmutableObject implements Serializable {
public static Optional<Lock> acquire( public static Optional<Lock> acquire(
String resourceName, @Nullable String tld, Duration leaseLength) { String resourceName, @Nullable String tld, Duration leaseLength) {
String scope = tld != null ? tld : GLOBAL; String scope = tld != null ? tld : GLOBAL;
Supplier<AcquireResult> lockAcquirer = Callable<AcquireResult> lockAcquirer =
() -> { () -> {
DateTime now = tm().getTransactionTime(); DateTime now = tm().getTransactionTime();
@ -221,7 +222,7 @@ public class Lock extends ImmutableObject implements Serializable {
/** Release the lock. */ /** Release the lock. */
public void release() { public void release() {
// Just use the default clock because we aren't actually doing anything that will use the clock. // Just use the default clock because we aren't actually doing anything that will use the clock.
Supplier<Void> lockReleaser = ThrowingRunnable lockReleaser =
() -> { () -> {
// To release a lock, check that no one else has already obtained it and if not // To release a lock, check that no one else has already obtained it and if not
// delete it. If the lock in the database was different, then this lock is gone already; // delete it. If the lock in the database was different, then this lock is gone already;
@ -246,7 +247,6 @@ public class Lock extends ImmutableObject implements Serializable {
logger.atInfo().log( logger.atInfo().log(
"Not deleting lock: %s - someone else has it: %s", lockId, loadedLock); "Not deleting lock: %s - someone else has it: %s", lockId, loadedLock);
} }
return null;
}; };
tm().transact(lockReleaser); tm().transact(lockReleaser);
} }

View file

@ -62,7 +62,7 @@ class DatabaseException extends PersistenceException {
* <p>If the {@code original Throwable} has at least one {@link SQLException} in its chain of * <p>If the {@code original Throwable} has at least one {@link SQLException} in its chain of
* causes, a {@link DatabaseException} is thrown; otherwise this does nothing. * causes, a {@link DatabaseException} is thrown; otherwise this does nothing.
*/ */
static void tryWrapAndThrow(Throwable original) { static void throwIfSqlException(Throwable original) {
Throwable t = original; Throwable t = original;
do { do {
if (t instanceof SQLException) { if (t instanceof SQLException) {

View file

@ -16,7 +16,6 @@ package google.registry.persistence.transaction;
import google.registry.persistence.PersistenceModule.TransactionIsolationLevel; import google.registry.persistence.PersistenceModule.TransactionIsolationLevel;
import google.registry.persistence.VKey; import google.registry.persistence.VKey;
import java.util.function.Supplier;
import javax.persistence.EntityManager; import javax.persistence.EntityManager;
import javax.persistence.Query; import javax.persistence.Query;
import javax.persistence.TypedQuery; import javax.persistence.TypedQuery;
@ -62,24 +61,6 @@ public interface JpaTransactionManager extends TransactionManager {
*/ */
Query query(String sqlString); Query query(String sqlString);
/** Executes the work in a transaction with no retries and returns the result. */
<T> T transactNoRetry(Supplier<T> work);
/**
* Executes the work in a transaction at the given {@link TransactionIsolationLevel} with no
* retries and returns the result.
*/
<T> T transactNoRetry(Supplier<T> work, TransactionIsolationLevel isolationLevel);
/** Executes the work in a transaction with no retries. */
void transactNoRetry(Runnable work);
/**
* Executes the work in a transaction at the given {@link TransactionIsolationLevel} with no
* retries.
*/
void transactNoRetry(Runnable work, TransactionIsolationLevel isolationLevel);
/** Deletes the entity by its id, throws exception if the entity is not deleted. */ /** Deletes the entity by its id, throws exception if the entity is not deleted. */
<T> void assertDelete(VKey<T> key); <T> void assertDelete(VKey<T> key);
@ -103,7 +84,4 @@ public interface JpaTransactionManager extends TransactionManager {
/** Return the {@link TransactionIsolationLevel} used in the current transaction. */ /** Return the {@link TransactionIsolationLevel} used in the current transaction. */
TransactionIsolationLevel getCurrentTransactionIsolationLevel(); TransactionIsolationLevel getCurrentTransactionIsolationLevel();
/** Asserts that the current transaction runs at the given level. */
void assertTransactionIsolationLevel(TransactionIsolationLevel expectedLevel);
} }

View file

@ -15,11 +15,12 @@
package google.registry.persistence.transaction; package google.registry.persistence.transaction;
import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Throwables.throwIfUnchecked;
import static com.google.common.collect.ImmutableList.toImmutableList; import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableMap.toImmutableMap; import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.common.collect.ImmutableSet.toImmutableSet; import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static google.registry.config.RegistryConfig.getHibernatePerTransactionIsolationEnabled; import static google.registry.config.RegistryConfig.getHibernateAllowNestedTransactions;
import static google.registry.persistence.transaction.DatabaseException.tryWrapAndThrow; import static google.registry.persistence.transaction.DatabaseException.throwIfSqlException;
import static google.registry.util.PreconditionsUtils.checkArgumentNotNull; import static google.registry.util.PreconditionsUtils.checkArgumentNotNull;
import static java.util.AbstractMap.SimpleEntry; import static java.util.AbstractMap.SimpleEntry;
import static java.util.stream.Collectors.joining; import static java.util.stream.Collectors.joining;
@ -31,6 +32,7 @@ import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Streams; import com.google.common.collect.Streams;
import com.google.common.flogger.FluentLogger; import com.google.common.flogger.FluentLogger;
import com.google.common.flogger.StackSize;
import google.registry.model.ImmutableObject; import google.registry.model.ImmutableObject;
import google.registry.persistence.JpaRetries; import google.registry.persistence.JpaRetries;
import google.registry.persistence.PersistenceModule.TransactionIsolationLevel; import google.registry.persistence.PersistenceModule.TransactionIsolationLevel;
@ -52,7 +54,7 @@ import java.util.Map;
import java.util.NoSuchElementException; import java.util.NoSuchElementException;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.function.Supplier; import java.util.concurrent.Callable;
import java.util.stream.Stream; import java.util.stream.Stream;
import java.util.stream.StreamSupport; import java.util.stream.StreamSupport;
import javax.annotation.Nullable; import javax.annotation.Nullable;
@ -76,6 +78,9 @@ public class JpaTransactionManagerImpl implements JpaTransactionManager {
private static final FluentLogger logger = FluentLogger.forEnclosingClass(); private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static final Retrier retrier = new Retrier(new SystemSleeper(), 3); private static final Retrier retrier = new Retrier(new SystemSleeper(), 3);
private static final String NESTED_TRANSACTION_MESSAGE =
"Nested transaction detected. Try refactoring to avoid nested transactions. If unachievable,"
+ " use reTransact() in nested transactions";
// EntityManagerFactory is thread safe. // EntityManagerFactory is thread safe.
private final EntityManagerFactory emf; private final EntityManagerFactory emf;
@ -138,21 +143,23 @@ public class JpaTransactionManagerImpl implements JpaTransactionManager {
} }
@Override @Override
public void assertTransactionIsolationLevel(TransactionIsolationLevel expectedLevel) { public <T> T reTransact(Callable<T> work) {
assertInTransaction(); // This prevents inner transaction from retrying, thus avoiding a cascade retry effect.
TransactionIsolationLevel currentLevel = getCurrentTransactionIsolationLevel(); if (inTransaction()) {
if (currentLevel != expectedLevel) { return transactNoRetry(work, null);
throw new IllegalStateException(
String.format(
"Current transaction isolation level (%s) is not as expected (%s)",
currentLevel, expectedLevel));
} }
return retrier.callWithRetry(
() -> transactNoRetry(work, null), JpaRetries::isFailedTxnRetriable);
} }
@Override @Override
public <T> T transact(Supplier<T> work, TransactionIsolationLevel isolationLevel) { public <T> T transact(Callable<T> work, TransactionIsolationLevel isolationLevel) {
// This prevents inner transaction from retrying, thus avoiding a cascade retry effect.
if (inTransaction()) { if (inTransaction()) {
if (!getHibernateAllowNestedTransactions()) {
throw new IllegalStateException(NESTED_TRANSACTION_MESSAGE);
}
logger.atWarning().withStackTrace(StackSize.MEDIUM).log(NESTED_TRANSACTION_MESSAGE);
// This prevents inner transaction from retrying, thus avoiding a cascade retry effect.
return transactNoRetry(work, isolationLevel); return transactNoRetry(work, isolationLevel);
} }
return retrier.callWithRetry( return retrier.callWithRetry(
@ -160,30 +167,32 @@ public class JpaTransactionManagerImpl implements JpaTransactionManager {
} }
@Override @Override
public <T> T reTransact(Supplier<T> work) { public <T> T transact(Callable<T> work) {
return transact(work);
}
@Override
public <T> T transact(Supplier<T> work) {
return transact(work, null); return transact(work, null);
} }
@Override
public <T> T transactNoRetry( public <T> T transactNoRetry(
Supplier<T> work, @Nullable TransactionIsolationLevel isolationLevel) { Callable<T> work, @Nullable TransactionIsolationLevel isolationLevel) {
if (inTransaction()) { if (inTransaction()) {
if (isolationLevel != null && getHibernatePerTransactionIsolationEnabled()) { // This check will no longer be necessary when the transact() method always throws
TransactionIsolationLevel enclosingLevel = getCurrentTransactionIsolationLevel(); // inside a nested transaction, as the only way to pass a non-null isolation level
if (isolationLevel != enclosingLevel) { // is by calling the transact() method (and its variants), which would have already
throw new IllegalStateException( // thrown before calling transactNoRetry() when inside a nested transaction.
String.format( //
"Isolation level conflict detected in nested transactions.\n" // For now, we still need it, so we don't accidentally call a nested transact() with an
+ "Enclosing transaction: %s\nCurrent transaction: %s", // isolation level override. This buys us time to detect nested transact() calls and either
enclosingLevel, isolationLevel)); // remove them or change the call site to reTransact().
} if (isolationLevel != null) {
throw new IllegalStateException(
"Transaction isolation level cannot be specified for nested transactions");
}
try {
return work.call();
} catch (Exception e) {
throwIfSqlException(e);
throwIfUnchecked(e);
throw new RuntimeException(e);
} }
return work.get();
} }
TransactionInfo txnInfo = transactionInfo.get(); TransactionInfo txnInfo = transactionInfo.get();
txnInfo.entityManager = emf.createEntityManager(); txnInfo.entityManager = emf.createEntityManager();
@ -191,43 +200,36 @@ public class JpaTransactionManagerImpl implements JpaTransactionManager {
try { try {
txn.begin(); txn.begin();
txnInfo.start(clock); txnInfo.start(clock);
if (isolationLevel != null) { if (isolationLevel != null && isolationLevel != getDefaultTransactionIsolationLevel()) {
if (getHibernatePerTransactionIsolationEnabled()) { getEntityManager()
getEntityManager() .createNativeQuery(
.createNativeQuery( String.format("SET TRANSACTION ISOLATION LEVEL %s", isolationLevel.getMode()))
String.format("SET TRANSACTION ISOLATION LEVEL %s", isolationLevel.getMode())) .executeUpdate();
.executeUpdate(); logger.atInfo().log(
logger.atInfo().log("Running transaction at %s", isolationLevel); "Overriding transaction isolation level from %s to %s",
} else { getDefaultTransactionIsolationLevel(), isolationLevel);
logger.atWarning().log(
"Per-transaction isolation level disabled, but %s was requested", isolationLevel);
}
} }
T result = work.get(); T result = work.call();
txn.commit(); txn.commit();
return result; return result;
} catch (RuntimeException | Error e) { } catch (Throwable e) {
// Error is unchecked! // Catch a Throwable here so even Errors would lead to a rollback.
try { try {
txn.rollback(); txn.rollback();
logger.atWarning().log("Error during transaction; transaction rolled back."); logger.atWarning().log("Error during transaction; transaction rolled back.");
} catch (Throwable rollbackException) { } catch (Exception rollbackException) {
logger.atSevere().withCause(rollbackException).log("Rollback failed; suppressing error."); logger.atSevere().withCause(rollbackException).log("Rollback failed; suppressing error.");
} }
tryWrapAndThrow(e); throwIfSqlException(e);
throw e; throwIfUnchecked(e);
throw new RuntimeException(e);
} finally { } finally {
txnInfo.clear(); txnInfo.clear();
} }
} }
@Override @Override
public <T> T transactNoRetry(Supplier<T> work) { public void transact(ThrowingRunnable work, TransactionIsolationLevel isolationLevel) {
return transactNoRetry(work, null);
}
@Override
public void transact(Runnable work, TransactionIsolationLevel isolationLevel) {
transact( transact(
() -> { () -> {
work.run(); work.run();
@ -237,28 +239,17 @@ public class JpaTransactionManagerImpl implements JpaTransactionManager {
} }
@Override @Override
public void reTransact(Runnable work) { public void transact(ThrowingRunnable work) {
transact(work);
}
@Override
public void transact(Runnable work) {
transact(work, null); transact(work, null);
} }
@Override @Override
public void transactNoRetry(Runnable work, TransactionIsolationLevel isolationLevel) { public void reTransact(ThrowingRunnable work) {
transactNoRetry( reTransact(
() -> { () -> {
work.run(); work.run();
return null; return null;
}, });
isolationLevel);
}
@Override
public void transactNoRetry(Runnable work) {
transactNoRetry(work, null);
} }
@Override @Override

View file

@ -22,7 +22,7 @@ import google.registry.persistence.PersistenceModule.TransactionIsolationLevel;
import google.registry.persistence.VKey; import google.registry.persistence.VKey;
import java.util.NoSuchElementException; import java.util.NoSuchElementException;
import java.util.Optional; import java.util.Optional;
import java.util.function.Supplier; import java.util.concurrent.Callable;
import java.util.stream.Stream; import java.util.stream.Stream;
import org.joda.time.DateTime; import org.joda.time.DateTime;
@ -48,51 +48,51 @@ public interface TransactionManager {
void assertInTransaction(); void assertInTransaction();
/** Executes the work in a transaction and returns the result. */ /** Executes the work in a transaction and returns the result. */
<T> T transact(Supplier<T> work); <T> T transact(Callable<T> work);
/** /**
* Executes the work in a transaction at the given {@link TransactionIsolationLevel} and returns * Executes the work in a transaction at the given {@link TransactionIsolationLevel} and returns
* the result. * the result.
*/ */
<T> T transact(Supplier<T> work, TransactionIsolationLevel isolationLevel); <T> T transact(Callable<T> work, TransactionIsolationLevel isolationLevel);
/** /**
* Executes the work in a (potentially wrapped) transaction and returns the result. * Executes the work in a (potentially wrapped) transaction and returns the result.
* *
* <p>Calls to this method are typically going to be in inner functions, that are called either as * <p>Calls to this method are typically going to be in inner functions, that are called either as
* top-level transactions themselves or are nested inside of larger transactions (e.g. a * top-level transactions themselves or are nested inside larger transactions (e.g. a
* transactional flow). Invocations of reTransact must be vetted to occur in both situations and * transactional flow). Invocations of reTransact must be vetted to occur in both situations and
* with such complexity that it is not trivial to refactor out the nested transaction calls. New * with such complexity that it is not trivial to refactor out the nested transaction calls. New
* code should be written in such a way as to avoid requiring reTransact in the first place. * code should be written in such a way as to avoid requiring reTransact in the first place.
* *
* <p>In the future we will be enforcing that {@link #transact(Supplier)} calls be top-level only, * <p>In the future we will be enforcing that {@link #transact(Callable)} calls be top-level only,
* with reTransact calls being the only ones that can potentially be an inner nested transaction * with reTransact calls being the only ones that can potentially be an inner nested transaction
* (which is a noop). Note that, as this can be a nested inner exception, there is no overload * (which is a noop). Note that, as this can be a nested inner exception, there is no overload
* provided to specify a (potentially conflicting) transaction isolation level. * provided to specify a (potentially conflicting) transaction isolation level.
*/ */
<T> T reTransact(Supplier<T> work); <T> T reTransact(Callable<T> work);
/** Executes the work in a transaction. */ /** Executes the work in a transaction. */
void transact(Runnable work); void transact(ThrowingRunnable work);
/** Executes the work in a transaction at the given {@link TransactionIsolationLevel}. */ /** Executes the work in a transaction at the given {@link TransactionIsolationLevel}. */
void transact(Runnable work, TransactionIsolationLevel isolationLevel); void transact(ThrowingRunnable work, TransactionIsolationLevel isolationLevel);
/** /**
* Executes the work in a (potentially wrapped) transaction and returns the result. * Executes the work in a (potentially wrapped) transaction and returns the result.
* *
* <p>Calls to this method are typically going to be in inner functions, that are called either as * <p>Calls to this method are typically going to be in inner functions, that are called either as
* top-level transactions themselves or are nested inside of larger transactions (e.g. a * top-level transactions themselves or are nested inside larger transactions (e.g. a
* transactional flow). Invocations of reTransact must be vetted to occur in both situations and * transactional flow). Invocations of reTransact must be vetted to occur in both situations and
* with such complexity that it is not trivial to refactor out the nested transaction calls. New * with such complexity that it is not trivial to refactor out the nested transaction calls. New
* code should be written in such a way as to avoid requiring reTransact in the first place. * code should be written in such a way as to avoid requiring reTransact in the first place.
* *
* <p>In the future we will be enforcing that {@link #transact(Runnable)} calls be top-level only, * <p>In the future we will be enforcing that {@link #transact(ThrowingRunnable)} calls be
* with reTransact calls being the only ones that can potentially be an inner nested transaction * top-level only, with reTransact calls being the only ones that can potentially be an inner
* (which is a noop). Note that, as this can be a nested inner exception, there is no overload * * nested transaction (which is a noop). Note that, as this can be a nested inner exception, there
* provided to specify a (potentially conflicting) transaction isolation level. * is no overload provided to specify a (potentially conflicting) transaction isolation level.
*/ */
void reTransact(Runnable work); void reTransact(ThrowingRunnable work);
/** Returns the time associated with the start of this particular transaction attempt. */ /** Returns the time associated with the start of this particular transaction attempt. */
DateTime getTransactionTime(); DateTime getTransactionTime();
@ -216,4 +216,15 @@ public interface TransactionManager {
/** Returns a QueryComposer which can be used to perform queries against the current database. */ /** Returns a QueryComposer which can be used to perform queries against the current database. */
<T> QueryComposer<T> createQueryComposer(Class<T> entity); <T> QueryComposer<T> createQueryComposer(Class<T> entity);
/**
* A runnable that allows for checked exceptions to be thrown.
*
* <p>This makes it easier to write lambdas without having to worry about wrapping and re-throwing
* checked excpetions as unchecked ones.
*/
@FunctionalInterface
interface ThrowingRunnable {
void run() throws Exception;
}
} }

View file

@ -31,7 +31,6 @@ import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedMap; import com.google.common.collect.ImmutableSortedMap;
import com.google.common.testing.TestLogHandler; import com.google.common.testing.TestLogHandler;
import google.registry.config.RegistryConfig;
import google.registry.flows.certs.CertificateChecker; import google.registry.flows.certs.CertificateChecker;
import google.registry.model.eppcommon.Trid; import google.registry.model.eppcommon.Trid;
import google.registry.model.eppoutput.EppOutput.ResponseOrGreeting; import google.registry.model.eppoutput.EppOutput.ResponseOrGreeting;
@ -89,8 +88,8 @@ class FlowRunnerTest {
@Override @Override
public ResponseOrGreeting run() { public ResponseOrGreeting run() {
tm().assertTransactionIsolationLevel( assertThat(tm().getCurrentTransactionIsolationLevel())
isolationLevel.orElse(tm().getDefaultTransactionIsolationLevel())); .isEqualTo(isolationLevel.orElse(tm().getDefaultTransactionIsolationLevel()));
return mock(EppResponse.class); return mock(EppResponse.class);
} }
} }
@ -136,10 +135,8 @@ class FlowRunnerTest {
Optional.of(TransactionIsolationLevel.TRANSACTION_READ_UNCOMMITTED); Optional.of(TransactionIsolationLevel.TRANSACTION_READ_UNCOMMITTED);
flowRunner.flowClass = TestTransactionalFlow.class; flowRunner.flowClass = TestTransactionalFlow.class;
flowRunner.flowProvider = () -> new TestTransactionalFlow(flowRunner.isolationLevelOverride); flowRunner.flowProvider = () -> new TestTransactionalFlow(flowRunner.isolationLevelOverride);
if (RegistryConfig.getHibernatePerTransactionIsolationEnabled()) { flowRunner.run(eppMetricBuilder);
flowRunner.run(eppMetricBuilder); assertThat(eppMetricBuilder.build().getCommandName()).hasValue("TestTransactional");
assertThat(eppMetricBuilder.build().getCommandName()).hasValue("TestTransactional");
}
} }
@Test @Test

View file

@ -18,7 +18,7 @@ import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth8.assertThat; import static com.google.common.truth.Truth8.assertThat;
import static google.registry.persistence.transaction.DatabaseException.getSqlError; import static google.registry.persistence.transaction.DatabaseException.getSqlError;
import static google.registry.persistence.transaction.DatabaseException.getSqlExceptionDetails; import static google.registry.persistence.transaction.DatabaseException.getSqlExceptionDetails;
import static google.registry.persistence.transaction.DatabaseException.tryWrapAndThrow; import static google.registry.persistence.transaction.DatabaseException.throwIfSqlException;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
@ -99,13 +99,13 @@ public class DatabaseExceptionTest {
@Test @Test
void tryWrapAndThrow_notSQLException() { void tryWrapAndThrow_notSQLException() {
RuntimeException orig = new RuntimeException(new Exception()); RuntimeException orig = new RuntimeException(new Exception());
tryWrapAndThrow(orig); throwIfSqlException(orig);
} }
@Test @Test
void tryWrapAndThrow_hasSQLException() { void tryWrapAndThrow_hasSQLException() {
Throwable orig = new Throwable(new SQLException()); Throwable orig = new Throwable(new SQLException());
assertThrows(DatabaseException.class, () -> tryWrapAndThrow(orig)); assertThrows(DatabaseException.class, () -> throwIfSqlException(orig));
} }
@Test @Test

View file

@ -14,6 +14,7 @@
package google.registry.persistence.transaction; package google.registry.persistence.transaction;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableList.toImmutableList; import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import static google.registry.persistence.PersistenceModule.TransactionIsolationLevel.TRANSACTION_READ_COMMITTED; import static google.registry.persistence.PersistenceModule.TransactionIsolationLevel.TRANSACTION_READ_COMMITTED;
@ -28,6 +29,7 @@ import static google.registry.testing.TestDataHelper.fileClassPath;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.spy; import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@ -36,6 +38,7 @@ import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMap;
import google.registry.config.RegistryConfig; import google.registry.config.RegistryConfig;
import google.registry.model.ImmutableObject; import google.registry.model.ImmutableObject;
import google.registry.persistence.PersistenceModule.TransactionIsolationLevel;
import google.registry.persistence.VKey; import google.registry.persistence.VKey;
import google.registry.persistence.transaction.JpaTestExtensions.JpaUnitTestExtension; import google.registry.persistence.transaction.JpaTestExtensions.JpaUnitTestExtension;
import google.registry.testing.DatabaseHelper; import google.registry.testing.DatabaseHelper;
@ -43,7 +46,6 @@ import google.registry.testing.FakeClock;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.NoSuchElementException; import java.util.NoSuchElementException;
import java.util.function.Supplier;
import javax.persistence.Entity; import javax.persistence.Entity;
import javax.persistence.EntityManager; import javax.persistence.EntityManager;
import javax.persistence.Id; import javax.persistence.Id;
@ -53,6 +55,8 @@ import javax.persistence.PersistenceException;
import javax.persistence.RollbackException; import javax.persistence.RollbackException;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.api.function.Executable;
import org.mockito.MockedStatic;
/** /**
* Unit tests for SQL only APIs defined in {@link JpaTransactionManagerImpl}. Note that the tests * Unit tests for SQL only APIs defined in {@link JpaTransactionManagerImpl}. Note that the tests
@ -94,7 +98,7 @@ class JpaTransactionManagerImplTest {
insertPerson(10); insertPerson(10);
insertCompany("Foo"); insertCompany("Foo");
insertCompany("Bar"); insertCompany("Bar");
tm().assertTransactionIsolationLevel(tm().getDefaultTransactionIsolationLevel()); assertTransactionIsolationLevel(tm().getDefaultTransactionIsolationLevel());
}); });
assertPersonCount(1); assertPersonCount(1);
assertPersonExist(10); assertPersonExist(10);
@ -105,145 +109,98 @@ class JpaTransactionManagerImplTest {
@Test @Test
void transact_setIsolationLevel() { void transact_setIsolationLevel() {
// If not specified, run at the default isolation level.
tm().transact( tm().transact(
() -> { () -> assertTransactionIsolationLevel(tm().getDefaultTransactionIsolationLevel()),
tm().assertTransactionIsolationLevel( null);
RegistryConfig.getHibernatePerTransactionIsolationEnabled() tm().transact(
? TRANSACTION_READ_UNCOMMITTED () -> assertTransactionIsolationLevel(TRANSACTION_READ_UNCOMMITTED),
: tm().getDefaultTransactionIsolationLevel());
return null;
},
TRANSACTION_READ_UNCOMMITTED); TRANSACTION_READ_UNCOMMITTED);
// Make sure that we can start a new transaction on the same thread with a different isolation // Make sure that we can start a new transaction on the same thread at a different level.
// level.
tm().transact( tm().transact(
() -> { () -> assertTransactionIsolationLevel(TRANSACTION_REPEATABLE_READ),
tm().assertTransactionIsolationLevel(
RegistryConfig.getHibernatePerTransactionIsolationEnabled()
? TRANSACTION_REPEATABLE_READ
: tm().getDefaultTransactionIsolationLevel());
return null;
},
TRANSACTION_REPEATABLE_READ); TRANSACTION_REPEATABLE_READ);
} }
@Test @Test
void transact_nestedTransactions_perTransactionIsolationLevelEnabled() { void transact_nestedTransactions_disabled() {
if (!RegistryConfig.getHibernatePerTransactionIsolationEnabled()) { try (MockedStatic<RegistryConfig> config = mockStatic(RegistryConfig.class)) {
return; config.when(RegistryConfig::getHibernateAllowNestedTransactions).thenReturn(false);
} // transact() not allowed in nested transactions.
// Nested transactions allowed (both at the default isolation level). IllegalStateException thrown =
tm().transact( assertThrows(
() -> { IllegalStateException.class,
tm().assertTransactionIsolationLevel(tm().getDefaultTransactionIsolationLevel()); () ->
tm().transact( tm().transact(
() -> { () -> {
tm().assertTransactionIsolationLevel( assertTransactionIsolationLevel(
tm().getDefaultTransactionIsolationLevel()); tm().getDefaultTransactionIsolationLevel());
}); tm().transact(() -> null);
}); }));
// Nested transactions allowed (enclosed transaction does not have an override, using the assertThat(thrown).hasMessageThat().contains("Nested transaction detected");
// enclosing transaction's level). // reTransact() allowed in nested transactions.
tm().transact( tm().transact(
() -> { () -> {
tm().assertTransactionIsolationLevel(TRANSACTION_READ_UNCOMMITTED); assertTransactionIsolationLevel(tm().getDefaultTransactionIsolationLevel());
tm().transact( tm().reTransact(
() -> { () ->
tm().assertTransactionIsolationLevel(TRANSACTION_READ_UNCOMMITTED); assertTransactionIsolationLevel(
}); tm().getDefaultTransactionIsolationLevel()));
}, });
TRANSACTION_READ_UNCOMMITTED); // reTransact() respects enclosing transaction's isolation level.
// Nested transactions allowed (Both have the same override). tm().transact(
tm().transact( () -> {
() -> { assertTransactionIsolationLevel(TRANSACTION_READ_UNCOMMITTED);
tm().assertTransactionIsolationLevel(TRANSACTION_REPEATABLE_READ); tm().reTransact(
tm().transact( () -> assertTransactionIsolationLevel(TRANSACTION_READ_UNCOMMITTED));
() -> { },
tm().assertTransactionIsolationLevel(TRANSACTION_REPEATABLE_READ); TRANSACTION_READ_UNCOMMITTED);
}, }
TRANSACTION_REPEATABLE_READ);
},
TRANSACTION_REPEATABLE_READ);
// Nested transactions disallowed (enclosed transaction has an override that conflicts from the
// default).
IllegalStateException e =
assertThrows(
IllegalStateException.class,
() ->
tm().transact(
() -> {
tm().transact(() -> {}, TRANSACTION_READ_COMMITTED);
}));
assertThat(e).hasMessageThat().contains("conflict detected");
// Nested transactions disallowed (conflicting overrides).
e =
assertThrows(
IllegalStateException.class,
() ->
tm().transact(
() -> {
tm().transact(() -> {}, TRANSACTION_READ_COMMITTED);
},
TRANSACTION_REPEATABLE_READ));
assertThat(e).hasMessageThat().contains("conflict detected");
} }
@Test @Test
void transact_nestedTransactions_perTransactionIsolationLevelDisabled() { void transact_nestedTransactions_enabled() {
if (RegistryConfig.getHibernatePerTransactionIsolationEnabled()) { try (MockedStatic<RegistryConfig> config = mockStatic(RegistryConfig.class)) {
return; config.when(RegistryConfig::getHibernateAllowNestedTransactions).thenReturn(true);
// transact() allowed in nested transactions.
tm().transact(
() -> {
assertTransactionIsolationLevel(tm().getDefaultTransactionIsolationLevel());
tm().reTransact(
() ->
assertTransactionIsolationLevel(
tm().getDefaultTransactionIsolationLevel()));
});
// transact() not allowed in nested transactions if isolation level is specified.
IllegalStateException thrown =
assertThrows(
IllegalStateException.class,
() ->
tm().transact(
() -> {
assertTransactionIsolationLevel(
tm().getDefaultTransactionIsolationLevel());
tm().transact(() -> null, TRANSACTION_READ_COMMITTED);
}));
assertThat(thrown).hasMessageThat().contains("cannot be specified");
// reTransact() allowed in nested transactions.
tm().transact(
() -> {
assertTransactionIsolationLevel(tm().getDefaultTransactionIsolationLevel());
tm().reTransact(
() ->
assertTransactionIsolationLevel(
tm().getDefaultTransactionIsolationLevel()));
});
// reTransact() respects enclosing transaction's isolation level.
tm().transact(
() -> {
assertTransactionIsolationLevel(TRANSACTION_READ_UNCOMMITTED);
tm().reTransact(
() -> assertTransactionIsolationLevel(TRANSACTION_READ_UNCOMMITTED));
},
TRANSACTION_READ_UNCOMMITTED);
} }
tm().transact(
() -> {
tm().assertTransactionIsolationLevel(tm().getDefaultTransactionIsolationLevel());
tm().transact(
() -> {
tm().assertTransactionIsolationLevel(
tm().getDefaultTransactionIsolationLevel());
});
});
tm().transact(
() -> {
tm().assertTransactionIsolationLevel(tm().getDefaultTransactionIsolationLevel());
tm().transact(
() -> {
tm().assertTransactionIsolationLevel(
tm().getDefaultTransactionIsolationLevel());
});
},
TRANSACTION_READ_UNCOMMITTED);
tm().transact(
() -> {
tm().assertTransactionIsolationLevel(tm().getDefaultTransactionIsolationLevel());
tm().transact(
() -> {
tm().assertTransactionIsolationLevel(
tm().getDefaultTransactionIsolationLevel());
},
TRANSACTION_READ_UNCOMMITTED);
});
tm().transact(
() -> {
tm().assertTransactionIsolationLevel(tm().getDefaultTransactionIsolationLevel());
tm().transact(
() -> {
tm().assertTransactionIsolationLevel(
tm().getDefaultTransactionIsolationLevel());
},
TRANSACTION_READ_UNCOMMITTED);
},
TRANSACTION_READ_UNCOMMITTED);
tm().transact(
() -> {
tm().assertTransactionIsolationLevel(tm().getDefaultTransactionIsolationLevel());
tm().transact(
() -> {
tm().assertTransactionIsolationLevel(
tm().getDefaultTransactionIsolationLevel());
},
TRANSACTION_READ_COMMITTED);
},
TRANSACTION_READ_UNCOMMITTED);
} }
@Test @Test
@ -299,32 +256,55 @@ class JpaTransactionManagerImplTest {
OptimisticLockException.class, OptimisticLockException.class,
() -> spyJpaTm.transact(() -> spyJpaTm.delete(theEntityKey))); () -> spyJpaTm.transact(() -> spyJpaTm.delete(theEntityKey)));
verify(spyJpaTm, times(3)).delete(theEntityKey); verify(spyJpaTm, times(3)).delete(theEntityKey);
Supplier<Runnable> supplier = assertThrows(
() -> { OptimisticLockException.class,
Runnable work = () -> spyJpaTm.delete(theEntityKey); () -> spyJpaTm.transact(() -> spyJpaTm.delete(theEntityKey)));
work.run();
return null;
};
assertThrows(OptimisticLockException.class, () -> spyJpaTm.transact(supplier));
verify(spyJpaTm, times(6)).delete(theEntityKey); verify(spyJpaTm, times(6)).delete(theEntityKey);
} }
@Test @Test
void transactNoRetry_doesNotRetryOptimisticLockException() { void transactNoRetry_nested() {
JpaTransactionManager spyJpaTm = spy(tm()); JpaTransactionManagerImpl tm = (JpaTransactionManagerImpl) tm();
doThrow(OptimisticLockException.class).when(spyJpaTm).delete(any(VKey.class)); // Calling transactNoRetry() without an isolation level override inside a transaction is fine.
spyJpaTm.transactNoRetry(() -> spyJpaTm.insert(theEntity)); tm.transact(
assertThrows(
OptimisticLockException.class,
() -> spyJpaTm.transactNoRetry(() -> spyJpaTm.delete(theEntityKey)));
verify(spyJpaTm, times(1)).delete(theEntityKey);
Supplier<Runnable> supplier =
() -> { () -> {
Runnable work = () -> spyJpaTm.delete(theEntityKey); tm.transactNoRetry(
work.run(); () -> {
assertTransactionIsolationLevel(tm.getDefaultTransactionIsolationLevel());
return null;
},
null);
});
// Calling transactNoRetry() with an isolation level override inside a transaction is not
// allowed.
IllegalStateException thrown =
assertThrows(
IllegalStateException.class,
() -> tm.transact(() -> tm.transactNoRetry(() -> null, TRANSACTION_READ_UNCOMMITTED)));
assertThat(thrown).hasMessageThat().contains("cannot be specified");
}
@Test
void transactNoRetry_doesNotRetryOptimisticLockException() {
JpaTransactionManagerImpl spyJpaTm = spy((JpaTransactionManagerImpl) tm());
doThrow(OptimisticLockException.class).when(spyJpaTm).delete(any(VKey.class));
spyJpaTm.transactNoRetry(
() -> {
spyJpaTm.insert(theEntity);
return null; return null;
}; },
assertThrows(OptimisticLockException.class, () -> spyJpaTm.transactNoRetry(supplier)); null);
Executable transaction =
() ->
spyJpaTm.transactNoRetry(
() -> {
spyJpaTm.delete(theEntityKey);
return null;
},
null);
assertThrows(OptimisticLockException.class, transaction);
verify(spyJpaTm, times(1)).delete(theEntityKey);
assertThrows(OptimisticLockException.class, transaction);
verify(spyJpaTm, times(2)).delete(theEntityKey); verify(spyJpaTm, times(2)).delete(theEntityKey);
} }
@ -338,13 +318,8 @@ class JpaTransactionManagerImplTest {
assertThrows( assertThrows(
RuntimeException.class, () -> spyJpaTm.transact(() -> spyJpaTm.delete(theEntityKey))); RuntimeException.class, () -> spyJpaTm.transact(() -> spyJpaTm.delete(theEntityKey)));
verify(spyJpaTm, times(3)).delete(theEntityKey); verify(spyJpaTm, times(3)).delete(theEntityKey);
Supplier<Runnable> supplier = assertThrows(
() -> { RuntimeException.class, () -> spyJpaTm.transact(() -> spyJpaTm.delete(theEntityKey)));
Runnable work = () -> spyJpaTm.delete(theEntityKey);
work.run();
return null;
};
assertThrows(RuntimeException.class, () -> spyJpaTm.transact(supplier));
verify(spyJpaTm, times(6)).delete(theEntityKey); verify(spyJpaTm, times(6)).delete(theEntityKey);
} }
@ -740,20 +715,13 @@ class JpaTransactionManagerImplTest {
doThrow(OptimisticLockException.class).when(spyJpaTm).delete(any(VKey.class)); doThrow(OptimisticLockException.class).when(spyJpaTm).delete(any(VKey.class));
spyJpaTm.transact(() -> spyJpaTm.insert(theEntity)); spyJpaTm.transact(() -> spyJpaTm.insert(theEntity));
Supplier<Runnable> supplier =
() -> {
Runnable work = () -> spyJpaTm.delete(theEntityKey);
work.run();
return null;
};
assertThrows( assertThrows(
OptimisticLockException.class, OptimisticLockException.class,
() -> () ->
spyJpaTm.transact( spyJpaTm.transact(
() -> { () -> {
spyJpaTm.exists(theEntity); spyJpaTm.exists(theEntity);
spyJpaTm.transact(supplier); spyJpaTm.transact(() -> spyJpaTm.delete(theEntityKey));
})); }));
verify(spyJpaTm, times(3)).exists(theEntity); verify(spyJpaTm, times(3)).exists(theEntity);
@ -814,6 +782,16 @@ class JpaTransactionManagerImplTest {
assertCompanyCount(0); assertCompanyCount(0);
} }
private static void assertTransactionIsolationLevel(TransactionIsolationLevel expectedLevel) {
tm().assertInTransaction();
TransactionIsolationLevel currentLevel = tm().getCurrentTransactionIsolationLevel();
checkState(
currentLevel == expectedLevel,
"Current transaction isolation level (%s) is not as expected (%s)",
currentLevel,
expectedLevel);
}
private static int countTable(String tableName) { private static int countTable(String tableName) {
return tm().transact( return tm().transact(
() -> { () -> {

View file

@ -14,6 +14,9 @@
package google.registry.persistence.transaction; package google.registry.persistence.transaction;
import static com.google.common.base.Throwables.throwIfUnchecked;
import static google.registry.persistence.transaction.DatabaseException.throwIfSqlException;
import com.google.common.collect.ImmutableCollection; import com.google.common.collect.ImmutableCollection;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMap;
@ -21,7 +24,7 @@ import google.registry.model.ImmutableObject;
import google.registry.persistence.PersistenceModule.TransactionIsolationLevel; import google.registry.persistence.PersistenceModule.TransactionIsolationLevel;
import google.registry.persistence.VKey; import google.registry.persistence.VKey;
import java.util.Optional; import java.util.Optional;
import java.util.function.Supplier; import java.util.concurrent.Callable;
import java.util.stream.Stream; import java.util.stream.Stream;
import javax.persistence.EntityManager; import javax.persistence.EntityManager;
import javax.persistence.Query; import javax.persistence.Query;
@ -60,11 +63,6 @@ public class ReplicaSimulatingJpaTransactionManager implements JpaTransactionMan
return delegate.getCurrentTransactionIsolationLevel(); return delegate.getCurrentTransactionIsolationLevel();
} }
@Override
public void assertTransactionIsolationLevel(TransactionIsolationLevel expectedLevel) {
delegate.assertTransactionIsolationLevel(expectedLevel);
}
@Override @Override
public EntityManager getStandaloneEntityManager() { public EntityManager getStandaloneEntityManager() {
return delegate.getStandaloneEntityManager(); return delegate.getStandaloneEntityManager();
@ -101,9 +99,15 @@ public class ReplicaSimulatingJpaTransactionManager implements JpaTransactionMan
} }
@Override @Override
public <T> T transact(Supplier<T> work, TransactionIsolationLevel isolationLevel) { public <T> T transact(Callable<T> work, TransactionIsolationLevel isolationLevel) {
if (delegate.inTransaction()) { if (inTransaction()) {
return work.get(); try {
return work.call();
} catch (Exception e) {
throwIfSqlException(e);
throwIfUnchecked(e);
throw new RuntimeException(e);
}
} }
return delegate.transact( return delegate.transact(
() -> { () -> {
@ -111,33 +115,23 @@ public class ReplicaSimulatingJpaTransactionManager implements JpaTransactionMan
.getEntityManager() .getEntityManager()
.createNativeQuery("SET TRANSACTION READ ONLY") .createNativeQuery("SET TRANSACTION READ ONLY")
.executeUpdate(); .executeUpdate();
return work.get(); return work.call();
}, },
isolationLevel); isolationLevel);
} }
@Override @Override
public <T> T reTransact(Supplier<T> work) { public <T> T reTransact(Callable<T> work) {
return transact(work); return transact(work);
} }
@Override @Override
public <T> T transact(Supplier<T> work) { public <T> T transact(Callable<T> work) {
return transact(work, null); return transact(work, null);
} }
@Override @Override
public <T> T transactNoRetry(Supplier<T> work, TransactionIsolationLevel isolationLevel) { public void transact(ThrowingRunnable work, TransactionIsolationLevel isolationLevel) {
return transact(work, isolationLevel);
}
@Override
public <T> T transactNoRetry(Supplier<T> work) {
return transactNoRetry(work, null);
}
@Override
public void transact(Runnable work, TransactionIsolationLevel isolationLevel) {
transact( transact(
() -> { () -> {
work.run(); work.run();
@ -147,25 +141,15 @@ public class ReplicaSimulatingJpaTransactionManager implements JpaTransactionMan
} }
@Override @Override
public void reTransact(Runnable work) { public void reTransact(ThrowingRunnable work) {
transact(work); transact(work);
} }
@Override @Override
public void transact(Runnable work) { public void transact(ThrowingRunnable work) {
transact(work, null); transact(work, null);
} }
@Override
public void transactNoRetry(Runnable work, TransactionIsolationLevel isolationLevel) {
transact(work, isolationLevel);
}
@Override
public void transactNoRetry(Runnable work) {
transactNoRetry(work, null);
}
@Override @Override
public DateTime getTransactionTime() { public DateTime getTransactionTime() {
return delegate.getTransactionTime(); return delegate.getTransactionTime();