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,