Skip to content

Commit 5978a5d

Browse files
author
Claude (on behalf of Steven Schlansker)
committed
perf(format): generate row-codec schema projections lazily on first decode
Schema-evolution projection codecs were compiled eagerly at builder time: the row/array/map builders enumerated the full historical cross-product and loaded one generated codec class per combination, so a deep nested version history paid its whole class cost up front whether or not those versions ever appeared on the wire. Defer that compilation to decode. Each builder now builds an immutable hash -> ProjectionSource index that holds only the inputs (the VersionedSchema and codegen context); the codec class is compiled the first time a payload with that hash is decoded and cached in a per-encoder LongMap. No new synchronization: encoders are single-threaded, and the class compile is already memoized globally by the shared code generator, so a concurrent first-miss on the same hash compiles the class once. The build-time collision guards stay eager over the full cross-product (row/array via SchemaHistory's strict-hash guard, map via its two combined-hash guards), so a hash clash still fails fast at build rather than on an unlucky decode. Because the build-time class count no longer tracks annotated history, the projection-count warning no longer flags a real cost; remove PROJECTION_COUNT_WARN_THRESHOLD and warnIfManyProjections. Also fix stale Javadoc that claimed map keys are always read at the current schema and that map support is in progress, and update the row-format guide's generation-cost note to the lazy model.
1 parent 3d2f8f1 commit 5978a5d

11 files changed

Lines changed: 345 additions & 233 deletions

File tree

docs/guide/java/row-format.md

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -289,14 +289,13 @@ map may evolve more than one distinct bean class across its key and value. A top
289289
its own hash identifying both layouts together; a map nested inside an array, another map, or a
290290
bean field has its layouts folded into the enclosing payload's hash.
291291

292-
When a versioned bean contains other versioned beans, the reader generates one projection codec
293-
class per combination of versions across the composition. The count grows as the product of the
294-
version counts of the distinct nested versioned bean classes, not the number of fields, so
295-
reusing a class across several fields adds no combinations. A map whose key and value both evolve
296-
multiplies their version counts the same way. If the product across distinct classes becomes a
297-
concern, drop entries from each bean's `History` interface once you no longer need to read payloads
298-
from that range. Retiring a history entry is purely a read-side decision; the writer always uses the
299-
current schema.
292+
When a versioned bean contains other versioned beans, the reader can read one projection layout per
293+
combination of versions across the composition. A reader compiles a combination's codec the first
294+
time it decodes a payload at that combination, so the cost tracks the historical versions you
295+
actually receive, not the number you could in principle define. A map whose key and value both
296+
evolve combines their versions the same way. Retiring an entry from a bean's `History` interface
297+
once you no longer read payloads from that range stops the reader from accepting those payloads; it
298+
is purely a read-side decision, and the writer always uses the current schema.
300299

301300
## Related Topics
302301

java/fory-format/src/main/java/org/apache/fory/format/encoder/ArrayCodecBuilder.java

Lines changed: 45 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323

2424
import java.lang.invoke.MethodHandle;
2525
import java.util.Collection;
26-
import java.util.HashMap;
2726
import java.util.HashSet;
2827
import java.util.Map;
2928
import java.util.Set;
@@ -104,62 +103,74 @@ private Function<BinaryArrayWriter, ArrayEncoder<C>> buildVersionedWithWriter(
104103
SchemaHistory history = buildElementSchemaHistory(elementField.name(), elementType);
105104
SchemaHistory.VersionedSchema current = history.current();
106105

107-
// Generate per-combination row codec classes and per-combination array codec classes. The
108-
// suffix encodes each chosen inner-bean version so that distinct cross-product entries do not
109-
// collide on a single generated class.
106+
// Index of hash → deferred projection source per non-current combination. Building it compiles
107+
// nothing: a combination's row and array codec classes are generated the first time a payload
108+
// with that hash is decoded. The suffix encodes each chosen inner-bean version so distinct
109+
// cross-product entries do not collide on a single generated class.
110110
//
111111
// Keyed by the raw strict hash straight from SchemaHistory, which already proves these hashes
112112
// are unique across versions() and distinct from the current schema, so no builder-side
113113
// collision check is needed here (unlike the map codec's combined (key, value) hash).
114-
Map<Long, ProjectionArrayFactory> projectionFactories = new HashMap<>();
114+
LongMap<BinaryArrayEncoder.ProjectionSource> projectionSources = new LongMap<>();
115+
String elementName = elementField.name();
115116
for (SchemaHistory.VersionedSchema vs : history.versions()) {
116117
if (vs == current) {
117118
continue;
118119
}
119-
String suffix = ProjectionRouting.projectionSuffix(vs);
120-
// Generates the projection row codec for every nested versioned bean class in this
121-
// combination, both map key and value, so the array codec's references all resolve.
122-
Map<Class<?>, String> nestedSuffixes = ProjectionRouting.nestedSuffixesFor(vs, codecFormat);
123-
Class<?> arrayClass =
124-
Encoders.loadOrGenProjectionArrayCodecClass(
125-
collectionType, TypeRef.of(elementClass), codecFormat, suffix, nestedSuffixes);
126-
MethodHandle ctor = Encoders.constructorHandleFor(arrayClass, GeneratedArrayEncoder.class);
127-
// forElement substitutes each chosen historical struct into its leaf, so the element field at
128-
// this combination is simply the single field of vs.schema(); wrap it back in the list field.
129-
Field histListField =
130-
DataTypes.arrayField(elementField.name(), DataTypes.fieldOfSchema(vs.schema(), 0));
131-
projectionFactories.put(vs.strictHash(), new ProjectionArrayFactory(histListField, ctor));
120+
projectionSources.put(vs.strictHash(), new ProjectionSource(elementClass, elementName, vs));
132121
}
133122
final Function<BinaryArrayWriter, GeneratedArrayEncoder> currentFactory =
134123
generatedEncoderFactory();
135124
long currentHash = current.strictHash();
136125
return new Function<BinaryArrayWriter, ArrayEncoder<C>>() {
137126
@Override
138127
public ArrayEncoder<C> apply(final BinaryArrayWriter writer) {
139-
LongMap<BinaryArrayEncoder.ProjectionArrayCodec> proj =
140-
new LongMap<>(projectionFactories.size());
141-
for (Map.Entry<Long, ProjectionArrayFactory> entry : projectionFactories.entrySet()) {
142-
proj.put(entry.getKey(), entry.getValue().instantiate(fory));
143-
}
144128
return new BinaryArrayEncoder<>(
145-
writer, currentFactory.apply(writer), sizeEmbedded, currentHash, proj);
129+
writer,
130+
currentFactory.apply(writer),
131+
sizeEmbedded,
132+
currentHash,
133+
projectionSources,
134+
fory);
146135
}
147136
};
148137
}
149138

