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]
