weiqingy commented on code in PR #28973:
URL: https://github.com/apache/flink/pull/28973#discussion_r4291710924


##########
flink-table/flink-table-type-utils/src/main/java/org/apache/flink/table/runtime/typeutils/RowDataSerializer.java:
##########
@@ -387,25 +483,236 @@ public TypeSerializerSchemaCompatibility<RowData> 
resolveSchemaCompatibility(
 
             RowDataSerializerSnapshot oldRowDataSerializerSnapshot =
                     (RowDataSerializerSnapshot) oldSerializerSnapshot;
-            if (!Arrays.equals(types, oldRowDataSerializerSnapshot.types)) {
+            // A side that carries no names cannot disagree with anything: 
without names a reorder
+            // is undetectable, so identical types keep meaning "compatible as 
is" there, exactly as
+            // they do unarmed.
+            boolean namesDisagree =
+                    stateSchemaEvolutionEnabled
+                            && fieldNames != null
+                            && oldRowDataSerializerSnapshot.fieldNames != null
+                            && !Arrays.equals(fieldNames, 
oldRowDataSerializerSnapshot.fieldNames);
+
+            // Identical positional layout: the nested composite path. Equal 
types at equal
+            // positions say nothing about which field is which, so an armed 
resolution has to see
+            // the names agree as well before it can treat the layout as 
unchanged -- otherwise a
+            // reorder or a rename among same-typed fields resolves here as 
needing no migration
+            // and leaves every value sitting under a neighbour's name.
+            if (Arrays.equals(types, oldRowDataSerializerSnapshot.types) && 
!namesDisagree) {
+                
CompositeTypeSerializerUtil.IntermediateCompatibilityResult<RowData>
+                        intermediateResult =
+                                CompositeTypeSerializerUtil
+                                        
.constructIntermediateCompatibilityResult(
+                                                
nestedSerializersSnapshotDelegate
+                                                        
.getNestedSerializerSnapshots(),
+                                                oldRowDataSerializerSnapshot
+                                                        
.nestedSerializersSnapshotDelegate
+                                                        
.getNestedSerializerSnapshots());
+
+                if 
(intermediateResult.isCompatibleWithReconfiguredSerializer()) {
+                    RowDataSerializer reconfiguredCompositeSerializer = 
restoreSerializer();
+                    return 
TypeSerializerSchemaCompatibility.compatibleWithReconfiguredSerializer(
+                            reconfiguredCompositeSerializer);
+                }
+
+                return intermediateResult.getFinalResult();
+            }
+
+            if (!stateSchemaEvolutionEnabled) {
                 return TypeSerializerSchemaCompatibility.incompatible();
             }
 
-            
CompositeTypeSerializerUtil.IntermediateCompatibilityResult<RowData>
-                    intermediateResult =
-                            
CompositeTypeSerializerUtil.constructIntermediateCompatibilityResult(
-                                    nestedSerializersSnapshotDelegate
-                                            .getNestedSerializerSnapshots(),
-                                    
oldRowDataSerializerSnapshot.nestedSerializersSnapshotDelegate
-                                            .getNestedSerializerSnapshots());
-
-            if (intermediateResult.isCompatibleWithReconfiguredSerializer()) {
-                RowDataSerializer reconfiguredCompositeSerializer = 
restoreSerializer();
-                return 
TypeSerializerSchemaCompatibility.compatibleWithReconfiguredSerializer(
-                        reconfiguredCompositeSerializer);
+            // The new side must carry field names. A name-less new serializer 
is reachable -- a
+            // structured type resolves to one -- and admitting it would open 
a permanent name-less
+            // evolution channel rather than a ramp for savepoints taken 
before names were stored.
+            if (fieldNames == null) {
+                return TypeSerializerSchemaCompatibility.incompatible();
             }
 
-            return intermediateResult.getFinalResult();
+            return oldRowDataSerializerSnapshot.fieldNames != null
+                    ? checkNameBasedEvolution(oldRowDataSerializerSnapshot)
+                    : checkPositionalEvolution(oldRowDataSerializerSnapshot);
+        }
+
+        private TypeSerializerSchemaCompatibility<RowData> 
checkNameBasedEvolution(
+                RowDataSerializerSnapshot oldSnapshot) {
+            int[] oldToNew = buildNameMapping(oldSnapshot.fieldNames, 
this.fieldNames);
+            int[] newToOld = buildNameMapping(this.fieldNames, 
oldSnapshot.fieldNames);
+
+            // (A) Every new-only field (no matching old field) must be 
nullable.
+            for (int newPos = 0; newPos < newToOld.length; newPos++) {
+                if (newToOld[newPos] == -1 && !types[newPos].isNullable()) {
+                    return TypeSerializerSchemaCompatibility.incompatible();
+                }
+            }
+
+            // (B) Every old field must survive with a compatible type, and 
nested snapshots are
+            //     aligned old->new so nested ROW evolution can recurse. Leaf 
(non-ROW) fields
+            //     require an exactly equal type; ROW fields defer to the 
nested recursion in (C).
+            TypeSerializerSnapshot<?>[] newNested =
+                    
nestedSerializersSnapshotDelegate.getNestedSerializerSnapshots();
+            TypeSerializerSnapshot<?>[] alignedNewNested =
+                    new TypeSerializerSnapshot<?>[oldSnapshot.types.length];
+            for (int oldPos = 0; oldPos < oldToNew.length; oldPos++) {
+                int newPos = oldToNew[oldPos];
+                if (newPos == -1) {
+                    return TypeSerializerSchemaCompatibility.incompatible(); 
// field removed
+                }
+                LogicalType oldType = oldSnapshot.types[oldPos];
+                LogicalType newType = types[newPos];
+                if (!bothRowWithSameNullability(oldType, newType) && 
!oldType.equals(newType)) {
+                    return TypeSerializerSchemaCompatibility.incompatible(); 
// leaf type changed
+                }
+                alignedNewNested[oldPos] = newNested[newPos];
+            }
+
+            // (C) Recurse into the aligned nested snapshot pairs.
+            return resolveAlignedNested(alignedNewNested, oldSnapshot);
+        }
+
+        /**
+         * Resolves against a prior snapshot that carries no field names, 
matching fields by
+         * position.
+         *
+         * <p>Position is a stable identity only for an append. An insertion 
in the middle is
+         * indistinguishable from a retype plus an append, and the two demand 
opposite migrations,
+         * so the old layout has to be a prefix of the new one.
+         */
+        private TypeSerializerSchemaCompatibility<RowData> 
checkPositionalEvolution(
+                RowDataSerializerSnapshot oldSnapshot) {
+            if (types.length < oldSnapshot.types.length) {
+                return TypeSerializerSchemaCompatibility.incompatible();
+            }
+            for (int i = 0; i < oldSnapshot.types.length; i++) {
+                LogicalType oldType = oldSnapshot.types[i];
+                LogicalType newType = types[i];
+                if (!bothRowWithSameNullability(oldType, newType) && 
!oldType.equals(newType)) {
+                    return TypeSerializerSchemaCompatibility.incompatible();
+                }
+            }
+            for (int i = oldSnapshot.types.length; i < types.length; i++) {
+                if (!types[i].isNullable()) {
+                    return TypeSerializerSchemaCompatibility.incompatible();
+                }
+            }
+
+            // constructIntermediateCompatibilityResult requires both arrays 
to have the same
+            // length, so only the prefix the old layout covers is handed to 
it.
+            TypeSerializerSnapshot<?>[] alignedNewNested =
+                    Arrays.copyOf(
+                            
nestedSerializersSnapshotDelegate.getNestedSerializerSnapshots(),
+                            oldSnapshot.types.length);
+            return resolveAlignedNested(alignedNewNested, oldSnapshot);
+        }
+
+        private TypeSerializerSchemaCompatibility<RowData> 
resolveAlignedNested(
+                TypeSerializerSnapshot<?>[] alignedNewNested,
+                RowDataSerializerSnapshot oldSnapshot) {
+            
CompositeTypeSerializerUtil.IntermediateCompatibilityResult<RowData> nested =
+                    
CompositeTypeSerializerUtil.constructIntermediateCompatibilityResult(
+                            alignedNewNested,
+                            oldSnapshot.nestedSerializersSnapshotDelegate
+                                    .getNestedSerializerSnapshots());
+            // A reconfigured nested serializer is deliberately not propagated 
here, unlike on the
+            // identical-layout path. Reconfiguration exists so a new 
serializer can read old bytes;
+            // once the values have been remapped there are no old bytes left, 
because the migrated
+            // row is re-encoded by the state's own new serializer.
+            return nested.isIncompatible()
+                    ? TypeSerializerSchemaCompatibility.incompatible()
+                    : 
TypeSerializerSchemaCompatibility.compatibleAfterMigration();

Review Comment:
   Valid; fixed in 5681e6b1b32 by rejecting rather than re-encoding. On the 
opted-in evolution paths (name-based and positional), every non-ROW field 
carried over from the old schema must now resolve compatible-as-is. Nested ROW 
fields recurse under the same rule, including an unchanged nested row holding a 
reordered RAW field: on its own it resolves as reconfigured, but under a 
remapped parent its RAW payload is copied just the same. Each field is checked 
separately, so another field's migration can no longer mask a reconfiguration. 
The identical-layout path is unchanged.
   
   Why not decode and re-encode:
   - The remap rebuilds only the ROW spine and copies every other value's 
bytes; re-encoding would add a second, per-field migration mechanism inside it.
   - It would add per-record Kryo work.
   - Failing at restore is safer for a rare change, in line with FLIP-527's 
"fail safely".
   
   Tests in `RowDataSerializerSchemaEvolutionTest`: 
`reconfiguredRawLeafIsIncompatibleWhenTheRowEvolves` is the comment's example 
(field added, Kryo registrations reordered), plus 
`reconfiguredRawLeafInUnchangedNestedRowIsIncompatible` and 
`positionalReconfiguredRawLeafIsIncompatible`; all three fail without the fix. 
The control `reorderedKryoRegistrationsInUnchangedLayoutAreReconfigured` shows 
the reorder surfaces only through the nested snapshot. These are resolve-level 
tests: an incompatible verdict fails the restore before `migrate` is called, so 
a binary-row `migrate` test would only exercise a path the fix makes 
unreachable.
   
   Pre-existing on master, out of scope:
   - ARRAY/MAP snapshots compare element serializers with `equals`, so a Kryo 
reorder inside ARRAY/MAP of RAW resolves compatible-as-is and passes this check 
too.
   - On an unchanged layout, `RowDataSerializerSnapshot` answers a reconfigured 
nested result with `restoreSerializer()`, discarding the reconfigured nested 
serializers.
   - A TTL toggle on an unchanged layout turns a RAW leaf's reconfigured 
verdict into `compatibleAfterMigration`, and the old bytes are copied unchanged.
   
   I'll open a follow-up JIRA for these.
   



##########
flink-table/flink-table-type-utils/src/main/java/org/apache/flink/table/runtime/typeutils/RowDataSerializer.java:
##########
@@ -387,25 +483,236 @@ public TypeSerializerSchemaCompatibility<RowData> 
resolveSchemaCompatibility(
 
             RowDataSerializerSnapshot oldRowDataSerializerSnapshot =
                     (RowDataSerializerSnapshot) oldSerializerSnapshot;
-            if (!Arrays.equals(types, oldRowDataSerializerSnapshot.types)) {
+            // A side that carries no names cannot disagree with anything: 
without names a reorder
+            // is undetectable, so identical types keep meaning "compatible as 
is" there, exactly as
+            // they do unarmed.
+            boolean namesDisagree =
+                    stateSchemaEvolutionEnabled
+                            && fieldNames != null
+                            && oldRowDataSerializerSnapshot.fieldNames != null
+                            && !Arrays.equals(fieldNames, 
oldRowDataSerializerSnapshot.fieldNames);
+
+            // Identical positional layout: the nested composite path. Equal 
types at equal
+            // positions say nothing about which field is which, so an armed 
resolution has to see
+            // the names agree as well before it can treat the layout as 
unchanged -- otherwise a
+            // reorder or a rename among same-typed fields resolves here as 
needing no migration
+            // and leaves every value sitting under a neighbour's name.
+            if (Arrays.equals(types, oldRowDataSerializerSnapshot.types) && 
!namesDisagree) {
+                
CompositeTypeSerializerUtil.IntermediateCompatibilityResult<RowData>
+                        intermediateResult =
+                                CompositeTypeSerializerUtil
+                                        
.constructIntermediateCompatibilityResult(
+                                                
nestedSerializersSnapshotDelegate
+                                                        
.getNestedSerializerSnapshots(),
+                                                oldRowDataSerializerSnapshot
+                                                        
.nestedSerializersSnapshotDelegate
+                                                        
.getNestedSerializerSnapshots());
+
+                if 
(intermediateResult.isCompatibleWithReconfiguredSerializer()) {
+                    RowDataSerializer reconfiguredCompositeSerializer = 
restoreSerializer();
+                    return 
TypeSerializerSchemaCompatibility.compatibleWithReconfiguredSerializer(
+                            reconfiguredCompositeSerializer);
+                }
+
+                return intermediateResult.getFinalResult();
+            }
+
+            if (!stateSchemaEvolutionEnabled) {
                 return TypeSerializerSchemaCompatibility.incompatible();
             }
 
-            
CompositeTypeSerializerUtil.IntermediateCompatibilityResult<RowData>
-                    intermediateResult =
-                            
CompositeTypeSerializerUtil.constructIntermediateCompatibilityResult(
-                                    nestedSerializersSnapshotDelegate
-                                            .getNestedSerializerSnapshots(),
-                                    
oldRowDataSerializerSnapshot.nestedSerializersSnapshotDelegate
-                                            .getNestedSerializerSnapshots());
-
-            if (intermediateResult.isCompatibleWithReconfiguredSerializer()) {
-                RowDataSerializer reconfiguredCompositeSerializer = 
restoreSerializer();
-                return 
TypeSerializerSchemaCompatibility.compatibleWithReconfiguredSerializer(
-                        reconfiguredCompositeSerializer);
+            // The new side must carry field names. A name-less new serializer 
is reachable -- a
+            // structured type resolves to one -- and admitting it would open 
a permanent name-less
+            // evolution channel rather than a ramp for savepoints taken 
before names were stored.
+            if (fieldNames == null) {
+                return TypeSerializerSchemaCompatibility.incompatible();
             }
 
-            return intermediateResult.getFinalResult();
+            return oldRowDataSerializerSnapshot.fieldNames != null
+                    ? checkNameBasedEvolution(oldRowDataSerializerSnapshot)
+                    : checkPositionalEvolution(oldRowDataSerializerSnapshot);
+        }
+
+        private TypeSerializerSchemaCompatibility<RowData> 
checkNameBasedEvolution(
+                RowDataSerializerSnapshot oldSnapshot) {
+            int[] oldToNew = buildNameMapping(oldSnapshot.fieldNames, 
this.fieldNames);
+            int[] newToOld = buildNameMapping(this.fieldNames, 
oldSnapshot.fieldNames);
+
+            // (A) Every new-only field (no matching old field) must be 
nullable.
+            for (int newPos = 0; newPos < newToOld.length; newPos++) {
+                if (newToOld[newPos] == -1 && !types[newPos].isNullable()) {
+                    return TypeSerializerSchemaCompatibility.incompatible();
+                }
+            }
+
+            // (B) Every old field must survive with a compatible type, and 
nested snapshots are
+            //     aligned old->new so nested ROW evolution can recurse. Leaf 
(non-ROW) fields
+            //     require an exactly equal type; ROW fields defer to the 
nested recursion in (C).
+            TypeSerializerSnapshot<?>[] newNested =
+                    
nestedSerializersSnapshotDelegate.getNestedSerializerSnapshots();
+            TypeSerializerSnapshot<?>[] alignedNewNested =
+                    new TypeSerializerSnapshot<?>[oldSnapshot.types.length];
+            for (int oldPos = 0; oldPos < oldToNew.length; oldPos++) {
+                int newPos = oldToNew[oldPos];
+                if (newPos == -1) {
+                    return TypeSerializerSchemaCompatibility.incompatible(); 
// field removed
+                }
+                LogicalType oldType = oldSnapshot.types[oldPos];
+                LogicalType newType = types[newPos];
+                if (!bothRowWithSameNullability(oldType, newType) && 
!oldType.equals(newType)) {
+                    return TypeSerializerSchemaCompatibility.incompatible(); 
// leaf type changed
+                }
+                alignedNewNested[oldPos] = newNested[newPos];
+            }
+
+            // (C) Recurse into the aligned nested snapshot pairs.
+            return resolveAlignedNested(alignedNewNested, oldSnapshot);
+        }
+
+        /**
+         * Resolves against a prior snapshot that carries no field names, 
matching fields by
+         * position.
+         *
+         * <p>Position is a stable identity only for an append. An insertion 
in the middle is
+         * indistinguishable from a retype plus an append, and the two demand 
opposite migrations,
+         * so the old layout has to be a prefix of the new one.
+         */
+        private TypeSerializerSchemaCompatibility<RowData> 
checkPositionalEvolution(
+                RowDataSerializerSnapshot oldSnapshot) {
+            if (types.length < oldSnapshot.types.length) {
+                return TypeSerializerSchemaCompatibility.incompatible();
+            }
+            for (int i = 0; i < oldSnapshot.types.length; i++) {
+                LogicalType oldType = oldSnapshot.types[i];
+                LogicalType newType = types[i];
+                if (!bothRowWithSameNullability(oldType, newType) && 
!oldType.equals(newType)) {
+                    return TypeSerializerSchemaCompatibility.incompatible();
+                }
+            }
+            for (int i = oldSnapshot.types.length; i < types.length; i++) {
+                if (!types[i].isNullable()) {
+                    return TypeSerializerSchemaCompatibility.incompatible();
+                }
+            }
+
+            // constructIntermediateCompatibilityResult requires both arrays 
to have the same
+            // length, so only the prefix the old layout covers is handed to 
it.
+            TypeSerializerSnapshot<?>[] alignedNewNested =
+                    Arrays.copyOf(
+                            
nestedSerializersSnapshotDelegate.getNestedSerializerSnapshots(),
+                            oldSnapshot.types.length);
+            return resolveAlignedNested(alignedNewNested, oldSnapshot);
+        }
+
+        private TypeSerializerSchemaCompatibility<RowData> 
resolveAlignedNested(
+                TypeSerializerSnapshot<?>[] alignedNewNested,
+                RowDataSerializerSnapshot oldSnapshot) {
+            
CompositeTypeSerializerUtil.IntermediateCompatibilityResult<RowData> nested =
+                    
CompositeTypeSerializerUtil.constructIntermediateCompatibilityResult(
+                            alignedNewNested,
+                            oldSnapshot.nestedSerializersSnapshotDelegate
+                                    .getNestedSerializerSnapshots());
+            // A reconfigured nested serializer is deliberately not propagated 
here, unlike on the
+            // identical-layout path. Reconfiguration exists so a new 
serializer can read old bytes;
+            // once the values have been remapped there are no old bytes left, 
because the migrated
+            // row is re-encoded by the state's own new serializer.
+            return nested.isIncompatible()
+                    ? TypeSerializerSchemaCompatibility.incompatible()
+                    : 
TypeSerializerSchemaCompatibility.compatibleAfterMigration();
+        }
+
+        /**
+         * Whether a field pair may skip the exact type check and have its 
contents compared by the
+         * nested snapshot recursion instead.
+         *
+         * <p>That recursion compares the ROW's fields and never the ROW's own 
nullability, so the
+         * nullability has to be settled here. Without it, narrowing a 
nullable ROW field to NOT
+         * NULL is accepted and {@code migrate} then carries an existing null 
straight into the
+         * non-null field. Requiring equality matches how a leaf field is 
treated, since {@link
+         * LogicalType#equals} compares nullability too.
+         */
+        private static boolean bothRowWithSameNullability(
+                LogicalType oldType, LogicalType newType) {
+            return oldType.getTypeRoot() == LogicalTypeRoot.ROW
+                    && newType.getTypeRoot() == LogicalTypeRoot.ROW
+                    && oldType.isNullable() == newType.isNullable();
+        }
+
+        @Override
+        public RowData migrate(
+                TypeSerializerSnapshot<RowData> oldSerializerSnapshot, RowData 
value) {
+            if (value == null) {
+                return null;
+            }

Review Comment:
   Valid, fixed in 1425fa35644: `migrate` now returns the value unchanged when 
the option is off, which is master's behaviour. With the option off, a 
same-typed rename or reorder resolves compatible-as-is by position, and only a 
TTL toggle turns that into `compatibleAfterMigration`, so passing values 
through keeps positional semantics. The opted-in path is unaffected: `migrate` 
runs on a snapshot of the registered new serializer, the serializer whose 
snapshot resolved compatibility, so it sees the same 
`stateSchemaEvolutionEnabled` flag. Because the guard keys on that flag, it 
also keeps RowData list elements and map values positional on a TTL toggle; 
this PR never sets the flag for them (#29449 does).
   
   - `sameTypedReorderStaysPositionalWhenNotOptedIn` (renamed and extended) and 
`sameTypedRenameStaysPositionalWhenNotOptedIn` (new) in 
`RowDataSerializerSchemaEvolutionTest` assert compatible-as-is, then call 
`migrate` the way `TtlAwareSerializer` does on a TTL toggle and expect the 
value untouched. Both fail without the guard (rename yields null, reorder swaps 
values).
   - The TTL transition is modelled at unit level, since 
`flink-table-type-utils` has no flink-runtime dependency. The other half, a 
toggle mapping to `compatibleAfterMigration` and invoking `migrate`, is already 
covered in flink-runtime by `TtlAwareSerializerUpgradeTest` and 
`TtlAwareSerializerTest`.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to