mandrean opened a new issue, #3652:
URL: https://github.com/apache/fory/issues/3652

   ### Search before asking
   
   - [x] I had searched in the [issues](https://github.com/apache/fory/issues) 
and found no similar issues.
   
   Closest issues I found were apache/fory#3337 and apache/fory#1972. They look 
related to map-like serializers and missing fields, but this repro is 
specifically `CompatibleMode.COMPATIBLE` schema evolution where the old writer 
schema has a `TreeMap<String, String>` subclass field inside a collection 
element and the new reader schema removes that field.
   
   ### Version
   
   - Fory Java: `org.apache.fory:fory-core:0.17.0`
   - JDK: 21.0.5
   - OS: macOS locally; also observed on Linux CI with a shaded Fory 0.17.0 
build
   
   ### Component(s)
   
   Java
   
   ### Minimal reproduce step
   
   The example is anonymized from a real schema-evolution failure. The shape is:
   
   - V1 writes a catalog snapshot containing a list of movie documents.
   - Each V1 movie document has a `LegacyLabelsV1 extends TreeMap<String, 
String>` field.
   - V2 still has the snapshot/list/document shape, but removes the legacy 
labels field from the movie document.
   - Both sides use `CompatibleMode.COMPATIBLE`; the V1 and V2 classes are 
registered with the same IDs to model the same logical classes before/after the 
schema change.
   
   Paste this into `jshell --class-path 
~/.m2/repository/org/apache/fory/fory-core/0.17.0/fory-core-0.17.0.jar` after 
fetching `org.apache.fory:fory-core:0.17.0`:
   
   ```java
   import org.apache.fory.Fory;
   import org.apache.fory.config.*;
   import java.util.*;
   
   class LegacyLabelsV1 extends TreeMap<String, String> { public 
LegacyLabelsV1() {} }
   class LegacyLabelsV2 extends TreeMap<String, String> { public 
LegacyLabelsV2() {} }
   
   class MovieDocumentV1 {
     public String id;
     public LegacyLabelsV1 labels;
     public MovieDocumentV1() {}
     MovieDocumentV1(String id) {
       this.id = id;
       this.labels = new LegacyLabelsV1();
       this.labels.put("region", "se");
     }
   }
   
   class MovieDocumentV2 {
     public String id;
     public MovieDocumentV2() {}
   }
   
   class MovieDocumentV2WithLabels {
     public String id;
     public LegacyLabelsV2 labels;
     public MovieDocumentV2WithLabels() {}
   }
   
   class CatalogSnapshotV1 {
     public String batchId;
     public ArrayList<MovieDocumentV1> movies;
     public CatalogSnapshotV1() {}
     CatalogSnapshotV1(String batchId) {
       this.batchId = batchId;
       this.movies = new ArrayList<>();
       this.movies.add(new MovieDocumentV1("m-1"));
     }
   }
   
   class CatalogSnapshotV2 {
     public String batchId;
     public ArrayList<MovieDocumentV2> movies;
     public CatalogSnapshotV2() {}
   }
   
   class CatalogSnapshotV2WithLabels {
     public String batchId;
     public ArrayList<MovieDocumentV2WithLabels> movies;
     public CatalogSnapshotV2WithLabels() {}
   }
   
   Throwable root(Throwable t) {
     while (t.getCause() != null) t = t.getCause();
     return t;
   }
   
   Fory fory(boolean asyncCompilation, boolean codegen) {
     return Fory.builder()
         .withLanguage(Language.JAVA)
         .withCompatibleMode(CompatibleMode.COMPATIBLE)
         .requireClassRegistration(false)
         .withDeserializeUnknownClass(true)
         .withAsyncCompilation(asyncCompilation)
         .withCodegen(codegen)
         .build();
   }
   
   byte[] writeV1(boolean asyncCompilation, boolean codegen) {
     Fory writer = fory(asyncCompilation, codegen);
     writer.register(CatalogSnapshotV1.class, 4100);
     writer.register(MovieDocumentV1.class, 4101);
     writer.register(LegacyLabelsV1.class, 4102);
     return writer.serialize(new CatalogSnapshotV1("batch-1"));
   }
   
   void removedFieldCase(boolean asyncCompilation, boolean codegen) {
     try {
       byte[] bytes = writeV1(asyncCompilation, codegen);
   
       Fory reader = fory(asyncCompilation, codegen);
       reader.register(CatalogSnapshotV2.class, 4100);
       reader.register(MovieDocumentV2.class, 4101);
       reader.register(LegacyLabelsV2.class, 4102);
   
       Object decoded = reader.deserialize(bytes, CatalogSnapshotV2.class);
       System.out.println("removed-field PASS async=" + asyncCompilation + " 
codegen=" + codegen + " -> " + decoded.getClass());
     } catch (Throwable t) {
       Throwable r = root(t);
       System.out.println("removed-field FAIL async=" + asyncCompilation + " 
codegen=" + codegen + " -> " + t.getClass().getName() + ": " + t.getMessage());
       System.out.println("root: " + r.getClass().getName() + ": " + 
r.getMessage());
       for (StackTraceElement e : r.getStackTrace()) {
         if (e.getClassName().contains("MapLikeSerializer") || 
e.getClassName().contains("TreeMap")) {
           System.out.println("  at " + e);
         }
       }
     }
   }
   
   void fieldStillPresentControl(boolean asyncCompilation, boolean codegen) {
     try {
       byte[] bytes = writeV1(asyncCompilation, codegen);
   
       Fory reader = fory(asyncCompilation, codegen);
       reader.register(CatalogSnapshotV2WithLabels.class, 4100);
       reader.register(MovieDocumentV2WithLabels.class, 4101);
       reader.register(LegacyLabelsV2.class, 4102);
   
       Object decoded = reader.deserialize(bytes, 
CatalogSnapshotV2WithLabels.class);
       System.out.println("present-field PASS async=" + asyncCompilation + " 
codegen=" + codegen + " -> " + decoded.getClass());
     } catch (Throwable t) {
       Throwable r = root(t);
       System.out.println("present-field FAIL async=" + asyncCompilation + " 
codegen=" + codegen + " -> " + t.getClass().getName() + ": " + t.getMessage());
       System.out.println("root: " + r.getClass().getName() + ": " + 
r.getMessage());
     }
   }
   
   removedFieldCase(true, true);
   removedFieldCase(false, false);
   fieldStillPresentControl(true, true);
   fieldStillPresentControl(false, false);
   ```
   
   Observed output:
   
   ```text
   removed-field FAIL async=true codegen=true -> 
org.apache.fory.exception.DeserializationException: Failed to deserialize input
   root: java.lang.ClassCastException: class java.lang.Object cannot be cast to 
class java.lang.Comparable
     at java.base/java.util.TreeMap.compare(TreeMap.java:1604)
     at java.base/java.util.TreeMap.addEntryToEmptyMap(TreeMap.java:811)
     at java.base/java.util.TreeMap.put(TreeMap.java:820)
     at java.base/java.util.TreeMap.put(TreeMap.java:569)
     at 
org.apache.fory.serializer.collection.MapLikeSerializer.readJavaChunkGeneric(MapLikeSerializer.java:901)
     at 
org.apache.fory.serializer.collection.MapLikeSerializer.readElements(MapLikeSerializer.java:650)
     at 
org.apache.fory.serializer.collection.MapLikeSerializer.read(MapLikeSerializer.java:627)
   
   removed-field FAIL async=false codegen=false -> 
org.apache.fory.exception.DeserializationException: Failed to deserialize input
   root: java.lang.ClassCastException: class java.lang.Object cannot be cast to 
class java.lang.Comparable
     at java.base/java.util.TreeMap.compare(TreeMap.java:1604)
     at java.base/java.util.TreeMap.addEntryToEmptyMap(TreeMap.java:811)
     at java.base/java.util.TreeMap.put(TreeMap.java:820)
     at java.base/java.util.TreeMap.put(TreeMap.java:569)
     at 
org.apache.fory.serializer.collection.MapLikeSerializer.readJavaChunkGeneric(MapLikeSerializer.java:901)
     at 
org.apache.fory.serializer.collection.MapLikeSerializer.readElements(MapLikeSerializer.java:650)
     at 
org.apache.fory.serializer.collection.MapLikeSerializer.read(MapLikeSerializer.java:627)
   
   present-field PASS async=true codegen=true -> class 
...CatalogSnapshotV2WithLabels
   present-field PASS async=false codegen=false -> class 
...CatalogSnapshotV2WithLabels
   ```
   
   ### What did you expect to see?
   
   With `CompatibleMode.COMPATIBLE`, deserialization should succeed when the 
reader schema removes the `labels` field. The old `TreeMap`-subclass field 
should be consumed/skipped correctly, and the reader should produce a 
`CatalogSnapshotV2` containing the remaining compatible fields.
   
   ### What did you see instead?
   
   Deserialization fails while Fory tries to read the removed map-like field. 
In the minimal repro, the key appears to be read as `Object`, then inserted 
into `TreeMap`, which fails because `Object` is not `Comparable`:
   
   ```text
   Caused by: java.lang.ClassCastException: class java.lang.Object cannot be 
cast to class java.lang.Comparable
     at java.base/java.util.TreeMap.compare(TreeMap.java:1604)
     at java.base/java.util.TreeMap.put(TreeMap.java:820)
     at 
org.apache.fory.serializer.collection.MapLikeSerializer.readJavaChunkGeneric(MapLikeSerializer.java:901)
   ```
   
   In the real-world binary with the same schema-evolution shape, after 
restoring the old classes as empty/deprecated placeholders so they are 
loadable, deserialization reaches the same map-like read path and fails with a 
nearby `MapLikeSerializer` null serializer failure:
   
   ```text
   Caused by: java.lang.NullPointerException: Cannot invoke 
"org.apache.fory.serializer.Serializer.read(...)" because "keySerializer" is 
null
     at 
org.apache.fory.serializer.collection.MapLikeSerializer.readJavaChunk(MapLikeSerializer.java:833)
     at 
org.apache.fory.serializer.collection.MapLikeSerializer.readElements(MapLikeSerializer.java:647)
     at 
org.apache.fory.serializer.collection.MapLikeSerializer.read(MapLikeSerializer.java:627)
   ```
   
   ### Anything Else?
   
   A few troubleshooting notes:
   
   - The failure is not specific to codegen or async compilation. It reproduces 
with both `.withAsyncCompilation(true).withCodegen(true)` and 
`.withAsyncCompilation(false).withCodegen(false)`.
   - A control where the reader still declares the map-like field passes under 
both configurations.
   - `withDeserializeUnknownClass(true)` does not change the outcome because 
the relevant old classes are loadable/registered; the failure happens while 
consuming the removed field's serialized map data.
   - Looking at `MapLikeSerializer` in 0.17.0, the non-generic path can enter 
`readJavaChunk` with `keySerializer == null`/`valueSerializer == null` when the 
chunk header says the key/value are declared types, and the generic path can 
resolve the removed field's key as `Object` rather than `String`. Both outcomes 
point to the compatible-mode skip/read path for removed map-like fields not 
retaining or reconstructing the declared key/value serializers.
   
   ### Are you willing to submit a PR?
   
   - [x] I'm willing to submit a PR!
   
   


-- 
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]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to