You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ignite.apache.org by sd...@apache.org on 2022/05/12 14:04:02 UTC

[ignite-3] branch main updated: IGNITE-16921 Support schema changes concerning inheritance hierarchy (#802)

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

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


The following commit(s) were added to refs/heads/main by this push:
     new 154447cf6 IGNITE-16921 Support schema changes concerning inheritance hierarchy (#802)
154447cf6 is described below

commit 154447cf6c202b22b11ed98c0191e2f069ebd7c4
Author: Roman Puchkovskiy <ro...@gmail.com>
AuthorDate: Thu May 12 18:03:57 2022 +0400

    IGNITE-16921 Support schema changes concerning inheritance hierarchy (#802)
---
 .../network/serialization/BrokenFieldAccessor.java |  11 +-
 .../serialization/BrokenSerializationMethods.java  |  57 +++++
 .../network/serialization/ClassDescriptor.java     |  92 +++++++-
 .../serialization/ClassDescriptorMerger.java       |  57 +++++
 ...ClassNameMapBackedClassIndexedDescriptors.java} |  10 +-
 .../network/serialization/FieldAccessor.java       |   2 +-
 .../network/serialization/FieldDescriptor.java     |  41 +++-
 .../network/serialization/MergedLayer.java         |  99 +++++++++
 .../PerSessionSerializationService.java            | 141 ++++++++----
 .../serialization/SerializationException.java      |   4 +-
 .../marshal/DefaultUserObjectMarshaller.java       |  13 +-
 .../marshal/SchemaMismatchEventSource.java         |  11 +
 .../marshal/SchemaMismatchHandlers.java            |  36 +--
 .../marshal/StructuredObjectMarshaller.java        |  54 +++--
 .../serialization/ClassDescriptorMergerTest.java   | 181 +++++++++++++++
 ...sNameMapBackedClassIndexedDescriptorsTest.java} |   8 +-
 ...ltUserObjectMarshallerWithSchemaChangeTest.java | 242 ++++++++++++++++++++-
 17 files changed, 932 insertions(+), 127 deletions(-)

diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/BrokenFieldAccessor.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/BrokenFieldAccessor.java
index 1cf9c6575..41e766d35 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/BrokenFieldAccessor.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/BrokenFieldAccessor.java
@@ -22,11 +22,11 @@ package org.apache.ignite.internal.network.serialization;
  */
 class BrokenFieldAccessor implements FieldAccessor {
     private final String fieldName;
-    private final Class<?> declaringClass;
+    private final String declaringClassName;
 
-    BrokenFieldAccessor(String fieldName, Class<?> declaringClass) {
+    BrokenFieldAccessor(String fieldName, String declaringClassName) {
         this.fieldName = fieldName;
-        this.declaringClass = declaringClass;
+        this.declaringClassName = declaringClassName;
     }
 
     /** {@inheritDoc} */
@@ -137,9 +137,8 @@ class BrokenFieldAccessor implements FieldAccessor {
         throw brokenException();
     }
 
-    private RuntimeException brokenException() {
-        return new IllegalStateException("Field " + declaringClass.getName() + "#" + fieldName
+    private IllegalStateException brokenException() {
+        return new IllegalStateException("Field " + declaringClassName + "#" + fieldName
                 + " is missing locally, no accesses to it should be performed, this is a bug");
     }
-
 }
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/BrokenSerializationMethods.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/BrokenSerializationMethods.java
new file mode 100644
index 000000000..c1b64adce
--- /dev/null
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/BrokenSerializationMethods.java
@@ -0,0 +1,57 @@
+/*
+ * 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.network.serialization;
+
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+
+/**
+ * {@link SpecialSerializationMethods} implementation that always throws.
+ */
+class BrokenSerializationMethods implements SpecialSerializationMethods {
+    private final String missingClassName;
+
+    BrokenSerializationMethods(String missingClassName) {
+        this.missingClassName = missingClassName;
+    }
+
+    @Override
+    public Object writeReplace(Object object) throws SpecialMethodInvocationException {
+        throw brokenException();
+    }
+
+    @Override
+    public Object readResolve(Object object) throws SpecialMethodInvocationException {
+        throw brokenException();
+    }
+
+    @Override
+    public void writeObject(Object object, ObjectOutputStream stream) throws SpecialMethodInvocationException {
+        throw brokenException();
+    }
+
+    @Override
+    public void readObject(Object object, ObjectInputStream stream) throws SpecialMethodInvocationException {
+        throw brokenException();
+    }
+
+    private IllegalStateException brokenException() {
+        return new IllegalStateException("Class " + missingClassName
+                + " is missing locally, no accesses to it should be performed, this is a bug");
+    }
+}
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassDescriptor.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassDescriptor.java
index c108beaee..8f9360faa 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassDescriptor.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassDescriptor.java
@@ -35,10 +35,17 @@ import org.jetbrains.annotations.Nullable;
  * Class descriptor for the user object serialization.
  */
 public class ClassDescriptor implements DeclaredType {
+    /**
+     * Class name.
+     */
+    private final String className;
+
     /**
      * Class. It is local to the current JVM; the descriptor could
      * be created on a remote JVM/machine where a class with same name could represent a different class.
+     * Might be absent in a descriptor describing a remote class that is not present locally.
      */
+    @Nullable
     private final Class<?> localClass;
 
     /**
@@ -116,6 +123,8 @@ public class ClassDescriptor implements DeclaredType {
 
     private final List<ClassDescriptor> lineage;
 
+    private final List<MergedLayer> mergedLineage;
+
     private final SpecialSerializationMethods serializationMethods;
 
     /**
@@ -133,7 +142,7 @@ public class ClassDescriptor implements DeclaredType {
     }
 
     /**
-     * Creates a descriptor describing a remote class.
+     * Creates a descriptor describing a remote class (when a local class corresponding to the remote class exists).
      */
     public static ClassDescriptor forRemote(
             Class<?> localClass,
@@ -151,6 +160,7 @@ public class ClassDescriptor implements DeclaredType {
         Objects.requireNonNull(localDescriptor);
 
         return new ClassDescriptor(
+                localClass.getName(),
                 localClass,
                 descriptorId,
                 superClassDescriptor,
@@ -162,10 +172,44 @@ public class ClassDescriptor implements DeclaredType {
                 fields,
                 serialization,
                 ClassDescriptorMerger.mergeFields(localDescriptor.fields(), fields),
+                false,
                 localDescriptor
         );
     }
 
+    /**
+     * Creates a descriptor describing a remote class (when no local class corresponding to the remote class exists).
+     */
+    public static ClassDescriptor forRemote(
+            String className,
+            int descriptorId,
+            @Nullable ClassDescriptor superClassDescriptor,
+            @Nullable ClassDescriptor componentTypeDescriptor,
+            boolean isPrimitive,
+            boolean isArray,
+            boolean isRuntimeEnum,
+            boolean isRuntimeTypeKnownUpfront,
+            List<FieldDescriptor> fields,
+            Serialization serialization
+    ) {
+        return new ClassDescriptor(
+                className,
+                null,
+                descriptorId,
+                superClassDescriptor,
+                componentTypeDescriptor,
+                isPrimitive,
+                isArray,
+                isRuntimeEnum,
+                isRuntimeTypeKnownUpfront,
+                fields,
+                serialization,
+                fields.stream().map(MergedField::remoteOnly).collect(toList()),
+                false,
+                null
+        );
+    }
+
     /**
      * Constructor for the local class case.
      */
@@ -178,6 +222,7 @@ public class ClassDescriptor implements DeclaredType {
             Serialization serialization
     ) {
         this(
+                localClass.getName(),
                 localClass,
                 descriptorId,
                 superClassDescriptor,
@@ -189,6 +234,7 @@ public class ClassDescriptor implements DeclaredType {
                 fields,
                 serialization,
                 fields.stream().map(field -> new MergedField(field, field)).collect(toList()),
+                true,
                 null
         );
     }
@@ -197,7 +243,8 @@ public class ClassDescriptor implements DeclaredType {
      * Constructor.
      */
     private ClassDescriptor(
-            Class<?> localClass,
+            String className,
+            @Nullable Class<?> localClass,
             int descriptorId,
             @Nullable ClassDescriptor superClassDescriptor,
             @Nullable ClassDescriptor componentTypeDescriptor,
@@ -208,8 +255,13 @@ public class ClassDescriptor implements DeclaredType {
             List<FieldDescriptor> fields,
             Serialization serialization,
             List<MergedField> mergedFields,
+            boolean thisIsLocal,
             @Nullable ClassDescriptor localDescriptor
     ) {
+        assert localClass != null && (thisIsLocal || localDescriptor != null)
+                || (localClass == null && !thisIsLocal && localDescriptor == null);
+
+        this.className = className;
         this.localClass = localClass;
         this.descriptorId = descriptorId;
         this.superClassDescriptor = superClassDescriptor;
@@ -222,7 +274,7 @@ public class ClassDescriptor implements DeclaredType {
         this.fields = List.copyOf(fields);
         this.serialization = serialization;
 
-        this.localDescriptor = localDescriptor == null ? this : localDescriptor;
+        this.localDescriptor = thisIsLocal ? this : localDescriptor;
 
         this.mergedFields = List.copyOf(mergedFields);
 
@@ -233,8 +285,16 @@ public class ClassDescriptor implements DeclaredType {
         fieldNullsBitmapIndices = computeFieldNullsBitmapIndices(fields);
 
         lineage = computeLineage(this);
+        if (thisIsLocal) {
+            mergedLineage = lineage.stream().map(layer -> new MergedLayer(layer, layer)).collect(toList());
+        } else if (localDescriptor == null) {
+            mergedLineage = lineage.stream().map(MergedLayer::remoteOnly).collect(toList());
+        } else {
+            mergedLineage = ClassDescriptorMerger.mergeLineages(localDescriptor.lineage(), this.lineage);
+        }
 
-        serializationMethods = new SpecialSerializationMethodsImpl(this);
+        serializationMethods = localClass != null ? new SpecialSerializationMethodsImpl(this)
+                : new BrokenSerializationMethods(className);
     }
 
     private static int computePrimitiveFieldsDataSize(List<FieldDescriptor> fields) {
@@ -388,16 +448,21 @@ public class ClassDescriptor implements DeclaredType {
      * @return Class' name.
      */
     public String className() {
-        return localClass.getName();
+        return className;
     }
 
     /**
      * Returns descriptor's class (represented by a local class). Local means 'on this machine', but the descriptor could
      * be created on a remote machine where a class with same name could represent a different class.
      *
-     * @return Class.
+     * @return local class
+     * @throws IllegalStateException if no local class exists for the described class (this could happen for a remote descriptor)
      */
     public Class<?> localClass() {
+        if (localClass == null) {
+            throw new IllegalStateException("No local class exists for '" + className + "'");
+        }
+
         return localClass;
     }
 
@@ -558,14 +623,23 @@ public class ClassDescriptor implements DeclaredType {
         return descriptorId == BuiltInType.PROXY.descriptorId();
     }
 
+    public boolean hasLocal() {
+        return localDescriptor != null;
+    }
+
     /**
      * Returns descriptor of the local version of the remote class described by this descriptor
      * (or {@code null} if the current descriptor describes a local class).
      *
      * @return descriptor of the local version of the remote class described by this descriptor
      *         (or {@code null} if the current descriptor describes a local class)
+     * @throws IllegalStateException if no local counterpart exists for the described class (this could happen for a remote descriptor)
      */
     public ClassDescriptor local() {
+        if (localDescriptor == null) {
+            throw new IllegalStateException("No local descriptor exists for '" + className + "'");
+        }
+
         return localDescriptor;
     }
 
@@ -585,7 +659,7 @@ public class ClassDescriptor implements DeclaredType {
      * @return {@code true} if this descriptor describes same class as the given descriptor
      */
     public boolean describesSameClass(ClassDescriptor other) {
-        return localClass == other.localClass;
+        return Objects.equals(className, other.className);
     }
 
     /**
@@ -799,6 +873,10 @@ public class ClassDescriptor implements DeclaredType {
         return lineage;
     }
 
+    public List<MergedLayer> mergedLineage() {
+        return mergedLineage;
+    }
+
     /**
      * Returns {@code true} if the descriptor describes a String that is represented with Latin-1 internally.
      * Needed to apply an optimization.
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassDescriptorMerger.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassDescriptorMerger.java
index 93d743fdd..a4da38ed1 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassDescriptorMerger.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassDescriptorMerger.java
@@ -17,8 +17,12 @@
 
 package org.apache.ignite.internal.network.serialization;
 
+import static java.util.stream.Collectors.toSet;
+
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Set;
+import java.util.function.Predicate;
 
 /**
  * Merges {@link ClassDescriptor} components.
@@ -69,4 +73,57 @@ class ClassDescriptorMerger {
 
         return List.copyOf(mergedFields);
     }
+
+    static List<MergedLayer> mergeLineages(List<ClassDescriptor> localLineage, List<ClassDescriptor> remoteLineage) {
+        Set<String> commonClassNames = classNames(localLineage);
+        commonClassNames.retainAll(classNames(remoteLineage));
+
+        Predicate<ClassDescriptor> isCommon = layer -> commonClassNames.contains(layer.className());
+
+        List<MergedLayer> result = new ArrayList<>();
+
+        int localIndex = 0;
+        int remoteIndex = 0;
+
+        while (localIndex < localLineage.size() && remoteIndex < remoteLineage.size()) {
+            while (localIndex < localLineage.size() && !isCommon.test(localLineage.get(localIndex))) {
+                result.add(MergedLayer.localOnly(localLineage.get(localIndex)));
+                localIndex++;
+            }
+            while (remoteIndex < remoteLineage.size() && !isCommon.test(remoteLineage.get(remoteIndex))) {
+                result.add(MergedLayer.remoteOnly(remoteLineage.get(remoteIndex)));
+                remoteIndex++;
+            }
+
+            if (localIndex >= localLineage.size() || remoteIndex >= remoteLineage.size()) {
+                break;
+            }
+
+            // in both lists, we are standing on descriptors with same class name
+            ClassDescriptor localLayer = localLineage.get(localIndex);
+            ClassDescriptor remoteLayer = remoteLineage.get(remoteIndex);
+            result.add(new MergedLayer(localLayer, remoteLayer));
+
+            localIndex++;
+            remoteIndex++;
+        }
+
+        // tails
+        while (localIndex < localLineage.size()) {
+            result.add(MergedLayer.localOnly(localLineage.get(localIndex)));
+            localIndex++;
+        }
+        while (remoteIndex < remoteLineage.size()) {
+            result.add(MergedLayer.remoteOnly(remoteLineage.get(remoteIndex)));
+            remoteIndex++;
+        }
+
+        return List.copyOf(result);
+    }
+
+    private static Set<String> classNames(List<ClassDescriptor> localLineage) {
+        return localLineage.stream()
+                .map(ClassDescriptor::className)
+                .collect(toSet());
+    }
 }
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/MapBackedClassIndexedDescriptors.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassNameMapBackedClassIndexedDescriptors.java
similarity index 76%
rename from modules/network/src/main/java/org/apache/ignite/internal/network/serialization/MapBackedClassIndexedDescriptors.java
rename to modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassNameMapBackedClassIndexedDescriptors.java
index 20ddcef8b..096a97b6b 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/MapBackedClassIndexedDescriptors.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassNameMapBackedClassIndexedDescriptors.java
@@ -21,12 +21,12 @@ import java.util.Map;
 import org.jetbrains.annotations.Nullable;
 
 /**
- * A map-backed implementation of {@link ClassIndexedDescriptors}.
+ * A map-backed implementation of {@link ClassIndexedDescriptors}, map stores class names as keys.
  */
-public class MapBackedClassIndexedDescriptors implements ClassIndexedDescriptors {
-    private final Map<Class<?>, ClassDescriptor> descriptorsByClass;
+public class ClassNameMapBackedClassIndexedDescriptors implements ClassIndexedDescriptors {
+    private final Map<String, ClassDescriptor> descriptorsByClass;
 
-    public MapBackedClassIndexedDescriptors(Map<Class<?>, ClassDescriptor> descriptorsByClass) {
+    public ClassNameMapBackedClassIndexedDescriptors(Map<String, ClassDescriptor> descriptorsByClass) {
         this.descriptorsByClass = descriptorsByClass;
     }
 
@@ -34,6 +34,6 @@ public class MapBackedClassIndexedDescriptors implements ClassIndexedDescriptors
     @Override
     @Nullable
     public ClassDescriptor getDescriptor(Class<?> clazz) {
-        return descriptorsByClass.get(clazz);
+        return descriptorsByClass.get(clazz.getName());
     }
 }
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/FieldAccessor.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/FieldAccessor.java
index 40717dc5d..5fdfe5328 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/FieldAccessor.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/FieldAccessor.java
@@ -48,7 +48,7 @@ public interface FieldAccessor {
         if (field != null) {
             return new UnsafeFieldAccessor(field);
         } else {
-            return new BrokenFieldAccessor(fieldName, declaringClass);
+            return new BrokenFieldAccessor(fieldName, declaringClass.getName());
         }
     }
 
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/FieldDescriptor.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/FieldDescriptor.java
index ef9cb0f21..335e60fcc 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/FieldDescriptor.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/FieldDescriptor.java
@@ -30,7 +30,7 @@ public class FieldDescriptor implements DeclaredType {
 
     /**
      * Type of the field (represented by a local class). Local means 'on this machine', but the descriptor could
-     * be created on a remote machine where a class with same name could represent a different class..
+     * be created on a remote machine where a class with same name could represent a different class.
      */
     private final Class<?> localClass;
 
@@ -56,7 +56,15 @@ public class FieldDescriptor implements DeclaredType {
      * Creates a {@link FieldDescriptor} from a local {@link Field}.
      */
     public static FieldDescriptor local(Field field, int typeDescriptorId) {
-        return new FieldDescriptor(field, typeDescriptorId);
+        return new FieldDescriptor(
+                        field.getName(),
+                        field.getType(),
+                        typeDescriptorId,
+                        false,
+                        field.getType().isPrimitive(),
+                        Classes.isRuntimeTypeKnownUpfront(field.getType()),
+                        FieldAccessor.forField(field)
+                );
     }
 
     /**
@@ -80,7 +88,7 @@ public class FieldDescriptor implements DeclaredType {
     }
 
     /**
-     * Creates a {@link FieldDescriptor} for a remote field.
+     * Creates a {@link FieldDescriptor} for a remote field when the defining class is present locally.
      */
     public static FieldDescriptor remote(
             String fieldName,
@@ -102,17 +110,26 @@ public class FieldDescriptor implements DeclaredType {
     }
 
     /**
-     * Constructor.
+     * Creates a {@link FieldDescriptor} for a remote field when the defining class is not present locally.
+     * The resulting FieldDescriptor cannot be used for accessing the field using {@link #accessor()} (because the
+     * returned {@link FieldAccessor} will throw on any access attempt).
      */
-    private FieldDescriptor(Field field, int typeDescriptorId) {
-        this(
-                field.getName(),
-                field.getType(),
+    public static FieldDescriptor remote(
+            String fieldName,
+            Class<?> fieldClazz,
+            int typeDescriptorId,
+            boolean unshared,
+            boolean isPrimitive,
+            boolean isRuntimeTypeKnownUpfront,
+            String declaringClassName) {
+        return new FieldDescriptor(
+                fieldName,
+                fieldClazz,
                 typeDescriptorId,
-                false,
-                field.getType().isPrimitive(),
-                Classes.isRuntimeTypeKnownUpfront(field.getType()),
-                FieldAccessor.forField(field)
+                unshared,
+                isPrimitive,
+                isRuntimeTypeKnownUpfront,
+                new BrokenFieldAccessor(fieldName, declaringClassName)
         );
     }
 
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/MergedLayer.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/MergedLayer.java
new file mode 100644
index 000000000..313f34827
--- /dev/null
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/MergedLayer.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.network.serialization;
+
+import java.util.Objects;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Contains information about a class layer, both from local and remote classes. Any of the parts (local/remote) might be absent.
+ */
+public class MergedLayer {
+    @Nullable
+    private final ClassDescriptor localLayer;
+    @Nullable
+    private final ClassDescriptor remoteLayer;
+
+    /**
+     * Creates an instance with local layer only.
+     *
+     * @param localLayer    the local layer
+     * @return an instance with local layer only
+     */
+    public static MergedLayer localOnly(ClassDescriptor localLayer) {
+        return new MergedLayer(localLayer, null);
+    }
+
+    /**
+     * Creates an instance with remote layer only.
+     *
+     * @param remoteLayer the remote layer
+     * @return an instance with remote layer only
+     */
+    public static MergedLayer remoteOnly(ClassDescriptor remoteLayer) {
+        return new MergedLayer(null, remoteLayer);
+    }
+
+    /**
+     * Constructs a new instance.
+     */
+    public MergedLayer(@Nullable ClassDescriptor localLayer, @Nullable ClassDescriptor remoteLayer) {
+        assert localLayer != null || remoteLayer != null : "Both descriptors are null";
+        assert localLayer == null || remoteLayer == null || Objects.equals(localLayer.className(), remoteLayer.className())
+                : "Descriptors of different classes: " + localLayer.className() + " and " + remoteLayer.className();
+
+        this.localLayer = localLayer;
+        this.remoteLayer = remoteLayer;
+    }
+
+    /**
+     * Returns {@code true} if local layer is present.
+     *
+     * @return {@code true} if local layer is present
+     */
+    public boolean hasLocal() {
+        return localLayer != null;
+    }
+
+    /**
+     * Returns local layer.
+     *
+     * @return local layer
+     */
+    public ClassDescriptor local() {
+        return Objects.requireNonNull(localLayer, "localLayer is null");
+    }
+
+    /**
+     * Returns {@code true} if remote layer is present.
+     *
+     * @return {@code true} if remote layer is present
+     */
+    public boolean hasRemote() {
+        return remoteLayer != null;
+    }
+
+    /**
+     * Returns remote layer.
+     *
+     * @return remote layer
+     */
+    public ClassDescriptor remote() {
+        return Objects.requireNonNull(remoteLayer, "remoteLayer is null");
+    }
+}
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/PerSessionSerializationService.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/PerSessionSerializationService.java
index d1ba3f12f..5a1eb0967 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/PerSessionSerializationService.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/PerSessionSerializationService.java
@@ -61,9 +61,9 @@ public class PerSessionSerializationService {
     private final Int2ObjectMap<ClassDescriptor> mergedIdToDescriptorMap = new Int2ObjectOpenHashMap<>();
     /**
      * Map with merged class descriptors. They are the result of the merging of a local and a remote descriptor.
-     * The key in this map is the class.
+     * The key in this map is the class name.
      */
-    private final Map<Class<?>, ClassDescriptor> mergedClassToDescriptorMap = new HashMap<>();
+    private final Map<String, ClassDescriptor> mergedClassToDescriptorMap = new HashMap<>();
 
     /**
      * A collection of the descriptors that were sent to the remote node.
@@ -86,7 +86,7 @@ public class PerSessionSerializationService {
         this.serializationService = serializationService;
         this.descriptors = new CompositeDescriptorRegistry(
                 new MapBackedIdIndexedDescriptors(mergedIdToDescriptorMap),
-                new MapBackedClassIndexedDescriptors(mergedClassToDescriptorMap),
+                new ClassNameMapBackedClassIndexedDescriptors(mergedClassToDescriptorMap),
                 serializationService.getLocalDescriptorRegistry()
         );
     }
@@ -134,14 +134,10 @@ public class PerSessionSerializationService {
      * @param descriptorIds Class descriptors.
      * @return List of class descriptor network messages.
      */
-    @Nullable
     public static List<ClassDescriptorMessage> createClassDescriptorsMessages(IntSet descriptorIds, ClassDescriptorRegistry registry) {
-        List<ClassDescriptorMessage> messages = descriptorIds.intStream()
-                .mapToObj(registry::getDescriptor)
-                .filter(descriptor -> {
-                    int descriptorId = descriptor.descriptorId();
-                    return !shouldBeBuiltIn(descriptorId);
-                })
+        return descriptorIds.intStream()
+                .mapToObj(registry::getRequiredDescriptor)
+                .filter(descriptor -> !shouldBeBuiltIn(descriptor.descriptorId()))
                 .map(descriptor -> {
                     List<FieldDescriptorMessage> fields = descriptor.fields().stream()
                             .map(d -> {
@@ -168,9 +164,8 @@ public class PerSessionSerializationService {
                             .componentTypeName(descriptor.componentTypeName())
                             .attributes(classDescriptorAttributeFlags(descriptor))
                             .build();
-                }).collect(toList());
-
-        return messages;
+                })
+                .collect(toList());
     }
 
     private static byte fieldFlags(FieldDescriptor fieldDescriptor) {
@@ -240,11 +235,10 @@ public class PerSessionSerializationService {
                 if (knownMergedDescriptor(classMessage.descriptorId())) {
                     it.remove();
                 } else if (dependenciesAreMerged(classMessage)) {
-                    Class<?> localClass = classForName(classMessage.className());
-                    ClassDescriptor mergedDescriptor = messageToMergedClassDescriptor(classMessage, localClass);
+                    ClassDescriptor mergedDescriptor = messageToMergedClassDescriptor(classMessage);
 
                     mergedIdToDescriptorMap.put(classMessage.descriptorId(), mergedDescriptor);
-                    mergedClassToDescriptorMap.put(localClass, mergedDescriptor);
+                    mergedClassToDescriptorMap.put(classMessage.className(), mergedDescriptor);
 
                     it.remove();
 
@@ -271,34 +265,27 @@ public class PerSessionSerializationService {
      * Converts {@link ClassDescriptorMessage} to a {@link ClassDescriptor} and merges it with a local {@link ClassDescriptor} of the
      * same class.
      *
-     * @param clsMsg ClassDescriptorMessage.
-     * @param localClass the local class
+     * @param classMessage ClassDescriptorMessage.
      * @return Merged class descriptor.
      */
-    private ClassDescriptor messageToMergedClassDescriptor(ClassDescriptorMessage clsMsg, Class<?> localClass) {
-        ClassDescriptor localDescriptor = serializationService.getOrCreateLocalDescriptor(localClass);
-        return buildRemoteDescriptor(clsMsg, localClass, localDescriptor);
+    private ClassDescriptor messageToMergedClassDescriptor(ClassDescriptorMessage classMessage) {
+        @Nullable Class<?> localClass = maybeClassForName(classMessage.className());
+
+        if (localClass != null) {
+            return buildRemoteDescriptor(classMessage, localClass);
+        } else {
+            return buildRemoteDescriptor(classMessage);
+        }
     }
 
-    private ClassDescriptor buildRemoteDescriptor(
-            ClassDescriptorMessage classMessage,
-            Class<?> localClass,
-            ClassDescriptor localDescriptor
-    ) {
+    private ClassDescriptor buildRemoteDescriptor(ClassDescriptorMessage classMessage, Class<?> localClass) {
+        ClassDescriptor localDescriptor = serializationService.getOrCreateLocalDescriptor(localClass);
+
         List<FieldDescriptor> remoteFields = classMessage.fields().stream()
-                .map(fieldMsg -> fieldDescriptorFromMessage(fieldMsg, localClass))
+                .map(fieldMessage -> fieldDescriptorFromMessage(fieldMessage, localClass))
                 .collect(toList());
 
-        SerializationType serializationType = SerializationType.getByValue(classMessage.serializationType());
-
-        var serialization = new Serialization(
-                serializationType,
-                bitValue(classMessage.serializationFlags(), ClassDescriptorMessage.HAS_WRITE_OBJECT_MASK),
-                bitValue(classMessage.serializationFlags(), ClassDescriptorMessage.HAS_READ_OBJECT_MASK),
-                bitValue(classMessage.serializationFlags(), ClassDescriptorMessage.HAS_READ_OBJECT_NO_DATA_MASK),
-                bitValue(classMessage.serializationFlags(), ClassDescriptorMessage.HAS_WRITE_REPLACE_MASK),
-                bitValue(classMessage.serializationFlags(), ClassDescriptorMessage.HAS_READ_RESOLVE_MASK)
-        );
+        Serialization serialization = buildSerialization(classMessage);
 
         return ClassDescriptor.forRemote(
                 localClass,
@@ -315,6 +302,40 @@ public class PerSessionSerializationService {
         );
     }
 
+    private ClassDescriptor buildRemoteDescriptor(ClassDescriptorMessage classMessage) {
+        List<FieldDescriptor> remoteFields = classMessage.fields().stream()
+                .map(fieldMessage -> fieldDescriptorFromMessage(fieldMessage, classMessage.className()))
+                .collect(toList());
+
+        Serialization serialization = buildSerialization(classMessage);
+
+        return ClassDescriptor.forRemote(
+                classMessage.className(),
+                classMessage.descriptorId(),
+                remoteSuperClassDescriptor(classMessage),
+                remoteComponentTypeDescriptor(classMessage),
+                bitValue(classMessage.attributes(), ClassDescriptorMessage.IS_PRIMITIVE_MASK),
+                bitValue(classMessage.attributes(), ClassDescriptorMessage.IS_ARRAY_MASK),
+                bitValue(classMessage.attributes(), ClassDescriptorMessage.IS_RUNTIME_ENUM_MASK),
+                bitValue(classMessage.attributes(), ClassDescriptorMessage.IS_RUNTIME_TYPE_KNOWN_UPFRONT_MASK),
+                remoteFields,
+                serialization
+        );
+    }
+
+    private Serialization buildSerialization(ClassDescriptorMessage classMessage) {
+        SerializationType serializationType = SerializationType.getByValue(classMessage.serializationType());
+
+        return new Serialization(
+                serializationType,
+                bitValue(classMessage.serializationFlags(), ClassDescriptorMessage.HAS_WRITE_OBJECT_MASK),
+                bitValue(classMessage.serializationFlags(), ClassDescriptorMessage.HAS_READ_OBJECT_MASK),
+                bitValue(classMessage.serializationFlags(), ClassDescriptorMessage.HAS_READ_OBJECT_NO_DATA_MASK),
+                bitValue(classMessage.serializationFlags(), ClassDescriptorMessage.HAS_WRITE_REPLACE_MASK),
+                bitValue(classMessage.serializationFlags(), ClassDescriptorMessage.HAS_READ_RESOLVE_MASK)
+        );
+    }
+
     private boolean bitValue(byte flags, int bitMask) {
         return (flags & bitMask) != 0;
     }
@@ -335,24 +356,37 @@ public class PerSessionSerializationService {
         return remoteClassDescriptor(clsMsg.componentTypeDescriptorId(), clsMsg.componentTypeName());
     }
 
-    private FieldDescriptor fieldDescriptorFromMessage(FieldDescriptorMessage fieldMsg, Class<?> declaringClass) {
-        int typeDescriptorId = fieldMsg.typeDescriptorId();
+    private FieldDescriptor fieldDescriptorFromMessage(FieldDescriptorMessage fieldMessage, Class<?> declaringClass) {
+        int typeDescriptorId = fieldMessage.typeDescriptorId();
         return FieldDescriptor.remote(
-                fieldMsg.name(),
-                fieldType(typeDescriptorId, fieldMsg.className()),
+                fieldMessage.name(),
+                fieldType(typeDescriptorId, fieldMessage.className()),
                 typeDescriptorId,
-                bitValue(fieldMsg.flags(), FieldDescriptorMessage.UNSHARED_MASK),
-                bitValue(fieldMsg.flags(), FieldDescriptorMessage.IS_PRIMITIVE),
-                bitValue(fieldMsg.flags(), FieldDescriptorMessage.IS_RUNTIME_TYPE_KNOWN_UPFRONT),
+                bitValue(fieldMessage.flags(), FieldDescriptorMessage.UNSHARED_MASK),
+                bitValue(fieldMessage.flags(), FieldDescriptorMessage.IS_PRIMITIVE),
+                bitValue(fieldMessage.flags(), FieldDescriptorMessage.IS_RUNTIME_TYPE_KNOWN_UPFRONT),
                 declaringClass
         );
     }
 
+    private FieldDescriptor fieldDescriptorFromMessage(FieldDescriptorMessage fieldMessage, String declaringClassName) {
+        int typeDescriptorId = fieldMessage.typeDescriptorId();
+        return FieldDescriptor.remote(
+                fieldMessage.name(),
+                fieldType(typeDescriptorId, fieldMessage.className()),
+                typeDescriptorId,
+                bitValue(fieldMessage.flags(), FieldDescriptorMessage.UNSHARED_MASK),
+                bitValue(fieldMessage.flags(), FieldDescriptorMessage.IS_PRIMITIVE),
+                bitValue(fieldMessage.flags(), FieldDescriptorMessage.IS_RUNTIME_TYPE_KNOWN_UPFRONT),
+                declaringClassName
+        );
+    }
+
     private ClassDescriptor remoteClassDescriptor(int descriptorId, String typeName) {
         if (shouldBeBuiltIn(descriptorId)) {
             return serializationService.getLocalDescriptor(descriptorId);
         } else {
-            return mergedClassToDescriptorMap.get(classForName(typeName));
+            return mergedClassToDescriptorMap.get(typeName);
         }
     }
 
@@ -360,15 +394,26 @@ public class PerSessionSerializationService {
         if (shouldBeBuiltIn(descriptorId)) {
             return BuiltInType.findByDescriptorId(descriptorId).clazz();
         } else {
-            return classForName(typeName);
+            return requiredClassForName(typeName);
         }
     }
 
-    private Class<?> classForName(String className) {
+    private Class<?> requiredClassForName(String className) {
+        Class<?> result = maybeClassForName(className);
+
+        if (result == null) {
+            throw new SerializationException("Class " + className + " is not found");
+        }
+
+        return result;
+    }
+
+    @Nullable
+    private Class<?> maybeClassForName(String className) {
         try {
             return Class.forName(className, true, classLoader);
         } catch (ClassNotFoundException e) {
-            throw new SerializationException("Class " + className + " is not found", e);
+            return null;
         }
     }
 
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/SerializationException.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/SerializationException.java
index 5327d5ab9..a9e0188d7 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/SerializationException.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/SerializationException.java
@@ -19,7 +19,7 @@ package org.apache.ignite.internal.network.serialization;
 
 /** Exception that occurs during (de-)serialization. */
 public class SerializationException extends RuntimeException {
-    public SerializationException(String message, Throwable cause) {
-        super(message, cause);
+    public SerializationException(String message) {
+        super(message);
     }
 }
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/DefaultUserObjectMarshaller.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/DefaultUserObjectMarshaller.java
index 52b4acb31..5e95de818 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/DefaultUserObjectMarshaller.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/DefaultUserObjectMarshaller.java
@@ -396,12 +396,12 @@ public class DefaultUserObjectMarshaller implements UserObjectMarshaller, Schema
     ) throws IOException, UnmarshalException {
         int objectId = readObjectId(input);
 
-        Object preInstantiatedObject = preInstantiate(remoteDescriptor, input, context);
-        context.registerReference(objectId, preInstantiatedObject, unshared);
+        Object object = preInstantiate(remoteDescriptor, input, context);
+        context.registerReference(objectId, object, unshared);
 
-        fillObjectFrom(input, preInstantiatedObject, remoteDescriptor, context);
+        fillObjectFrom(input, object, remoteDescriptor, context);
 
-        return preInstantiatedObject;
+        return object;
     }
 
     private Object preInstantiate(ClassDescriptor remoteDescriptor, IgniteDataInput input, UnmarshallingContext context)
@@ -493,4 +493,9 @@ public class DefaultUserObjectMarshaller implements UserObjectMarshaller, Schema
     public <T> void replaceSchemaMismatchHandler(Class<T> layerClass, SchemaMismatchHandler<T> handler) {
         schemaMismatchHandlers.registerHandler(layerClass, handler);
     }
+
+    @Override
+    public <T> void replaceSchemaMismatchHandler(String layerClassName, SchemaMismatchHandler<T> handler) {
+        schemaMismatchHandlers.registerHandler(layerClassName, handler);
+    }
 }
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/SchemaMismatchEventSource.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/SchemaMismatchEventSource.java
index f032c2703..0a74295d4 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/SchemaMismatchEventSource.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/SchemaMismatchEventSource.java
@@ -31,4 +31,15 @@ public interface SchemaMismatchEventSource {
      * @param <T>        layer type
      */
     <T> void replaceSchemaMismatchHandler(Class<T> layerClass, SchemaMismatchHandler<T> handler);
+
+    /**
+     * Sets the {@link SchemaMismatchHandler} for the given class if not set or replaces the existing one.
+     *
+     * <p>Note that the handlers are per declared class, not per concrete class lineage.
+     *
+     * @param layerClassName the name of the class; for schema changes concerning this class the events will be generated
+     * @param handler    the handler that will handle the schema mismatch events
+     * @param <T>        layer type
+     */
+    <T> void replaceSchemaMismatchHandler(String layerClassName, SchemaMismatchHandler<T> handler);
 }
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/SchemaMismatchHandlers.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/SchemaMismatchHandlers.java
index a0d627a15..bc5f58561 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/SchemaMismatchHandlers.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/SchemaMismatchHandlers.java
@@ -26,33 +26,41 @@ import java.util.Map;
  * A colleciton of {@link SchemaMismatchHandler}s keyed by classes to which they relate.
  */
 class SchemaMismatchHandlers {
-    private final Map<Class<?>, SchemaMismatchHandler<?>> handlers = new HashMap<>();
+    private final Map<String, SchemaMismatchHandler<?>> handlers = new HashMap<>();
     private final SchemaMismatchHandler<Object> defaultHandler = new DefaultSchemaMismatchHandler();
 
     <T> void registerHandler(Class<T> layerClass, SchemaMismatchHandler<T> handler) {
-        handlers.put(layerClass, handler);
+        registerHandler(layerClass.getName(), handler);
+    }
+
+    <T> void registerHandler(String layerClassName, SchemaMismatchHandler<T> handler) {
+        handlers.put(layerClassName, handler);
     }
 
-    @SuppressWarnings("unchecked")
     private SchemaMismatchHandler<Object> handlerFor(Class<?> clazz) {
-        SchemaMismatchHandler<Object> handler = (SchemaMismatchHandler<Object>) handlers.get(clazz);
+        return handlerFor(clazz.getName());
+    }
+
+    @SuppressWarnings("unchecked")
+    private SchemaMismatchHandler<Object> handlerFor(String className) {
+        SchemaMismatchHandler<Object> handler = (SchemaMismatchHandler<Object>) handlers.get(className);
         if (handler == null) {
             handler = defaultHandler;
         }
         return handler;
     }
 
-    void onFieldIgnored(Class<?> layerClass, Object instance, String fieldName, Object fieldValue) throws SchemaMismatchException {
-        handlerFor(layerClass).onFieldIgnored(instance, fieldName, fieldValue);
+    void onFieldIgnored(String layerClassName, Object instance, String fieldName, Object fieldValue) throws SchemaMismatchException {
+        handlerFor(layerClassName).onFieldIgnored(instance, fieldName, fieldValue);
     }
 
-    void onFieldMissed(Class<?> layerClass, Object instance, String fieldName) throws SchemaMismatchException {
-        handlerFor(layerClass).onFieldMissed(instance, fieldName);
+    void onFieldMissed(String layerClassName, Object instance, String fieldName) throws SchemaMismatchException {
+        handlerFor(layerClassName).onFieldMissed(instance, fieldName);
     }
 
-    void onFieldTypeChanged(Class<?> layerClass, Object instance, String fieldName, Class<?> remoteType, Object fieldValue)
+    void onFieldTypeChanged(String layerClassName, Object instance, String fieldName, Class<?> remoteType, Object fieldValue)
             throws SchemaMismatchException {
-        handlerFor(layerClass).onFieldTypeChanged(instance, fieldName, remoteType, fieldValue);
+        handlerFor(layerClassName).onFieldTypeChanged(instance, fieldName, remoteType, fieldValue);
     }
 
     void onExternalizableIgnored(Object instance, ObjectInput externalData) throws SchemaMismatchException {
@@ -71,11 +79,11 @@ class SchemaMismatchHandlers {
         handlerFor(instance.getClass()).onReadResolveDisappeared(instance);
     }
 
-    void onReadObjectIgnored(Class<?> layerClass, Object instance, ObjectInputStream objectData) throws SchemaMismatchException {
-        handlerFor(layerClass).onReadObjectIgnored(instance, objectData);
+    void onReadObjectIgnored(String layerClassName, Object instance, ObjectInputStream objectData) throws SchemaMismatchException {
+        handlerFor(layerClassName).onReadObjectIgnored(instance, objectData);
     }
 
-    void onReadObjectMissed(Class<?> layerClass, Object instance) throws SchemaMismatchException {
-        handlerFor(layerClass).onReadObjectMissed(instance);
+    void onReadObjectMissed(String layerClassName, Object instance) throws SchemaMismatchException {
+        handlerFor(layerClassName).onReadObjectMissed(instance);
     }
 }
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/StructuredObjectMarshaller.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/StructuredObjectMarshaller.java
index bc4db595b..e10ec2fb0 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/StructuredObjectMarshaller.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/StructuredObjectMarshaller.java
@@ -27,6 +27,7 @@ import org.apache.ignite.internal.network.serialization.DescriptorRegistry;
 import org.apache.ignite.internal.network.serialization.FieldAccessor;
 import org.apache.ignite.internal.network.serialization.FieldDescriptor;
 import org.apache.ignite.internal.network.serialization.MergedField;
+import org.apache.ignite.internal.network.serialization.MergedLayer;
 import org.apache.ignite.internal.network.serialization.SpecialMethodInvocationException;
 import org.apache.ignite.internal.network.serialization.marshal.UosObjectInputStream.UosGetField;
 import org.apache.ignite.internal.network.serialization.marshal.UosObjectOutputStream.UosPutField;
@@ -211,10 +212,25 @@ class StructuredObjectMarshaller implements DefaultFieldsReaderWriter {
 
     void fillStructuredObjectFrom(IgniteDataInput input, Object object, ClassDescriptor remoteDescriptor, UnmarshallingContext context)
             throws IOException, UnmarshalException {
-        List<ClassDescriptor> remoteLineage = remoteDescriptor.lineage();
+        List<MergedLayer> lineage = remoteDescriptor.mergedLineage();
 
-        for (ClassDescriptor remoteLayer : remoteLineage) {
-            fillStructuredObjectLayerFrom(input, remoteLayer, object, context);
+        for (MergedLayer mergedLayer : lineage) {
+            if (mergedLayer.hasRemote()) {
+                fillStructuredObjectLayerFrom(input, mergedLayer.remote(), object, context);
+            } else if (mergedLayer.hasLocal()) {
+                fireEventsForLocalOnlyLayer(object, mergedLayer.local());
+            }
+        }
+    }
+
+    private void fireEventsForLocalOnlyLayer(Object object, ClassDescriptor localLayer) throws SchemaMismatchException {
+        fireOnFieldMissedOnLayerFields(object, localLayer);
+        schemaMismatchHandlers.onReadObjectMissed(localLayer.className(), object);
+    }
+
+    private void fireOnFieldMissedOnLayerFields(Object object, ClassDescriptor layer) throws SchemaMismatchException {
+        for (FieldDescriptor localField : layer.fields()) {
+            schemaMismatchHandlers.onFieldMissed(layer.className(), object, localField.name());
         }
     }
 
@@ -224,17 +240,19 @@ class StructuredObjectMarshaller implements DefaultFieldsReaderWriter {
             Object object,
             UnmarshallingContext context
     ) throws IOException, UnmarshalException {
-        ClassDescriptor localLayer = remoteLayer.local();
+        boolean hasReadObjectLocally = remoteLayer.hasLocal() && remoteLayer.local().hasReadObject();
 
-        if (remoteLayer.hasWriteObject() && localLayer.hasReadObject()) {
-            fillObjectWithReadObjectFrom(input, object, remoteLayer, context);
-        } else if (remoteLayer.hasWriteObject() && !localLayer.hasReadObject()) {
-            fireReadObjectIgnored(remoteLayer, object, input, context);
+        if (remoteLayer.hasWriteObject()) {
+            if (hasReadObjectLocally) {
+                fillObjectWithReadObjectFrom(input, object, remoteLayer, context);
+            } else {
+                fireReadObjectIgnored(remoteLayer, object, input, context);
+            }
         } else {
             defaultFillFieldsFrom(input, object, remoteLayer, context);
 
-            if (localLayer.hasReadObject()) {
-                schemaMismatchHandlers.onReadObjectMissed(remoteLayer.localClass(), object);
+            if (hasReadObjectLocally) {
+                schemaMismatchHandlers.onReadObjectMissed(remoteLayer.className(), object);
             }
         }
     }
@@ -287,21 +305,21 @@ class StructuredObjectMarshaller implements DefaultFieldsReaderWriter {
         IgniteDataInput externalDataInput = new IgniteUnsafeDataInput(writeObjectDataBytes);
 
         try (var oos = new UosObjectInputStream(externalDataInput, valueReader, unsharedReader, this, context)) {
-            schemaMismatchHandlers.onReadObjectIgnored(remoteLayer.localClass(), object, oos);
+            schemaMismatchHandlers.onReadObjectIgnored(remoteLayer.className(), object, oos);
         }
     }
 
     /** {@inheritDoc} */
     @Override
-    public void defaultFillFieldsFrom(IgniteDataInput input, Object object, ClassDescriptor descriptor, UnmarshallingContext context)
+    public void defaultFillFieldsFrom(IgniteDataInput input, Object object, ClassDescriptor remoteLayer, UnmarshallingContext context)
             throws IOException, UnmarshalException {
-        @Nullable BitSet nullsBitSet = NullsBitsetReader.readNullsBitSet(input, descriptor);
+        @Nullable BitSet nullsBitSet = NullsBitsetReader.readNullsBitSet(input, remoteLayer);
 
-        for (MergedField mergedField : descriptor.mergedFields()) {
+        for (MergedField mergedField : remoteLayer.mergedFields()) {
             if (mergedField.hasRemote()) {
-                fillFieldWithNullSkippedCheckFrom(input, object, mergedField, descriptor, nullsBitSet, context);
+                fillFieldWithNullSkippedCheckFrom(input, object, mergedField, remoteLayer, nullsBitSet, context);
             } else {
-                schemaMismatchHandlers.onFieldMissed(descriptor.localClass(), object, mergedField.name());
+                schemaMismatchHandlers.onFieldMissed(remoteLayer.className(), object, mergedField.name());
             }
         }
     }
@@ -365,13 +383,13 @@ class StructuredObjectMarshaller implements DefaultFieldsReaderWriter {
 
     private void fireFieldIgnored(ClassDescriptor layerDescriptor, Object object, MergedField mergedField, Object fieldValue)
             throws SchemaMismatchException {
-        schemaMismatchHandlers.onFieldIgnored(layerDescriptor.localClass(), object, mergedField.name(), fieldValue);
+        schemaMismatchHandlers.onFieldIgnored(layerDescriptor.className(), object, mergedField.name(), fieldValue);
     }
 
     private void fireFieldTypeChanged(ClassDescriptor layerDescriptor, Object object, MergedField mergedField, Object fieldValue)
             throws SchemaMismatchException {
         schemaMismatchHandlers.onFieldTypeChanged(
-                layerDescriptor.localClass(),
+                layerDescriptor.className(),
                 object,
                 mergedField.name(),
                 mergedField.remote().localClass(),
diff --git a/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/ClassDescriptorMergerTest.java b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/ClassDescriptorMergerTest.java
index b206fae0d..1db4fc90f 100644
--- a/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/ClassDescriptorMergerTest.java
+++ b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/ClassDescriptorMergerTest.java
@@ -17,10 +17,15 @@
 
 package org.apache.ignite.internal.network.serialization;
 
+import static java.util.stream.Collectors.toUnmodifiableList;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.hasSize;
 import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
+import java.util.Arrays;
 import java.util.List;
 import org.junit.jupiter.api.Test;
 
@@ -137,6 +142,182 @@ class ClassDescriptorMergerTest {
         assertThat(mergedFields.get(1).hasRemote(), is(false));
     }
 
+    @Test
+    void mergesIdenticalLineages() {
+        List<ClassDescriptor> local = descriptorsForClasses("A", "B");
+        List<ClassDescriptor> remote = List.copyOf(local);
+
+        List<MergedLayer> mergedLineage = ClassDescriptorMerger.mergeLineages(local, remote);
+
+        assertThat(mergedLineage, hasSize(2));
+
+        assertMergedLayerIsFull(mergedLineage.get(0), "A");
+        assertMergedLayerIsFull(mergedLineage.get(1), "B");
+    }
+
+    private List<ClassDescriptor> descriptorsForClasses(String... classNames) {
+        return Arrays.stream(classNames)
+                .map(this::descriptorForClass)
+                .collect(toUnmodifiableList());
+    }
+
+    private ClassDescriptor descriptorForClass(String className) {
+        ClassDescriptor descriptor = mock(ClassDescriptor.class);
+
+        when(descriptor.className()).thenReturn(className);
+
+        return descriptor;
+    }
+
+    private void assertMergedLayerIsFull(MergedLayer mergedLayer, String expectedClassName) {
+        assertThat(mergedLayer.hasLocal(), is(true));
+        assertThat(mergedLayer.local().className(), is(expectedClassName));
+        assertThat(mergedLayer.hasRemote(), is(true));
+        assertThat(mergedLayer.remote().className(), is(expectedClassName));
+    }
+
+    @Test
+    void mergesLocalLineageExcessInTheBeginningToLocalOnlyPrefix() {
+        List<ClassDescriptor> local = descriptorsForClasses("A", "B");
+        List<ClassDescriptor> remote = descriptorsForClasses("B");
+
+        List<MergedLayer> mergedLineage = ClassDescriptorMerger.mergeLineages(local, remote);
+
+        assertThat(mergedLineage, hasSize(2));
+
+        assertMergedLayerIsLocalOnly(mergedLineage.get(0), "A");
+        assertMergedLayerIsFull(mergedLineage.get(1), "B");
+    }
+
+    private void assertMergedLayerIsLocalOnly(MergedLayer mergedLayer, String expectedClassName) {
+        assertThat(mergedLayer.hasLocal(), is(true));
+        assertThat(mergedLayer.local().className(), is(expectedClassName));
+        assertThat(mergedLayer.hasRemote(), is(false));
+        NullPointerException ex = assertThrows(NullPointerException.class, mergedLayer::remote);
+        assertThat(ex.getMessage(), is("remoteLayer is null"));
+    }
+
+    @Test
+    void mergesLocalLineageExcessInTheEndToLocalOnlyPostfix() {
+        List<ClassDescriptor> local = descriptorsForClasses("A", "B");
+        List<ClassDescriptor> remote = descriptorsForClasses("A");
+
+        List<MergedLayer> mergedLineage = ClassDescriptorMerger.mergeLineages(local, remote);
+
+        assertThat(mergedLineage, hasSize(2));
+
+        assertMergedLayerIsFull(mergedLineage.get(0), "A");
+        assertMergedLayerIsLocalOnly(mergedLineage.get(1), "B");
+    }
+
+    @Test
+    void mergesRemoteLineageExcessInTheBeginningToRemoteOnlyPrefix() {
+        List<ClassDescriptor> local = descriptorsForClasses("B");
+        List<ClassDescriptor> remote = descriptorsForClasses("A", "B");
+
+        List<MergedLayer> mergedLineage = ClassDescriptorMerger.mergeLineages(local, remote);
+
+        assertThat(mergedLineage, hasSize(2));
+
+        assertMergedLayerIsRemoteOnly(mergedLineage.get(0), "A");
+        assertMergedLayerIsFull(mergedLineage.get(1), "B");
+    }
+
+    private void assertMergedLayerIsRemoteOnly(MergedLayer mergedLayer, String expectedClassName) {
+        assertThat(mergedLayer.hasRemote(), is(true));
+        assertThat(mergedLayer.remote().className(), is(expectedClassName));
+        assertThat(mergedLayer.hasLocal(), is(false));
+        NullPointerException ex = assertThrows(NullPointerException.class, mergedLayer::local);
+        assertThat(ex.getMessage(), is("localLayer is null"));
+    }
+
+    @Test
+    void mergesRemoteLineageExcessInTheEndToRemoteOnlyPostfix() {
+        List<ClassDescriptor> local = descriptorsForClasses("A");
+        List<ClassDescriptor> remote = descriptorsForClasses("A", "B");
+
+        List<MergedLayer> mergedLineage = ClassDescriptorMerger.mergeLineages(local, remote);
+
+        assertThat(mergedLineage, hasSize(2));
+
+        assertMergedLayerIsFull(mergedLineage.get(0), "A");
+        assertMergedLayerIsRemoteOnly(mergedLineage.get(1), "B");
+    }
+
+    @Test
+    void mergesLocalLineageExcessInTheMiddleToLocalOnlyMiddle() {
+        List<ClassDescriptor> local = descriptorsForClasses("A", "B", "C");
+        List<ClassDescriptor> remote = descriptorsForClasses("A", "C");
+
+        List<MergedLayer> mergedLineage = ClassDescriptorMerger.mergeLineages(local, remote);
+
+        assertThat(mergedLineage, hasSize(3));
+
+        assertMergedLayerIsFull(mergedLineage.get(0), "A");
+        assertMergedLayerIsLocalOnly(mergedLineage.get(1), "B");
+        assertMergedLayerIsFull(mergedLineage.get(2), "C");
+    }
+
+    @Test
+    void mergesRemoteLineageExcessInTheMiddleToRemoteOnlyMiddle() {
+        List<ClassDescriptor> local = descriptorsForClasses("A", "C");
+        List<ClassDescriptor> remote = descriptorsForClasses("A", "B", "C");
+
+        List<MergedLayer> mergedLineage = ClassDescriptorMerger.mergeLineages(local, remote);
+
+        assertThat(mergedLineage, hasSize(3));
+
+        assertMergedLayerIsFull(mergedLineage.get(0), "A");
+        assertMergedLayerIsRemoteOnly(mergedLineage.get(1), "B");
+        assertMergedLayerIsFull(mergedLineage.get(2), "C");
+    }
+
+    @Test
+    void mergesBothLineagesExcessInTheBeginningToCorrespondingPrefix() {
+        List<ClassDescriptor> local = descriptorsForClasses("A", "B");
+        List<ClassDescriptor> remote = descriptorsForClasses("X", "Y", "B");
+
+        List<MergedLayer> mergedLineage = ClassDescriptorMerger.mergeLineages(local, remote);
+
+        assertThat(mergedLineage, hasSize(4));
+
+        assertMergedLayerIsLocalOnly(mergedLineage.get(0), "A");
+        assertMergedLayerIsRemoteOnly(mergedLineage.get(1), "X");
+        assertMergedLayerIsRemoteOnly(mergedLineage.get(2), "Y");
+        assertMergedLayerIsFull(mergedLineage.get(3), "B");
+    }
+
+    @Test
+    void mergesBothLineagesExcessInTheEndToCorrespondingPostfix() {
+        List<ClassDescriptor> local = descriptorsForClasses("A", "B");
+        List<ClassDescriptor> remote = descriptorsForClasses("A", "X", "Y");
+
+        List<MergedLayer> mergedLineage = ClassDescriptorMerger.mergeLineages(local, remote);
+
+        assertThat(mergedLineage, hasSize(4));
+
+        assertMergedLayerIsFull(mergedLineage.get(0), "A");
+        assertMergedLayerIsLocalOnly(mergedLineage.get(1), "B");
+        assertMergedLayerIsRemoteOnly(mergedLineage.get(2), "X");
+        assertMergedLayerIsRemoteOnly(mergedLineage.get(3), "Y");
+    }
+
+    @Test
+    void mergesBothLineagesExcessInTheMiddleToCorrespondingPostfix() {
+        List<ClassDescriptor> local = descriptorsForClasses("A", "B", "C");
+        List<ClassDescriptor> remote = descriptorsForClasses("A", "X", "Y", "C");
+
+        List<MergedLayer> mergedLineage = ClassDescriptorMerger.mergeLineages(local, remote);
+
+        assertThat(mergedLineage, hasSize(5));
+
+        assertMergedLayerIsFull(mergedLineage.get(0), "A");
+        assertMergedLayerIsLocalOnly(mergedLineage.get(1), "B");
+        assertMergedLayerIsRemoteOnly(mergedLineage.get(2), "X");
+        assertMergedLayerIsRemoteOnly(mergedLineage.get(3), "Y");
+        assertMergedLayerIsFull(mergedLineage.get(4), "C");
+    }
+
     private static class FieldHost {
         @SuppressWarnings("unused")
         private int test1;
diff --git a/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/MapBackedClassIndexedDescriptorsTest.java b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/ClassNameMapBackedClassIndexedDescriptorsTest.java
similarity index 85%
rename from modules/network/src/test/java/org/apache/ignite/internal/network/serialization/MapBackedClassIndexedDescriptorsTest.java
rename to modules/network/src/test/java/org/apache/ignite/internal/network/serialization/ClassNameMapBackedClassIndexedDescriptorsTest.java
index e8244c9fc..9c04cd824 100644
--- a/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/MapBackedClassIndexedDescriptorsTest.java
+++ b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/ClassNameMapBackedClassIndexedDescriptorsTest.java
@@ -27,27 +27,27 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 import java.util.Map;
 import org.junit.jupiter.api.Test;
 
-class MapBackedClassIndexedDescriptorsTest {
+class ClassNameMapBackedClassIndexedDescriptorsTest {
     private final ClassDescriptorRegistry unrelatedRegistry = new ClassDescriptorRegistry();
 
     @Test
     void retrievesKnownDescriptorByClass() {
         ClassDescriptor descriptor = unrelatedRegistry.getRequiredDescriptor(String.class);
-        var descriptors = new MapBackedClassIndexedDescriptors(Map.of(String.class, descriptor));
+        var descriptors = new ClassNameMapBackedClassIndexedDescriptors(Map.of(String.class.getName(), descriptor));
 
         assertThat(descriptors.getDescriptor(String.class), is(descriptor));
     }
 
     @Test
     void doesNotFindAnythingByClassWhenMapDoesNotContainTheClassDescriptor() {
-        var descriptors = new MapBackedClassIndexedDescriptors(emptyMap());
+        var descriptors = new ClassNameMapBackedClassIndexedDescriptors(emptyMap());
 
         assertThat(descriptors.getDescriptor(String.class), is(nullValue()));
     }
 
     @Test
     void throwsWhenQueriedAboutUnknownDescriptorByClass() {
-        var descriptors = new MapBackedClassIndexedDescriptors(emptyMap());
+        var descriptors = new ClassNameMapBackedClassIndexedDescriptors(emptyMap());
 
         Throwable thrownEx = assertThrows(IllegalStateException.class, () -> descriptors.getRequiredDescriptor(String.class));
         assertThat(thrownEx.getMessage(), startsWith("Did not find a descriptor by class"));
diff --git a/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/DefaultUserObjectMarshallerWithSchemaChangeTest.java b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/DefaultUserObjectMarshallerWithSchemaChangeTest.java
index 5cdb58e91..5fcd157db 100644
--- a/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/DefaultUserObjectMarshallerWithSchemaChangeTest.java
+++ b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/DefaultUserObjectMarshallerWithSchemaChangeTest.java
@@ -28,6 +28,7 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -45,15 +46,19 @@ import java.io.Serializable;
 import java.lang.reflect.Constructor;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Function;
+import java.util.function.UnaryOperator;
 import java.util.stream.Stream;
 import net.bytebuddy.ByteBuddy;
 import net.bytebuddy.description.modifier.Visibility;
+import net.bytebuddy.dynamic.DynamicType;
+import net.bytebuddy.implementation.StubMethod;
 import org.apache.ignite.internal.network.serialization.ClassDescriptor;
 import org.apache.ignite.internal.network.serialization.ClassDescriptorFactory;
 import org.apache.ignite.internal.network.serialization.ClassDescriptorRegistry;
+import org.apache.ignite.internal.network.serialization.ClassNameMapBackedClassIndexedDescriptors;
 import org.apache.ignite.internal.network.serialization.CompositeDescriptorRegistry;
 import org.apache.ignite.internal.network.serialization.FieldDescriptor;
-import org.apache.ignite.internal.network.serialization.MapBackedClassIndexedDescriptors;
 import org.apache.ignite.internal.network.serialization.MapBackedIdIndexedDescriptors;
 import org.apache.ignite.internal.testframework.IgniteTestUtils;
 import org.jetbrains.annotations.Nullable;
@@ -72,6 +77,8 @@ import org.mockito.junit.jupiter.MockitoExtension;
 @ExtendWith(MockitoExtension.class)
 public class DefaultUserObjectMarshallerWithSchemaChangeTest {
     private static final byte[] INT_42_BYTES_IN_LITTLE_ENDIAN = {42, 0, 0, 0};
+    private static final String NON_LEAF_CLASS_NAME = "test.NonLeaf";
+    private static final String LEAF_CLASS_NAME = "test.Leaf";
 
     private final ClassDescriptorRegistry localRegistry = new ClassDescriptorRegistry();
     private final ClassDescriptorFactory localFactory = new ClassDescriptorFactory(localRegistry);
@@ -103,9 +110,9 @@ public class DefaultUserObjectMarshallerWithSchemaChangeTest {
         verify(schemaMismatchHandler).onFieldIgnored(unmarshalled, "addedRemotely", extraField.value);
     }
 
-    private Class<?> addFieldTo(Class<?> localClass, String fieldName, Class<?> fieldType) {
+    private Class<?> addFieldTo(Class<?> baseClass, String fieldName, Class<?> fieldType) {
         return new ByteBuddy()
-                .redefine(localClass)
+                .redefine(baseClass)
                 .defineField(fieldName, fieldType, Visibility.PRIVATE)
                 .make()
                 .load(getClass().getClassLoader(), CHILD_FIRST)
@@ -205,6 +212,16 @@ public class DefaultUserObjectMarshallerWithSchemaChangeTest {
         return unmarshalNotNullLocally(marshalled, localClass, remoteClass);
     }
 
+    private Object marshalRemotelyAndUnmarshalLocally(
+            Object remoteInstance,
+            Class<?> localClass,
+            Class<?> remoteClass,
+            Function<ClassDescriptor, ClassDescriptor> reconstructSuperDescriptor
+    ) throws MarshalException, UnmarshalException {
+        MarshalledObject marshalled = remoteMarshaller.marshal(remoteInstance);
+        return unmarshalNotNullLocally(marshalled, localClass, remoteClass, reconstructSuperDescriptor);
+    }
+
     private <T> T unmarshalNotNullLocally(MarshalledObject marshalled, Class<?> localClass, Class<?> remoteClass)
             throws UnmarshalException {
         T unmarshalled = unmarshalLocally(marshalled, localClass, remoteClass);
@@ -212,9 +229,30 @@ public class DefaultUserObjectMarshallerWithSchemaChangeTest {
         return unmarshalled;
     }
 
+    private <T> T unmarshalNotNullLocally(
+            MarshalledObject marshalled,
+            Class<?> localClass,
+            Class<?> remoteClass,
+            Function<ClassDescriptor, ClassDescriptor> reconstructSuperDescriptor
+    ) throws UnmarshalException {
+        T unmarshalled = unmarshalLocally(marshalled, localClass, remoteClass, reconstructSuperDescriptor);
+        assertThat(unmarshalled, is(notNullValue()));
+        return unmarshalled;
+    }
+
     @Nullable
     private <T> T unmarshalLocally(MarshalledObject marshalled, Class<?> localClass, Class<?> remoteClass)
             throws UnmarshalException {
+        return unmarshalLocally(marshalled, localClass, remoteClass, Function.identity());
+    }
+
+    @Nullable
+    private <T> T unmarshalLocally(
+            MarshalledObject marshalled,
+            Class<?> localClass,
+            Class<?> remoteClass,
+            Function<ClassDescriptor, ClassDescriptor> reconstructSuperDescriptor
+    ) throws UnmarshalException {
         localFactory.create(localClass);
         ClassDescriptor localDescriptor = localRegistry.getRequiredDescriptor(localClass);
 
@@ -223,7 +261,7 @@ public class DefaultUserObjectMarshallerWithSchemaChangeTest {
         ClassDescriptor reconstructedRemoteDescriptor = ClassDescriptor.forRemote(
                 localDescriptor.localClass(),
                 remoteDescriptor.descriptorId(),
-                remoteDescriptor.superClassDescriptor(),
+                reconstructSuperDescriptor.apply(remoteDescriptor.superClassDescriptor()),
                 remoteDescriptor.componentTypeDescriptor(),
                 remoteDescriptor.isPrimitive(),
                 remoteDescriptor.isArray(),
@@ -238,7 +276,9 @@ public class DefaultUserObjectMarshallerWithSchemaChangeTest {
                 new MapBackedIdIndexedDescriptors(
                         Int2ObjectMaps.singleton(reconstructedRemoteDescriptor.descriptorId(), reconstructedRemoteDescriptor)
                 ),
-                new MapBackedClassIndexedDescriptors(Map.of(reconstructedRemoteDescriptor.localClass(), reconstructedRemoteDescriptor)),
+                new ClassNameMapBackedClassIndexedDescriptors(
+                        Map.of(reconstructedRemoteDescriptor.localClass().getName(), reconstructedRemoteDescriptor)
+                ),
                 localRegistry
         );
 
@@ -278,6 +318,196 @@ public class DefaultUserObjectMarshallerWithSchemaChangeTest {
         }
     }
 
+    @Test
+    void whenClassIsMergedIntoItsSubclassLocallyThenItsFieldsShouldNotBeFilledOnUnmarshalling() throws Exception {
+        Object unmarshalled = marshalRemotelyAndUnmarshalWithSuperclassDisappearingLocally();
+
+        assertThat(IgniteTestUtils.getFieldValue(unmarshalled, unmarshalled.getClass(), "value1"), is(0));
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    void whenClassIsMergedIntoItsSubclassLocallyThenItsFieldsShouldTriggerFieldMissedAndIgnoredEventsOnUnmarshalling() throws Exception {
+        SchemaMismatchHandler<Object> nonLeafLayerHandler = mock(SchemaMismatchHandler.class);
+        SchemaMismatchHandler<Object> leafLayerHandler = mock(SchemaMismatchHandler.class);
+
+        localMarshaller.replaceSchemaMismatchHandler(LEAF_CLASS_NAME, leafLayerHandler);
+        localMarshaller.replaceSchemaMismatchHandler(NON_LEAF_CLASS_NAME, nonLeafLayerHandler);
+
+        Object unmarshalled = marshalRemotelyAndUnmarshalWithSuperclassDisappearingLocally();
+
+        verify(leafLayerHandler).onFieldMissed(unmarshalled, "value1");
+        verify(nonLeafLayerHandler).onFieldIgnored(unmarshalled, "value1", 1);
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    void whenClassWithWriteObjectMethodIsMergedIntoItsSubclassLocallyThenReadObjectIgnoredEventShouldBeTriggeredOnUnmarshalling()
+            throws Exception {
+        SchemaMismatchHandler<Object> nonLeafLayerHandler = mock(SchemaMismatchHandler.class);
+
+        localMarshaller.replaceSchemaMismatchHandler(NON_LEAF_CLASS_NAME, nonLeafLayerHandler);
+
+        Object unmarshalled = marshalRemotelyAndUnmarshalWithSuperclassDisappearingLocally(this::withEmptyWriteObjectMethod);
+
+        verify(nonLeafLayerHandler).onReadObjectIgnored(eq(unmarshalled), any());
+    }
+
+    private DynamicType.Builder<Object> withEmptyWriteObjectMethod(DynamicType.Builder<Object> builder) {
+        return builder
+                .implement(Serializable.class)
+                .defineMethod("writeObject", void.class, Visibility.PRIVATE)
+                .withParameters(ObjectOutputStream.class)
+                .intercept(StubMethod.INSTANCE);
+    }
+
+    private Object marshalRemotelyAndUnmarshalWithSuperclassDisappearingLocally()
+            throws MarshalException, ReflectiveOperationException, UnmarshalException {
+        return marshalRemotelyAndUnmarshalWithSuperclassDisappearingLocally(UnaryOperator.identity());
+    }
+
+    private Object marshalRemotelyAndUnmarshalWithSuperclassDisappearingLocally(
+            UnaryOperator<DynamicType.Builder<Object>> remoteNonLeafClassCustomizer
+    ) throws ReflectiveOperationException, MarshalException, UnmarshalException {
+
+        DynamicType.Builder<Object> remoteNonLeafClassBuilder = new ByteBuddy()
+                .subclass(Object.class)
+                .name(NON_LEAF_CLASS_NAME)
+                .defineField("value1", int.class, Visibility.PRIVATE);
+
+        Class<?> remoteNonLeafClass = remoteNonLeafClassCustomizer.apply(remoteNonLeafClassBuilder)
+                .make()
+                .load(getClass().getClassLoader(), CHILD_FIRST)
+                .getLoaded();
+        Class<?> remoteLeafClass = new ByteBuddy()
+                .subclass(remoteNonLeafClass)
+                .name(LEAF_CLASS_NAME)
+                .defineField("value2", int.class, Visibility.PRIVATE)
+                .make()
+                .load(remoteNonLeafClass.getClassLoader())
+                .getLoaded();
+
+        Class<?> localLeafClass = new ByteBuddy()
+                .subclass(Object.class)
+                .name(LEAF_CLASS_NAME)
+                .defineField("value1", int.class, Visibility.PRIVATE)
+                .defineField("value2", int.class, Visibility.PRIVATE)
+                .make()
+                .load(getClass().getClassLoader(), CHILD_FIRST)
+                .getLoaded();
+
+        Object remoteInstance = instantiate(remoteLeafClass);
+        IgniteTestUtils.setFieldValue(remoteInstance, remoteInstance.getClass().getSuperclass(), "value1", 1);
+        IgniteTestUtils.setFieldValue(remoteInstance, remoteLeafClass, "value2", 2);
+
+        return marshalRemotelyAndUnmarshalLocally(
+                remoteInstance,
+                localLeafClass,
+                remoteLeafClass,
+                this::toRemoteDescriptorWithoutLocalClass
+        );
+    }
+
+    private ClassDescriptor toRemoteDescriptorWithoutLocalClass(ClassDescriptor superDescriptor) {
+        return ClassDescriptor.forRemote(
+                superDescriptor.className(),
+                superDescriptor.descriptorId(),
+                superDescriptor.superClassDescriptor(),
+                superDescriptor.componentTypeDescriptor(),
+                superDescriptor.isPrimitive(),
+                superDescriptor.isArray(),
+                superDescriptor.isRuntimeEnum(),
+                superDescriptor.isRuntimeTypeKnownUpfront(),
+                superDescriptor.fields(),
+                superDescriptor.serialization()
+        );
+    }
+
+    @Test
+    void whenSuperclassIsSplitFromClassLocallyThenSuperclassFieldsShouldNotBeFilledOnUnmarshalling() throws Exception {
+        Object unmarshalled = marshalRemotelyAndUnmarshalWithSuperclassAppearingLocally();
+
+        assertThat(IgniteTestUtils.getFieldValue(unmarshalled, unmarshalled.getClass().getSuperclass(), "value1"), is(0));
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    void whenSuperclassIsSplitFromClassLocallyThenSuperclassFieldsShouldTriggerFieldMissedAndIgnoredEventsOnUnmarshalling()
+            throws Exception {
+        SchemaMismatchHandler<Object> nonLeafLayerHandler = mock(SchemaMismatchHandler.class);
+        SchemaMismatchHandler<Object> leafLayerHandler = mock(SchemaMismatchHandler.class);
+
+        localMarshaller.replaceSchemaMismatchHandler(LEAF_CLASS_NAME, leafLayerHandler);
+        localMarshaller.replaceSchemaMismatchHandler(NON_LEAF_CLASS_NAME, nonLeafLayerHandler);
+
+        Object unmarshalled = marshalRemotelyAndUnmarshalWithSuperclassAppearingLocally();
+
+        verify(leafLayerHandler).onFieldIgnored(unmarshalled, "value1", 1);
+        verify(nonLeafLayerHandler).onFieldMissed(unmarshalled, "value1");
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    void whenSuperclassWithReadObjectMethodIsSplitFromClassLocallyThenReadObjectMissedEventShouldBeTriggeredOnUnmarshalling()
+            throws Exception {
+        SchemaMismatchHandler<Object> nonLeafLayerHandler = mock(SchemaMismatchHandler.class);
+
+        localMarshaller.replaceSchemaMismatchHandler(NON_LEAF_CLASS_NAME, nonLeafLayerHandler);
+
+        Object unmarshalled = marshalRemotelyAndUnmarshalWithSuperclassAppearingLocally(this::withEmptyReadObjectMethod);
+
+        verify(nonLeafLayerHandler).onReadObjectMissed(eq(unmarshalled));
+    }
+
+    private DynamicType.Builder<Object> withEmptyReadObjectMethod(DynamicType.Builder<Object> builder) {
+        return builder
+                .implement(Serializable.class)
+                .defineMethod("readObject", void.class, Visibility.PRIVATE)
+                .withParameters(ObjectInputStream.class)
+                .intercept(StubMethod.INSTANCE);
+    }
+
+    private Object marshalRemotelyAndUnmarshalWithSuperclassAppearingLocally()
+            throws ReflectiveOperationException, MarshalException, UnmarshalException {
+        return marshalRemotelyAndUnmarshalWithSuperclassAppearingLocally(UnaryOperator.identity());
+    }
+
+    private Object marshalRemotelyAndUnmarshalWithSuperclassAppearingLocally(
+            UnaryOperator<DynamicType.Builder<Object>> localNonLeafClassCustomizer
+    ) throws ReflectiveOperationException, MarshalException, UnmarshalException {
+
+        Class<?> remoteLeafClass = new ByteBuddy()
+                .subclass(Object.class)
+                .name(LEAF_CLASS_NAME)
+                .defineField("value1", int.class, Visibility.PRIVATE)
+                .defineField("value2", int.class, Visibility.PRIVATE)
+                .make()
+                .load(getClass().getClassLoader(), CHILD_FIRST)
+                .getLoaded();
+
+        DynamicType.Builder<Object> localNonLeafClassBuilder = new ByteBuddy()
+                .subclass(Object.class)
+                .name(NON_LEAF_CLASS_NAME)
+                .defineField("value1", int.class, Visibility.PRIVATE);
+        Class<?> localNonLeafClass = localNonLeafClassCustomizer.apply(localNonLeafClassBuilder)
+                .make()
+                .load(getClass().getClassLoader(), CHILD_FIRST)
+                .getLoaded();
+        Class<?> localLeafClass = new ByteBuddy()
+                .subclass(localNonLeafClass)
+                .name(LEAF_CLASS_NAME)
+                .defineField("value2", int.class, Visibility.PRIVATE)
+                .make()
+                .load(localNonLeafClass.getClassLoader())
+                .getLoaded();
+
+        Object remoteInstance = instantiate(remoteLeafClass);
+        IgniteTestUtils.setFieldValue(remoteInstance, remoteInstance.getClass(), "value1", 1);
+        IgniteTestUtils.setFieldValue(remoteInstance, remoteInstance.getClass(), "value2", 2);
+
+        return marshalRemotelyAndUnmarshalLocally(remoteInstance, localLeafClass, remoteLeafClass);
+    }
+
     @ParameterizedTest
     @MethodSource("extraFieldsForGetField")
     void getFieldReturnsDefaultValueWhenRemoteClassHasExtraField(ExtraFieldForGetField extraField) throws Exception {
@@ -589,7 +819,7 @@ public class DefaultUserObjectMarshallerWithSchemaChangeTest {
 
         private void writeObject(ObjectOutputStream stream) throws IOException {
             stream.putFields();
-            stream.writeFields();;
+            stream.writeFields();
         }
 
         private void readObject(ObjectInputStream stream) throws IOException, ClassNotFoundException {