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

tkalkirill 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 3fc5202a78 IGNITE-19228 Schema validation during tx processing: common 
framework (#2055)
3fc5202a78 is described below

commit 3fc5202a785b6c22ea9cda911d242d297c072067
Author: Roman Puchkovskiy <[email protected]>
AuthorDate: Fri May 12 16:29:29 2023 +0400

    IGNITE-19228 Schema validation during tx processing: common framework 
(#2055)
---
 .../catalog/descriptors/TableColumnDescriptor.java |  46 +++++-
 .../handler/requests/table/ClientTableCommon.java  |  55 +-------
 .../ignite/internal/util/CollectionUtils.java      |  12 ++
 .../ignite/internal/util/CollectionUtilsTest.java  |   8 ++
 .../asserts/CompletableFutureAssert.java           |  77 ++++++++++
 .../ignite/internal/schema/NativeTypeSpec.java     |  80 +++++++++++
 modules/table/build.gradle                         |   2 +
 .../distributed/ItTxDistributedTestSingleNode.java |   2 +
 .../internal/table/distributed/TableManager.java   |   3 +
 .../raft/RebalanceRaftGroupEventsListener.java     |  14 +-
 .../replicator/ForwardValidationResult.java        | 112 +++++++++++++++
 .../IncompatibleSchemaAbortException.java          |  31 ++++
 .../replicator/PartitionReplicaListener.java       |  45 +++++-
 .../replicator/SchemaCompatValidator.java          | 117 ++++++++++++++++
 .../distributed/schema/ColumnDefinitionDiff.java   |  37 +++++
 .../table/distributed/schema/FullTableSchema.java  | 144 +++++++++++++++++++
 .../distributed/schema/NonHistoricSchemas.java     |  99 +++++++++++++
 .../internal/table/distributed/schema/Schemas.java |  48 +++++++
 .../distributed/schema/TableDefinitionDiff.java    | 116 +++++++++++++++
 .../table/distributed/schema/package-info.java     |  22 +++
 .../ignite/internal/utils/RebalanceUtil.java       |  26 +---
 .../RepeatedFinishReadWriteTransactionTest.java    |   2 +-
 .../PartitionReplicaListenerIndexLockingTest.java  |   2 +
 .../replication/PartitionReplicaListenerTest.java  | 156 ++++++++++++++++++++-
 .../distributed/schema/FullTableSchemaTest.java    | 108 ++++++++++++++
 .../table/impl/DummyInternalTableImpl.java         |  21 ++-
 .../ignite/internal/table/impl/DummySchemas.java   |  73 ++++++++++
 .../org/apache/ignite/internal/tx/TxManager.java   |   4 +-
 .../ignite/internal/tx/impl/TxManagerImpl.java     |   3 +-
 .../internal/tx/test/TestTransactionIds.java       |   2 +-
 30 files changed, 1369 insertions(+), 98 deletions(-)

diff --git 
a/modules/catalog/src/main/java/org/apache/ignite/internal/catalog/descriptors/TableColumnDescriptor.java
 
b/modules/catalog/src/main/java/org/apache/ignite/internal/catalog/descriptors/TableColumnDescriptor.java
index 47133fb964..be1d86177c 100644
--- 
a/modules/catalog/src/main/java/org/apache/ignite/internal/catalog/descriptors/TableColumnDescriptor.java
+++ 
b/modules/catalog/src/main/java/org/apache/ignite/internal/catalog/descriptors/TableColumnDescriptor.java
@@ -36,7 +36,7 @@ public class TableColumnDescriptor implements Serializable {
     private int length;
     private int precision;
     private int scale;
-    private DefaultValue defaultValue;
+    private final DefaultValue defaultValue;
 
     /**
      * Constructor.
@@ -80,6 +80,50 @@ public class TableColumnDescriptor implements Serializable {
         return defaultValue;
     }
 
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+
+        TableColumnDescriptor that = (TableColumnDescriptor) o;
+
+        if (nullable != that.nullable) {
+            return false;
+        }
+        if (length != that.length) {
+            return false;
+        }
+        if (precision != that.precision) {
+            return false;
+        }
+        if (scale != that.scale) {
+            return false;
+        }
+        if (!name.equals(that.name)) {
+            return false;
+        }
+        if (type != that.type) {
+            return false;
+        }
+        return defaultValue != null ? defaultValue.equals(that.defaultValue) : 
that.defaultValue == null;
+    }
+
+    @Override
+    public int hashCode() {
+        int result = name.hashCode();
+        result = 31 * result + type.hashCode();
+        result = 31 * result + (nullable ? 1 : 0);
+        result = 31 * result + length;
+        result = 31 * result + precision;
+        result = 31 * result + scale;
+        result = 31 * result + (defaultValue != null ? defaultValue.hashCode() 
: 0);
+        return result;
+    }
+
     /** {@inheritDoc} */
     @Override
     public String toString() {
diff --git 
a/modules/client-handler/src/main/java/org/apache/ignite/client/handler/requests/table/ClientTableCommon.java
 
b/modules/client-handler/src/main/java/org/apache/ignite/client/handler/requests/table/ClientTableCommon.java
index 70354ff864..ac6c45a5a0 100644
--- 
a/modules/client-handler/src/main/java/org/apache/ignite/client/handler/requests/table/ClientTableCommon.java
+++ 
b/modules/client-handler/src/main/java/org/apache/ignite/client/handler/requests/table/ClientTableCommon.java
@@ -373,58 +373,13 @@ public class ClientTableCommon {
      * @return Client type code.
      */
     public static ColumnType getColumnType(NativeTypeSpec spec) {
-        switch (spec) {
-            case INT8:
-                return ColumnType.INT8;
+        ColumnType columnType = spec.asColumnTypeOrNull();
 
-            case INT16:
-                return ColumnType.INT16;
-
-            case INT32:
-                return ColumnType.INT32;
-
-            case INT64:
-                return ColumnType.INT64;
-
-            case FLOAT:
-                return ColumnType.FLOAT;
-
-            case DOUBLE:
-                return ColumnType.DOUBLE;
-
-            case DECIMAL:
-                return ColumnType.DECIMAL;
-
-            case NUMBER:
-                return ColumnType.NUMBER;
-
-            case UUID:
-                return ColumnType.UUID;
-
-            case STRING:
-                return ColumnType.STRING;
-
-            case BYTES:
-                return ColumnType.BYTE_ARRAY;
-
-            case BITMASK:
-                return ColumnType.BITMASK;
-
-            case DATE:
-                return ColumnType.DATE;
-
-            case TIME:
-                return ColumnType.TIME;
-
-            case DATETIME:
-                return ColumnType.DATETIME;
-
-            case TIMESTAMP:
-                return ColumnType.TIMESTAMP;
-
-            default:
-                throw new IgniteException(PROTOCOL_ERR, "Unsupported native 
type: " + spec);
+        if (columnType == null) {
+            throw new IgniteException(PROTOCOL_ERR, "Unsupported native type: 
" + spec);
         }
+
+        return columnType;
     }
 
     /**
diff --git 
a/modules/core/src/main/java/org/apache/ignite/internal/util/CollectionUtils.java
 
b/modules/core/src/main/java/org/apache/ignite/internal/util/CollectionUtils.java
index facc79eb6a..c1e182faee 100644
--- 
a/modules/core/src/main/java/org/apache/ignite/internal/util/CollectionUtils.java
+++ 
b/modules/core/src/main/java/org/apache/ignite/internal/util/CollectionUtils.java
@@ -22,6 +22,7 @@ import static java.util.Collections.emptyIterator;
 import static java.util.Collections.emptyList;
 import static java.util.Collections.unmodifiableCollection;
 import static java.util.Collections.unmodifiableSet;
+import static java.util.stream.Collectors.toSet;
 
 import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
 import it.unimi.dsi.fastutil.ints.IntSet;
@@ -596,4 +597,15 @@ public final class CollectionUtils {
     public static IntSet setOf(Collection<Integer> coll) {
         return IntSets.unmodifiable(new IntOpenHashSet(coll));
     }
+
+    /**
+     * Returns an intersection of two sets.
+     *
+     * @param op1 First operand.
+     * @param op2 Second operand.
+     * @return Result of the intersection.
+     */
+    public static <T> Set<T> intersect(Set<T> op1, Set<T> op2) {
+        return op1.stream().filter(op2::contains).collect(toSet());
+    }
 }
diff --git 
a/modules/core/src/test/java/org/apache/ignite/internal/util/CollectionUtilsTest.java
 
b/modules/core/src/test/java/org/apache/ignite/internal/util/CollectionUtilsTest.java
index f4e226e159..cbd508f211 100644
--- 
a/modules/core/src/test/java/org/apache/ignite/internal/util/CollectionUtilsTest.java
+++ 
b/modules/core/src/test/java/org/apache/ignite/internal/util/CollectionUtilsTest.java
@@ -22,11 +22,13 @@ import static java.util.function.Function.identity;
 import static java.util.stream.Collectors.toList;
 import static org.apache.ignite.internal.util.CollectionUtils.concat;
 import static org.apache.ignite.internal.util.CollectionUtils.difference;
+import static org.apache.ignite.internal.util.CollectionUtils.intersect;
 import static org.apache.ignite.internal.util.CollectionUtils.setOf;
 import static org.apache.ignite.internal.util.CollectionUtils.union;
 import static org.apache.ignite.internal.util.CollectionUtils.viewReadOnly;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.is;
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -359,4 +361,10 @@ public class CollectionUtilsTest {
         assertThrows(UnsupportedOperationException.class, () -> copy.add(42));
         assertThrows(UnsupportedOperationException.class, () -> 
copy.remove(3));
     }
+
+    @Test
+    void testIntersect() {
+        assertThat(intersect(Set.of(1, 2), Set.of(2, 3)), is(Set.of(2)));
+        assertThat(intersect(Set.of(1, 2), Set.of(3, 4)), is(Set.of()));
+    }
 }
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
new file mode 100644
index 0000000000..10a61937d0
--- /dev/null
+++ 
b/modules/core/src/testFixtures/java/org/apache/ignite/internal/testframework/asserts/CompletableFutureAssert.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.testframework.asserts;
+
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Assertions related to {@link CompletableFuture}.
+ */
+public class CompletableFutureAssert {
+    /**
+     * Asserts that the given future completes with an exception being an 
instance of the given class and returns
+     * that exception for further examination.
+     *
+     * <p>Unlike {@link 
org.apache.ignite.internal.testframework.matchers.CompletableFutureExceptionMatcher#willThrowFast(Class)},
+     * this method allows to examine the actual exception thrown further in 
the test.
+     *
+     * @param future Future to work on.
+     * @param expectedExceptionClass Expected class of the exception.
+     * @param <X> Exception type.
+     * @return Matched exception.
+     */
+    public static <X extends Throwable> X assertWillThrowFast(
+            CompletableFuture<?> future,
+            Class<X> expectedExceptionClass
+    ) {
+        try {
+            Object normalResult = future.get(1, TimeUnit.SECONDS);
+
+            return fail("Expected the future to be completed with an exception 
of class " + expectedExceptionClass
+                    + ", but it completed normally with result " + 
normalResult);
+        } 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) {
+            Throwable unwrapped = unwrapThrowable(e);
+
+            if (expectedExceptionClass.isInstance(unwrapped)) {
+                return (X) unwrapped;
+            } else {
+                return fail(
+                        "Expected the future to be completed with an exception 
of class " + expectedExceptionClass.getName() + ", but got "
+                                + e.getClass().getName(),
+                        e
+                );
+            }
+        }
+    }
+
+    private static Throwable unwrapThrowable(Throwable e) {
+        while (e instanceof ExecutionException || e instanceof 
CompletionException) {
+            e = e.getCause();
+        }
+
+        return e;
+    }
+}
diff --git 
a/modules/schema/src/main/java/org/apache/ignite/internal/schema/NativeTypeSpec.java
 
b/modules/schema/src/main/java/org/apache/ignite/internal/schema/NativeTypeSpec.java
index a87a651f13..b7b6e732a2 100644
--- 
a/modules/schema/src/main/java/org/apache/ignite/internal/schema/NativeTypeSpec.java
+++ 
b/modules/schema/src/main/java/org/apache/ignite/internal/schema/NativeTypeSpec.java
@@ -26,6 +26,10 @@ import java.time.LocalTime;
 import java.util.BitSet;
 import org.apache.ignite.internal.schema.row.InternalTuple;
 import org.apache.ignite.internal.tostring.S;
+import org.apache.ignite.lang.ErrorGroups.Common;
+import org.apache.ignite.lang.IgniteException;
+import org.apache.ignite.sql.ColumnType;
+import org.jetbrains.annotations.Nullable;
 
 /**
  * Base class for storage built-in data types definition. The class contains 
predefined values for fixed-sized types and some of the
@@ -355,6 +359,82 @@ public enum NativeTypeSpec {
         }
     }
 
+    /**
+     * Gets client type corresponding to this server type.
+     *
+     * @return Client type code.
+     */
+    public ColumnType asColumnType() {
+        ColumnType columnType = asColumnTypeOrNull();
+
+        if (columnType == null) {
+            throw new IgniteException(Common.UNEXPECTED_ERR, "Unsupported 
native type: " + this);
+        }
+
+        return columnType;
+    }
+
+    /**
+     * Gets client type corresponding to this server type.
+     *
+     * @return Client type code.
+     */
+    @Nullable
+    public ColumnType asColumnTypeOrNull() {
+        switch (this) {
+            case INT8:
+                return ColumnType.INT8;
+
+            case INT16:
+                return ColumnType.INT16;
+
+            case INT32:
+                return ColumnType.INT32;
+
+            case INT64:
+                return ColumnType.INT64;
+
+            case FLOAT:
+                return ColumnType.FLOAT;
+
+            case DOUBLE:
+                return ColumnType.DOUBLE;
+
+            case DECIMAL:
+                return ColumnType.DECIMAL;
+
+            case NUMBER:
+                return ColumnType.NUMBER;
+
+            case UUID:
+                return ColumnType.UUID;
+
+            case STRING:
+                return ColumnType.STRING;
+
+            case BYTES:
+                return ColumnType.BYTE_ARRAY;
+
+            case BITMASK:
+                return ColumnType.BITMASK;
+
+            case DATE:
+                return ColumnType.DATE;
+
+            case TIME:
+                return ColumnType.TIME;
+
+            case DATETIME:
+                return ColumnType.DATETIME;
+
+            case TIMESTAMP:
+                return ColumnType.TIMESTAMP;
+
+            default:
+                return null;
+        }
+    }
+
     /**
      * Maps object to native type.
      *
diff --git a/modules/table/build.gradle b/modules/table/build.gradle
index 70611297f3..d66a209095 100644
--- a/modules/table/build.gradle
+++ b/modules/table/build.gradle
@@ -42,6 +42,7 @@ dependencies {
     implementation project(':ignite-distribution-zones')
     implementation project(':ignite-vault')
     implementation project(':ignite-cluster-management')
+    implementation project(':ignite-catalog')
     implementation libs.jetbrains.annotations
     implementation libs.fastutil.core
     implementation libs.auto.service.annotations
@@ -81,6 +82,7 @@ dependencies {
     testFixturesImplementation project(':ignite-raft-api')
     testFixturesImplementation project(':ignite-replicator')
     testFixturesImplementation project(':ignite-configuration')
+    testFixturesImplementation project(':ignite-catalog')
     testFixturesImplementation(testFixtures(project(':ignite-core')))
     testFixturesImplementation(testFixtures(project(':ignite-storage-api')))
     testFixturesImplementation(testFixtures(project(':ignite-transactions')))
diff --git 
a/modules/table/src/integrationTest/java/org/apache/ignite/distributed/ItTxDistributedTestSingleNode.java
 
b/modules/table/src/integrationTest/java/org/apache/ignite/distributed/ItTxDistributedTestSingleNode.java
index 195e11ec86..b1b0ac602f 100644
--- 
a/modules/table/src/integrationTest/java/org/apache/ignite/distributed/ItTxDistributedTestSingleNode.java
+++ 
b/modules/table/src/integrationTest/java/org/apache/ignite/distributed/ItTxDistributedTestSingleNode.java
@@ -96,6 +96,7 @@ import 
org.apache.ignite.internal.table.distributed.replicator.PlacementDriver;
 import org.apache.ignite.internal.table.distributed.storage.InternalTableImpl;
 import org.apache.ignite.internal.table.impl.DummyInternalTableImpl;
 import org.apache.ignite.internal.table.impl.DummySchemaManagerImpl;
+import org.apache.ignite.internal.table.impl.DummySchemas;
 import org.apache.ignite.internal.thread.NamedThreadFactory;
 import org.apache.ignite.internal.tx.InternalTransaction;
 import org.apache.ignite.internal.tx.TxManager;
@@ -503,6 +504,7 @@ public class ItTxDistributedTestSingleNode extends 
TxAbstractTest {
                                                 txStateStorage,
                                                 placementDriver,
                                                 storageUpdateHandler,
+                                                new 
DummySchemas(schemaManager),
                                                 peer -> 
assignment.equals(peer.consistentId()),
                                                 completedFuture(schemaManager)
                                         ),
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/TableManager.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/TableManager.java
index a10ab28812..dda37dce6c 100644
--- 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/TableManager.java
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/TableManager.java
@@ -143,6 +143,7 @@ import 
org.apache.ignite.internal.table.distributed.raft.snapshot.outgoing.Outgo
 import 
org.apache.ignite.internal.table.distributed.raft.snapshot.outgoing.SnapshotAwarePartitionDataStorage;
 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.NonHistoricSchemas;
 import org.apache.ignite.internal.table.distributed.storage.InternalTableImpl;
 import org.apache.ignite.internal.table.distributed.storage.PartitionStorages;
 import org.apache.ignite.internal.table.event.TableEvent;
@@ -826,6 +827,7 @@ public class TableManager extends Producer<TableEvent, 
TableEventParameters> imp
                                                             txStateStorage,
                                                             placementDriver,
                                                             
storageUpdateHandler,
+                                                            new 
NonHistoricSchemas(schemaManager),
                                                             this::isLocalPeer,
                                                             
schemaManager.schemaRegistry(causalityToken, tblId)
                                                     ),
@@ -2098,6 +2100,7 @@ public class TableManager extends Producer<TableEvent, 
TableEventParameters> imp
                                             txStatePartitionStorage,
                                             placementDriver,
                                             storageUpdateHandler,
+                                            new 
NonHistoricSchemas(schemaManager),
                                             this::isLocalPeer,
                                             
completedFuture(schemaManager.schemaRegistry(tblId))
                                     ),
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/raft/RebalanceRaftGroupEventsListener.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/raft/RebalanceRaftGroupEventsListener.java
index a8324a8cd2..b7fb7d9bc0 100644
--- 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/raft/RebalanceRaftGroupEventsListener.java
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/raft/RebalanceRaftGroupEventsListener.java
@@ -24,11 +24,11 @@ import static 
org.apache.ignite.internal.metastorage.dsl.Operations.ops;
 import static org.apache.ignite.internal.metastorage.dsl.Operations.put;
 import static org.apache.ignite.internal.metastorage.dsl.Operations.remove;
 import static org.apache.ignite.internal.metastorage.dsl.Statements.iif;
-import static org.apache.ignite.internal.utils.RebalanceUtil.intersect;
+import static org.apache.ignite.internal.util.CollectionUtils.difference;
+import static org.apache.ignite.internal.util.CollectionUtils.intersect;
 import static 
org.apache.ignite.internal.utils.RebalanceUtil.pendingPartAssignmentsKey;
 import static 
org.apache.ignite.internal.utils.RebalanceUtil.plannedPartAssignmentsKey;
 import static 
org.apache.ignite.internal.utils.RebalanceUtil.stablePartAssignmentsKey;
-import static org.apache.ignite.internal.utils.RebalanceUtil.subtract;
 import static org.apache.ignite.internal.utils.RebalanceUtil.switchAppendKey;
 import static org.apache.ignite.internal.utils.RebalanceUtil.switchReduceKey;
 import static org.apache.ignite.internal.utils.RebalanceUtil.union;
@@ -336,20 +336,20 @@ public class RebalanceRaftGroupEventsListener implements 
RaftGroupEventsListener
             Set<Assignment> stable = createAssignments(configuration);
 
             // Were reduced
-            Set<Assignment> reducedNodes = subtract(retrievedSwitchReduce, 
stable);
+            Set<Assignment> reducedNodes = difference(retrievedSwitchReduce, 
stable);
 
             // Were added
-            Set<Assignment> addedNodes = subtract(stable, retrievedStable);
+            Set<Assignment> addedNodes = difference(stable, retrievedStable);
 
             // For further reduction
-            Set<Assignment> calculatedSwitchReduce = 
subtract(retrievedSwitchReduce, reducedNodes);
+            Set<Assignment> calculatedSwitchReduce = 
difference(retrievedSwitchReduce, reducedNodes);
 
             // For further addition
             Set<Assignment> calculatedSwitchAppend = 
union(retrievedSwitchAppend, reducedNodes);
-            calculatedSwitchAppend = subtract(calculatedSwitchAppend, 
addedNodes);
+            calculatedSwitchAppend = difference(calculatedSwitchAppend, 
addedNodes);
             calculatedSwitchAppend = intersect(calculatedAssignments, 
calculatedSwitchAppend);
 
-            Set<Assignment> calculatedPendingReduction = subtract(stable, 
retrievedSwitchReduce);
+            Set<Assignment> calculatedPendingReduction = difference(stable, 
retrievedSwitchReduce);
 
             Set<Assignment> calculatedPendingAddition = union(stable, 
reducedNodes);
             calculatedPendingAddition = intersect(calculatedAssignments, 
calculatedPendingAddition);
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/ForwardValidationResult.java
new file mode 100644
index 0000000000..560615dce6
--- /dev/null
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/ForwardValidationResult.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.table.distributed.replicator;
+
+import java.util.Objects;
+import java.util.UUID;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Result of forward schema compatibility validation.
+ */
+public class ForwardValidationResult {
+    private static final ForwardValidationResult SUCCESS = new 
ForwardValidationResult(true, null, -1, -1);
+
+    private final boolean ok;
+    @Nullable
+    private final UUID failedTableId;
+    private final int fromSchemaVersion;
+    private final int toSchemaVersion;
+
+    /**
+     * Returns a successful validation result.
+     *
+     * @return A successful validation result.
+     */
+    public static ForwardValidationResult success() {
+        return SUCCESS;
+    }
+
+    /**
+     * Creates a validation result denoting validation failure.
+     *
+     * @param failedTableId Table which schema change is incompatible.
+     * @param fromSchemaVersion Version number of the schema from which an 
incompatible transition tried to be made.
+     * @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);
+    }
+
+    private ForwardValidationResult(boolean ok, @Nullable UUID failedTableId, 
int fromSchemaVersion, int toSchemaVersion) {
+        this.ok = ok;
+        this.failedTableId = failedTableId;
+        this.fromSchemaVersion = fromSchemaVersion;
+        this.toSchemaVersion = toSchemaVersion;
+    }
+
+    /**
+     * Returns whether the validation was successful.
+     *
+     * @return Whether the validation was successful
+     */
+    public boolean isSuccessful() {
+        return ok;
+    }
+
+    /**
+     * Returns ID of the table for which the validation has failed. Should 
only be called for a failed validation result,
+     * otherwise an exception is thrown.
+     *
+     * @return Table ID.
+     */
+    public UUID failedTableId() {
+        return Objects.requireNonNull(failedTableId);
+    }
+
+    /**
+     * 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.
+     *
+     * @return Version number of the schema from which an incompatible 
transition tried to be made.
+     */
+    public int fromSchemaVersion() {
+        throwIfSuccessful();
+
+        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.
+     *
+     * @return Version number of the schema to which an incompatible 
transition tried to be made.
+     */
+    public int toSchemaVersion() {
+        throwIfSuccessful();
+
+        return toSchemaVersion;
+    }
+}
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/IncompatibleSchemaAbortException.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/IncompatibleSchemaAbortException.java
new file mode 100644
index 0000000000..4855ee00bf
--- /dev/null
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/IncompatibleSchemaAbortException.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.table.distributed.replicator;
+
+import org.apache.ignite.lang.ErrorGroups.Transactions;
+import org.apache.ignite.tx.TransactionException;
+
+/**
+ * Thrown when, during an attempt to commit a transaction, it turns out that 
the transaction cannot be committed
+ * because an incompatible schema change has happened.
+ */
+public class IncompatibleSchemaAbortException extends TransactionException {
+    public IncompatibleSchemaAbortException(String message) {
+        super(Transactions.TX_COMMIT_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 6cafe2c33f..a232111407 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
@@ -21,6 +21,7 @@ import static java.util.Objects.requireNonNull;
 import static java.util.concurrent.CompletableFuture.allOf;
 import static java.util.concurrent.CompletableFuture.completedFuture;
 import static java.util.concurrent.CompletableFuture.failedFuture;
+import static java.util.stream.Collectors.toList;
 import static org.apache.ignite.internal.util.CollectionUtils.nullOrEmpty;
 import static org.apache.ignite.internal.util.IgniteUtils.filter;
 import static org.apache.ignite.internal.util.IgniteUtils.findAny;
@@ -56,7 +57,6 @@ import org.apache.ignite.internal.hlc.HybridTimestamp;
 import org.apache.ignite.internal.raft.Command;
 import org.apache.ignite.internal.raft.Peer;
 import org.apache.ignite.internal.raft.service.RaftGroupService;
-import org.apache.ignite.internal.replicator.ReplicationGroupId;
 import org.apache.ignite.internal.replicator.TablePartitionId;
 import 
org.apache.ignite.internal.replicator.exception.PrimaryReplicaMissException;
 import org.apache.ignite.internal.replicator.exception.ReplicationException;
@@ -102,6 +102,7 @@ import 
org.apache.ignite.internal.table.distributed.replication.request.ReadWrit
 import 
org.apache.ignite.internal.table.distributed.replication.request.ReadWriteSingleRowReplicaRequest;
 import 
org.apache.ignite.internal.table.distributed.replication.request.ReadWriteSwapRowReplicaRequest;
 import 
org.apache.ignite.internal.table.distributed.replicator.action.RequestType;
+import org.apache.ignite.internal.table.distributed.schema.Schemas;
 import org.apache.ignite.internal.tx.Lock;
 import org.apache.ignite.internal.tx.LockKey;
 import org.apache.ignite.internal.tx.LockManager;
@@ -204,6 +205,8 @@ public class PartitionReplicaListener implements 
ReplicaListener {
 
     private final CompletableFuture<SchemaRegistry> schemaFut;
 
+    private final SchemaCompatValidator schemaCompatValidator;
+
     /**
      * The constructor.
      *
@@ -240,6 +243,7 @@ public class PartitionReplicaListener implements 
ReplicaListener {
             TxStateStorage txStateStorage,
             PlacementDriver placementDriver,
             StorageUpdateHandler storageUpdateHandler,
+            Schemas schemas,
             Function<Peer, Boolean> isLocalPeerChecker,
             CompletableFuture<SchemaRegistry> schemaFut
     ) {
@@ -264,6 +268,8 @@ public class PartitionReplicaListener implements 
ReplicaListener {
         this.replicationGroupId = new TablePartitionId(tableId, partId);
 
         cursors = new 
ConcurrentSkipListMap<>(IgniteUuid.globalOrderComparator());
+
+        schemaCompatValidator = new SchemaCompatValidator(schemas);
     }
 
     @Override
@@ -964,6 +970,7 @@ public class PartitionReplicaListener implements 
ReplicaListener {
      * Processes transaction finish request.
      * <ol>
      *     <li>Get commit timestamp from finish replica request.</li>
+     *     <li>If attempting a commit, validate commit (and, if not valid, 
switch to abort)</li>
      *     <li>Run specific raft {@code FinishTxCommand} command, that will 
apply txn state to corresponding txStateStorage.</li>
      *     <li>Send cleanup requests to all enlisted primary replicas.</li>
      * </ol>
@@ -976,13 +983,37 @@ public class PartitionReplicaListener implements 
ReplicaListener {
         List<TablePartitionId> aggregatedGroupIds = 
request.groups().values().stream()
                 .flatMap(List::stream)
                 .map(IgniteBiTuple::get1)
-                .collect(Collectors.toList());
+                .collect(toList());
 
         UUID txId = request.txId();
 
-        boolean commit = request.commit();
+        if (request.commit()) {
+            return schemaCompatValidator.validateForwards(txId, 
aggregatedGroupIds, request.commitTimestamp())
+                    .thenCompose(validationResult -> {
+                        return finishAndCleanup(request, 
validationResult.isSuccessful(), aggregatedGroupIds, txId)
+                                .thenAccept(unused -> 
throwIfValidationFailed(validationResult));
+                    });
+        } else {
+            // Aborting.
+            return finishAndCleanup(request, false, aggregatedGroupIds, txId);
+        }
+    }
+
+    private static void throwIfValidationFailed(ForwardValidationResult 
validationResult) {
+        if (!validationResult.isSuccessful()) {
+            throw new IncompatibleSchemaAbortException("Commit failed because 
schema "
+                    + validationResult.fromSchemaVersion() + " is not 
forward-compatible with "
+                    + validationResult.toSchemaVersion() + " for table " + 
validationResult.failedTableId());
+        }
+    }
 
-        CompletableFuture<Object> changeStateFuture = 
finishTransaction(aggregatedGroupIds, txId, commit);
+    private CompletableFuture<Void> finishAndCleanup(
+            TxFinishReplicaRequest request,
+            boolean commit,
+            List<TablePartitionId> aggregatedGroupIds,
+            UUID txId
+    ) {
+        CompletableFuture<?> changeStateFuture = 
finishTransaction(aggregatedGroupIds, txId, commit);
 
         // TODO: https://issues.apache.org/jira/browse/IGNITE-17578 Cleanup 
process should be asynchronous.
         CompletableFuture<?>[] cleanupFutures = new 
CompletableFuture[request.groups().size()];
@@ -1028,7 +1059,7 @@ public class PartitionReplicaListener implements 
ReplicaListener {
                 .tablePartitionIds(
                         aggregatedGroupIds.stream()
                                 
.map(PartitionReplicaListener::tablePartitionId)
-                                .collect(Collectors.toList())
+                                .collect(toList())
                 );
 
         if (commit) {
@@ -2118,7 +2149,7 @@ public class PartitionReplicaListener implements 
ReplicaListener {
      * @return Future with boolean value, indicating whether the transaction 
was committed before timestamp.
      */
     private CompletableFuture<Boolean> resolveTxState(
-            ReplicationGroupId commitGrpId,
+            TablePartitionId commitGrpId,
             UUID txId,
             HybridTimestamp timestamp
     ) {
@@ -2162,7 +2193,7 @@ public class PartitionReplicaListener implements 
ReplicaListener {
      * @param txId Transaction ID.
      * @return Constructed {@link UpdateCommand} object.
      */
-    private UpdateCommand updateCommand(TablePartitionId tablePartId, UUID 
rowUuid, ByteBuffer rowBuf, UUID txId) {
+    private UpdateCommand updateCommand(TablePartitionId tablePartId, UUID 
rowUuid, @Nullable ByteBuffer rowBuf, UUID txId) {
         UpdateCommandBuilder bldr = MSG_FACTORY.updateCommand()
                 .tablePartitionId(tablePartitionId(tablePartId))
                 .rowUuid(rowUuid)
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
new file mode 100644
index 0000000000..f16a231bfe
--- /dev/null
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/SchemaCompatValidator.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.table.distributed.replicator;
+
+import static java.util.stream.Collectors.toSet;
+
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import org.apache.ignite.internal.hlc.HybridTimestamp;
+import org.apache.ignite.internal.replicator.TablePartitionId;
+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.schema.TableDefinitionDiff;
+import org.apache.ignite.internal.tx.TransactionIds;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Validates schema compatibility.
+ */
+class SchemaCompatValidator {
+    private final Schemas schemas;
+
+    SchemaCompatValidator(Schemas schemas) {
+        this.schemas = schemas;
+    }
+
+    /**
+     * Performs commit forward compatibility validation. That is, for each 
table enlisted in the transaction, checks to see whether the
+     * initial schema (identified by the begin timestamp) is 
forward-compatible with the commit schema (identified by the commit
+     * timestamp).
+     *
+     * @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.
+     */
+    CompletableFuture<ForwardValidationResult> validateForwards(
+            UUID txId,
+            List<TablePartitionId> enlistedGroupIds,
+            @Nullable HybridTimestamp commitTimestamp
+    ) {
+        HybridTimestamp beginTimestamp = TransactionIds.beginTimestamp(txId);
+
+        Set<UUID> tableIds = enlistedGroupIds.stream()
+                .map(TablePartitionId::tableId)
+                .collect(toSet());
+
+        assert commitTimestamp != null;
+        // Using compareTo() instead of after()/begin() because the latter 
methods take clock skew into account
+        // which only makes sense when comparing 'unrelated' timestamps. 
beginTs and commitTs have a causal relationship,
+        // so we don't need to account for clock skew.
+        assert commitTimestamp.compareTo(beginTimestamp) > 0;
+
+        return schemas.waitForSchemasAvailability(commitTimestamp)
+                .thenApply(ignored -> validateSchemasCompatibility(tableIds, 
commitTimestamp, beginTimestamp));
+    }
+
+    private ForwardValidationResult validateSchemasCompatibility(
+            Set<UUID> tableIds,
+            HybridTimestamp commitTimestamp,
+            HybridTimestamp beginTimestamp
+    ) {
+        for (UUID tableId : tableIds) {
+            ForwardValidationResult validationResult = 
validateSchemaCompatibility(beginTimestamp, commitTimestamp, tableId);
+
+            if (!validationResult.isSuccessful()) {
+                return validationResult;
+            }
+        }
+
+        return ForwardValidationResult.success();
+    }
+
+    private ForwardValidationResult validateSchemaCompatibility(
+            HybridTimestamp beginTimestamp,
+            HybridTimestamp commitTimestamp,
+            UUID tableId
+    ) {
+        List<FullTableSchema> tableSchemas = 
schemas.tableSchemaVersionsBetween(tableId, beginTimestamp, commitTimestamp);
+
+        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());
+            }
+        }
+
+        return ForwardValidationResult.success();
+    }
+
+    private boolean isCompatible(FullTableSchema prevSchema, FullTableSchema 
nextSchema) {
+        TableDefinitionDiff diff = nextSchema.diffFrom(prevSchema);
+
+        // TODO: IGNITE-19229 - more sophisticated logic
+        return diff.isEmpty();
+    }
+}
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/ColumnDefinitionDiff.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/ColumnDefinitionDiff.java
new file mode 100644
index 0000000000..7ed0cd4929
--- /dev/null
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/ColumnDefinitionDiff.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.table.distributed.schema;
+
+import org.apache.ignite.internal.catalog.descriptors.TableColumnDescriptor;
+
+/**
+ * Captures a difference between 'old' and 'new' versions of the same column 
definition.
+ */
+public class ColumnDefinitionDiff {
+    @SuppressWarnings("PMD.UnusedPrivateField")
+    private final TableColumnDescriptor oldColumn;
+    @SuppressWarnings("PMD.UnusedPrivateField")
+    private final TableColumnDescriptor newColumn;
+
+    // TODO: IGNITE-19229 - extend
+
+    public ColumnDefinitionDiff(TableColumnDescriptor oldColumn, 
TableColumnDescriptor newColumn) {
+        this.oldColumn = oldColumn;
+        this.newColumn = newColumn;
+    }
+}
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/FullTableSchema.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/FullTableSchema.java
new file mode 100644
index 0000000000..fbe9e88aca
--- /dev/null
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/FullTableSchema.java
@@ -0,0 +1,144 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.table.distributed.schema;
+
+import static java.util.function.Function.identity;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toMap;
+import static org.apache.ignite.internal.util.CollectionUtils.intersect;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.function.Function;
+import org.apache.ignite.internal.catalog.descriptors.IndexDescriptor;
+import org.apache.ignite.internal.catalog.descriptors.TableColumnDescriptor;
+
+/**
+ * Represents a full table schema: that is, the definition of the table and 
all objects (indexes, constraints, etc)
+ * that belong to the table.
+ */
+public class FullTableSchema {
+    private final int schemaVersion;
+    private final int tableId;
+
+    private final List<TableColumnDescriptor> columns;
+
+    private final List<IndexDescriptor> indexes;
+
+    /**
+     * Constructor.
+     */
+    public FullTableSchema(
+            int schemaVersion,
+            int tableId,
+            List<TableColumnDescriptor> columns,
+            List<IndexDescriptor> indexes
+    ) {
+        this.schemaVersion = schemaVersion;
+        this.tableId = tableId;
+        this.columns = columns;
+        this.indexes = indexes;
+    }
+
+    /**
+     * Returns version of the table definition.
+     *
+     * @return Version of the table definition.
+     */
+    public int schemaVersion() {
+        return schemaVersion;
+    }
+
+    /**
+     * Returns ID of the table.
+     *
+     * @return ID of the table
+     */
+    public int tableId() {
+        return tableId;
+    }
+
+    /**
+     * Returns definitions of the columns of the table.
+     *
+     * @return Definitions of the columns of the table.
+     */
+    public List<TableColumnDescriptor> columns() {
+        return columns;
+    }
+
+    /**
+     * Returns definitions of indexes belonging to the table.
+     *
+     * @return Definitions of indexes belonging to the table.
+     */
+    public List<IndexDescriptor> indexes() {
+        return indexes;
+    }
+
+    /**
+     * Computes a diff between this and a previous schema.
+     *
+     * @param prevSchema Previous table schema.
+     * @return Difference between the schemas.
+     */
+    public TableDefinitionDiff diffFrom(FullTableSchema prevSchema) {
+        Map<String, TableColumnDescriptor> prevColumnsByName = 
toMapByName(prevSchema.columns, TableColumnDescriptor::name);
+        Map<String, TableColumnDescriptor> thisColumnsByName = 
toMapByName(this.columns, TableColumnDescriptor::name);
+
+        List<TableColumnDescriptor> addedColumns = 
subtractKeyed(thisColumnsByName, prevColumnsByName);
+        List<TableColumnDescriptor> removedColumns = 
subtractKeyed(prevColumnsByName, thisColumnsByName);
+
+        Set<String> intersectionColumnNames = 
intersect(thisColumnsByName.keySet(), prevColumnsByName.keySet());
+        List<ColumnDefinitionDiff> changedColumns = new ArrayList<>();
+        for (String commonColumnName : intersectionColumnNames) {
+            TableColumnDescriptor prevColumn = 
prevColumnsByName.get(commonColumnName);
+            TableColumnDescriptor thisColumn = 
thisColumnsByName.get(commonColumnName);
+
+            if (columnChanged(prevColumn, thisColumn)) {
+                changedColumns.add(new ColumnDefinitionDiff(prevColumn, 
thisColumn));
+            }
+        }
+
+        Map<String, IndexDescriptor> prevIndexesByName = 
toMapByName(prevSchema.indexes, IndexDescriptor::name);
+        Map<String, IndexDescriptor> thisIndexesByName = 
toMapByName(this.indexes, IndexDescriptor::name);
+
+        List<IndexDescriptor> addedIndexes = subtractKeyed(thisIndexesByName, 
prevIndexesByName);
+        List<IndexDescriptor> removedIndexes = 
subtractKeyed(prevIndexesByName, thisIndexesByName);
+
+        return new TableDefinitionDiff(addedColumns, removedColumns, 
changedColumns, addedIndexes, removedIndexes);
+    }
+
+    private static <T> Map<String, T> toMapByName(List<T> elements, 
Function<T, String> nameExtractor) {
+        return elements.stream().collect(toMap(nameExtractor, identity()));
+    }
+
+    private static <T> List<T> subtractKeyed(Map<String, T> minuend, 
Map<String, T> subtrahend) {
+        return minuend.entrySet().stream()
+                .filter(entry -> !subtrahend.containsKey(entry.getKey()))
+                .map(Entry::getValue)
+                .collect(toList());
+    }
+
+    private static boolean columnChanged(TableColumnDescriptor prevColumn, 
TableColumnDescriptor newColumn) {
+        return !prevColumn.equals(newColumn);
+    }
+}
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
new file mode 100644
index 0000000000..64c99970fc
--- /dev/null
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/NonHistoricSchemas.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.table.distributed.schema;
+
+import static java.util.concurrent.CompletableFuture.completedFuture;
+import static java.util.stream.Collectors.toList;
+
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import org.apache.ignite.internal.catalog.commands.DefaultValue;
+import org.apache.ignite.internal.catalog.descriptors.TableColumnDescriptor;
+import org.apache.ignite.internal.hlc.HybridTimestamp;
+import org.apache.ignite.internal.schema.Column;
+import org.apache.ignite.internal.schema.SchemaDescriptor;
+import org.apache.ignite.internal.schema.SchemaManager;
+import org.apache.ignite.internal.schema.SchemaRegistry;
+
+/**
+ * A dummy implementation over {@link SchemaManager}. It is dummy because:
+ *
+ * <ul>
+ *     <li>It imitates historicity, but always takes the latest known 
schema</li>
+ *     <li>{@link #tableSchemaVersionsBetween(UUID, HybridTimestamp, 
HybridTimestamp)} always returns a single schema to avoid
+ *     validation failures</li>
+ * </ul>
+ *
+ * <p>The point of this implementation is to allow the system work in the 
pre-SchemaSync fashion before the switch to CatalogService
+ * is possible.
+ */
+// TODO: IGNITE-19447 - remove when switched to the CatalogService
+public class NonHistoricSchemas implements Schemas {
+    private final SchemaManager schemaManager;
+
+    public NonHistoricSchemas(SchemaManager schemaManager) {
+        this.schemaManager = schemaManager;
+    }
+
+    @Override
+    public CompletableFuture<?> waitForSchemasAvailability(HybridTimestamp ts) 
{
+        return completedFuture(null);
+    }
+
+    @Override
+    public List<FullTableSchema> tableSchemaVersionsBetween(UUID tableId, 
HybridTimestamp fromIncluding, HybridTimestamp toIncluding) {
+        SchemaRegistry schemaRegistry = schemaManager.schemaRegistry(tableId);
+        SchemaDescriptor schemaDescriptor = schemaRegistry.schema();
+
+        List<TableColumnDescriptor> columns = 
schemaDescriptor.columnNames().stream()
+                .map(colName -> {
+                    Column column = schemaDescriptor.column(colName);
+
+                    assert column != null;
+
+                    return columnDescriptor(column);
+                })
+                .collect(toList());
+
+        var fullSchema = new FullTableSchema(
+                1,
+                1,
+                columns,
+                List.of()
+        );
+
+        return List.of(fullSchema);
+    }
+
+    /**
+     * 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.
+     *
+     * @param column Column to convert.
+     * @return Conversion result.
+     */
+    public static TableColumnDescriptor columnDescriptor(Column column) {
+        return new TableColumnDescriptor(
+                column.name(),
+                column.type().spec().asColumnType(),
+                column.nullable(),
+                DefaultValue.constant(column.defaultValue())
+        );
+    }
+}
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
new file mode 100644
index 0000000000..42c55a0ce5
--- /dev/null
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/Schemas.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.table.distributed.schema;
+
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import org.apache.ignite.internal.hlc.HybridTimestamp;
+
+/**
+ * Provides access to table schemas.
+ */
+public interface Schemas {
+    /**
+     * Obtains a future that completes when all schemas activating not later 
than the given timestamp are available.
+     *
+     * @param ts Timestamp we are interested in. This is the timestamp 
transaction processing logic is interested in (like beginTs or
+     *     commitTs), not the timestamp after subtraction described in section 
'Waiting for safe time in the past' of
+     *     <a 
href="https://cwiki.apache.org/confluence/display/IGNITE/IEP-98:+Schema+Synchronization";>IEP-98</a>
+     * @return Future that completes when all schemas activating not later 
than the given timestamp are available.
+     */
+    CompletableFuture<?> waitForSchemasAvailability(HybridTimestamp ts);
+
+    /**
+     * Returns all schema versions between (including) the two that were 
effective at the given timestamps.
+     *
+     * @param tableId ID of the table which schemas need to be considered.
+     * @param fromIncluding Start timestamp.
+     * @param toIncluding End timestamp.
+     * @return All schema versions between (including) the two that were 
effective at the given timestamps.
+     */
+    List<FullTableSchema> tableSchemaVersionsBetween(UUID tableId, 
HybridTimestamp fromIncluding, HybridTimestamp toIncluding);
+}
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/TableDefinitionDiff.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/TableDefinitionDiff.java
new file mode 100644
index 0000000000..9981e86710
--- /dev/null
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/TableDefinitionDiff.java
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.table.distributed.schema;
+
+import static java.util.Collections.emptyList;
+
+import java.util.List;
+import org.apache.ignite.internal.catalog.descriptors.IndexDescriptor;
+import org.apache.ignite.internal.catalog.descriptors.TableColumnDescriptor;
+
+/**
+ * Captures the difference between two versions of the same table definition.
+ */
+public class TableDefinitionDiff {
+    private static final TableDefinitionDiff EMPTY = new TableDefinitionDiff(
+            emptyList(), emptyList(), emptyList(), emptyList(), emptyList()
+    );
+
+    private final List<TableColumnDescriptor> addedColumns;
+    private final List<TableColumnDescriptor> removedColumns;
+    private final List<ColumnDefinitionDiff> changedColumns;
+
+    private final List<IndexDescriptor> addedIndexes;
+    private final List<IndexDescriptor> removedIndexes;
+
+    // TODO: IGNITE-19229 - other change types
+
+    /**
+     * Returns an empty diff (meaning there is no difference).
+     *
+     * @return Empty diff (meaning there is no difference).
+     */
+    public static TableDefinitionDiff empty() {
+        return EMPTY;
+    }
+
+    /**
+     * Constructor.
+     */
+    public TableDefinitionDiff(
+            List<TableColumnDescriptor> addedColumns,
+            List<TableColumnDescriptor> removedColumns,
+            List<ColumnDefinitionDiff> changedColumns,
+            List<IndexDescriptor> addedIndexes,
+            List<IndexDescriptor> removedIndexes
+    ) {
+        this.addedColumns = List.copyOf(addedColumns);
+        this.removedColumns = List.copyOf(removedColumns);
+        this.changedColumns = List.copyOf(changedColumns);
+        this.addedIndexes = List.copyOf(addedIndexes);
+        this.removedIndexes = List.copyOf(removedIndexes);
+    }
+
+    /**
+     * Returns columns that were added.
+     */
+    public List<TableColumnDescriptor> addedColumns() {
+        return addedColumns;
+    }
+
+    /**
+     * Returns columns that were removed.
+     */
+    public List<TableColumnDescriptor> removedColumns() {
+        return removedColumns;
+    }
+
+    /**
+     * Returns columns that were changed.
+     */
+    public List<ColumnDefinitionDiff> changedColumns() {
+        return changedColumns;
+    }
+
+    /**
+     * Returns indexes that were added.
+     */
+    public List<IndexDescriptor> addedIndexes() {
+        return addedIndexes;
+    }
+
+    /**
+     * Returns indexes that were removed.
+     */
+    public List<IndexDescriptor> removedIndexes() {
+        return removedIndexes;
+    }
+
+    /**
+     * Returns whether this diff is empty (so no difference at all).
+     *
+     * @return Whether this diff is empty (so no difference at all).
+     */
+    public boolean isEmpty() {
+        return addedColumns.isEmpty()
+                && removedColumns.isEmpty()
+                && changedColumns.isEmpty()
+                && addedIndexes.isEmpty()
+                && removedIndexes.isEmpty();
+    }
+}
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/package-info.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/package-info.java
new file mode 100644
index 0000000000..1f92efab55
--- /dev/null
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * This is a temporary package to host code needed until CatalogService is 
ready
+ * TODO: IGNITE-19447 - remove/rework when switched to full-blown usage of 
CatalogService.
+ */
+package org.apache.ignite.internal.table.distributed.schema;
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/utils/RebalanceUtil.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/utils/RebalanceUtil.java
index 1dd45571c2..81050182af 100644
--- 
a/modules/table/src/main/java/org/apache/ignite/internal/utils/RebalanceUtil.java
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/utils/RebalanceUtil.java
@@ -25,6 +25,7 @@ import static 
org.apache.ignite.internal.metastorage.dsl.Conditions.value;
 import static org.apache.ignite.internal.metastorage.dsl.Operations.ops;
 import static org.apache.ignite.internal.metastorage.dsl.Operations.put;
 import static org.apache.ignite.internal.metastorage.dsl.Statements.iif;
+import static org.apache.ignite.internal.util.CollectionUtils.difference;
 
 import java.nio.charset.StandardCharsets;
 import java.util.Collection;
@@ -32,7 +33,6 @@ import java.util.HashSet;
 import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.CompletableFuture;
-import java.util.stream.Collectors;
 import org.apache.ignite.internal.affinity.AffinityUtils;
 import org.apache.ignite.internal.affinity.Assignment;
 import org.apache.ignite.internal.metastorage.Entry;
@@ -250,7 +250,7 @@ public class RebalanceUtil {
 
         ByteArray pendingKey = pendingPartAssignmentsKey(partId);
 
-        Set<Assignment> pendingAssignments = subtract(assignments, 
switchReduce);
+        Set<Assignment> pendingAssignments = difference(assignments, 
switchReduce);
 
         byte[] pendingByteArray = ByteUtils.toBytes(pendingAssignments);
         byte[] assignmentsByteArray = ByteUtils.toBytes(assignments);
@@ -294,17 +294,6 @@ public class RebalanceUtil {
         return metaStorageMgr.invoke(resultingOperation).thenApply(unused -> 
null);
     }
 
-    /**
-     * Removes nodes from set of nodes.
-     *
-     * @param minuend Set to remove nodes from.
-     * @param subtrahend Set of nodes to be removed.
-     * @return Result of the subtraction.
-     */
-    public static <T> Set<T> subtract(Set<T> minuend, Set<T> subtrahend) {
-        return minuend.stream().filter(v -> 
!subtrahend.contains(v)).collect(Collectors.toSet());
-    }
-
     /**
      * Adds nodes to the set of nodes.
      *
@@ -319,15 +308,4 @@ public class RebalanceUtil {
 
         return res;
     }
-
-    /**
-     * Returns an intersection of two set of nodes.
-     *
-     * @param op1 First operand.
-     * @param op2 Second operand.
-     * @return Result of the intersection.
-     */
-    public static <T> Set<T> intersect(Set<T> op1, Set<T> op2) {
-        return op1.stream().filter(op2::contains).collect(Collectors.toSet());
-    }
 }
diff --git 
a/modules/table/src/test/java/org/apache/ignite/internal/table/RepeatedFinishReadWriteTransactionTest.java
 
b/modules/table/src/test/java/org/apache/ignite/internal/table/RepeatedFinishReadWriteTransactionTest.java
index af54528561..8c39b45678 100644
--- 
a/modules/table/src/test/java/org/apache/ignite/internal/table/RepeatedFinishReadWriteTransactionTest.java
+++ 
b/modules/table/src/test/java/org/apache/ignite/internal/table/RepeatedFinishReadWriteTransactionTest.java
@@ -268,7 +268,7 @@ public class RepeatedFinishReadWriteTransactionTest {
         @Override
         public CompletableFuture<Void> cleanup(ClusterNode recipientNode,
                 List<IgniteBiTuple<TablePartitionId, Long>> tablePartitionIds, 
UUID txId, boolean commit,
-                HybridTimestamp commitTimestamp) {
+                @Nullable HybridTimestamp commitTimestamp) {
             return null;
         }
 
diff --git 
a/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/replication/PartitionReplicaListenerIndexLockingTest.java
 
b/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/replication/PartitionReplicaListenerIndexLockingTest.java
index d47de41365..44a8fbc65c 100644
--- 
a/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/replication/PartitionReplicaListenerIndexLockingTest.java
+++ 
b/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/replication/PartitionReplicaListenerIndexLockingTest.java
@@ -72,6 +72,7 @@ import 
org.apache.ignite.internal.table.distributed.replicator.PlacementDriver;
 import 
org.apache.ignite.internal.table.distributed.replicator.action.RequestType;
 import org.apache.ignite.internal.table.impl.DummyInternalTableImpl;
 import org.apache.ignite.internal.table.impl.DummySchemaManagerImpl;
+import org.apache.ignite.internal.table.impl.DummySchemas;
 import org.apache.ignite.internal.testframework.IgniteAbstractTest;
 import org.apache.ignite.internal.tx.Lock;
 import org.apache.ignite.internal.tx.LockManager;
@@ -198,6 +199,7 @@ public class PartitionReplicaListenerIndexLockingTest 
extends IgniteAbstractTest
                         safeTime,
                         mock(LowWatermark.class)
                 ),
+                new DummySchemas(schemaManager),
                 peer -> true,
                 CompletableFuture.completedFuture(schemaManager)
         );
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 c9bd4bfe02..5079324369 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
@@ -18,10 +18,14 @@
 package org.apache.ignite.internal.table.distributed.replication;
 
 import static java.util.concurrent.CompletableFuture.completedFuture;
+import static 
org.apache.ignite.internal.hlc.HybridTimestamp.hybridTimestampToLong;
+import static 
org.apache.ignite.internal.testframework.asserts.CompletableFutureAssert.assertWillThrowFast;
 import static 
org.apache.ignite.internal.testframework.matchers.CompletableFutureExceptionMatcher.willThrowFast;
 import static 
org.apache.ignite.internal.testframework.matchers.CompletableFutureMatcher.willSucceedFast;
 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.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -29,8 +33,10 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
 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.Mockito.lenient;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
 import java.nio.ByteBuffer;
 import java.util.ArrayList;
@@ -48,12 +54,15 @@ import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Function;
 import java.util.function.Supplier;
 import java.util.stream.IntStream;
 import org.apache.ignite.distributed.TestPartitionDataStorage;
 import org.apache.ignite.internal.binarytuple.BinaryTupleBuilder;
 import org.apache.ignite.internal.binarytuple.BinaryTuplePrefixBuilder;
+import org.apache.ignite.internal.catalog.commands.DefaultValue;
+import org.apache.ignite.internal.catalog.descriptors.TableColumnDescriptor;
 import 
org.apache.ignite.internal.configuration.testframework.ConfigurationExtension;
 import 
org.apache.ignite.internal.configuration.testframework.InjectConfiguration;
 import org.apache.ignite.internal.hlc.HybridClock;
@@ -94,15 +103,19 @@ import 
org.apache.ignite.internal.table.distributed.SortedIndexLocker;
 import org.apache.ignite.internal.table.distributed.StorageUpdateHandler;
 import org.apache.ignite.internal.table.distributed.TableMessagesFactory;
 import 
org.apache.ignite.internal.table.distributed.TableSchemaAwareIndexStorage;
+import org.apache.ignite.internal.table.distributed.command.FinishTxCommand;
 import org.apache.ignite.internal.table.distributed.command.PartitionCommand;
 import org.apache.ignite.internal.table.distributed.command.TxCleanupCommand;
 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.LeaderOrTxState;
 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.replicator.action.RequestType;
+import org.apache.ignite.internal.table.distributed.schema.FullTableSchema;
+import org.apache.ignite.internal.table.distributed.schema.Schemas;
 import org.apache.ignite.internal.table.impl.DummyInternalTableImpl;
 import org.apache.ignite.internal.table.impl.DummySchemaManagerImpl;
 import org.apache.ignite.internal.testframework.IgniteAbstractTest;
@@ -113,19 +126,24 @@ import org.apache.ignite.internal.tx.TxManager;
 import org.apache.ignite.internal.tx.TxMeta;
 import org.apache.ignite.internal.tx.TxState;
 import org.apache.ignite.internal.tx.impl.HeapLockManager;
+import org.apache.ignite.internal.tx.message.TxFinishReplicaRequest;
 import org.apache.ignite.internal.tx.message.TxMessagesFactory;
 import org.apache.ignite.internal.tx.storage.state.test.TestTxStateStorage;
 import org.apache.ignite.internal.tx.test.TestTransactionIds;
 import org.apache.ignite.internal.util.Cursor;
 import org.apache.ignite.internal.util.Lazy;
 import org.apache.ignite.internal.util.PendingComparableValuesTracker;
+import org.apache.ignite.lang.ErrorGroups.Transactions;
+import org.apache.ignite.lang.IgniteBiTuple;
 import org.apache.ignite.lang.IgniteException;
 import org.apache.ignite.network.ClusterNode;
 import org.apache.ignite.network.NetworkAddress;
 import org.apache.ignite.network.TopologyService;
+import org.apache.ignite.sql.ColumnType;
 import org.apache.ignite.tx.TransactionException;
 import org.jetbrains.annotations.Nullable;
 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.junitpioneer.jupiter.cartesian.CartesianTest;
@@ -208,12 +226,18 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
     @Mock
     private RaftGroupService mockRaftClient;
 
+    @Mock
+    private TxManager txManager;
+
     @Mock
     private TopologyService topologySrv;
 
     @Mock
     private PendingComparableValuesTracker<HybridTimestamp, Void> 
safeTimeClock;
 
+    @Mock
+    private Schemas schemas;
+
     /** Schema descriptor for tests. */
     private SchemaDescriptor schemaDescriptor;
 
@@ -290,6 +314,8 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
 
         
lenient().when(safeTimeClock.waitFor(any())).thenReturn(completedFuture(null));
 
+        
lenient().when(schemas.waitForSchemasAvailability(any())).thenReturn(completedFuture(null));
+
         UUID pkIndexId = UUID.randomUUID();
         UUID sortedIndexId = UUID.randomUUID();
         UUID hashIndexId = UUID.randomUUID();
@@ -332,7 +358,7 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
         partitionReplicaListener = new PartitionReplicaListener(
                 testMvPartitionStorage,
                 mockRaftClient,
-                mock(TxManager.class),
+                txManager,
                 lockManager,
                 Runnable::run,
                 partId,
@@ -352,6 +378,7 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
                         safeTimeClock,
                         mock(LowWatermark.class)
                 ),
+                schemas,
                 peer -> localNode.name().equals(peer.consistentId()),
                 completedFuture(schemaManager)
         );
@@ -1155,6 +1182,133 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
         cleanup(tx1);
     }
 
+    @Test
+    public void abortsSuccessfully() {
+        AtomicReference<Boolean> committed = interceptFinishTxCommand();
+
+        CompletableFuture<?> future = beginAndAbortTx();
+
+        assertThat(future, willSucceedFast());
+
+        assertThat(committed.get(), is(false));
+    }
+
+    private CompletableFuture<?> beginAndAbortTx() {
+        when(txManager.cleanup(any(), any(), any(), anyBoolean(), 
any())).thenReturn(completedFuture(null));
+
+        HybridTimestamp beginTimestamp = clock.now();
+        UUID txId = 
TestTransactionIds.TRANSACTION_ID_GENERATOR.transactionIdFor(beginTimestamp);
+
+        TxFinishReplicaRequest commitRequest = 
TX_MESSAGES_FACTORY.txFinishReplicaRequest()
+                .groupId(grpId)
+                .txId(txId)
+                .groups(Map.of(localNode, List.of(new IgniteBiTuple<>(grpId, 
1L))))
+                .commit(false)
+                .term(1L)
+                .build();
+
+        return partitionReplicaListener.invoke(commitRequest);
+    }
+
+    @Test
+    public void commitsOnSameSchemaSuccessfully() {
+        when(schemas.tableSchemaVersionsBetween(any(), any(), any()))
+                .thenReturn(List.of(
+                        tableSchema(1, List.of(nullableColumn("col")))
+                ));
+
+        AtomicReference<Boolean> committed = interceptFinishTxCommand();
+
+        CompletableFuture<?> future = beginAndCommitTx();
+
+        assertThat(future, willSucceedFast());
+
+        assertThat(committed.get(), is(true));
+    }
+
+    private static TableColumnDescriptor nullableColumn(String colName) {
+        return new TableColumnDescriptor(colName, ColumnType.INT32, true, 
DefaultValue.constant(null));
+    }
+
+    private static TableColumnDescriptor defaultedColumn(String colName, int 
defaultValue) {
+        return new TableColumnDescriptor(colName, ColumnType.INT32, false, 
DefaultValue.constant(defaultValue));
+    }
+
+    private static FullTableSchema tableSchema(int schemaVersion, 
List<TableColumnDescriptor> columns) {
+        return new FullTableSchema(schemaVersion, 1, columns, List.of());
+    }
+
+    private AtomicReference<Boolean> interceptFinishTxCommand() {
+        AtomicReference<Boolean> committed = new AtomicReference<>();
+
+        raftClientFutureClosure = command -> {
+            if (command instanceof FinishTxCommand) {
+                committed.set(((FinishTxCommand) command).commit());
+            }
+            return defaultMockRaftFutureClosure.apply(command);
+        };
+
+        return committed;
+    }
+
+    private CompletableFuture<?> beginAndCommitTx() {
+        when(txManager.cleanup(any(), any(), any(), anyBoolean(), 
any())).thenReturn(completedFuture(null));
+
+        HybridTimestamp beginTimestamp = clock.now();
+        UUID txId = 
TestTransactionIds.TRANSACTION_ID_GENERATOR.transactionIdFor(beginTimestamp);
+
+        HybridTimestamp commitTimestamp = clock.now();
+
+        TxFinishReplicaRequest commitRequest = 
TX_MESSAGES_FACTORY.txFinishReplicaRequest()
+                .groupId(grpId)
+                .txId(txId)
+                .groups(Map.of(localNode, List.of(new IgniteBiTuple<>(grpId, 
1L))))
+                .commit(true)
+                .commitTimestampLong(hybridTimestampToLong(commitTimestamp))
+                .term(1L)
+                .build();
+
+        return partitionReplicaListener.invoke(commitRequest);
+    }
+
+    @Test
+    @Disabled("IGNITE-19229")
+    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")))
+                ));
+
+        AtomicReference<Boolean> committed = interceptFinishTxCommand();
+
+        CompletableFuture<?> future = beginAndCommitTx();
+
+        assertThat(future, willSucceedFast());
+
+        assertThat(committed.get(), is(true));
+    }
+
+    @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)))
+                ));
+
+        AtomicReference<Boolean> committed = interceptFinishTxCommand();
+
+        CompletableFuture<?> future = beginAndCommitTx();
+
+        IncompatibleSchemaAbortException ex = assertWillThrowFast(future,
+                IncompatibleSchemaAbortException.class);
+        assertThat(ex.code(), is(Transactions.TX_COMMIT_ERR));
+        assertThat(ex.getMessage(), containsString("Commit failed because 
schema 1 is not forward-compatible with 2"));
+
+        assertThat(committed.get(), is(false));
+    }
+
     private static UUID beginTx() {
         return TestTransactionIds.newTransactionId();
     }
diff --git 
a/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/schema/FullTableSchemaTest.java
 
b/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/schema/FullTableSchemaTest.java
new file mode 100644
index 0000000000..fbe2ce2830
--- /dev/null
+++ 
b/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/schema/FullTableSchemaTest.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.table.distributed.schema;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+
+import java.util.List;
+import org.apache.ignite.internal.catalog.commands.DefaultValue;
+import org.apache.ignite.internal.catalog.descriptors.HashIndexDescriptor;
+import org.apache.ignite.internal.catalog.descriptors.IndexDescriptor;
+import org.apache.ignite.internal.catalog.descriptors.TableColumnDescriptor;
+import org.apache.ignite.sql.ColumnType;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.Test;
+
+class FullTableSchemaTest {
+    @Test
+    void sameSchemasHaveEmptyDiff() {
+        TableColumnDescriptor column = someColumn("a");
+        IndexDescriptor index = someIndex(1, "ind_a");
+
+        var schema1 = new FullTableSchema(1, 1, List.of(column), 
List.of(index));
+        var schema2 = new FullTableSchema(2, 1, List.of(column), 
List.of(index));
+
+        TableDefinitionDiff diff = schema2.diffFrom(schema1);
+
+        assertThat(diff.isEmpty(), is(true));
+    }
+
+    @NotNull
+    private static HashIndexDescriptor someIndex(int id, String name) {
+        return new HashIndexDescriptor(id, name, 1, List.of("a"));
+    }
+
+    @NotNull
+    private static TableColumnDescriptor someColumn(String columnName) {
+        return new TableColumnDescriptor(columnName, ColumnType.INT32, true, 
DefaultValue.constant(null));
+    }
+
+    @Test
+    void addedRemovedColumnsAreReflectedInDiff() {
+        TableColumnDescriptor column1 = someColumn("a");
+        TableColumnDescriptor column2 = someColumn("b");
+        TableColumnDescriptor column3 = someColumn("c");
+
+        var schema1 = new FullTableSchema(1, 1, List.of(column1, column2), 
List.of());
+        var schema2 = new FullTableSchema(2, 1, List.of(column2, column3), 
List.of());
+
+        TableDefinitionDiff diff = schema2.diffFrom(schema1);
+
+        assertThat(diff.isEmpty(), is(false));
+        assertThat(diff.addedColumns(), is(List.of(column3)));
+        assertThat(diff.removedColumns(), is(List.of(column1)));
+        assertThat(diff.changedColumns(), is(empty()));
+    }
+
+    @Test
+    void changedColumnsAreReflectedInDiff() {
+        TableColumnDescriptor column1 = someColumn("a");
+
+        var schema1 = new FullTableSchema(1, 1, List.of(column1), List.of());
+        var schema2 = new FullTableSchema(2, 1,
+                List.of(new TableColumnDescriptor("a", ColumnType.STRING, 
true, DefaultValue.constant(null))),
+                List.of()
+        );
+
+        TableDefinitionDiff diff = schema2.diffFrom(schema1);
+
+        assertThat(diff.isEmpty(), is(false));
+
+        List<ColumnDefinitionDiff> changedColumns = diff.changedColumns();
+        assertThat(changedColumns, is(hasSize(1)));
+    }
+
+    @Test
+    void addedRemovedIndexesAreReflectedInDiff() {
+        IndexDescriptor index1 = someIndex(1, "a");
+        IndexDescriptor index2 = someIndex(2, "b");
+        IndexDescriptor index3 = someIndex(3, "c");
+
+        var schema1 = new FullTableSchema(1, 1, List.of(someColumn("a")), 
List.of(index1, index2));
+        var schema2 = new FullTableSchema(2, 1, List.of(someColumn("a")), 
List.of(index2, index3));
+
+        TableDefinitionDiff diff = schema2.diffFrom(schema1);
+
+        assertThat(diff.isEmpty(), is(false));
+        assertThat(diff.addedIndexes(), is(List.of(index3)));
+        assertThat(diff.removedIndexes(), is(List.of(index1)));
+    }
+}
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 efb3727dca..1859462772 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
@@ -17,6 +17,7 @@
 
 package org.apache.ignite.internal.table.impl;
 
+import static java.util.concurrent.CompletableFuture.completedFuture;
 import static org.junit.jupiter.api.Assertions.fail;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.lenient;
@@ -70,6 +71,8 @@ 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;
@@ -199,7 +202,7 @@ public class DummyInternalTableImpl extends 
InternalTableImpl {
         lenient().doReturn(groupId).when(svc).groupId();
         Peer leaderPeer = new Peer(UUID.randomUUID().toString());
         lenient().doReturn(leaderPeer).when(svc).leader();
-        lenient().doReturn(CompletableFuture.completedFuture(new 
LeaderWithTerm(leaderPeer, 1L))).when(svc).refreshAndGetLeaderWithTerm();
+        lenient().doReturn(completedFuture(new LeaderWithTerm(leaderPeer, 
1L))).when(svc).refreshAndGetLeaderWithTerm();
 
         if (!crossTableUsage) {
             // Delegate replica requests directly to replica listener.
@@ -303,8 +306,20 @@ 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()));
+                    }
+                },
                 peer -> true,
