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

sdanilov 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 86ee7fdee5 IGNITE-19454 Validate backwards schema compatibility (#2075)
86ee7fdee5 is described below

commit 86ee7fdee5779668708daa3e4e47cdfd61c007a3
Author: Roman Puchkovskiy <[email protected]>
AuthorDate: Fri May 19 11:30:29 2023 +0400

    IGNITE-19454 Validate backwards schema compatibility (#2075)
---
 .../java/org/apache/ignite/lang/ErrorGroups.java   |   3 +
 .../asserts/CompletableFutureAssert.java           |  10 +-
 ...tionResult.java => CompatValidationResult.java} |  36 ++-
 ...tType.java => IncompatibleSchemaException.java} |  50 +----
 .../replicator/PartitionReplicaListener.java       | 159 +++++++++----
 .../replicator/SchemaCompatValidator.java          |  79 +++++--
 .../distributed/replicator/action/RequestType.java |  38 +++-
 .../distributed/schema/NonHistoricSchemas.java     |  12 +
 .../internal/table/distributed/schema/Schemas.java |  21 ++
 .../distributed/storage/InternalTableImpl.java     |   4 +-
 .../replication/PartitionReplicaListenerTest.java  | 247 ++++++++++++++++++---
 .../table/impl/DummyInternalTableImpl.java         |  15 +-
 .../ignite/internal/table/impl/DummySchemas.java   |  12 +
 13 files changed, 518 insertions(+), 168 deletions(-)

diff --git a/modules/core/src/main/java/org/apache/ignite/lang/ErrorGroups.java 
b/modules/core/src/main/java/org/apache/ignite/lang/ErrorGroups.java
index 1f5cbaa0f9..ccefdc3cd2 100755
--- a/modules/core/src/main/java/org/apache/ignite/lang/ErrorGroups.java
+++ b/modules/core/src/main/java/org/apache/ignite/lang/ErrorGroups.java
@@ -276,6 +276,9 @@ public class ErrorGroups {
 
         /** Error occurred when trying to create a read-only transaction with 
a timestamp older than the data available in the tables. */
         public static final int TX_READ_ONLY_TOO_OLD_ERR = 
TX_ERR_GROUP.registerErrorCode(11);
+
+        /** Failure due to an incompatible schema change. */
+        public static final int TX_INCOMPATIBLE_SCHEMA_ERR = 
TX_ERR_GROUP.registerErrorCode(12);
     }
 
     /** Replicator error group. */
diff --git 
a/modules/core/src/testFixtures/java/org/apache/ignite/internal/testframework/asserts/CompletableFutureAssert.java
 
