This is an automated email from the ASF dual-hosted git repository.

ashapkin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ignite-3.git


The following commit(s) were added to refs/heads/main by this push:
     new 40eca02a9b4 IGNITE-27411 Added tests to prove that lost TX problem is 
gone (#7820)
40eca02a9b4 is described below

commit 40eca02a9b4c142bc8164333ad4fb58f59f1a647
Author: Anton Laletin <[email protected]>
AuthorDate: Wed Apr 8 17:11:10 2026 +0400

    IGNITE-27411 Added tests to prove that lost TX problem is gone (#7820)
---
 .../java/org/apache/ignite/lang/ErrorGroups.java   |   3 +
 modules/platforms/cpp/ignite/common/error_codes.h  |   1 +
 modules/platforms/cpp/ignite/odbc/common_types.cpp |   1 +
 .../platforms/dotnet/Apache.Ignite/ErrorCodes.g.cs |   3 +
 .../ignite/internal/sql/api/ItSqlApiBaseTest.java  | 192 +++++++++++++++++++++
 .../engine/QueryTransactionWrapperSelfTest.java    |  47 +++++
 .../distributed/storage/InternalTableImplTest.java |  50 +++++-
 .../ignite/internal/tx/impl/TxRecoveryEngine.java  |   4 +-
 .../apache/ignite/internal/tx/TxStateMetaTest.java |  11 ++
 9 files changed, 306 insertions(+), 6 deletions(-)

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 class ErrorGroups {
 
         /** 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 @@ namespace Apache.Ignite
 
             /// <summary> TxAlreadyFinishedWithException error. </summary>
             public const int TxAlreadyFinishedWithException = (GroupCode << 
16) | (19 & 0xFFFF);
+
+            /// <summary> TxAbortedDueToRecovery error. </summary>
+            public const int TxAbortedDueToRecovery = (GroupCode << 16) | (20 
& 0xFFFF);
         }
 
         /// <summary> Replicator errors. </summary>
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.sql.engine.util.SqlTestUtils.expectQuer
 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.assertInstanceOf;
 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.Collectors;
 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.ColumnMetadataImpl.ColumnOriginImpl;
 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.SqlRow;
 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 abstract class ItSqlApiBaseTest extends 
BaseSqlIntegrationTest {
                 "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<IgniteImpl> clusterNodes = CLUSTER.runningNodes()
+                .map(node -> unwrapIgniteImpl(node))
+                .collect(toList());
+
+        CompletableFuture<Void> failingRequestStarted = new 
CompletableFuture<>();
+        CompletableFuture<Void> finishRequestBlocked = new 
CompletableFuture<>();
+        CompletableFuture<Void> 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<IgniteException> 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 @@ public abstract class ItSqlApiBaseTest extends 
BaseSqlIntegrationTest {
 
     protected abstract ResultSet<SqlRow> 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.HashSet;
 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.QueryTransactionWrapperImpl;
 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 class QueryTransactionWrapperSelfTest extends 
BaseIgniteAbstractTest {
         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.PendingTxPartitionEnlistment;
 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 @@ public class InternalTableImplTest extends 
BaseIgniteAbstractTest {
 
         @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 @@ public class InternalTableImplTest extends 
BaseIgniteAbstractTest {
         }
     }
 
+    @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<BinaryRow> publisher = internalTable.scan(VALID_PARTITION, 
tx, VALID_INDEX_ID, IndexScanCriteria.unbounded());
+
+        CompletableFuture<Void> 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.TxState.isFinalState;
 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 class TxRecoveryEngine {
         // 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 static 
org.junit.jupiter.params.provider.Arguments.argumentSet;
 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 class TxStateMetaTest {
         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,

Reply via email to