chaokunyang commented on code in PR #3861:
URL: https://github.com/apache/fory/pull/3861#discussion_r3592419704


##########
java/fory-core/src/main/java/org/apache/fory/context/WriteContext.java:
##########
@@ -442,6 +444,58 @@ public BufferCallback getBufferCallback() {
     return bufferCallback;
   }
 
+  /**
+   * If {@code obj} carries a populated {@link ForyExtraFields} sink whose 
remote {@code TypeDef}
+   * differs from the local type, emits that remote schema header and replays 
every field (matched
+   * fields from the bean, unmatched fields from the sink) so a downstream 
peer with the full schema
+   * can recover them, excluded from writeRef(obj,typeinfo) because it is only 
called by methods
+   * which pass collection objects, and from writeNonRef(obj,typeinfo) which 
is gated on
+   * useDeclaredTypeInfo and !crossLanguage ie. the path for mononmorphic 
elements
+   */
+  public boolean tryWriteExtraFieldsSchema(TypeResolver resolver, TypeInfo 
typeInfo, Object obj) {
+    FieldAccessor sinkAccessor = typeInfo.getExtraFieldsSinkAccessor();
+    if (sinkAccessor == null) {
+      return false;
+    }
+    ForyExtraFields extraField = (ForyExtraFields) sinkAccessor.getObject(obj);
+    if (extraField == null || extraField.getTypeDef() == null) {
+      return false;
+    }
+    long sinkTypeDefId = extraField.getTypeDef().getId();
+    // The object was deserialized under a different (fuller) remote schema. 
Emit that remote schema
+    // header (from the writer-class TypeInfo) and replay all fields through 
the reader-class
+    // serializer that captured them, whose field order follows the remote 
schema.
+    TypeInfo headerTypeInfo = resolver.getTypeInfoByTypeDefId(sinkTypeDefId);
+    if (headerTypeInfo == null) {
+      throw new IllegalStateException(
+          "Cannot re-serialize "
+              + obj.getClass().getName()
+              + " with captured extra fields: this Fory instance does not have 
the object's original "
+              + "remote schema (TypeDef id "
+              + sinkTypeDefId
+              + ") cached, so it cannot emit the schema header needed to 
preserve those fields. "
+              + "Replay needs a Fory that has deserialized a payload carrying 
that schema and still "
+              + "has it cached, ie the instance that produced this object. 
Each instance "
+              + "retains only a bounded number of distinct remote schemas, so 
under very high schema "
+              + "churn the schema may not be retained and the captured fields 
cannot be replayed.");
+    }
+    Serializer<?> replaySerializer =
+        resolver.getExtraFieldsWriteSerializer(obj.getClass(), sinkTypeDefId);
+    if (replaySerializer == null) {
+      throw new IllegalStateException(
+          "Cannot replay captured extra fields on "
+              + obj.getClass()
+              + ": remote TypeDef "
+              + sinkTypeDefId
+              + " has not been read into this class on this Fory instance.");
+    }
+    resolver.writeTypeInfo(this, headerTypeInfo);

Review Comment:
   Preserve the original type header when the writer class is unavailable
   
   When the intermediary cannot load the original writer class, 
`headerTypeInfo.type` is `UnknownStruct` with `NONEXISTENT_META_SHARED_ID`. 
Calling `writeTypeInfo` here emits that placeholder and then writes a 
target-class replay body. The normal unknown-class path must go through 
`UnknownStructSerializer.write`, which rewrites the placeholder to the original 
compatible type id/user id and emits the remote TypeDef. Without that rewrite, 
the forwarded stream is undecodable in the rolling-deployment case this feature 
is intended to support. Please route this header through a schema-aware writer 
and cover it with isolated classloaders.



##########
java/fory-core/src/main/java/org/apache/fory/context/WriteContext.java:
##########
@@ -442,6 +444,58 @@ public BufferCallback getBufferCallback() {
     return bufferCallback;
   }
 
+  /**
+   * If {@code obj} carries a populated {@link ForyExtraFields} sink whose 
remote {@code TypeDef}
+   * differs from the local type, emits that remote schema header and replays 
every field (matched
+   * fields from the bean, unmatched fields from the sink) so a downstream 
peer with the full schema
+   * can recover them, excluded from writeRef(obj,typeinfo) because it is only 
called by methods
+   * which pass collection objects, and from writeNonRef(obj,typeinfo) which 
is gated on
+   * useDeclaredTypeInfo and !crossLanguage ie. the path for mononmorphic 
elements
+   */
+  public boolean tryWriteExtraFieldsSchema(TypeResolver resolver, TypeInfo 
typeInfo, Object obj) {
+    FieldAccessor sinkAccessor = typeInfo.getExtraFieldsSinkAccessor();
+    if (sinkAccessor == null) {
+      return false;
+    }
+    ForyExtraFields extraField = (ForyExtraFields) sinkAccessor.getObject(obj);
+    if (extraField == null || extraField.getTypeDef() == null) {
+      return false;
+    }
+    long sinkTypeDefId = extraField.getTypeDef().getId();
+    // The object was deserialized under a different (fuller) remote schema. 
Emit that remote schema
+    // header (from the writer-class TypeInfo) and replay all fields through 
the reader-class
+    // serializer that captured them, whose field order follows the remote 
schema.
+    TypeInfo headerTypeInfo = resolver.getTypeInfoByTypeDefId(sinkTypeDefId);
+    if (headerTypeInfo == null) {
+      throw new IllegalStateException(
+          "Cannot re-serialize "
+              + obj.getClass().getName()
+              + " with captured extra fields: this Fory instance does not have 
the object's original "
+              + "remote schema (TypeDef id "
+              + sinkTypeDefId
+              + ") cached, so it cannot emit the schema header needed to 
preserve those fields. "
+              + "Replay needs a Fory that has deserialized a payload carrying 
that schema and still "
+              + "has it cached, ie the instance that produced this object. 
Each instance "
+              + "retains only a bounded number of distinct remote schemas, so 
under very high schema "
+              + "churn the schema may not be retained and the captured fields 
cannot be replayed.");
+    }
+    Serializer<?> replaySerializer =
+        resolver.getExtraFieldsWriteSerializer(obj.getClass(), sinkTypeDefId);
+    if (replaySerializer == null) {
+      throw new IllegalStateException(
+          "Cannot replay captured extra fields on "
+              + obj.getClass()
+              + ": remote TypeDef "
+              + sinkTypeDefId
+              + " has not been read into this class on this Fory instance.");
+    }
+    resolver.writeTypeInfo(this, headerTypeInfo);

Review Comment:
   Key replay metadata by the remote TypeDef identity
   
   `writeSharedClassMeta` deduplicates metadata by `typeInfo.type` 
(`Class<?>`), but two remote versions of the same class have different TypeDef 
ids. If captured objects from both versions are forwarded in one root graph, 
the second header references the first TypeDef while its body is written by the 
second replay serializer, causing field corruption or reader-index drift. 
Replay metadata needs to be keyed by the checked TypeDef identity, with a 
regression test containing two versions of the same class in one graph.



##########
java/fory-core/src/main/java/org/apache/fory/serializer/CompatibleSerializer.java:
##########
@@ -225,6 +238,71 @@ public void write(WriteContext writeContext, T value) {
     serializer.write(writeContext, value);
   }
 
+  /**
+   * Writes all fields in remote-sorted order under the remote TypeDef schema: 
matched fields are
+   * read from local getters, unmatched fields are read from the {@link 
ForyExtraFields} map using
+   * fieldInfo.
+   */
+  private void writeFieldsIncludingExtraFields(
+      WriteContext writeContext, T value, ForyExtraFields extraField) {
+    RefWriter refWriter = writeContext.getRefWriter();
+    Generics generics = writeContext.getGenerics();
+    for (SerializationFieldInfo fieldInfo : allFields) {
+      writeFieldByCodecCategory(writeContext, value, refWriter, generics, 
fieldInfo, extraField);
+    }
+  }
+
+  private void writeFieldByCodecCategory(
+      WriteContext writeContext,
+      T value,
+      RefWriter refWriter,
+      Generics generics,
+      SerializationFieldInfo fieldInfo,
+      ForyExtraFields extraField) {
+
+    MemoryBuffer buffer = writeContext.getBuffer();
+
+    boolean useExtraField = false;
+    Object fieldValue = null;
+
+    Object temp = extraField.getOrDefault(fieldInfo.descriptor.getName(), 
NOT_FOUND);
+    if (temp != NOT_FOUND) {
+      useExtraField = true;
+      fieldValue = temp; // might be null, which is valid
+    }
+
+    switch (fieldInfo.codecCategory) {
+      case BUILD_IN:
+        if (useExtraField) {
+          AbstractObjectSerializer.writeBuildInFieldValue(
+              writeContext, typeResolver, refWriter, fieldInfo, buffer, 
fieldValue);
+        } else {
+          AbstractObjectSerializer.writeBuildInField(

Review Comment:
   Handle converted fields when replaying the remote schema
   
   A compatible scalar conversion such as remote `int score` to local `long 
score` produces a descriptor with a converter but no `fieldAccessor`. If any 
other field populates the sink, this branch calls `writeBuildInField` with that 
null accessor. The generated path has the same bug because it treats every 
`descriptor.getField() == null` as an unmatched field and reads an absent sink 
entry. Please define the reverse replay behavior for converter fields (or 
preserve their remote value) and test interpreter and codegen with a converted 
field plus an extra field.



##########
java/fory-core/src/main/java/org/apache/fory/serializer/ForyExtraFields.java:
##########
@@ -0,0 +1,114 @@
+/*
+ * 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.fory.serializer;
+
+import java.lang.reflect.Field;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.apache.fory.collection.ClassValueCache;
+import org.apache.fory.meta.TypeDef;
+import org.apache.fory.reflect.FieldAccessor;
+
+/**
+ * Opt-in sink for compatible-mode extra fields. A class participates by 
declaring a field of this
+ * type; the serialization framework detects it, excludes it from the normal 
field set, and routes
+ * unmatched remote fields into it instead of discarding them.
+ *
+ * <p>The remote {@link TypeDef} is stored transiently so that on 
re-serialization the framework can
+ * emit the remote schema header and replay all fields in their original 
order, allowing a
+ * downstream peer with the full schema to recover them.
+ */
+public final class ForyExtraFields {
+
+  private final Map<String, Object> fields = new HashMap<>();
+
+  public Object get(String name) {
+    return fields.get(name);
+  }
+
+  public Object getOrDefault(String name, Object defaultValue) {
+    return fields.getOrDefault(name, defaultValue);
+  }
+
+  public boolean isEmpty() {
+    return fields.isEmpty();
+  }
+
+  public boolean containsKey(String name) {
+    return fields.containsKey(name);
+  }
+
+  Object put(String name, Object value) {
+    return fields.put(name, value);
+  }
+
+  /**
+   * GC-transparent class-keyed cache: on JVM this is backed by ClassValue so 
entries do not prevent
+   * the associated Class (or its ClassLoader) from being collected. On 
Android/GraalVM it falls
+   * back to a ConcurrentHashMap.
+   */
+  private static final ClassValueCache<Optional<FieldAccessor>> SINK_CACHE =
+      ClassValueCache.newClassKeyCache(16);
+
+  public static Field findSinkField(Class<?> cls) {
+    return SINK_CACHE
+        .get(cls, () -> scanForExtraField(cls))
+        .map(FieldAccessor::getField)
+        .orElse(null);
+  }
+
+  /** Returns a {@link FieldAccessor} for the {@link ForyExtraFields} sink 
field on {@code cls}. */
+  public static FieldAccessor findSinkAccessor(Class<?> cls) {
+    return SINK_CACHE.get(cls, () -> scanForExtraField(cls)).orElse(null);
+  }
+
+  private static Optional<FieldAccessor> scanForExtraField(Class<?> cls) {
+    for (Class<?> c = cls; c != null && c != Object.class; c = 
c.getSuperclass()) {
+      for (Field f : c.getDeclaredFields()) {
+        if (ForyExtraFields.class == f.getType()) {
+          return Optional.of(FieldAccessor.createAccessor(f));
+        }
+      }
+    }
+    return Optional.empty();
+  }
+
+  // Excluded from Java serialization because TypeDef is runtime metadata used
+  // only for Fory serialization. If a ForyExtraFields instance is serialized
+  // with ObjectOutputStream, this field will be null after deserialization, so
+  // the captured extra fields cannot later be replayed by Fory.
+  private transient TypeDef typeDef;
+
+  public TypeDef getTypeDef() {
+    return typeDef;
+  }
+
+  public static void capture(
+      Object target, FieldAccessor sinkAccessor, TypeDef typeDef, String name, 
Object value) {
+    ForyExtraFields extraField = (ForyExtraFields) 
sinkAccessor.getObject(target);
+    if (extraField == null) {

Review Comment:
   Attach the TypeDef when the sink is already initialized
   
   This assigns `typeDef` only when the sink field was null. A common 
declaration such as `final ForyExtraFields extraFields = new ForyExtraFields()` 
captures map entries but leaves `typeDef` null, so `tryWriteExtraFieldsSchema` 
silently skips replay and the downstream peer loses the unknown fields. On 
first capture, an existing unbound sink also needs to bind the remote TypeDef, 
and subsequent captures should verify that the schema identity is consistent.



##########
java/fory-core/src/main/java/org/apache/fory/serializer/ForyExtraFields.java:
##########
@@ -0,0 +1,114 @@
+/*
+ * 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.fory.serializer;
+
+import java.lang.reflect.Field;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.apache.fory.collection.ClassValueCache;
+import org.apache.fory.meta.TypeDef;
+import org.apache.fory.reflect.FieldAccessor;
+
+/**
+ * Opt-in sink for compatible-mode extra fields. A class participates by 
declaring a field of this
+ * type; the serialization framework detects it, excludes it from the normal 
field set, and routes
+ * unmatched remote fields into it instead of discarding them.
+ *
+ * <p>The remote {@link TypeDef} is stored transiently so that on 
re-serialization the framework can
+ * emit the remote schema header and replay all fields in their original 
order, allowing a
+ * downstream peer with the full schema to recover them.
+ */
+public final class ForyExtraFields {
+
+  private final Map<String, Object> fields = new HashMap<>();

Review Comment:
   Use the complete remote field identity as the sink key
   
   A simple field name is not unique in a native Java TypeDef: superclass and 
subclass layers may legally contain fields with the same name, distinguished by 
declaring class and field id/name. Capturing both into this map overwrites one 
value, and replay writes both slots from the surviving value. Tagged fields are 
also decoded as `$tagN` when the writer class is unavailable, so the documented 
lookup by original name cannot work. Please store a stable schema field 
identity and expose an unambiguous lookup contract.



##########
java/fory-core/src/main/java/org/apache/fory/serializer/ForyExtraFields.java:
##########
@@ -0,0 +1,114 @@
+/*
+ * 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.fory.serializer;
+
+import java.lang.reflect.Field;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.apache.fory.collection.ClassValueCache;
+import org.apache.fory.meta.TypeDef;
+import org.apache.fory.reflect.FieldAccessor;
+
+/**
+ * Opt-in sink for compatible-mode extra fields. A class participates by 
declaring a field of this
+ * type; the serialization framework detects it, excludes it from the normal 
field set, and routes
+ * unmatched remote fields into it instead of discarding them.
+ *
+ * <p>The remote {@link TypeDef} is stored transiently so that on 
re-serialization the framework can
+ * emit the remote schema header and replay all fields in their original 
order, allowing a
+ * downstream peer with the full schema to recover them.
+ */
+public final class ForyExtraFields {
+
+  private final Map<String, Object> fields = new HashMap<>();
+
+  public Object get(String name) {
+    return fields.get(name);
+  }
+
+  public Object getOrDefault(String name, Object defaultValue) {
+    return fields.getOrDefault(name, defaultValue);
+  }
+
+  public boolean isEmpty() {
+    return fields.isEmpty();
+  }
+
+  public boolean containsKey(String name) {
+    return fields.containsKey(name);
+  }
+
+  Object put(String name, Object value) {
+    return fields.put(name, value);
+  }
+
+  /**
+   * GC-transparent class-keyed cache: on JVM this is backed by ClassValue so 
entries do not prevent
+   * the associated Class (or its ClassLoader) from being collected. On 
Android/GraalVM it falls
+   * back to a ConcurrentHashMap.
+   */
+  private static final ClassValueCache<Optional<FieldAccessor>> SINK_CACHE =
+      ClassValueCache.newClassKeyCache(16);
+
+  public static Field findSinkField(Class<?> cls) {
+    return SINK_CACHE
+        .get(cls, () -> scanForExtraField(cls))
+        .map(FieldAccessor::getField)
+        .orElse(null);
+  }
+
+  /** Returns a {@link FieldAccessor} for the {@link ForyExtraFields} sink 
field on {@code cls}. */
+  public static FieldAccessor findSinkAccessor(Class<?> cls) {
+    return SINK_CACHE.get(cls, () -> scanForExtraField(cls)).orElse(null);
+  }
+
+  private static Optional<FieldAccessor> scanForExtraField(Class<?> cls) {
+    for (Class<?> c = cls; c != null && c != Object.class; c = 
c.getSuperclass()) {
+      for (Field f : c.getDeclaredFields()) {
+        if (ForyExtraFields.class == f.getType()) {
+          return Optional.of(FieldAccessor.createAccessor(f));
+        }
+      }
+    }
+    return Optional.empty();
+  }
+
+  // Excluded from Java serialization because TypeDef is runtime metadata used
+  // only for Fory serialization. If a ForyExtraFields instance is serialized
+  // with ObjectOutputStream, this field will be null after deserialization, so
+  // the captured extra fields cannot later be replayed by Fory.
+  private transient TypeDef typeDef;
+
+  public TypeDef getTypeDef() {
+    return typeDef;
+  }
+
+  public static void capture(
+      Object target, FieldAccessor sinkAccessor, TypeDef typeDef, String name, 
Object value) {
+    ForyExtraFields extraField = (ForyExtraFields) 
sinkAccessor.getObject(target);
+    if (extraField == null) {
+      extraField = new ForyExtraFields();

Review Comment:
   Charge retained sink owners to the graph-memory budget
   
   The first capture retains a new `ForyExtraFields`, `HashMap`, backing/entry 
storage, and potentially a boxed primitive, but neither interpreter nor 
generated callers reserve graph memory for these owners. A valid list 
containing many partial objects can therefore grow the retained result far 
beyond `maxGraphMemoryBytes`. The compatible serializer/codegen owner should 
reserve stable lower-bound costs before allocating or growing the sink, and the 
budget rejection path needs a regression test.



##########
java/fory-core/src/main/java/org/apache/fory/serializer/CompatibleSerializer.java:
##########
@@ -285,7 +373,8 @@ private void readFields(ReadContext readContext, Object[] 
fields) {
     RefReader refReader = readContext.getRefReader();
     Generics generics = readContext.getGenerics();
     for (SerializationFieldInfo fieldInfo : allFields) {
-      fields[counter++] = readField(readContext, refReader, generics, 
fieldInfo, buffer, null);
+      fields[counter++] =

Review Comment:
   Do not opt records into a sink path that cannot capture fields
   
   A record component of type `ForyExtraFields` is detected as a sink, but the 
interpreter record path reads into an `Object[]` with `captureUnmatched=false`, 
so unknown fields are still skipped. Generated code attempts to call `capture` 
before the record exists, using the record collector/array rather than a record 
instance, and fails on the accessor receiver. Please either implement 
constructor-owned sink collection for records or reject record sinks explicitly 
in code and documentation.



##########
java/fory-core/src/main/java/org/apache/fory/builder/CompatibleCodecBuilder.java:
##########
@@ -386,22 +463,93 @@ private Expression skipField(Descriptor descriptor) {
         remoteFieldInfo(descriptor));
   }
 
+  @Override
+  protected Expression getFieldValue(Expression bean, Descriptor descriptor) {
+    if (extraFieldsSinkField != null && descriptor.getField() == null) {
+      if (extraFieldsSinkExpr == null || extraFieldsSinkExprBean != bean) {
+        extraFieldsSinkExprBean = bean;
+        extraFieldsSinkExpr =
+            tryInlineCast(
+                new Invoke(extraFieldsSinkAccessorExpr(), "getObject", 
OBJECT_TYPE, bean),
+                TypeRef.of(ForyExtraFields.class));
+      }
+      Invoke raw =
+          new Invoke(
+              extraFieldsSinkExpr, "get", OBJECT_TYPE, 
Literal.ofString(descriptor.getName()));
+      return tryInlineCast(raw, descriptor.getTypeRef());
+    }
+    return super.getFieldValue(bean, descriptor);
+  }
+
   @Override
   protected Expression setFieldValue(Expression bean, Descriptor descriptor, 
Expression value) {
     if (descriptor.getField() == null) {
       FieldConverter<?> converter = descriptor.getFieldConverter();
       if (converter != null) {
         return setFieldConverterTargetValue(bean, descriptor, converter, 
value);
       }
-      // Field doesn't exist in current class, skip set this field value.
+      // If Field doesn't exist in current class,
+      if (extraFieldsSinkField != null) {
+        return new StaticInvoke(
+            ForyExtraFields.class,
+            "capture",
+            TypeRef.of(void.class),
+            bean,
+            extraFieldsSinkAccessorExpr(),
+            typeDefFieldExpr(),
+            Literal.ofString(descriptor.getName()),
+            value);
+      }
       // Note that the field value shouldn't be an inlined value, otherwise 
field value read may
-      // be ignored.
-      // Add an ignored call here to make expression type to void.
+      // be ignored. Add an ignored call here to make expression type to void.
       return new StaticInvoke(ExceptionUtils.class, "ignore", value);
     }
     return super.setFieldValue(bean, descriptor, value);
   }
 
+  private String ensureLazyFieldGenerated(
+      String cachedFieldName, String nameConstant, TypeRef<?> fieldType, 
Expression initializer) {
+    if (cachedFieldName == null) {
+      cachedFieldName = ctx.newName(nameConstant);
+      ctx.addField(false, true, ctx.type(fieldType), cachedFieldName, 
initializer);
+    }
+    return cachedFieldName;
+  }
+
+  private Expression extraFieldsSinkAccessorExpr() {
+    extraFieldsSinkAccessorName =
+        ensureLazyFieldGenerated(
+            extraFieldsSinkAccessorName,
+            EXTRA_FIELDS_SINK_ACCESSOR_NAME,
+            TypeRef.of(FieldAccessor.class),
+            new StaticInvoke(
+                FieldAccessor.class,
+                "createAccessor",
+                TypeRef.of(FieldAccessor.class),
+                getOrCreateField(

Review Comment:
   Keep the sink field's declaring class in generated accessors
   
   The scanner returns the exact inherited sink `Field`, but this code discards 
its declaring class and resolves only `beanClass + name`. If a subclass legally 
hides the inherited sink with a different-typed field of the same name, 
generated code binds the subclass field. That causes valid input to fail and 
can write a `ForyExtraFields` reference into the wrong field on Unsafe-based 
accessors. Please retain the exact `Field`/accessor or resolve it through the 
original declaring class.



##########
java/fory-core/src/main/java/org/apache/fory/serializer/ForyExtraFields.java:
##########
@@ -0,0 +1,114 @@
+/*
+ * 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.fory.serializer;
+
+import java.lang.reflect.Field;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.apache.fory.collection.ClassValueCache;
+import org.apache.fory.meta.TypeDef;
+import org.apache.fory.reflect.FieldAccessor;
+
+/**
+ * Opt-in sink for compatible-mode extra fields. A class participates by 
declaring a field of this
+ * type; the serialization framework detects it, excludes it from the normal 
field set, and routes
+ * unmatched remote fields into it instead of discarding them.
+ *
+ * <p>The remote {@link TypeDef} is stored transiently so that on 
re-serialization the framework can
+ * emit the remote schema header and replay all fields in their original 
order, allowing a
+ * downstream peer with the full schema to recover them.
+ */
+public final class ForyExtraFields {
+
+  private final Map<String, Object> fields = new HashMap<>();
+
+  public Object get(String name) {
+    return fields.get(name);
+  }
+
+  public Object getOrDefault(String name, Object defaultValue) {
+    return fields.getOrDefault(name, defaultValue);
+  }
+
+  public boolean isEmpty() {
+    return fields.isEmpty();
+  }
+
+  public boolean containsKey(String name) {
+    return fields.containsKey(name);
+  }
+
+  Object put(String name, Object value) {
+    return fields.put(name, value);
+  }
+
+  /**
+   * GC-transparent class-keyed cache: on JVM this is backed by ClassValue so 
entries do not prevent
+   * the associated Class (or its ClassLoader) from being collected. On 
Android/GraalVM it falls
+   * back to a ConcurrentHashMap.
+   */
+  private static final ClassValueCache<Optional<FieldAccessor>> SINK_CACHE =
+      ClassValueCache.newClassKeyCache(16);
+
+  public static Field findSinkField(Class<?> cls) {
+    return SINK_CACHE
+        .get(cls, () -> scanForExtraField(cls))
+        .map(FieldAccessor::getField)
+        .orElse(null);
+  }
+
+  /** Returns a {@link FieldAccessor} for the {@link ForyExtraFields} sink 
field on {@code cls}. */
+  public static FieldAccessor findSinkAccessor(Class<?> cls) {
+    return SINK_CACHE.get(cls, () -> scanForExtraField(cls)).orElse(null);
+  }
+
+  private static Optional<FieldAccessor> scanForExtraField(Class<?> cls) {
+    for (Class<?> c = cls; c != null && c != Object.class; c = 
c.getSuperclass()) {
+      for (Field f : c.getDeclaredFields()) {
+        if (ForyExtraFields.class == f.getType()) {

Review Comment:
   Ignore static fields when discovering an extra-fields sink
   
   Discovery checks only the field type, so a `static ForyExtraFields` constant 
is selected even though `FieldAccessor.createAccessor` rejects static fields. 
Serializer initialization then fails for an otherwise valid model, including 
xlang or non-compatible configurations where this feature should be irrelevant. 
Please restrict discovery to instance fields and add a static-field regression 
test.



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