b/modules/core/src/testFixtures/java/org/apache/ignite/internal/testframework/asserts/CompletableFutureAssert.java
index 10a61937d0..cac1d9fa20 100644
--- 
a/modules/core/src/testFixtures/java/org/apache/ignite/internal/testframework/asserts/CompletableFutureAssert.java
+++ 
b/modules/core/src/testFixtures/java/org/apache/ignite/internal/testframework/asserts/CompletableFutureAssert.java
@@ -45,11 +45,10 @@ public class CompletableFutureAssert {
             CompletableFuture<?> future,
             Class<X> expectedExceptionClass
     ) {
-        try {
-            Object normalResult = future.get(1, TimeUnit.SECONDS);
+        Object normalResult;
 
-            return fail("Expected the future to be completed with an exception 
of class " + expectedExceptionClass
-                    + ", but it completed normally with result " + 
normalResult);
+        try {
+            normalResult = future.get(1, TimeUnit.SECONDS);
         } catch (TimeoutException e) {
             return fail("Expected the future to be completed with an exception 
of class in 1 second, but it did not complete in time");
         } catch (Throwable e) {
@@ -65,6 +64,9 @@ public class CompletableFutureAssert {
                 );
             }
         }
+
+        return fail("Expected the future to be completed with an exception of 
class " + expectedExceptionClass
+                + ", but it completed normally with result " + normalResult);
     }
 
     private static Throwable unwrapThrowable(Throwable e) {
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/ForwardValidationResult.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/CompatValidationResult.java
similarity index 71%
rename from 
modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/ForwardValidationResult.java
rename to 
modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/CompatValidationResult.java
index 560615dce6..def1742451 100644
--- 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/ForwardValidationResult.java
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/CompatValidationResult.java
@@ -22,12 +22,12 @@ import java.util.UUID;
 import org.jetbrains.annotations.Nullable;
 
 /**
- * Result of forward schema compatibility validation.
+ * Result of a schema compatibility validation.
  */
-public class ForwardValidationResult {
-    private static final ForwardValidationResult SUCCESS = new 
ForwardValidationResult(true, null, -1, -1);
+public class CompatValidationResult {
+    private static final CompatValidationResult SUCCESS = new 
CompatValidationResult(true, null, -1, -1);
 
-    private final boolean ok;
+    private final boolean successful;
     @Nullable
     private final UUID failedTableId;
     private final int fromSchemaVersion;
@@ -38,7 +38,7 @@ public class ForwardValidationResult {
      *
      * @return A successful validation result.
      */
-    public static ForwardValidationResult success() {
+    public static CompatValidationResult success() {
         return SUCCESS;
     }
 
@@ -50,12 +50,12 @@ public class ForwardValidationResult {
      * @param toSchemaVersion Version number of the schema to which an 
incompatible transition tried to be made.
      * @return A validation result for a failure.
      */
-    public static ForwardValidationResult failure(UUID failedTableId, int 
fromSchemaVersion, int toSchemaVersion) {
-        return new ForwardValidationResult(false, failedTableId, 
fromSchemaVersion, toSchemaVersion);
+    public static CompatValidationResult failure(UUID failedTableId, int 
fromSchemaVersion, int toSchemaVersion) {
+        return new CompatValidationResult(false, failedTableId, 
fromSchemaVersion, toSchemaVersion);
     }
 
-    private ForwardValidationResult(boolean ok, @Nullable UUID failedTableId, 
int fromSchemaVersion, int toSchemaVersion) {
-        this.ok = ok;
+    private CompatValidationResult(boolean successful, @Nullable UUID 
failedTableId, int fromSchemaVersion, int toSchemaVersion) {
+        this.successful = successful;
         this.failedTableId = failedTableId;
         this.fromSchemaVersion = fromSchemaVersion;
         this.toSchemaVersion = toSchemaVersion;
@@ -67,7 +67,7 @@ public class ForwardValidationResult {
      * @return Whether the validation was successful
      */
     public boolean isSuccessful() {
-        return ok;
+        return successful;
     }
 
     /**
@@ -81,31 +81,23 @@ public class ForwardValidationResult {
     }
 
     /**
-     * Returns version number of the schema from which an incompatible 
transition tried to be made. Should only be called for a failed
-     * validation result, otherwise an exception is thrown.
+     * Returns version number of the schema from which an incompatible 
transition tried to be made.
      *
      * @return Version number of the schema from which an incompatible 
transition tried to be made.
      */
     public int fromSchemaVersion() {
-        throwIfSuccessful();
+        assert !successful : "Should not be called on a successful result";
 
         return fromSchemaVersion;
     }
 
-    private void throwIfSuccessful() {
-        if (ok) {
-            throw new IllegalStateException("Should not be called on a 
successful result");
-        }
-    }
-
     /**
-     * Returns version number of the schema to which an incompatible 
transition tried to be made. Should only be called for a failed
-     *      * validation result, otherwise an exception is thrown.
+     * Returns version number of the schema to which an incompatible 
transition tried to be made.
      *
      * @return Version number of the schema to which an incompatible 
transition tried to be made.
      */
     public int toSchemaVersion() {
-        throwIfSuccessful();
+        assert !successful : "Should not be called on a successful result";
 
         return toSchemaVersion;
     }
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/action/RequestType.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/IncompatibleSchemaException.java
similarity index 59%
copy from 
modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/action/RequestType.java
copy to 
modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/IncompatibleSchemaException.java
index 6baae3a1c5..f553110951 100644
--- 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/action/RequestType.java
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/IncompatibleSchemaException.java
@@ -15,47 +15,17 @@
  * limitations under the License.
  */
 
-package org.apache.ignite.internal.table.distributed.replicator.action;
+package org.apache.ignite.internal.table.distributed.replicator;
+
+import org.apache.ignite.lang.ErrorGroups.Transactions;
+import org.apache.ignite.tx.TransactionException;
 
 /**
- * Transaction operation type.
+ * Thrown when, during an attempt to execute a transactional operation, it 
turns out that the operation cannot be executed
+ * because an incompatible schema change has happened.
  */
-public enum RequestType {
-    RW_GET,
-
-    RW_GET_ALL,
-
-    RW_DELETE,
-
-    RW_DELETE_ALL,
-
-    RW_DELETE_EXACT,
-
-    RW_DELETE_EXACT_ALL,
-
-    RW_INSERT,
-
-    RW_INSERT_ALL,
-
-    RW_UPSERT,
-
-    RW_UPSERT_ALL,
-
-    RW_REPLACE,
-
-    RW_REPLACE_IF_EXIST,
-
-    RW_GET_AND_DELETE,
-
-    RW_GET_AND_REPLACE,
-
-    RW_GET_AND_UPSERT,
-
-    RW_SCAN,
-
-    RO_GET,
-
-    RO_GET_ALL,
-
-    RO_SCAN
+public class IncompatibleSchemaException extends TransactionException {
+    public IncompatibleSchemaException(String message) {
+        super(Transactions.TX_INCOMPATIBLE_SCHEMA_ERR, message);
+    }
 }
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/PartitionReplicaListener.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/PartitionReplicaListener.java
index a232111407..020a9921d8 100644
--- 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/PartitionReplicaListener.java
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/PartitionReplicaListener.java
@@ -438,7 +438,7 @@ public class PartitionReplicaListener implements 
ReplicaListener {
             BinaryRow candidate =
                     newestCommitTimestamp == null || 
!readResult.isWriteIntent() ? null : cursor.committed(newestCommitTimestamp);
 
-            resolutionFuts.add(resolveReadResult(readResult, readTimestamp, () 
-> candidate));
+            resolutionFuts.add(resolveRoReadResult(readResult, readTimestamp, 
() -> candidate));
         }
 
         return allOf(resolutionFuts.toArray(new 
CompletableFuture[0])).thenCompose(unused -> {
@@ -655,16 +655,28 @@ public class PartitionReplicaListener implements 
ReplicaListener {
             @SuppressWarnings("resource") PartitionTimestampCursor cursor = 
(PartitionTimestampCursor) cursors.computeIfAbsent(cursorId,
                     id -> mvDataStorage.scan(HybridTimestamp.MAX_VALUE));
 
-            while (batchRows.size() < batchCount && cursor.hasNext()) {
-                BinaryRow resolvedReadResult = 
resolveReadResult(cursor.next(), txId);
+            return continueScanBatchRetrieval(cursor, batchCount, txId, 
batchRows);
+        });
+    }
 
-                if (resolvedReadResult != null) {
-                    batchRows.add(resolvedReadResult);
-                }
-            }
+    private CompletableFuture<List<BinaryRow>> continueScanBatchRetrieval(
+            PartitionTimestampCursor cursor,
+            int batchCount,
+            UUID txId,
+            List<BinaryRow> batchRows
+    ) {
+        if (batchRows.size() < batchCount && cursor.hasNext()) {
+            return resolveAndCheckReadCompatibility(cursor.next(), txId)
+                    .thenCompose(resolvedReadResult -> {
+                        if (resolvedReadResult != null) {
+                            batchRows.add(resolvedReadResult);
+                        }
 
-            return completedFuture(batchRows);
-        });
+                        return continueScanBatchRetrieval(cursor, batchCount, 
txId, batchRows);
+                    });
+        }
+
+        return completedFuture(batchRows);
     }
 
     /**
@@ -839,7 +851,7 @@ public class PartitionReplicaListener implements 
ReplicaListener {
 
         ReadResult readResult = mvDataStorage.read(rowId, timestamp);
 
-        return resolveReadResult(readResult, timestamp, () -> {
+        return resolveRoReadResult(readResult, timestamp, () -> {
             if (readResult.newestCommitTimestamp() == null) {
                 return null;
             }
@@ -893,14 +905,22 @@ public class PartitionReplicaListener implements 
ReplicaListener {
                     return lockManager.acquire(txId, new LockKey(tableId, 
currentRow.rowId()), LockMode.S)
                             .thenComposeAsync(rowLock -> { // Table row S lock
                                 ReadResult readResult = 
mvDataStorage.read(currentRow.rowId(), HybridTimestamp.MAX_VALUE);
-                                BinaryRow resolvedReadResult = 
resolveReadResult(readResult, txId);
-
-                                if (resolvedReadResult != null) {
-                                    result.add(resolvedReadResult);
-                                }
-
-                                // Proceed scan.
-                                return continueIndexScan(txId, indexLocker, 
indexCursor, batchSize, result, isUpperBoundAchieved);
+                                return 
resolveAndCheckReadCompatibility(readResult, txId)
+                                        .thenCompose(resolvedReadResult -> {
+                                            if (resolvedReadResult != null) {
+                                                result.add(resolvedReadResult);
+                                            }
+
+                                            // Proceed scan.
+                                            return continueIndexScan(
+                                                    txId,
+                                                    indexLocker,
+                                                    indexCursor,
+                                                    batchSize,
+                                                    result,
+                                                    isUpperBoundAchieved
+                                            );
+                                        });
                             }, scanRequestExecutor);
                 });
     }
@@ -920,14 +940,15 @@ public class PartitionReplicaListener implements 
ReplicaListener {
         return lockManager.acquire(txId, new LockKey(tableId, rowId), 
LockMode.S)
                 .thenComposeAsync(rowLock -> { // Table row S lock
                     ReadResult readResult = mvDataStorage.read(rowId, 
HybridTimestamp.MAX_VALUE);
-                    BinaryRow resolvedReadResult = 
resolveReadResult(readResult, txId);
-
-                    if (resolvedReadResult != null) {
-                        result.add(resolvedReadResult);
-                    }
+                    return resolveAndCheckReadCompatibility(readResult, txId)
+                            .thenCompose(resolvedReadResult -> {
+                                if (resolvedReadResult != null) {
+                                    result.add(resolvedReadResult);
+                                }
 
-                    // Proceed lookup.
-                    return continueIndexLookup(txId, indexCursor, batchSize, 
result);
+                                // Proceed lookup.
+                                return continueIndexLookup(txId, indexCursor, 
batchSize, result);
+                            });
                 }, scanRequestExecutor);
     }
 
@@ -945,7 +966,7 @@ public class PartitionReplicaListener implements 
ReplicaListener {
 
         ReadResult readResult = mvDataStorage.read(rowId, timestamp);
 
-        return resolveReadResult(readResult, timestamp, () -> {
+        return resolveRoReadResult(readResult, timestamp, () -> {
             if (readResult.newestCommitTimestamp() == null) {
                 return null;
             }
@@ -988,10 +1009,10 @@ public class PartitionReplicaListener implements 
ReplicaListener {
         UUID txId = request.txId();
 
         if (request.commit()) {
-            return schemaCompatValidator.validateForwards(txId, 
aggregatedGroupIds, request.commitTimestamp())
+            return schemaCompatValidator.validateForward(txId, 
aggregatedGroupIds, request.commitTimestamp())
                     .thenCompose(validationResult -> {
                         return finishAndCleanup(request, 
validationResult.isSuccessful(), aggregatedGroupIds, txId)
-                                .thenAccept(unused -> 
throwIfValidationFailed(validationResult));
+                                .thenAccept(unused -> 
throwIfSchemaValidationOnCommitFailed(validationResult));
                     });
         } else {
             // Aborting.
@@ -999,7 +1020,7 @@ public class PartitionReplicaListener implements 
ReplicaListener {
         }
     }
 
-    private static void throwIfValidationFailed(ForwardValidationResult 
validationResult) {
+    private static void 
throwIfSchemaValidationOnCommitFailed(CompatValidationResult validationResult) {
         if (!validationResult.isSuccessful()) {
             throw new IncompatibleSchemaAbortException("Commit failed because 
schema "
                     + validationResult.fromSchemaVersion() + " is not 
forward-compatible with "
@@ -1185,21 +1206,48 @@ public class PartitionReplicaListener implements 
ReplicaListener {
 
         return pkLocker.locksForLookup(txId, binaryRow)
                 .thenCompose(ignored -> {
-                    try (Cursor<RowId> cursor = 
pkIndexStorage.get().get(binaryRow)) {
-                        for (RowId rowId : cursor) {
-                            BinaryRow row = 
resolveReadResult(mvDataStorage.read(rowId, HybridTimestamp.MAX_VALUE), txId);
 
-                            if (row != null) {
-                                return action.apply(rowId, row);
-                            }
+                    boolean cursorClosureSetUp = false;
+                    Cursor<RowId> cursor = null;
+
+                    try {
+                        cursor = pkIndexStorage.get().get(binaryRow);
+
+                        Cursor<RowId> finalCursor = cursor;
+                        CompletableFuture<T> resolvingFuture = 
continueResolvingByPk(cursor, txId, action)
+                                .whenComplete((res, ex) -> 
finalCursor.close());
+
+                        cursorClosureSetUp = true;
+
+                        return resolvingFuture;
+                    } finally {
+                        if (!cursorClosureSetUp && cursor != null) {
+                            cursor.close();
                         }
+                    }
+                });
+    }
+
+    private <T> CompletableFuture<T> continueResolvingByPk(
+            Cursor<RowId> cursor,
+            UUID txId,
+            BiFunction<@Nullable RowId, @Nullable BinaryRow, 
CompletableFuture<T>> action
+    ) {
+        if (!cursor.hasNext()) {
+            return action.apply(null, null);
+        }
+
+        RowId rowId = cursor.next();
 
-                        return action.apply(null, null);
-                    } catch (Exception e) {
-                        throw new 
IgniteInternalException(Replicator.REPLICA_COMMON_ERR,
-                                format("Unable to close cursor [tableId={}]", 
tableId), e);
+        return resolveAndCheckReadCompatibility(mvDataStorage.read(rowId, 
HybridTimestamp.MAX_VALUE), txId)
+                .thenCompose(row -> {
+                    if (row != null) {
+                        return action.apply(rowId, row);
+                    } else {
+                        return continueResolvingByPk(cursor, txId, action);
                     }
                 });
+
     }
 
     /**
@@ -1358,7 +1406,7 @@ public class PartitionReplicaListener implements 
ReplicaListener {
         TablePartitionId committedPartitionId = request.commitPartitionId();
 
         assert committedPartitionId != null || request.requestType() == 
RequestType.RW_GET_ALL
-                : "Commit partition partition is null [type=" + 
request.requestType() + ']';
+                : "Commit partition is null [type=" + request.requestType() + 
']';
 
         switch (request.requestType()) {
             case RW_GET_ALL: {
@@ -1939,7 +1987,7 @@ public class PartitionReplicaListener implements 
ReplicaListener {
         BinaryRow expectedRow = request.oldBinaryRow();
         TablePartitionId commitPartitionId = request.commitPartitionId();
 
-        assert commitPartitionId != null : "Commit partition partition is null 
[type=" + request.requestType() + ']';
+        assert commitPartitionId != null : "Commit partition is null [type=" + 
request.requestType() + ']';
 
         UUID txId = request.transactionId();
 
@@ -2037,6 +2085,26 @@ public class PartitionReplicaListener implements 
ReplicaListener {
         }
     }
 
+    private CompletableFuture<BinaryRow> 
resolveAndCheckReadCompatibility(ReadResult readResult, UUID txId) {
+        BinaryRow row = resolveRwReadResult(readResult, txId);
+
+        if (row == null) {
+            return completedFuture(row);
+        }
+
+        return schemaCompatValidator.validateBackwards(row.schemaVersion(), 
tableId, txId)
+                .thenCompose(validationResult -> {
+                    if (validationResult.isSuccessful()) {
+                        return completedFuture(row);
+                    } else {
+                        throw new IncompatibleSchemaException("Operation 
failed because schema "
+                                + validationResult.fromSchemaVersion() + " is 
not backward-compatible with "
+                                + validationResult.toSchemaVersion() + " for 
table " + validationResult.failedTableId());
+                    }
+                });
+
+    }
+
     /**
      * Resolves a read result for RW transaction.
      *
@@ -2044,8 +2112,9 @@ public class PartitionReplicaListener implements 
ReplicaListener {
      * @param txId Transaction id.
      * @return Resolved binary row.
      */
-    private BinaryRow resolveReadResult(ReadResult readResult, UUID txId) {
-        // Here is a safety join (waiting of the future result), because the 
resolution for RW transaction cannot lead to a network request.
+    @Nullable
+    private BinaryRow resolveRwReadResult(ReadResult readResult, UUID txId) {
+        // This is a safe join (waiting of the future result), because the 
resolution for RW transaction cannot lead to a network request.
         return resolveReadResult(readResult, txId, null, null).join();
     }
 
@@ -2057,7 +2126,7 @@ public class PartitionReplicaListener implements 
ReplicaListener {
      * @param lastCommitted Action to get the latest committed row.
      * @return Future to resolved binary row.
      */
-    private CompletableFuture<BinaryRow> resolveReadResult(
+    private CompletableFuture<BinaryRow> resolveRoReadResult(
             ReadResult readResult,
             HybridTimestamp timestamp,
             Supplier<BinaryRow> lastCommitted
@@ -2071,7 +2140,7 @@ public class PartitionReplicaListener implements 
ReplicaListener {
      *     <li>If txId is not null (RW request), assert that retrieved tx id 
matches proposed one or that retrieved tx id is null
      *     and return binary row. Currently it's only possible to retrieve 
write intents if they belong to the same transaction,
      *     locks prevent reading write intents created by others.</li>
-     *     <li>If txId is not null (RO request), perform write intent 
resolution if given readResult is a write intent itself
+     *     <li>If txId is null (RO request), perform write intent resolution 
if given readResult is a write intent itself
      *     or return binary row otherwise.</li>
      * </ol>
      *
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/SchemaCompatValidator.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/SchemaCompatValidator.java
index f16a231bfe..a9c5513c56 100644
--- 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/SchemaCompatValidator.java
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/SchemaCompatValidator.java
@@ -49,9 +49,9 @@ class SchemaCompatValidator {
      * @param txId ID of the transaction that gets validated.
      * @param enlistedGroupIds IDs of the partitions that are enlisted with 
the transaction.
      * @param commitTimestamp Commit timestamp (or {@code null} if it's an 
abort).
-     * @return Future completed with validation result.
+     * @return Future of validation result.
      */
-    CompletableFuture<ForwardValidationResult> validateForwards(
+    CompletableFuture<CompatValidationResult> validateForward(
             UUID txId,
             List<TablePartitionId> enlistedGroupIds,
             @Nullable HybridTimestamp commitTimestamp
@@ -69,26 +69,26 @@ class SchemaCompatValidator {
         assert commitTimestamp.compareTo(beginTimestamp) > 0;
 
         return schemas.waitForSchemasAvailability(commitTimestamp)
-                .thenApply(ignored -> validateSchemasCompatibility(tableIds, 
commitTimestamp, beginTimestamp));
+                .thenApply(ignored -> 
validateForwardSchemasCompatibility(tableIds, commitTimestamp, beginTimestamp));
     }
 
-    private ForwardValidationResult validateSchemasCompatibility(
+    private CompatValidationResult validateForwardSchemasCompatibility(
             Set<UUID> tableIds,
             HybridTimestamp commitTimestamp,
             HybridTimestamp beginTimestamp
     ) {
         for (UUID tableId : tableIds) {
-            ForwardValidationResult validationResult = 
validateSchemaCompatibility(beginTimestamp, commitTimestamp, tableId);
+            CompatValidationResult validationResult = 
validateForwardSchemaCompatibility(beginTimestamp, commitTimestamp, tableId);
 
             if (!validationResult.isSuccessful()) {
                 return validationResult;
             }
         }
 
-        return ForwardValidationResult.success();
+        return CompatValidationResult.success();
     }
 
-    private ForwardValidationResult validateSchemaCompatibility(
+    private CompatValidationResult validateForwardSchemaCompatibility(
             HybridTimestamp beginTimestamp,
             HybridTimestamp commitTimestamp,
             UUID tableId
@@ -98,20 +98,71 @@ class SchemaCompatValidator {
         assert !tableSchemas.isEmpty();
 
         for (int i = 0; i < tableSchemas.size() - 1; i++) {
-            FullTableSchema from = tableSchemas.get(i);
-            FullTableSchema to = tableSchemas.get(i + 1);
-            if (!isCompatible(from, to)) {
-                return ForwardValidationResult.failure(tableId, 
from.schemaVersion(), to.schemaVersion());
+            FullTableSchema oldSchema = tableSchemas.get(i);
+            FullTableSchema newSchema = tableSchemas.get(i + 1);
+            if (!isForwardCompatible(oldSchema, newSchema)) {
+                return CompatValidationResult.failure(tableId, 
oldSchema.schemaVersion(), newSchema.schemaVersion());
             }
         }
 
-        return ForwardValidationResult.success();
+        return CompatValidationResult.success();
     }
 
-    private boolean isCompatible(FullTableSchema prevSchema, FullTableSchema 
nextSchema) {
+    private boolean isForwardCompatible(FullTableSchema prevSchema, 
FullTableSchema nextSchema) {
         TableDefinitionDiff diff = nextSchema.diffFrom(prevSchema);
 
-        // TODO: IGNITE-19229 - more sophisticated logic
+        // TODO: IGNITE-19229 - more sophisticated logic.
         return diff.isEmpty();
     }
+
+    /**
+     * Performs backward compatibility validation of a tuple that was just 
read in the transaction.
+     *
+     * <ul>
+     *     <li>If the tuple was written with a schema version earlier or same 
as the initial schema version of the transaction,
+     *     the read is valid.</li>
+     *     <li>If the tuple was written with a schema version later than the 
initial schema version of the transaction,
+     *     the read is valid only if the initial schema version is backward 
compatible with the tuple schema version.</li>
+     * </ul>
+     *
+     * @param tupleSchemaVersion Schema version ID of the tuple.
+     * @param tableId ID of the table to which the tuple belongs.
+     * @param txId ID of the transaction that gets validated.
+     * @return Future of validation result.
+     */
+    CompletableFuture<CompatValidationResult> validateBackwards(int 
tupleSchemaVersion, UUID tableId, UUID txId) {
+        HybridTimestamp beginTimestamp = TransactionIds.beginTimestamp(txId);
+
+        return schemas.waitForSchemasAvailability(beginTimestamp)
+                .thenCompose(ignored -> 
schemas.waitForSchemaAvailability(tableId, tupleSchemaVersion))
+                .thenApply(ignored -> 
validateBackwardSchemaCompatibility(tupleSchemaVersion, tableId, 
beginTimestamp));
+    }
+
+    private CompatValidationResult validateBackwardSchemaCompatibility(
+            int tupleSchemaVersion,
+            UUID tableId,
+            HybridTimestamp beginTimestamp
+    ) {
+        List<FullTableSchema> tableSchemas = 
schemas.tableSchemaVersionsBetween(tableId, beginTimestamp, tupleSchemaVersion);
+
+        if (tableSchemas.isEmpty()) {
+            // The tuple was not written with a future schema.
+            return CompatValidationResult.success();
+        }
+
+        for (int i = 0; i < tableSchemas.size() - 1; i++) {
+            FullTableSchema oldSchema = tableSchemas.get(i);
+            FullTableSchema newSchema = tableSchemas.get(i + 1);
+            if (!isBackwardCompatible(oldSchema, newSchema)) {
+                return CompatValidationResult.failure(tableId, 
oldSchema.schemaVersion(), newSchema.schemaVersion());
+            }
+        }
+
+        return CompatValidationResult.success();
+    }
+
+    private boolean isBackwardCompatible(FullTableSchema oldSchema, 
FullTableSchema newSchema) {
+        // TODO: IGNITE-19229 - is backward compatibility always symmetric 
with the forward compatibility?
+        return isForwardCompatible(newSchema, oldSchema);
+    }
 }
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/action/RequestType.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/action/RequestType.java
index 6baae3a1c5..ac1a7671d2 100644
--- 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/action/RequestType.java
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/action/RequestType.java
@@ -57,5 +57,41 @@ public enum RequestType {
 
     RO_GET_ALL,
 
-    RO_SCAN
+    RO_SCAN;
+
+    /**
+     * Returns {@code true} if the operation works with a single row.
+     */
+    public boolean isSingleRow() {
+        switch (this) {
+            case RW_GET:
+            case RW_DELETE:
+            case RW_GET_AND_DELETE:
+            case RW_DELETE_EXACT:
+            case RW_INSERT:
+            case RW_UPSERT:
+            case RW_GET_AND_UPSERT:
+            case RW_GET_AND_REPLACE:
+            case RW_REPLACE_IF_EXIST:
+                return true;
+            default:
+                return false;
+        }
+    }
+
+    /**
+     * Returns {@code true} if the operation works with multiple rows.
+     */
+    public boolean isMultipleRows() {
+        switch (this) {
+            case RW_GET_ALL:
+            case RW_DELETE_ALL:
+            case RW_DELETE_EXACT_ALL:
+            case RW_INSERT_ALL:
+            case RW_UPSERT_ALL:
+                return true;
+            default:
+                return false;
+        }
+    }
 }
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/NonHistoricSchemas.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/NonHistoricSchemas.java
index 64c99970fc..66f38fe125 100644
--- 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/NonHistoricSchemas.java
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/NonHistoricSchemas.java
@@ -56,6 +56,11 @@ public class NonHistoricSchemas implements Schemas {
         return completedFuture(null);
     }
 
+    @Override
+    public CompletableFuture<?> waitForSchemaAvailability(UUID tableId, int 
schemaVersion) {
+        return completedFuture(null);
+    }
+
     @Override
     public List<FullTableSchema> tableSchemaVersionsBetween(UUID tableId, 
HybridTimestamp fromIncluding, HybridTimestamp toIncluding) {
         SchemaRegistry schemaRegistry = schemaManager.schemaRegistry(tableId);
@@ -81,6 +86,13 @@ public class NonHistoricSchemas implements Schemas {
         return List.of(fullSchema);
     }
 
+    @Override
+    public List<FullTableSchema> tableSchemaVersionsBetween(UUID tableId, 
HybridTimestamp fromIncluding, int toIncluding) {
+        // Returning an empty list makes sure that backward validation never 
fails, which is what we want before
+        // we switch to CatalogService completely.
+        return List.of();
+    }
+
     /**
      * Converts a {@link Column} to a {@link TableColumnDescriptor}. Please 
note that the conversion is not full; it's
      * used in the code that actually doesn't care about columns.
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/Schemas.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/Schemas.java
index 42c55a0ce5..2ed9db14ac 100644
--- 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/Schemas.java
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/Schemas.java
@@ -36,6 +36,15 @@ public interface Schemas {
      */
     CompletableFuture<?> waitForSchemasAvailability(HybridTimestamp ts);
 
+    /**
+     * Obtains a future that completes when the given schema version becomes 
available.
+     *
+     * @param tableId ID of the table of interest.
+     * @param schemaVersion ID of the schema version.
+     * @return Future that completes when the given schema version becomes 
available.
+     */
+    CompletableFuture<?> waitForSchemaAvailability(UUID tableId, int 
schemaVersion);
+
     /**
      * Returns all schema versions between (including) the two that were 
effective at the given timestamps.
      *
@@ -45,4 +54,16 @@ public interface Schemas {
      * @return All schema versions between (including) the two that were 
effective at the given timestamps.
      */
     List<FullTableSchema> tableSchemaVersionsBetween(UUID tableId, 
HybridTimestamp fromIncluding, HybridTimestamp toIncluding);
+
+    /**
+     * Returns all schema versions between (including) the one that was 
effective at the given timestamp and
+     * the one identified by a schema version ID. If the starting schema (the 
one effective at fromIncluding)
+     * is actually a later schema than the one identified by toIncluding, then 
an empty list is returned.
+     *
+     * @param tableId ID of the table which schemas need to be considered.
+     * @param fromIncluding Start timestamp.
+     * @param toIncluding End schema version ID.
+     * @return All schema versions between (including) the given timestamp and 
schema version.
+     */
+    List<FullTableSchema> tableSchemaVersionsBetween(UUID tableId, 
HybridTimestamp fromIncluding, int toIncluding);
 }
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/storage/InternalTableImpl.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/storage/InternalTableImpl.java
index 57a6774ca9..d037a88baf 100644
--- 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/storage/InternalTableImpl.java
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/storage/InternalTableImpl.java
@@ -217,7 +217,7 @@ public class InternalTableImpl implements InternalTable {
      */
     private <R> CompletableFuture<R> enlistInTx(
             BinaryRowEx row,
-            InternalTransaction tx,
+            @Nullable InternalTransaction tx,
             IgniteTetraFunction<TablePartitionId, InternalTransaction, 
ReplicationGroupId, Long, ReplicaRequest> op
     ) {
         // Check whether proposed tx is read-only. Complete future 
exceptionally if true.
@@ -279,7 +279,7 @@ public class InternalTableImpl implements InternalTable {
      */
     private <T> CompletableFuture<T> enlistInTx(
             Collection<BinaryRowEx> keyRows,
-            InternalTransaction tx,
+            @Nullable InternalTransaction tx,
             IgniteFiveFunction<TablePartitionId, Collection<BinaryRow>, 
InternalTransaction, ReplicationGroupId, Long, ReplicaRequest> op,
             Function<CompletableFuture<Object>[], CompletableFuture<T>> reducer
     ) {
diff --git 
a/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/replication/PartitionReplicaListenerTest.java
 
b/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/replication/PartitionReplicaListenerTest.java
index cbf3f6b19b..e8a3b4b896 100644
--- 
a/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/replication/PartitionReplicaListenerTest.java
+++ 
b/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/replication/PartitionReplicaListenerTest.java
@@ -26,6 +26,7 @@ import static 
org.apache.ignite.internal.util.ArrayUtils.asList;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -34,6 +35,7 @@ import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.Mockito.lenient;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
@@ -58,6 +60,7 @@ import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Function;
 import java.util.function.Supplier;
 import java.util.stream.IntStream;
+import java.util.stream.Stream;
 import org.apache.ignite.distributed.TestPartitionDataStorage;
 import org.apache.ignite.internal.binarytuple.BinaryTupleBuilder;
 import org.apache.ignite.internal.binarytuple.BinaryTuplePrefixBuilder;
@@ -110,6 +113,7 @@ import 
org.apache.ignite.internal.table.distributed.command.UpdateCommand;
 import org.apache.ignite.internal.table.distributed.raft.PartitionDataStorage;
 import 
org.apache.ignite.internal.table.distributed.replication.request.ReadWriteReplicaRequest;
 import 
org.apache.ignite.internal.table.distributed.replicator.IncompatibleSchemaAbortException;
+import 
org.apache.ignite.internal.table.distributed.replicator.IncompatibleSchemaException;
 import org.apache.ignite.internal.table.distributed.replicator.LeaderOrTxState;
 import 
org.apache.ignite.internal.table.distributed.replicator.PartitionReplicaListener;
 import org.apache.ignite.internal.table.distributed.replicator.PlacementDriver;
@@ -146,6 +150,9 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 import org.junitpioneer.jupiter.cartesian.CartesianTest;
 import org.junitpioneer.jupiter.cartesian.CartesianTest.Values;
 import org.mockito.Mock;
@@ -158,6 +165,12 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
     /** Partition id. */
     private static final int partId = 0;
 
+    private static final int CURRENT_SCHEMA_VERSION = 1;
+
+    private static final int FUTURE_SCHEMA_VERSION = 2;
+
+    private static final int FUTURE_SCHEMA_ROW_INDEXED_VALUE = 0;
+
     /** Table id. */
     private final UUID tblId = UUID.randomUUID();
 
@@ -241,9 +254,15 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
     /** Schema descriptor for tests. */
     private SchemaDescriptor schemaDescriptor;
 
+    /** Schema descriptor, version 2. */
+    private SchemaDescriptor schemaDescriptorVersion2;
+
     /** Key-value marshaller for tests. */
     private KvMarshaller<TestKey, TestValue> kvMarshaller;
 
+    /** Key-value marshaller using schema version 2. */
+    private KvMarshaller<TestKey, TestValue> kvMarshallerVersion2;
+
     /** Partition replication listener to test. */
     private PartitionReplicaListener partitionReplicaListener;
 
@@ -315,18 +334,14 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
         
lenient().when(safeTimeClock.waitFor(any())).thenReturn(completedFuture(null));
 
         
lenient().when(schemas.waitForSchemasAvailability(any())).thenReturn(completedFuture(null));
+        lenient().when(schemas.waitForSchemaAvailability(any(), 
anyInt())).thenReturn(completedFuture(null));
 
         UUID pkIndexId = UUID.randomUUID();
         UUID sortedIndexId = UUID.randomUUID();
         UUID hashIndexId = UUID.randomUUID();
 
-        schemaDescriptor = new SchemaDescriptor(1, new Column[]{
-                new Column("intKey".toUpperCase(Locale.ROOT), 
NativeTypes.INT32, false),
-                new Column("strKey".toUpperCase(Locale.ROOT), 
NativeTypes.STRING, false),
-        }, new Column[]{
-                new Column("intVal".toUpperCase(Locale.ROOT), 
NativeTypes.INT32, false),
-                new Column("strVal".toUpperCase(Locale.ROOT), 
NativeTypes.STRING, false),
-        });
+        schemaDescriptor = schemaDescriptorWith(CURRENT_SCHEMA_VERSION);
+        schemaDescriptorVersion2 = schemaDescriptorWith(FUTURE_SCHEMA_VERSION);
 
         Function<BinaryRow, BinaryTuple> row2Tuple = 
BinaryRowConverter.keyExtractor(schemaDescriptor);
 
@@ -383,15 +398,30 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
                 completedFuture(schemaManager)
         );
 
-        MarshallerFactory marshallerFactory = new 
ReflectionMarshallerFactory();
-
         sortedIndexBinarySchema = 
BinaryTupleSchema.createSchema(schemaDescriptor, new int[]{2 /* intVal column 
*/});
 
-        kvMarshaller = marshallerFactory.create(schemaDescriptor, 
TestKey.class, TestValue.class);
+        kvMarshaller = marshallerFor(schemaDescriptor);
+        kvMarshallerVersion2 = marshallerFor(schemaDescriptorVersion2);
 
         reset();
     }
 
+    private static SchemaDescriptor schemaDescriptorWith(int ver) {
+        return new SchemaDescriptor(ver, new Column[]{
+                new Column("intKey".toUpperCase(Locale.ROOT), 
NativeTypes.INT32, false),
+                new Column("strKey".toUpperCase(Locale.ROOT), 
NativeTypes.STRING, false),
+        }, new Column[]{
+                new Column("intVal".toUpperCase(Locale.ROOT), 
NativeTypes.INT32, false),
+                new Column("strVal".toUpperCase(Locale.ROOT), 
NativeTypes.STRING, false),
+        });
+    }
+
+    private static KvMarshaller<TestKey, TestValue> 
marshallerFor(SchemaDescriptor descriptor) {
+        MarshallerFactory marshallerFactory = new 
ReflectionMarshallerFactory();
+
+        return marshallerFactory.create(descriptor, TestKey.class, 
TestValue.class);
+    }
+
     private TableSchemaAwareIndexStorage pkStorage() {
         return Objects.requireNonNull(pkStorageSupplier.get());
     }
@@ -935,8 +965,8 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
         cleanup(txId);
     }
 
-    private void doSingleRowRequest(UUID txId, BinaryRow binaryRow, 
RequestType requestType) {
-        
partitionReplicaListener.invoke(TABLE_MESSAGES_FACTORY.readWriteSingleRowReplicaRequest()
+    private CompletableFuture<?> doSingleRowRequest(UUID txId, BinaryRow 
binaryRow, RequestType requestType) {
+        return 
partitionReplicaListener.invoke(TABLE_MESSAGES_FACTORY.readWriteSingleRowReplicaRequest()
                 .transactionId(txId)
                 .requestType(requestType)
                 .binaryRow(binaryRow)
@@ -946,8 +976,8 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
         );
     }
 
-    private void doMultiRowRequest(UUID txId, Collection<BinaryRow> 
binaryRows, RequestType requestType) {
-        
partitionReplicaListener.invoke(TABLE_MESSAGES_FACTORY.readWriteMultiRowReplicaRequest()
+    private CompletableFuture<?> doMultiRowRequest(UUID txId, 
Collection<BinaryRow> binaryRows, RequestType requestType) {
+        return 
partitionReplicaListener.invoke(TABLE_MESSAGES_FACTORY.readWriteMultiRowReplicaRequest()
                 .transactionId(txId)
                 .requestType(requestType)
                 .binaryRows(binaryRows)
@@ -1197,7 +1227,7 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
         when(txManager.cleanup(any(), any(), any(), anyBoolean(), 
any())).thenReturn(completedFuture(null));
 
         HybridTimestamp beginTimestamp = clock.now();
-        UUID txId = 
TestTransactionIds.TRANSACTION_ID_GENERATOR.transactionIdFor(beginTimestamp);
+        UUID txId = transactionIdFor(beginTimestamp);
 
         TxFinishReplicaRequest commitRequest = 
TX_MESSAGES_FACTORY.txFinishReplicaRequest()
                 .groupId(grpId)
@@ -1210,11 +1240,15 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
         return partitionReplicaListener.invoke(commitRequest);
     }
 
+    private static UUID transactionIdFor(HybridTimestamp beginTimestamp) {
+        return 
TestTransactionIds.TRANSACTION_ID_GENERATOR.transactionIdFor(beginTimestamp);
+    }
+
     @Test
     public void commitsOnSameSchemaSuccessfully() {
         when(schemas.tableSchemaVersionsBetween(any(), any(), any()))
                 .thenReturn(List.of(
-                        tableSchema(1, List.of(nullableColumn("col")))
+                        tableSchema(CURRENT_SCHEMA_VERSION, 
List.of(nullableColumn("col")))
                 ));
 
         AtomicReference<Boolean> committed = interceptFinishTxCommand();
@@ -1255,7 +1289,7 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
         when(txManager.cleanup(any(), any(), any(), anyBoolean(), 
any())).thenReturn(completedFuture(null));
 
         HybridTimestamp beginTimestamp = clock.now();
-        UUID txId = 
TestTransactionIds.TRANSACTION_ID_GENERATOR.transactionIdFor(beginTimestamp);
+        UUID txId = transactionIdFor(beginTimestamp);
 
         HybridTimestamp commitTimestamp = clock.now();
 
@@ -1276,8 +1310,8 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
     public void commitsOnCompatibleSchemaChangeSuccessfully() {
         when(schemas.tableSchemaVersionsBetween(any(), any(), any()))
                 .thenReturn(List.of(
-                        tableSchema(1, List.of(nullableColumn("col1"))),
-                        tableSchema(2, List.of(nullableColumn("col1"), 
nullableColumn("col2")))
+                        tableSchema(CURRENT_SCHEMA_VERSION, 
List.of(nullableColumn("col1"))),
+                        tableSchema(FUTURE_SCHEMA_VERSION, 
List.of(nullableColumn("col1"), nullableColumn("col2")))
                 ));
 
         AtomicReference<Boolean> committed = interceptFinishTxCommand();
@@ -1291,11 +1325,7 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
 
     @Test
     public void abortsCommitOnIncompatibleSchema() {
-        when(schemas.tableSchemaVersionsBetween(any(), any(), any()))
-                .thenReturn(List.of(
-                        tableSchema(1, List.of(defaultedColumn("col", 4))),
-                        tableSchema(2, List.of(defaultedColumn("col", 5)))
-                ));
+        simulateForwardIncompatibleSchemaChange(CURRENT_SCHEMA_VERSION, 
FUTURE_SCHEMA_VERSION);
 
         AtomicReference<Boolean> committed = interceptFinishTxCommand();
 
@@ -1309,6 +1339,158 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
         assertThat(committed.get(), is(false));
     }
 
+    private void simulateForwardIncompatibleSchemaChange(int 
fromSchemaVersion, int toSchemaVersion) {
+        when(schemas.tableSchemaVersionsBetween(any(), any(), any()))
+                .thenReturn(incompatibleSchemaVersions(fromSchemaVersion, 
toSchemaVersion));
+    }
+
+    private void simulateBackwardIncompatibleSchemaChange(int 
fromSchemaVersion, int toSchemaVersion) {
+        when(schemas.tableSchemaVersionsBetween(any(), any(), anyInt()))
+                .thenReturn(incompatibleSchemaVersions(fromSchemaVersion, 
toSchemaVersion));
+    }
+
+    private static List<FullTableSchema> incompatibleSchemaVersions(int 
fromSchemaVersion, int toSchemaVersion) {
+        return List.of(
+                tableSchema(fromSchemaVersion, List.of(defaultedColumn("col", 
4))),
+                tableSchema(toSchemaVersion, List.of(defaultedColumn("col", 
5)))
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource("singleRowRequestTypes")
+    public void 
failsWhenReadingSingleRowFromFutureIncompatibleSchema(RequestType requestType) {
+        testFailsWhenReadingFromFutureIncompatibleSchema((targetTxId, key) -> 
doSingleRowRequest(
+                targetTxId,
+                binaryRow(key, new TestValue(1, "v1"), kvMarshaller),
+                requestType
+        ));
+    }
+
+    private void 
testFailsWhenReadingFromFutureIncompatibleSchema(ListenerInvocation 
listenerInvocation) {
+        UUID targetTxId = transactionIdFor(clock.now());
+
+        TestKey key = simulateWriteWithSchemaVersionFromFuture();
+
+        simulateBackwardIncompatibleSchemaChange(CURRENT_SCHEMA_VERSION, 
FUTURE_SCHEMA_VERSION);
+
+        AtomicReference<Boolean> committed = interceptFinishTxCommand();
+
+        CompletableFuture<?> future = listenerInvocation.invoke(targetTxId, 
key);
+
+        assertFailureDueToBackwardIncompatibleSchemaChange(future, committed);
+    }
+
+    private static Stream<Arguments> singleRowRequestTypes() {
+        return Arrays.stream(RequestType.values())
+                .filter(RequestType::isSingleRow)
+                .map(Arguments::of);
+    }
+
+    private TestKey simulateWriteWithSchemaVersionFromFuture() {
+        UUID futureSchemaVersionTxId = transactionIdFor(clock.now());
+
+        TestKey key = nextKey();
+        BinaryRow futureSchemaVersionRow = binaryRow(key, new TestValue(2, 
"v2"), kvMarshallerVersion2);
+        var rowId = new RowId(partId);
+
+        BinaryTuple indexedValue = new BinaryTuple(sortedIndexBinarySchema,
+                new BinaryTupleBuilder(1, 
false).appendInt(FUTURE_SCHEMA_ROW_INDEXED_VALUE).build()
+        );
+
+        pkStorage().put(futureSchemaVersionRow, rowId);
+        testMvPartitionStorage.addWrite(rowId, futureSchemaVersionRow, 
futureSchemaVersionTxId, tblId, partId);
+        sortedIndexStorage.storage().put(new IndexRowImpl(indexedValue, 
rowId));
+        testMvPartitionStorage.commitWrite(rowId, clock.now());
+
+        return key;
+    }
+
+    private static void assertFailureDueToBackwardIncompatibleSchemaChange(
+            CompletableFuture<?> future,
+            AtomicReference<Boolean> committed
+    ) {
+        IncompatibleSchemaException ex = assertWillThrowFast(future,
+                IncompatibleSchemaException.class);
+        assertThat(ex.code(), is(Transactions.TX_INCOMPATIBLE_SCHEMA_ERR));
+        assertThat(ex.getMessage(), containsString("Operation failed because 
schema 1 is not backward-compatible with 2"));
+
+        // Tx should not be finished.
+        assertThat(committed.get(), is(nullValue()));
+    }
+
+    @ParameterizedTest
+    @MethodSource("multiRowsRequestTypes")
+    public void 
failsWhenReadingMultiRowsFromFutureIncompatibleSchema(RequestType requestType) {
+        testFailsWhenReadingFromFutureIncompatibleSchema((targetTxId, key) -> 
doMultiRowRequest(
+                targetTxId,
+                List.of(binaryRow(key, new TestValue(1, "v1"), kvMarshaller)),
+                requestType
+        ));
+    }
+
+    private static Stream<Arguments> multiRowsRequestTypes() {
+        return Arrays.stream(RequestType.values())
+                .filter(RequestType::isMultipleRows)
+                .map(Arguments::of);
+    }
+
+    @Test
+    public void failsWhenReplacingOnTupleWithIncompatibleSchemaFromFuture() {
+        testFailsWhenReadingFromFutureIncompatibleSchema(
+                (targetTxId, key) -> 
partitionReplicaListener.invoke(TABLE_MESSAGES_FACTORY.readWriteSwapRowReplicaRequest()
+                        .transactionId(targetTxId)
+                        .requestType(RequestType.RW_REPLACE)
+                        .oldBinaryRow(binaryRow(key, new TestValue(1, "v1"), 
kvMarshaller))
+                        .binaryRow(binaryRow(key, new TestValue(3, "v3"), 
kvMarshaller))
+                        .term(1L)
+                        .commitPartitionId(new 
TablePartitionId(UUID.randomUUID(), partId))
+                        .build()
+                )
+        );
+    }
+
+    @Test
+    public void 
failsWhenScanByExactMatchReadsTupleWithIncompatibleSchemaFromFuture() {
+        testFailsWhenReadingFromFutureIncompatibleSchema(
+                (targetTxId, key) -> 
partitionReplicaListener.invoke(TABLE_MESSAGES_FACTORY.readWriteScanRetrieveBatchReplicaRequest()
+                        .transactionId(targetTxId)
+                        .indexToUse(sortedIndexStorage.id())
+                        .exactKey(toIndexKey(FUTURE_SCHEMA_ROW_INDEXED_VALUE))
+                        .term(1L)
+                        .scanId(1)
+                        .batchSize(100)
+                        .build()
+                )
+        );
+    }
+
+    @Test
+    public void 
failsWhenScanByIndexReadsTupleWithIncompatibleSchemaFromFuture() {
+        testFailsWhenReadingFromFutureIncompatibleSchema(
+                (targetTxId, key) -> 
partitionReplicaListener.invoke(TABLE_MESSAGES_FACTORY.readWriteScanRetrieveBatchReplicaRequest()
+                        .transactionId(targetTxId)
+                        .indexToUse(sortedIndexStorage.id())
+                        .term(1L)
+                        .scanId(1)
+                        .batchSize(100)
+                        .build()
+                )
+        );
+    }
+
+    @Test
+    public void failsWhenFullScanReadsTupleWithIncompatibleSchemaFromFuture() {
+        testFailsWhenReadingFromFutureIncompatibleSchema(
+                (targetTxId, key) -> 
partitionReplicaListener.invoke(TABLE_MESSAGES_FACTORY.readWriteScanRetrieveBatchReplicaRequest()
+                        .transactionId(targetTxId)
+                        .term(1L)
+                        .scanId(1)
+                        .batchSize(100)
+                        .build()
+                )
+        );
+    }
+
     private static UUID beginTx() {
         return TestTransactionIds.newTransactionId();
     }
@@ -1383,12 +1565,16 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
 
     private BinaryRow nextBinaryKey() {
         try {
-            return kvMarshaller.marshal(new TestKey(monotonicInt(), "key " + 
monotonicInt()));
+            return kvMarshaller.marshal(nextKey());
         } catch (MarshallerException e) {
             throw new IgniteException(e);
         }
     }
 
+    private static TestKey nextKey() {
+        return new TestKey(monotonicInt(), "key " + monotonicInt());
+    }
+
     private static int monotonicInt() {
         return nextMonotonicInt.getAndIncrement();
     }
@@ -1402,8 +1588,12 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
     }
 
     private BinaryRow binaryRow(TestKey key, TestValue value) {
+        return binaryRow(key, value, kvMarshaller);
+    }
+
+    private static BinaryRow binaryRow(TestKey key, TestValue value, 
KvMarshaller<TestKey, TestValue> marshaller) {
         try {
-            return kvMarshaller.marshal(key, value);
+            return marshaller.marshal(key, value);
         } catch (MarshallerException e) {
             throw new AssertionError(e);
         }
@@ -1514,4 +1704,9 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
             return S.toString(TestValue.class, this);
         }
     }
+
+    @FunctionalInterface
+    private interface ListenerInvocation {
+        CompletableFuture<?> invoke(UUID targetTxId, TestKey key);
+    }
 }
diff --git 
a/modules/table/src/testFixtures/java/org/apache/ignite/internal/table/impl/DummyInternalTableImpl.java
 
b/modules/table/src/testFixtures/java/org/apache/ignite/internal/table/impl/DummyInternalTableImpl.java
index 1859462772..a147752931 100644
--- 
a/modules/table/src/testFixtures/java/org/apache/ignite/internal/table/impl/DummyInternalTableImpl.java
+++ 
b/modules/table/src/testFixtures/java/org/apache/ignite/internal/table/impl/DummyInternalTableImpl.java
@@ -71,8 +71,6 @@ import 
org.apache.ignite.internal.table.distributed.raft.PartitionDataStorage;
 import org.apache.ignite.internal.table.distributed.raft.PartitionListener;
 import 
org.apache.ignite.internal.table.distributed.replicator.PartitionReplicaListener;
 import org.apache.ignite.internal.table.distributed.replicator.PlacementDriver;
-import org.apache.ignite.internal.table.distributed.schema.FullTableSchema;
-import org.apache.ignite.internal.table.distributed.schema.Schemas;
 import org.apache.ignite.internal.table.distributed.storage.InternalTableImpl;
 import org.apache.ignite.internal.tx.InternalTransaction;
 import org.apache.ignite.internal.tx.TxManager;
@@ -306,18 +304,7 @@ public class DummyInternalTableImpl extends 
InternalTableImpl {
                 txStateStorage().getOrCreateTxStateStorage(PART_ID),
                 placementDriver,
                 storageUpdateHandler,
-                new Schemas() {
-                    @Override
-                    public CompletableFuture<?> 
waitForSchemasAvailability(HybridTimestamp ts) {
-                        return completedFuture(null);
-                    }
-
-                    @Override
-                    public List<FullTableSchema> 
tableSchemaVersionsBetween(UUID tableId, HybridTimestamp fromIncluding,
-                            HybridTimestamp toIncluding) {
-                        return List.of(new FullTableSchema(1, 1, List.of(), 
List.of()));
-                    }
-                },
+                new DummySchemas(schemaManager),
                 peer -> true,
                 completedFuture(schemaManager)
         );
diff --git 
a/modules/table/src/testFixtures/java/org/apache/ignite/internal/table/impl/DummySchemas.java
 
b/modules/table/src/testFixtures/java/org/apache/ignite/internal/table/impl/DummySchemas.java
index 89b112db48..b70bb900c7 100644
--- 
a/modules/table/src/testFixtures/java/org/apache/ignite/internal/table/impl/DummySchemas.java
+++ 
b/modules/table/src/testFixtures/java/org/apache/ignite/internal/table/impl/DummySchemas.java
@@ -47,6 +47,11 @@ public class DummySchemas implements Schemas {
         return completedFuture(null);
     }
 
+    @Override
+    public CompletableFuture<?> waitForSchemaAvailability(UUID tableId, int 
schemaVersion) {
+        return completedFuture(null);
+    }
+
     @Override
     public List<FullTableSchema> tableSchemaVersionsBetween(UUID tableId, 
HybridTimestamp fromIncluding, HybridTimestamp toIncluding) {
         SchemaDescriptor schemaDescriptor = schemaRegistry.schema();
@@ -70,4 +75,11 @@ public class DummySchemas implements Schemas {
 
         return List.of(fullSchema);
     }
+
+    @Override
+    public List<FullTableSchema> tableSchemaVersionsBetween(UUID tableId, 
HybridTimestamp fromIncluding, int toIncluding) {
+        // Returning an empty list makes sure that backward validation never 
fails, which is what we want before
+        // we switch to CatalogService completely.
+        return List.of();
+    }
 }

Reply via email to