diff --git a/modules/api/src/main/java/org/apache/ignite/lang/ErrorGroups.java b/modules/api/src/main/java/org/apache/ignite/lang/ErrorGroups.java index 6b85252d5f5..c576275646f 100755 --- a/modules/api/src/main/java/org/apache/ignite/lang/ErrorGroups.java +++ b/modules/api/src/main/java/org/apache/ignite/lang/ErrorGroups.java @@ -479,6 +479,9 @@ public static class Transactions { /** Operation failed because the transaction is already finished due to an error. */ public static final int TX_ALREADY_FINISHED_WITH_EXCEPTION_ERR = TX_ERR_GROUP.registerErrorCode((short) 19); + + /** Operation failed because the transaction is aborted due to a recovery. */ + public static final int TX_ABORTED_DUE_TO_RECOVERY_ERR = TX_ERR_GROUP.registerErrorCode((short) 20); } /** Replicator error group. */ diff --git a/modules/platforms/cpp/ignite/common/error_codes.h b/modules/platforms/cpp/ignite/common/error_codes.h index 8a09d2fe3c6..a852a9c6b83 100644 --- a/modules/platforms/cpp/ignite/common/error_codes.h +++ b/modules/platforms/cpp/ignite/common/error_codes.h @@ -141,6 +141,7 @@ enum class code : underlying_t { TX_DELAYED_ACK = 0x70011, TX_KILLED = 0x70012, TX_ALREADY_FINISHED_WITH_EXCEPTION = 0x70013, + TX_ABORTED_DUE_TO_RECOVERY = 0x70014, // Replicator group. Group code: 8 REPLICA_COMMON = 0x80001, diff --git a/modules/platforms/cpp/ignite/odbc/common_types.cpp b/modules/platforms/cpp/ignite/odbc/common_types.cpp index 98c7acc4588..d2ca530211a 100644 --- a/modules/platforms/cpp/ignite/odbc/common_types.cpp +++ b/modules/platforms/cpp/ignite/odbc/common_types.cpp @@ -212,6 +212,7 @@ sql_state error_code_to_sql_state(error::code code) { case error::code::TX_DELAYED_ACK: case error::code::TX_KILLED: case error::code::TX_ALREADY_FINISHED_WITH_EXCEPTION: + case error::code::TX_ABORTED_DUE_TO_RECOVERY: return sql_state::S25000_INVALID_TRANSACTION_STATE; // Replicator group. Group code: 8 diff --git a/modules/platforms/dotnet/Apache.Ignite/ErrorCodes.g.cs b/modules/platforms/dotnet/Apache.Ignite/ErrorCodes.g.cs index c979af85214..36db50e89bf 100644 --- a/modules/platforms/dotnet/Apache.Ignite/ErrorCodes.g.cs +++ b/modules/platforms/dotnet/Apache.Ignite/ErrorCodes.g.cs @@ -376,6 +376,9 @@ public static class Transactions /// TxAlreadyFinishedWithException error. public const int TxAlreadyFinishedWithException = (GroupCode << 16) | (19 & 0xFFFF); + + /// TxAbortedDueToRecovery error. + public const int TxAbortedDueToRecovery = (GroupCode << 16) | (20 & 0xFFFF); } /// Replicator errors. diff --git a/modules/sql-engine/src/integrationTest/java/org/apache/ignite/internal/sql/api/ItSqlApiBaseTest.java b/modules/sql-engine/src/integrationTest/java/org/apache/ignite/internal/sql/api/ItSqlApiBaseTest.java index 07cbfd22326..ae548e3f9ce 100644 --- a/modules/sql-engine/src/integrationTest/java/org/apache/ignite/internal/sql/api/ItSqlApiBaseTest.java +++ b/modules/sql-engine/src/integrationTest/java/org/apache/ignite/internal/sql/api/ItSqlApiBaseTest.java @@ -28,6 +28,8 @@ import static org.apache.ignite.internal.testframework.IgniteTestUtils.assertThrowsWithCause; import static org.apache.ignite.internal.testframework.IgniteTestUtils.assertThrowsWithCode; import static org.apache.ignite.internal.testframework.IgniteTestUtils.await; +import static org.apache.ignite.internal.util.ExceptionUtils.hasCause; +import static org.apache.ignite.lang.ErrorGroups.Replicator.REPLICA_MISS_ERR; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.hasSize; @@ -37,6 +39,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.time.Instant; import java.time.ZoneId; @@ -51,9 +54,11 @@ import java.util.stream.IntStream; import org.apache.calcite.rel.core.JoinRelType; import org.apache.ignite.Ignite; +import org.apache.ignite.internal.app.IgniteImpl; import org.apache.ignite.internal.catalog.commands.CatalogUtils; import org.apache.ignite.internal.catalog.events.CatalogEvent; import org.apache.ignite.internal.catalog.events.CreateTableEventParameters; +import org.apache.ignite.internal.client.tx.ClientLazyTransaction; import org.apache.ignite.internal.event.EventListener; import org.apache.ignite.internal.sql.BaseSqlIntegrationTest; import org.apache.ignite.internal.sql.ColumnMetadataImpl; @@ -61,7 +66,11 @@ import org.apache.ignite.internal.sql.engine.QueryCancelledException; import org.apache.ignite.internal.sql.engine.exec.fsm.QueryInfo; import org.apache.ignite.internal.testframework.IgniteTestUtils; +import org.apache.ignite.internal.tx.InternalTransaction; import org.apache.ignite.internal.tx.TxManager; +import org.apache.ignite.internal.tx.TxState; +import org.apache.ignite.internal.tx.TxStateMeta; +import org.apache.ignite.internal.tx.message.TxMessageGroup; import org.apache.ignite.internal.util.CompletableFutures; import org.apache.ignite.lang.CancelHandle; import org.apache.ignite.lang.CancellationToken; @@ -83,7 +92,9 @@ import org.apache.ignite.sql.Statement; import org.apache.ignite.sql.Statement.StatementBuilder; import org.apache.ignite.tx.Transaction; +import org.apache.ignite.tx.TransactionException; import org.apache.ignite.tx.TransactionOptions; +import org.awaitility.Awaitility; import org.hamcrest.Matcher; import org.jetbrains.annotations.Nullable; import org.junit.jupiter.api.AfterEach; @@ -740,6 +751,175 @@ public void runtimeErrorInQueryCausesTransactionToFail(String query) { "Transaction is already finished due to an error"); } + @Test + public void runtimeErrorReturnsSameTransactionErrorBeforeAndAfterRollbackCompletion() throws Exception { + sql("CREATE TABLE tst(id INTEGER PRIMARY KEY, val INTEGER)"); + + IgniteSql sql = igniteSql(); + + Transaction tx = igniteTx().begin(); + + // Enlist enough operations to make rollback non-trivial. + for (int i = 0; i < 100; i++) { + execute(tx, sql, "INSERT INTO tst VALUES (?, ?)", i, i); + } + + UUID txId = txId(tx); + + assertThrowsSqlException( + Sql.RUNTIME_ERR, + "Division by zero", + () -> execute(tx, sql, "SELECT val / 0 FROM tst WHERE id = ?", 0) + ); + + IgniteException[] immediateExceptions = new IgniteException[5]; + for (int i = 0; i < immediateExceptions.length; i++) { + immediateExceptions[i] = (IgniteException) assertThrowsWithCause( + () -> executeForRead(sql, tx, "SELECT * FROM tst WHERE id = ?", 1), + IgniteException.class + ); + } + + if (tx instanceof InternalTransaction) { + assertNotNull(txId, "Expected transaction id for test transaction implementation"); + + Awaitility.await() + .atMost(5, TimeUnit.SECONDS) + .until(() -> { + TxStateMeta meta = txManager().stateMeta(txId); + + return meta != null && TxState.isFinalState(meta.txState()); + }); + } + + IgniteException abortedStateException = (IgniteException) assertThrowsWithCause( + () -> executeForRead(sql, tx, "SELECT * FROM tst WHERE id = ?", 1), + IgniteException.class + ); + + assertEquals(Transactions.TX_ALREADY_FINISHED_WITH_EXCEPTION_ERR, abortedStateException.code()); + assertTrue(abortedStateException.getMessage().contains("Transaction is already finished due to an error")); + + for (IgniteException immediateException : immediateExceptions) { + assertEquals(abortedStateException.code(), immediateException.code()); + assertTrue(immediateException.getMessage().contains("Transaction is already finished due to an error")); + } + } + + @Test + public void secondRequestDuringRollbackReturnsFinishedWithExceptionAndPreservesOriginalCause() { + sql("CREATE TABLE tst(id INTEGER PRIMARY KEY, val INTEGER)"); + sql("INSERT INTO tst VALUES (0, 1)"); + + IgniteSql sql = igniteSql(); + + Transaction tx = igniteTx().begin(); + + List clusterNodes = CLUSTER.runningNodes() + .map(node -> unwrapIgniteImpl(node)) + .collect(toList()); + + CompletableFuture failingRequestStarted = new CompletableFuture<>(); + CompletableFuture finishRequestBlocked = new CompletableFuture<>(); + CompletableFuture releaseFinishRequest = new CompletableFuture<>(); + + for (IgniteImpl clusterNode : clusterNodes) { + // Install predicates in cluster + clusterNode.dropMessages((recipientConsistentId, msg) -> { + if (!failingRequestStarted.isDone()) { + return false; + } + + if (msg.groupType() != TxMessageGroup.GROUP_TYPE + || msg.messageType() != TxMessageGroup.TX_FINISH_REQUEST) { + return false; + } + + finishRequestBlocked.complete(null); + + return !releaseFinishRequest.isDone(); + }); + } + + try { + CompletableFuture failingRequestFut = IgniteTestUtils.runAsync(() -> { + failingRequestStarted.complete(null); + + IgniteException ex = assertInstanceOf( + IgniteException.class, + assertThrowsWithCause( + () -> execute(tx, sql, "SELECT val / 0 FROM tst WHERE id = ?", 0), + IgniteException.class + ) + ); + + assertTrue(hasCause(ex, "Division by zero", Throwable.class)); + assertTrue( + ex.code() == Sql.RUNTIME_ERR || ex.code() == Transactions.TX_ALREADY_FINISHED_WITH_EXCEPTION_ERR, + "Unexpected code for a request that triggers rollback [code=" + ex.code() + ']' + ); + + return ex; + }); + + Awaitility.await() + .atMost(5, TimeUnit.SECONDS) + .until(finishRequestBlocked::isDone); + + IgniteException parallelRequestException = assertInstanceOf( + IgniteException.class, + assertThrowsWithCause( + () -> executeForRead(sql, tx, "SELECT * FROM tst WHERE id = ?", 0), + IgniteException.class + ) + ); + + assertEquals(Transactions.TX_ALREADY_FINISHED_WITH_EXCEPTION_ERR, parallelRequestException.code()); + assertTrue(parallelRequestException.getMessage().contains("Transaction is already finished due to an error")); + assertTrue( + hasCause(parallelRequestException, "Division by zero", Throwable.class), + "Expected original rollback cause in user-visible exception chain" + ); + + releaseFinishRequest.complete(null); + + IgniteException firstRequestException = await(failingRequestFut); + + assertTrue(hasCause(firstRequestException, "Division by zero", Throwable.class)); + } finally { + clusterNodes.forEach(IgniteImpl::stopDroppingMessages); + } + } + + @Test + public void rollbackWithExceptionCauseIsPropagatedToSubsequentSqlRequest() { + sql("CREATE TABLE tst(id INTEGER PRIMARY KEY, val INTEGER)"); + sql("INSERT INTO tst VALUES (?, ?)", 1, 1); + + Transaction tx = igniteTx().begin(); + + assumeTrue(tx instanceof InternalTransaction, "InternalTransaction is required"); + + InternalTransaction internalTx = (InternalTransaction) tx; + String rollbackCauseMessage = "rollback-cause-primary-replica-changed"; + TransactionException rollbackCause = new TransactionException(REPLICA_MISS_ERR, rollbackCauseMessage); + + await(internalTx.rollbackWithExceptionAsync(rollbackCause)); + + IgniteException ex = assertInstanceOf( + IgniteException.class, + assertThrowsWithCause( + () -> executeForRead(igniteSql(), tx, "SELECT * FROM tst WHERE id = ?", 1), + IgniteException.class + ) + ); + + assertEquals(Transactions.TX_ALREADY_FINISHED_WITH_EXCEPTION_ERR, ex.code()); + assertTrue(ex.getMessage().contains("Transaction is already finished due to an error")); + assertTrue(hasCause(ex, TransactionException.class)); + assertTrue(hasCause(ex, rollbackCauseMessage, Throwable.class), "Expected rollback cause message in user-visible exception chain"); + } + @Test public void testLockIsNotReleasedAfterTxRollback() { IgniteSql sql = igniteSql(); @@ -1413,6 +1593,18 @@ protected ResultSet executeForRead(IgniteSql sql, @Nullable Transaction protected abstract ResultSet executeForRead(IgniteSql sql, @Nullable Transaction tx, Statement statement, Object... args); + private static @Nullable UUID txId(Transaction tx) { + if (tx instanceof InternalTransaction) { + return ((InternalTransaction) tx).id(); + } + + if (tx instanceof ClientLazyTransaction) { + return ((ClientLazyTransaction) tx).startedTx().txId(); + } + + return null; + } + protected void checkSqlError( int code, String msg, diff --git a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/QueryTransactionWrapperSelfTest.java b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/QueryTransactionWrapperSelfTest.java index 9b99be7a335..e76f133727c 100644 --- a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/QueryTransactionWrapperSelfTest.java +++ b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/QueryTransactionWrapperSelfTest.java @@ -18,6 +18,7 @@ package org.apache.ignite.internal.sql.engine; import static org.apache.ignite.internal.sql.engine.util.SqlTestUtils.assertThrowsSqlException; +import static org.apache.ignite.lang.ErrorGroups.Transactions.TX_ALREADY_FINISHED_WITH_EXCEPTION_ERR; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -34,6 +35,7 @@ import java.util.Set; import java.util.UUID; import org.apache.ignite.internal.hlc.HybridTimestampTracker; +import org.apache.ignite.internal.lang.IgniteInternalException; import org.apache.ignite.internal.sql.engine.framework.NoOpTransaction; import org.apache.ignite.internal.sql.engine.sql.IgniteSqlCommitTransaction; import org.apache.ignite.internal.sql.engine.sql.IgniteSqlStartTransaction; @@ -45,8 +47,11 @@ import org.apache.ignite.internal.sql.engine.tx.ScriptTransactionContext; import org.apache.ignite.internal.testframework.BaseIgniteAbstractTest; import org.apache.ignite.internal.tx.TxManager; +import org.apache.ignite.internal.tx.TxStateMeta; +import org.apache.ignite.internal.tx.TxStateMetaFinishing; import org.apache.ignite.internal.tx.impl.TransactionInflights; import org.apache.ignite.lang.ErrorGroups.Sql; +import org.apache.ignite.tx.TransactionException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -231,6 +236,48 @@ public void testScriptTransactionWrapperTxInflightsInteraction() { assertEquals(1, inflights.size()); } + @Test + public void testInflightTrackerUsesFinishedWithErrorClassificationForFinishingTx() { + NoOpTransaction tx = NoOpTransaction.readWrite("test-rw", false); + IgniteInternalException failure = new IgniteInternalException(321, "boom"); + TxStateMeta finishingMeta = new TxStateMetaFinishing(null, null, false, null, failure, failure.code()); + + when(transactionInflights.track(tx.id())).thenReturn(false); + when(txManager.stateMeta(tx.id())).thenReturn(finishingMeta); + + TransactionException ex = assertThrowsExactly( + TransactionException.class, + () -> new InflightTransactionalOperationTracker(transactionInflights, txManager).registerOperationStart(tx) + ); + + assertEquals(TX_ALREADY_FINISHED_WITH_EXCEPTION_ERR, ex.code()); + assertEquals(failure, ex.getCause()); + } + + @Test + public void testExplicitSqlTransactionUsesFinishedWithErrorClassificationForFinishingTx() { + NoOpTransaction tx = NoOpTransaction.readWrite("test-rw", false); + IgniteInternalException failure = new IgniteInternalException(321, "boom"); + TxStateMeta finishingMeta = new TxStateMetaFinishing(null, null, false, null, failure, failure.code()); + + when(txManager.stateMeta(tx.id())).thenReturn(finishingMeta); + + QueryTransactionContext txCtx = new QueryTransactionContextImpl( + txManager, + observableTimeTracker, + tx, + new InflightTransactionalOperationTracker(transactionInflights, txManager) + ); + + TransactionException ex = assertThrowsExactly( + TransactionException.class, + () -> txCtx.getOrStartSqlManaged(false, false) + ); + + assertEquals(TX_ALREADY_FINISHED_WITH_EXCEPTION_ERR, ex.code()); + assertEquals(failure, ex.getCause()); + } + private void prepareTransactionsMocks() { when(txManager.beginExplicit(any(), anyBoolean(), any())).thenAnswer( inv -> { diff --git a/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/storage/InternalTableImplTest.java b/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/storage/InternalTableImplTest.java index e43bb7b60a6..c7f0918999c 100644 --- a/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/storage/InternalTableImplTest.java +++ b/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/storage/InternalTableImplTest.java @@ -120,6 +120,7 @@ import org.apache.ignite.internal.tx.TxManager; import org.apache.ignite.internal.tx.TxState; import org.apache.ignite.internal.tx.TxStateMeta; +import org.apache.ignite.internal.tx.TxStateMetaFinishing; import org.apache.ignite.internal.tx.impl.ReadWriteTransactionImpl; import org.apache.ignite.internal.tx.impl.TransactionInflights; import org.apache.ignite.internal.tx.impl.VolatileTxStateMetaStorage; @@ -587,10 +588,10 @@ void testUnboundedRange() { @ParameterizedTest @CsvSource({ - "0, 0", // GREATER | LESS (both exclusive) - "1, 0", // GREATER_OR_EQUAL | LESS (lower inclusive, upper exclusive) - "0, 2", // GREATER | LESS_OR_EQUAL (lower exclusive, upper inclusive) - "1, 2" // GREATER_OR_EQUAL | LESS_OR_EQUAL (both inclusive) + "0, 0", // GREATER | LESS (both exclusive) + "1, 0", // GREATER_OR_EQUAL | LESS (lower inclusive, upper exclusive) + "0, 2", // GREATER | LESS_OR_EQUAL (lower exclusive, upper inclusive) + "1, 2" // GREATER_OR_EQUAL | LESS_OR_EQUAL (both inclusive) }) void testRangeWithDifferentFlags(int lowerFlag, int upperFlag) { InternalTableImpl internalTable = newInternalTable(TABLE_ID, 1); @@ -950,6 +951,47 @@ void testScanAfterExceptionalAbortThrowsFinishedWithErrCode() { } } + @Test + void testScanWhileFinishingAfterErrorThrowsFinishedWithErrCode() { + InternalTableImpl internalTable = newInternalTable(TABLE_ID, 1); + + InternalTransaction tx = new ReadWriteTransactionImpl( + txManager, + mock(HybridTimestampTracker.class), + TestTransactionIds.newTransactionId(), + randomUUID(), + false, + 1, + null + ); + + UUID txId = tx.id(); + IllegalStateException failure = new IllegalStateException("boom"); + + when(txManager.stateMeta(txId)).thenReturn(new TxStateMetaFinishing(null, null, false, null, failure, null)); + tx.rollbackWithExceptionAsync(new TransactionException(TX_ALREADY_FINISHED_WITH_EXCEPTION_ERR, + "Transaction is already finished")).join(); + + Publisher publisher = internalTable.scan(VALID_PARTITION, tx, VALID_INDEX_ID, IndexScanCriteria.unbounded()); + + CompletableFuture completed = new CompletableFuture<>(); + + publisher.subscribe(new BlackholeSubscriber(completed)); + + try { + completed.get(10, TimeUnit.SECONDS); + fail("Expected TransactionException but scan completed successfully"); + } catch (Exception e) { + Throwable unwrapped = unwrapCause(e); + assertThat("Error should be TransactionException", unwrapped, is(instanceOf(TransactionException.class))); + + TransactionException txEx = (TransactionException) unwrapped; + assertThat("Error code should be TX_ALREADY_FINISHED_WITH_EXCEPTION_ERR", + txEx.code(), is(TX_ALREADY_FINISHED_WITH_EXCEPTION_ERR)); + assertThat("Cause should be the recorded exception", txEx.getCause(), is(failure)); + } + } + /** * Tests for label propagation from OperationContext to TxStateMeta. */ diff --git a/modules/transactions/src/main/java/org/apache/ignite/internal/tx/impl/TxRecoveryEngine.java b/modules/transactions/src/main/java/org/apache/ignite/internal/tx/impl/TxRecoveryEngine.java index 787a933b1ac..f21bafb12a0 100644 --- a/modules/transactions/src/main/java/org/apache/ignite/internal/tx/impl/TxRecoveryEngine.java +++ b/modules/transactions/src/main/java/org/apache/ignite/internal/tx/impl/TxRecoveryEngine.java @@ -28,6 +28,7 @@ import static org.apache.ignite.internal.tx.TxStateMetaFinishing.castToFinishing; import static org.apache.ignite.internal.util.CompletableFutures.nullCompletedFuture; import static org.apache.ignite.internal.util.ExceptionUtils.sneakyThrow; +import static org.apache.ignite.lang.ErrorGroups.Transactions.TX_ABORTED_DUE_TO_RECOVERY_ERR; import static org.apache.ignite.lang.ErrorGroups.Transactions.TX_ROLLBACK_ERR; import java.util.Map; @@ -89,13 +90,12 @@ public CompletableFuture triggerTxRecovery( // If the transaction state is pending, then the transaction should be rolled back, // meaning that the state is changed to aborted and a corresponding cleanup request // is sent in a common durable manner to a partition that has initiated recovery. - // TODO https://issues.apache.org/jira/browse/IGNITE-27386 the reason of rollback needs to be explained. return txManager.finish( HybridTimestampTracker.emptyTracker(), // Tx recovery is executed on the commit partition. commitPartitionId, false, - new TransactionInternalException(TX_ROLLBACK_ERR, format("Transaction has been aborted" + new TransactionInternalException(TX_ABORTED_DUE_TO_RECOVERY_ERR, format("Transaction has been aborted" + " due to transaction recovery {}.", formatTxInfo(txId, txManager))), true, false, diff --git a/modules/transactions/src/test/java/org/apache/ignite/internal/tx/TxStateMetaTest.java b/modules/transactions/src/test/java/org/apache/ignite/internal/tx/TxStateMetaTest.java index 370c5b2e35e..1cd13f6e3c2 100644 --- a/modules/transactions/src/test/java/org/apache/ignite/internal/tx/TxStateMetaTest.java +++ b/modules/transactions/src/test/java/org/apache/ignite/internal/tx/TxStateMetaTest.java @@ -28,6 +28,7 @@ import java.util.UUID; import java.util.function.Function; import java.util.stream.Stream; +import org.apache.ignite.internal.lang.IgniteInternalException; import org.apache.ignite.internal.replicator.ZonePartitionId; import org.apache.ignite.internal.replicator.message.ReplicaMessagesFactory; import org.apache.ignite.internal.tx.message.TxMessagesFactory; @@ -147,6 +148,16 @@ public void testAbandonedMetaMessageKeepsLastExceptionErrorCode() { assertEquals(321, message.asTxStateMetaAbandoned().lastExceptionErrorCode()); } + @Test + public void testFinishingMetaDerivesLastExceptionErrorCodeFromFinishReason() { + IgniteInternalException finishReason = new IgniteInternalException(321, "boom"); + + TxStateMetaFinishing meta = PENDING_META.finishing(finishReason); + + assertEquals(finishReason, meta.lastException()); + assertEquals(321, meta.lastExceptionErrorCode()); + } + private static ArgumentSet args( String name, @Nullable TxStateMeta meta,