150-
private final class ProjectionArrayFactory {
151-
private final Field elementField;
152-
private final MethodHandle ctor;
153-
154-
ProjectionArrayFactory(Field elementField, MethodHandle ctor) {
155-
this.elementField = elementField;
156-
this.ctor = ctor;
139+
/**
140+
* Deferred projection codec for one historical element version. Holds only the inputs to generate
141+
* the codec; the row and array codec classes are compiled on the first {@link #compile} call (the
142+
* first decode of this version's hash), not at build time.
143+
*/
144+
private final class ProjectionSource implements BinaryArrayEncoder.ProjectionSource {
145+
private final Class<?> elementClass;
146+
private final String elementName;
147+
private final SchemaHistory.VersionedSchema version;
148+
149+
ProjectionSource(
150+
Class<?> elementClass, String elementName, SchemaHistory.VersionedSchema version) {
151+
this.elementClass = elementClass;
152+
this.elementName = elementName;
153+
this.version = version;
157154
}
158155

159-
BinaryArrayEncoder.ProjectionArrayCodec instantiate(Fory fory) {
156+
@Override
157+
public BinaryArrayEncoder.ProjectionArrayCodec compile(Fory fory) {
158+
String suffix = ProjectionRouting.projectionSuffix(version);
159+
// Generates the projection row codec for every nested versioned bean class in this
160+
// combination, both map key and value, so the array codec's references all resolve.
161+
Map<Class<?>, String> nestedSuffixes =
162+
ProjectionRouting.nestedSuffixesFor(version, codecFormat);
163+
Class<?> arrayClass =
164+
Encoders.loadOrGenProjectionArrayCodecClass(
165+
collectionType, TypeRef.of(elementClass), codecFormat, suffix, nestedSuffixes);
166+
MethodHandle ctor = Encoders.constructorHandleFor(arrayClass, GeneratedArrayEncoder.class);
167+
// forElement substitutes each chosen historical struct into its leaf, so the element field at
168+
// this combination is simply the single field of vs.schema(); wrap it back in the list field.
169+
Field histListField =
170+
DataTypes.arrayField(elementName, DataTypes.fieldOfSchema(version.schema(), 0));
160171
try {
161-
BinaryArrayWriter projWriter = codecFormat.newArrayWriter(elementField);
162-
Object[] references = {elementField, projWriter, fory};
172+
BinaryArrayWriter projWriter = codecFormat.newArrayWriter(histListField);
173+
Object[] references = {histListField, projWriter, fory};
163174
GeneratedArrayEncoder codec = (GeneratedArrayEncoder) ctor.invokeExact(references);
164175
return new BinaryArrayEncoder.ProjectionArrayCodec(projWriter, codec);
165176
} catch (Throwable e) {

java/fory-format/src/main/java/org/apache/fory/format/encoder/BaseCodecBuilder.java

Lines changed: 1 addition & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,10 @@
2626
import org.apache.fory.format.type.CustomTypeEncoderRegistry;
2727
import org.apache.fory.format.type.Schema;
2828
import org.apache.fory.format.type.SchemaHistory;
29-
import org.apache.fory.logging.Logger;
30-
import org.apache.fory.logging.LoggerFactory;
3129
import org.apache.fory.reflect.TypeRef;
3230
import org.apache.fory.type.TypeResolutionContext;
3331

3432
public class BaseCodecBuilder<B extends BaseCodecBuilder<B>> {
35-
private static final Logger LOG = LoggerFactory.getLogger(BaseCodecBuilder.class);
36-
37-
/**
38-
* Number of historical schemas for one bean above which {@link #buildSchemaHistory} logs a
39-
* warning. Each distinct schema becomes one generated projection codec class (compiled and loaded
40-
* at build time), and the count grows as the product of the per-class version counts across
41-
* nested versioned beans. The JVM handles far more classes than this; the threshold flags a
42-
* likely misconfigured version history, since no hand-written history reaches it by accident.
43-
*/
44-
private static final int PROJECTION_COUNT_WARN_THRESHOLD = 256;
4533

4634
protected Schema schema;
4735
protected int initialBufferSize = 16;
@@ -115,9 +103,7 @@ public B compactEncoding() {
115103
* unchanged.
116104
*/
117105
protected SchemaHistory buildSchemaHistory(final Class<?> targetClass) {
118-
SchemaHistory history = SchemaHistory.build(targetClass, schemaTransform());
119-
warnIfManyProjections(targetClass.getName(), history);
120-
return history;
106+
return SchemaHistory.build(targetClass, schemaTransform());
121107
}
122108

123109
/**
@@ -129,19 +115,6 @@ protected SchemaHistory buildSchemaHistory(final Class<?> targetClass) {
129115
*/
130116
protected SchemaHistory buildElementSchemaHistory(
131117
final String fieldName, final TypeRef<?> elementType) {
132-
SchemaHistory history = elementSchemaHistory(fieldName, elementType);
133-
warnIfManyProjections("element " + elementType, history);
134-
return history;
135-
}
136-
137-
/**
138-
* Builds a position's history without the per-position projection-count warning. The map path
139-
* uses this and warns once on the key/value cross-product, which is the count of classes it
140-
* generates; the array path uses {@link #buildElementSchemaHistory}, whose per-position count is
141-
* exact.
142-
*/
143-
protected SchemaHistory elementSchemaHistory(
144-
final String fieldName, final TypeRef<?> elementType) {
145118
return SchemaHistory.forElement(fieldName, elementType, schemaTransform());
146119
}
147120

@@ -161,27 +134,6 @@ private UnaryOperator<Schema> schemaTransform() {
161134
: UnaryOperator.identity();
162135
}
163136

164-
private static void warnIfManyProjections(final String label, final SchemaHistory history) {
165-
warnIfManyProjections(label, history.versions().size());
166-
}
167-
168-
/**
169-
* Warn when {@code projectionCount} generated projection codec classes exceeds the threshold. The
170-
* map path passes the key/value cross-product here, not a single position's count, because that
171-
* product is the number of classes it actually generates.
172-
*/
173-
protected static void warnIfManyProjections(final String label, final int projectionCount) {
174-
if (projectionCount > PROJECTION_COUNT_WARN_THRESHOLD) {
175-
LOG.warn(
176-
"Schema evolution for {} resolved {} historical schemas, each generating a projection "
177-
+ "codec class. This count grows as the product of per-class version counts across "
178-
+ "nested versioned beans; retire @ForyVersion history ranges you no longer read to "
179-
+ "reduce it.",
180-
label,
181-
projectionCount);
182-
}
183-
}
184-
185137
@SuppressWarnings("unchecked")
186138
protected B castThis() {
187139
return (B) this;

java/fory-format/src/main/java/org/apache/fory/format/encoder/BinaryArrayEncoder.java

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
package org.apache.fory.format.encoder;
2121

22+
import org.apache.fory.Fory;
2223
import org.apache.fory.collection.LongMap;
2324
import org.apache.fory.exception.ClassNotCompatibleException;
2425
import org.apache.fory.format.row.binary.BinaryArray;
@@ -39,9 +40,23 @@ class BinaryArrayEncoder<T> implements ArrayEncoder<T> {
3940
*/
4041
private final long currentHash;
4142

42-
/** Per-version projection codecs and their element fields. {@code null} disables versioning. */
43+
/**
44+
* Hash → source able to compile a projection codec for an older element schema. {@code null}
45+
* disables versioning (evolution off, no hash prefix). Non-null but empty under evolution with no
46+
* historical versions: the hash prefix is still written for flag-mismatch detection. Shared and
47+
* immutable; a combination's codec class is compiled only the first time its hash is decoded.
48+
*/
49+
private final LongMap<ProjectionSource> projectionSources;
50+
51+
/**
52+
* Per-encoder cache of projection codecs compiled on first decode of their hash. Lock-free: an
53+
* encoder is single-threaded (see {@link ArrayEncoder}), and the class compile is memoized
54+
* globally by the shared code generator.
55+
*/
4356
private final LongMap<ProjectionArrayCodec> projections;
4457

58+
private final Fory fory;
59+
4560
/**
4661
* A projection variant of the array codec along with the writer used to materialize an array
4762
* instance of the right physical type (standard vs. compact) for the historical element field.
@@ -56,24 +71,32 @@ static final class ProjectionArrayCodec {
5671
}
5772
}
5873

74+
/** Compiles one historical element version's projection codec on first decode of its hash. */
75+
interface ProjectionSource {
76+
ProjectionArrayCodec compile(Fory fory);
77+
}
78+
5979
BinaryArrayEncoder(
6080
final BinaryArrayWriter writer,
6181
final GeneratedArrayEncoder codec,
6282
final boolean sizeEmbedded) {
63-
this(writer, codec, sizeEmbedded, 0L, null);
83+
this(writer, codec, sizeEmbedded, 0L, null, null);
6484
}
6585

6686
BinaryArrayEncoder(
6787
final BinaryArrayWriter writer,
6888
final GeneratedArrayEncoder codec,
6989
final boolean sizeEmbedded,
7090
final long currentHash,
71-
final LongMap<ProjectionArrayCodec> projections) {
91+
final LongMap<ProjectionSource> projectionSources,
92+
final Fory fory) {
7293
this.writer = writer;
7394
this.codec = codec;
7495
this.sizeEmbedded = sizeEmbedded;
7596
this.currentHash = currentHash;
76-
this.projections = projections;
97+
this.projectionSources = projectionSources;
98+
this.fory = fory;
99+
this.projections = projectionSources == null ? null : new LongMap<>(projectionSources.size);
77100
}
78101

79102
@Override
@@ -107,7 +130,7 @@ public T decode(final byte[] bytes) {
107130

108131
@SuppressWarnings("unchecked")
109132
T decode(final MemoryBuffer buffer, final int size) {
110-
if (projections == null) {
133+
if (projectionSources == null) {
111134
// Evolution off: the whole payload is body, with no hash prefix. Reading evolution-on bytes
112135
// here cannot be caught: the array wire form has no hash slot when evolution is off, and an
113136
// evolution-on payload's leading 8-byte FNV hash is indistinguishable from a valid array
@@ -132,7 +155,7 @@ T decode(final MemoryBuffer buffer, final int size) {
132155
buffer.readerIndex(readerIndex + bodySize);
133156
return fromArray(array);
134157
}
135-
ProjectionArrayCodec projection = projections.get(peerHash);
158+
ProjectionArrayCodec projection = resolveProjection(peerHash);
136159
if (projection == null) {
137160
throw new ClassNotCompatibleException(
138161
String.format(
@@ -146,10 +169,29 @@ T decode(final MemoryBuffer buffer, final int size) {
146169
return (T) projection.codec.fromArray(array);
147170
}
148171

172+
/**
173+
* The projection codec for {@code peerHash}, or {@code null} if no historical version has that
174+
* hash. Compiles the codec on first encounter and caches it. Single-threaded by the {@link
175+
* ArrayEncoder} contract, so the cache needs no locking.
176+
*/
177+
private ProjectionArrayCodec resolveProjection(final long peerHash) {
178+
ProjectionArrayCodec cached = projections.get(peerHash);
179+
if (cached != null) {
180+
return cached;
181+
}
182+
ProjectionSource source = projectionSources.get(peerHash);
183+
if (source == null) {
184+
return null;
185+
}
186+
ProjectionArrayCodec projection = source.compile(fory);
187+
projections.put(peerHash, projection);
188+
return projection;
189+
}
190+
149191
@Override
150192
public byte[] encode(final T obj) {
151193
final BinaryArray array = toArray(obj);
152-
if (projections == null) {
194+
if (projectionSources == null) {
153195
return writer.getBuffer().getBytes(0, array.getSizeInBytes());
154196
}
155197
// Build the result with a single allocation: the result byte[]. The hash header is poked
@@ -172,7 +214,7 @@ public int encode(final MemoryBuffer buffer, final T obj) {
172214
if (sizeEmbedded) {
173215
buffer.writeInt32(-1);
174216
}
175-
if (projections != null) {
217+
if (projectionSources != null) {
176218
buffer.writeInt64(currentHash);
177219
}
178220
try {

0 commit comments

Comments
 (0)