-                CompletableFuture.completedFuture(schemaManager)
+                completedFuture(schemaManager)
         );
 
         partitionListener = new PartitionListener(
@@ -398,7 +413,7 @@ public class DummyInternalTableImpl extends 
InternalTableImpl {
     /** {@inheritDoc} */
     @Override
     public CompletableFuture<ClusterNode> evaluateReadOnlyRecipientNode(int 
partId) {
-        return CompletableFuture.completedFuture(mock(ClusterNode.class));
+        return completedFuture(mock(ClusterNode.class));
     }
 
     @Override
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
new file mode 100644
index 0000000000..89b112db48
--- /dev/null
+++ 
b/modules/table/src/testFixtures/java/org/apache/ignite/internal/table/impl/DummySchemas.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.table.impl;
+
+import static java.util.concurrent.CompletableFuture.completedFuture;
+import static java.util.stream.Collectors.toList;
+
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import org.apache.ignite.internal.catalog.descriptors.TableColumnDescriptor;
+import org.apache.ignite.internal.hlc.HybridTimestamp;
+import org.apache.ignite.internal.schema.Column;
+import org.apache.ignite.internal.schema.SchemaDescriptor;
+import org.apache.ignite.internal.schema.SchemaRegistry;
+import org.apache.ignite.internal.table.distributed.schema.FullTableSchema;
+import org.apache.ignite.internal.table.distributed.schema.NonHistoricSchemas;
+import org.apache.ignite.internal.table.distributed.schema.Schemas;
+
+/**
+ * Dummy {@link Schemas} implementation that is not historic and always uses 
same {@link SchemaRegistry}.
+ */
+public class DummySchemas implements Schemas {
+    private final SchemaRegistry schemaRegistry;
+
+    public DummySchemas(SchemaRegistry schemaRegistry) {
+        this.schemaRegistry = schemaRegistry;
+    }
+
+    @Override
+    public CompletableFuture<?> waitForSchemasAvailability(HybridTimestamp ts) 
{
+        return completedFuture(null);
+    }
+
+    @Override
+    public List<FullTableSchema> tableSchemaVersionsBetween(UUID tableId, 
HybridTimestamp fromIncluding, HybridTimestamp toIncluding) {
+        SchemaDescriptor schemaDescriptor = schemaRegistry.schema();
+
+        List<TableColumnDescriptor> columns = 
schemaDescriptor.columnNames().stream()
+                .map(colName -> {
+                    Column column = schemaDescriptor.column(colName);
+
+                    assert column != null;
+
+                    return NonHistoricSchemas.columnDescriptor(column);
+                })
+                .collect(toList());
+
+        var fullSchema = new FullTableSchema(
+                1,
+                1,
+                columns,
+                List.of()
+        );
+
+        return List.of(fullSchema);
+    }
+}
diff --git 
a/modules/transactions/src/main/java/org/apache/ignite/internal/tx/TxManager.java
 
b/modules/transactions/src/main/java/org/apache/ignite/internal/tx/TxManager.java
index 5e91960401..3f00db332a 100644
--- 
a/modules/transactions/src/main/java/org/apache/ignite/internal/tx/TxManager.java
+++ 
b/modules/transactions/src/main/java/org/apache/ignite/internal/tx/TxManager.java
@@ -110,7 +110,7 @@ public interface TxManager extends IgniteComponent {
      * @param tablePartitionIds Table partition ids with raft terms.
      * @param txId Transaction id.
      * @param commit {@code True} if a commit requested.
-     * @param commitTimestamp Commit timestamp.
+     * @param commitTimestamp Commit timestamp ({@code null} if it's an abort).
      * @return Completable future of Void.
      */
     CompletableFuture<Void> cleanup(
@@ -118,7 +118,7 @@ public interface TxManager extends IgniteComponent {
             List<IgniteBiTuple<TablePartitionId, Long>> tablePartitionIds,
             UUID txId,
             boolean commit,
-            HybridTimestamp commitTimestamp
+            @Nullable HybridTimestamp commitTimestamp
     );
 
     /**
diff --git 
a/modules/transactions/src/main/java/org/apache/ignite/internal/tx/impl/TxManagerImpl.java
 
b/modules/transactions/src/main/java/org/apache/ignite/internal/tx/impl/TxManagerImpl.java
index b6cee11806..16f5235e10 100644
--- 
a/modules/transactions/src/main/java/org/apache/ignite/internal/tx/impl/TxManagerImpl.java
+++ 
b/modules/transactions/src/main/java/org/apache/ignite/internal/tx/impl/TxManagerImpl.java
@@ -45,6 +45,7 @@ import 
org.apache.ignite.internal.tx.message.TxMessagesFactory;
 import org.apache.ignite.lang.IgniteBiTuple;
 import org.apache.ignite.lang.IgniteInternalException;
 import org.apache.ignite.network.ClusterNode;
+import org.jetbrains.annotations.Nullable;
 import org.jetbrains.annotations.TestOnly;
 
 /**
@@ -196,7 +197,7 @@ public class TxManagerImpl implements TxManager {
             List<IgniteBiTuple<TablePartitionId, Long>> tablePartitionIds,
             UUID txId,
             boolean commit,
-            HybridTimestamp commitTimestamp
+            @Nullable HybridTimestamp commitTimestamp
     ) {
         var cleanupFutures = new CompletableFuture[tablePartitionIds.size()];
 
diff --git 
a/modules/transactions/src/testFixtures/java/org/apache/ignite/internal/tx/test/TestTransactionIds.java
 
b/modules/transactions/src/testFixtures/java/org/apache/ignite/internal/tx/test/TestTransactionIds.java
index 66e4a6814b..4f2ba869fa 100644
--- 
a/modules/transactions/src/testFixtures/java/org/apache/ignite/internal/tx/test/TestTransactionIds.java
+++ 
b/modules/transactions/src/testFixtures/java/org/apache/ignite/internal/tx/test/TestTransactionIds.java
@@ -33,7 +33,7 @@ public class TestTransactionIds {
     /** Hard-coded node ID. */
     private static final int SOLE_NODE_ID = 0xdeadbeef;
 
-    private static final TransactionIdGenerator TRANSACTION_ID_GENERATOR = new 
TransactionIdGenerator(SOLE_NODE_ID);
+    public static final TransactionIdGenerator TRANSACTION_ID_GENERATOR = new 
TransactionIdGenerator(SOLE_NODE_ID);
 
     /**
      * Generates new transaction ID using the global clock and hard-coded node 
ID.


Reply via email to