voonhous commented on code in PR #19144:
URL: https://github.com/apache/hudi/pull/19144#discussion_r3519904666
##########
hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibilityChecker.java:
##########
@@ -223,19 +244,22 @@ private SchemaCompatibilityResult getCompatibility(final
HoodieSchema reader,
final HoodieSchema
writer,
final
Deque<LocationInfo> locations) {
log.debug("Checking compatibility of reader {} with writer {}", reader,
writer);
- final ReaderWriter pair = new ReaderWriter(reader, writer);
- SchemaCompatibilityResult result = mMemoizeMap.get(pair);
- if (result != null) {
- if (result.getCompatibilityType() ==
SchemaCompatibilityType.RECURSION_IN_PROGRESS) {
- // Break the recursion here.
- // schemas are compatible unless proven incompatible:
- result = SchemaCompatibilityResult.compatible();
- }
- } else {
- // Mark this reader/writer pair as "in progress":
- mMemoizeMap.put(pair, SchemaCompatibilityResult.recursionInProgress());
- result = calculateCompatibility(reader, writer, locations);
- mMemoizeMap.put(pair, result);
+ final ReaderWriter pair = new ReaderWriter(reader, writer,
namesValidatedAt(locations));
+ final SchemaCompatibilityResult memoized =
memoizedCompatibleResults.get(pair);
+ if (memoized != null) {
+ return memoized;
+ }
+ if (!inProgressPairs.add(pair)) {
+ // Break the recursion here.
+ // schemas are compatible unless proven incompatible:
+ return SchemaCompatibilityResult.compatible();
+ }
+ SchemaCompatibilityResult result = calculateCompatibility(reader,
writer, locations);
+ inProgressPairs.remove(pair);
+ if (result.getCompatibilityType() == SchemaCompatibilityType.COMPATIBLE)
{
+ // Incompatible results embed the field path they were found at, so
they are
+ // recomputed per occurrence; memoizing them would report only the
first path.
+ memoizedCompatibleResults.put(pair, result);
Review Comment:
Verified issue: a COMPATIBLE result computed while an ancestor pair is still
in `inProgressPairs` rests on the optimistic recursion-break `compatible()`,
but is memoized here unconditionally; a later occurrence of the pair outside
the cycle reuses the tainted verdict. Repro (executed): reader `Top{g:
union[G{p:X}, G2{}], s: Z}` with `X{bad:int, z:Z}`, `Z{x: array<X>}` vs writer
with `X.bad=string` -> full check returns COMPATIBLE while the `s` subtree
alone is INCOMPATIBLE at `/s/x/bad` (the in-progress `(X,X')` INCOMPATIBLE is
discarded by the reader-union branch probe, the one place failures are
dropped). Without the union it degrades to silently dropping `/s/x/bad` from
the report. Fix direction: skip memoizing results whose computation overlapped
a recursion break on a still-in-progress pair.
##########
hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibilityChecker.java:
##########
@@ -223,19 +244,22 @@ private SchemaCompatibilityResult getCompatibility(final
HoodieSchema reader,
final HoodieSchema
writer,
final
Deque<LocationInfo> locations) {
log.debug("Checking compatibility of reader {} with writer {}", reader,
writer);
- final ReaderWriter pair = new ReaderWriter(reader, writer);
- SchemaCompatibilityResult result = mMemoizeMap.get(pair);
- if (result != null) {
- if (result.getCompatibilityType() ==
SchemaCompatibilityType.RECURSION_IN_PROGRESS) {
- // Break the recursion here.
- // schemas are compatible unless proven incompatible:
- result = SchemaCompatibilityResult.compatible();
- }
- } else {
- // Mark this reader/writer pair as "in progress":
- mMemoizeMap.put(pair, SchemaCompatibilityResult.recursionInProgress());
- result = calculateCompatibility(reader, writer, locations);
- mMemoizeMap.put(pair, result);
+ final ReaderWriter pair = new ReaderWriter(reader, writer,
namesValidatedAt(locations));
+ final SchemaCompatibilityResult memoized =
memoizedCompatibleResults.get(pair);
+ if (memoized != null) {
+ return memoized;
+ }
+ if (!inProgressPairs.add(pair)) {
+ // Break the recursion here.
+ // schemas are compatible unless proven incompatible:
+ return SchemaCompatibilityResult.compatible();
+ }
+ SchemaCompatibilityResult result = calculateCompatibility(reader,
writer, locations);
+ inProgressPairs.remove(pair);
+ if (result.getCompatibilityType() == SchemaCompatibilityType.COMPATIBLE)
{
Review Comment:
Incompatible pairs recompute per occurrence, so a shared named type
referenced k times per level over d levels costs k^d subtree re-checks and
accumulates k^d `Incompatibility` objects (measured: k=2, d=22 -> 3.7s / 4.2M
objects, OOM near d=25; k=4 hits the wall around d=12). To be fair this
exposure predates the branch -- the old identity memo never hit across
occurrences either -- and the memo now makes the compatible case linear. But
Spark's `deduceWriterSchema` runs this unconditionally per write, so worth
fixing while here: memoize a location-free INCOMPATIBLE verdict for pruning and
re-derive paths on reuse, or cap accumulation.
##########
hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibilityChecker.java:
##########
@@ -182,7 +198,12 @@ public String toString() {
* </p>
*/
private static final class ReaderWriterCompatibilityChecker {
- private final Map<ReaderWriter, SchemaCompatibilityResult> mMemoizeMap =
new HashMap<>();
+ // Compatible results only: they carry no location state, so they are safe
to reuse
+ // when the same value pair recurs at a different field path in the same
naming context.
+ private final Map<ReaderWriter, SchemaCompatibilityResult>
memoizedCompatibleResults = new HashMap<>();
Review Comment:
Values in this map are always value-equal to `compatible()` (`mergedWith`
only yields COMPATIBLE from two COMPATIBLE inputs whose lists are empty), so
this can be a `Set<ReaderWriter>` with `return
SchemaCompatibilityResult.compatible()` on hit -- less garbage, and the
compatible-only invariant becomes structural instead of comment-enforced.
##########
hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCache.java:
##########
@@ -28,21 +29,77 @@
* <p>This is a global cache which works for a JVM lifecycle.
* A collection of schema instances are maintained.
*
+ * <p>This value-keyed pool is the canonicalization mechanism behind
+ * {@link HoodieSchema#fromAvroSchema}, and can also be used directly to
intern schemas
+ * produced without an Avro source (builders, converters).
+ *
+ * <p>Interning is never lossy: entries are keyed on the schema's full
serialized content,
+ * NOT on {@link HoodieSchema#equals}. Avro equality (which HoodieSchema
equality delegates
+ * to) ignores doc strings and aliases, so keying on it would collapse schemas
that differ
+ * only in that metadata and silently drop it -- docs drive catalog sync
column comments and
+ * aliases drive schema-evolution field matching. Schemas that differ in docs
or aliases
+ * intern to distinct canonical instances even though they are {@code
equals()}.
+ *
* <p>NOTE: The schema which is used frequently should be cached through this
cache.
*/
public class HoodieSchemaCache {
// Ensure that there is only one variable instance of the same schema within
an entire JVM lifetime
- private static final LoadingCache<HoodieSchema, HoodieSchema> SCHEMA_CACHE =
- Caffeine.newBuilder().weakValues().maximumSize(1024).build(k -> k);
+ private static final LoadingCache<SchemaContentKey, HoodieSchema>
SCHEMA_CACHE =
+ Caffeine.newBuilder().weakValues().maximumSize(1024).build(key ->
key.schema);
/**
* Get schema variable from global cache. If not found, put it into the
cache and then return it.
*
+ * <p>Two schemas converge on one canonical instance only when their full
serialized form
+ * (including doc strings and aliases) is identical; see the class javadoc.
+ *
+ * <p>A schema that is valid in memory but cannot be serialized to JSON --
e.g. two distinct
+ * nested records that share a name, as some projection/reader paths produce
-- has no content
+ * key, so it is returned uncached instead of interned. Canonicalization is
only a
+ * de-duplication optimization, so skipping it stays correct.
+ *
* @param schema schema to get
* @return if found, return the exist schema variable, otherwise return the
param itself.
*/
public static HoodieSchema intern(HoodieSchema schema) {
- return SCHEMA_CACHE.get(schema);
+ SchemaContentKey key;
+ try {
+ key = new SchemaContentKey(schema);
+ } catch (AvroRuntimeException e) {
+ // Not serializable -> no content key derivable; skip interning rather
than fail the caller.
+ return schema;
+ }
+ return SCHEMA_CACHE.get(key);
+ }
+
+ /**
+ * Content-complete cache key: the serialized JSON form covers doc strings
and aliases that
+ * Avro equality ignores. The wrapper class is part of the key so a
logical-type subclass
+ * (e.g. {@link HoodieSchema.Decimal}) never collapses onto a plain wrapper
of equal content,
+ * which would break downcasts.
+ */
+ private static final class SchemaContentKey {
+ private final HoodieSchema schema;
+ private final String contentJson;
+
+ SchemaContentKey(HoodieSchema schema) {
+ this.schema = schema;
+ this.contentJson = schema.getAvroSchema().toString();
Review Comment:
Verified issue (Spark 4 profiles): on Avro 1.12.x `toString()` does not
throw for a second, structurally different nested record sharing a name -- it
emits a bare name ref -- so two different schemas produce byte-identical
`contentJson` and intern to one canonical with the wrong structure. Reproduced
end-to-end against this branch's classes with avro-1.12.0: two different
projections of a schema that reuses a named record (`projectSchema` builds
same-named pruned copies; they reach intern via
`HoodieSchemaUtils#projectSchema -> fromAvroSchema` and the per-record path) ->
the second query gets the first query's wrapper and reads the wrong nested
fields. The `AvroRuntimeException` catch only covers the 1.11 throwing
behavior. The key needs a structural walk that tracks named types by instance
-- or refuse to intern on redefinition -- rather than trusting `toString()`.
##########
hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibilityChecker.java:
##########
@@ -161,16 +177,16 @@ public boolean equals(Object obj) {
return false;
}
final ReaderWriter that = (ReaderWriter) obj;
- // Use pointer comparison here:
- return (this.mReader == that.mReader) && (this.mWriter == that.mWriter);
+ return this.mNamesValidated == that.mNamesValidated
Review Comment:
`HoodieSchema.equals` is Avro equality, which ignores reader field aliases
-- but `lookupWriterField` resolves writer fields through aliases, so the
verdict depends on metadata this key is blind to. Repro (executed): reader with
two equal-but-distinct `Inner` occurrences, one carrying `x`'s alias `oldX` and
one without, vs writer field `oldX` -> full check COMPATIBLE though the
alias-less occurrence alone is INCOMPATIBLE
(READER_FIELD_MISSING_DEFAULT_VALUE); swapping field order flips the verdict.
Hard to hit today (a single parse cannot produce the shape), but same fix locus
as the memo rework: fold aliases into the key or skip memoization when the
reader subtree carries aliases.
##########
hudi-common/src/main/java/org/apache/hudi/avro/AvroRecordContext.java:
##########
@@ -71,10 +70,10 @@ public AvroRecordContext() {
public static Object getFieldValueFromIndexedRecord(
IndexedRecord record,
String fieldName) {
- // Interning returns the canonical wrapper for this schema, whose lazily
built field list and
- // field map survive across calls, so the per-record cost is a cache hit
instead of an
- // O(schema width) wrapper rebuild.
- HoodieSchema currentSchema =
HoodieAvroSchemaCache.intern(record.getSchema());
+ // fromAvroSchema returns the canonical wrapper for this schema, whose
lazily built field
+ // list and field map survive across calls, so the per-record cost is a
cache hit instead
+ // of an O(schema width) wrapper rebuild.
+ HoodieSchema currentSchema =
HoodieSchema.fromAvroSchema(record.getSchema());
Review Comment:
Pre-existing (since #13646), but since this method is being touched: in the
loop below, a dotted path with a null intermediate struct NPEs --
`getValue(record, "a.b")` with `a == null` assigns `currentRecord = null` and
the next iteration derefs it. `HoodieAvroUtils#getNestedFieldVal` guards this
case and returns null. Worth adding the null check here plus a
`TestAvroRecordContext` case with `address = null` (nested ordering/precombine
fields reach this path).
##########
hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCache.java:
##########
@@ -28,21 +29,77 @@
* <p>This is a global cache which works for a JVM lifecycle.
* A collection of schema instances are maintained.
*
+ * <p>This value-keyed pool is the canonicalization mechanism behind
+ * {@link HoodieSchema#fromAvroSchema}, and can also be used directly to
intern schemas
+ * produced without an Avro source (builders, converters).
+ *
+ * <p>Interning is never lossy: entries are keyed on the schema's full
serialized content,
+ * NOT on {@link HoodieSchema#equals}. Avro equality (which HoodieSchema
equality delegates
+ * to) ignores doc strings and aliases, so keying on it would collapse schemas
that differ
+ * only in that metadata and silently drop it -- docs drive catalog sync
column comments and
+ * aliases drive schema-evolution field matching. Schemas that differ in docs
or aliases
+ * intern to distinct canonical instances even though they are {@code
equals()}.
+ *
* <p>NOTE: The schema which is used frequently should be cached through this
cache.
*/
public class HoodieSchemaCache {
// Ensure that there is only one variable instance of the same schema within
an entire JVM lifetime
- private static final LoadingCache<HoodieSchema, HoodieSchema> SCHEMA_CACHE =
- Caffeine.newBuilder().weakValues().maximumSize(1024).build(k -> k);
+ private static final LoadingCache<SchemaContentKey, HoodieSchema>
SCHEMA_CACHE =
+ Caffeine.newBuilder().weakValues().maximumSize(1024).build(key ->
key.schema);
/**
* Get schema variable from global cache. If not found, put it into the
cache and then return it.
*
+ * <p>Two schemas converge on one canonical instance only when their full
serialized form
+ * (including doc strings and aliases) is identical; see the class javadoc.
+ *
+ * <p>A schema that is valid in memory but cannot be serialized to JSON --
e.g. two distinct
+ * nested records that share a name, as some projection/reader paths produce
-- has no content
+ * key, so it is returned uncached instead of interned. Canonicalization is
only a
+ * de-duplication optimization, so skipping it stays correct.
+ *
* @param schema schema to get
* @return if found, return the exist schema variable, otherwise return the
param itself.
*/
public static HoodieSchema intern(HoodieSchema schema) {
- return SCHEMA_CACHE.get(schema);
+ SchemaContentKey key;
+ try {
+ key = new SchemaContentKey(schema);
Review Comment:
This now serializes the full schema JSON on every `intern()` call including
hits (~100us for a 500-col schema vs ~2ns for the old cached-hashCode lookup).
The direct callers run per log block (`HoodieDataBlock` read ctor,
`KeyBased`/`PositionBasedFileGroupRecordBuffer`), and `FileGroupRecordBuffer`
re-interns the schema handler's already-interned `requiredSchema` -- ~0.2-0.3ms
per block on wide tables. Cache `contentJson` lazily in a transient field on
`HoodieSchema`, or short-circuit already-canonical instances.
##########
hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchema.java:
##########
@@ -338,13 +346,42 @@ private HoodieSchema(Schema avroSchema,
List<HoodieSchemaField> fields) {
/**
* Factory method to create HoodieSchema from an Avro schema.
*
+ * <p>Returns the canonical instance for the given schema, converting and
interning it on
+ * first use: distinct Avro schema instances with identical serialized
content converge on
+ * one shared wrapper through {@link HoodieSchemaCache}, with a weak
identity fast path for
+ * the per-record hot path where all records of a file share the same live
{@link Schema}
+ * instance.
+ *
+ * <p>Interning is never lossy: the canonicalization key covers the schema's
full content,
+ * including doc strings and aliases, which Avro equality ignores. Schemas
that differ only
+ * in docs or aliases stay distinct wrappers (even though they are {@code
equals()}), so
+ * metadata consumed downstream (e.g. catalog sync column comments,
alias-based field
+ * matching) is always preserved.
+ *
+ * <p>Because the canonical wrapper may have been created from a
content-identical but
+ * different Avro schema instance, {@code fromAvroSchema(s).getAvroSchema()}
does not
+ * necessarily return {@code s} itself. Canonical instances are shared:
neither the wrapper
+ * nor its underlying Avro schema may be mutated.
Review Comment:
This contract is enforced only by javadoc: `addProp`/`setFields` (and
`HoodieSchemaField.addProp`) remain public and unguarded, and `parse()`'s
owned-instance protection is depth-0 -- navigated children are interned, so
`parse(json).getField(f).schema().addProp(...)` mutates a global canonical, and
a first-seen child's canonical aliases the owned Avro subtree. Verified
mechanically that a mutated canonical keeps being served under its stale
pre-mutation content key. Suggest a transient frozen flag set at intern time
(pool loader, the `AVRO_SCHEMA_CACHE.put` winner, and the uncached fallback),
with mutators throwing when frozen; `parse()` and builders stay unfrozen.
##########
hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchema.java:
##########
@@ -338,13 +346,42 @@ private HoodieSchema(Schema avroSchema,
List<HoodieSchemaField> fields) {
/**
* Factory method to create HoodieSchema from an Avro schema.
*
+ * <p>Returns the canonical instance for the given schema, converting and
interning it on
+ * first use: distinct Avro schema instances with identical serialized
content converge on
+ * one shared wrapper through {@link HoodieSchemaCache}, with a weak
identity fast path for
+ * the per-record hot path where all records of a file share the same live
{@link Schema}
+ * instance.
+ *
+ * <p>Interning is never lossy: the canonicalization key covers the schema's
full content,
Review Comment:
Nit: this rationale is spelled out in full here, on `equals()`, and in
`HoodieSchemaCache`'s class doc -- keep the full version once in
`HoodieSchemaCache` and a one-liner plus link in the two public javadocs. Also
'never lossy' needs the caveat that non-JSON-serializable schemas are returned
uncached on Avro 1.11, so not every call yields the canonical instance.
##########
hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchema.java:
##########
@@ -94,6 +96,12 @@
public class HoodieSchema implements Serializable {
private static final long serialVersionUID = 1L;
+ // Avro-identity fast path onto the value-keyed HoodieSchemaCache, backing
fromAvroSchema:
+ // records of one file share the same live Schema instance, so the
per-record hot path is a
+ // single weak-identity hit with no wrapper allocation or type dispatch.
+ private static final Cache<Schema, HoodieSchema> AVRO_SCHEMA_CACHE =
Review Comment:
This 1024-cap cache is now populated by every navigation call, not just the
old top-level intern sites: one parsed 500-col nullable schema is ~1500
identity-distinct Avro nodes (Avro primitives are not shared singletons), ~500
of which enter on a typical read, so two wide tables exceed the cap.
Single-table jobs are fine (TinyLFU keeps the per-record-hot keys), but
multi-table long-lived JVMs sustain insert pressure between hot-key accesses.
Consider `weakKeys` with no `maximumSize` (the usual weak-interner pattern) or
a much larger cap.
##########
hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCache.java:
##########
@@ -28,21 +29,77 @@
* <p>This is a global cache which works for a JVM lifecycle.
* A collection of schema instances are maintained.
*
+ * <p>This value-keyed pool is the canonicalization mechanism behind
+ * {@link HoodieSchema#fromAvroSchema}, and can also be used directly to
intern schemas
+ * produced without an Avro source (builders, converters).
+ *
+ * <p>Interning is never lossy: entries are keyed on the schema's full
serialized content,
+ * NOT on {@link HoodieSchema#equals}. Avro equality (which HoodieSchema
equality delegates
+ * to) ignores doc strings and aliases, so keying on it would collapse schemas
that differ
+ * only in that metadata and silently drop it -- docs drive catalog sync
column comments and
+ * aliases drive schema-evolution field matching. Schemas that differ in docs
or aliases
+ * intern to distinct canonical instances even though they are {@code
equals()}.
+ *
* <p>NOTE: The schema which is used frequently should be cached through this
cache.
*/
public class HoodieSchemaCache {
// Ensure that there is only one variable instance of the same schema within
an entire JVM lifetime
- private static final LoadingCache<HoodieSchema, HoodieSchema> SCHEMA_CACHE =
- Caffeine.newBuilder().weakValues().maximumSize(1024).build(k -> k);
+ private static final LoadingCache<SchemaContentKey, HoodieSchema>
SCHEMA_CACHE =
+ Caffeine.newBuilder().weakValues().maximumSize(1024).build(key ->
key.schema);
/**
* Get schema variable from global cache. If not found, put it into the
cache and then return it.
*
+ * <p>Two schemas converge on one canonical instance only when their full
serialized form
+ * (including doc strings and aliases) is identical; see the class javadoc.
+ *
+ * <p>A schema that is valid in memory but cannot be serialized to JSON --
e.g. two distinct
+ * nested records that share a name, as some projection/reader paths produce
-- has no content
+ * key, so it is returned uncached instead of interned. Canonicalization is
only a
+ * de-duplication optimization, so skipping it stays correct.
+ *
* @param schema schema to get
* @return if found, return the exist schema variable, otherwise return the
param itself.
*/
public static HoodieSchema intern(HoodieSchema schema) {
- return SCHEMA_CACHE.get(schema);
+ SchemaContentKey key;
+ try {
+ key = new SchemaContentKey(schema);
+ } catch (AvroRuntimeException e) {
+ // Not serializable -> no content key derivable; skip interning rather
than fail the caller.
+ return schema;
+ }
+ return SCHEMA_CACHE.get(key);
+ }
+
+ /**
+ * Content-complete cache key: the serialized JSON form covers doc strings
and aliases that
+ * Avro equality ignores. The wrapper class is part of the key so a
logical-type subclass
+ * (e.g. {@link HoodieSchema.Decimal}) never collapses onto a plain wrapper
of equal content,
+ * which would break downcasts.
+ */
+ private static final class SchemaContentKey {
+ private final HoodieSchema schema;
Review Comment:
The key strongly references the value, so `weakValues()` never fires (same
shape as the old cache), and each entry now also retains the full JSON string
(~80KB for a 1000-col schema) until size eviction -- worst case tens of MB of
dead strings in long-lived multi-table JVMs. A fixed-size digest of
`toString()` plus the wrapper class keeps the doc/alias sensitivity without
retaining the JSON.
##########
hudi-common/src/main/java/org/apache/hudi/common/schema/HoodieSchemaCompatibilityChecker.java:
##########
@@ -548,16 +572,23 @@ private SchemaCompatibilityResult
checkTimestampCompatibility(final HoodieSchema
return SchemaCompatibilityResult.compatible();
}
- private SchemaCompatibilityResult checkSchemaNames(final HoodieSchema
reader, final HoodieSchema writer,
- final
Deque<LocationInfo> locations) {
+ /**
+ * Whether fully-qualified schema names are validated at the current
location. Feeds both
+ * {@link #checkSchemaNames} and the memo key, which must agree on the
naming context.
+ */
+ private boolean namesValidatedAt(final Deque<LocationInfo> locations) {
checkState(!locations.isEmpty());
// NOTE: We're only going to validate schema names in following cases
// - This is a top-level schema (ie enclosing one)
// - This is a schema enclosed w/in a union (since in that case
schemas could be
// reverse-looked up by their fully-qualified names)
- boolean shouldCheckNames = checkNaming && (locations.size() == 1 ||
locations.peekLast().type == HoodieSchemaType.UNION);
+ return checkNaming && (locations.size() == 1 ||
locations.peekLast().type == HoodieSchemaType.UNION);
Review Comment:
Note (pre-existing, faithfully preserved by the context key):
`peekLast().type == UNION` applies to all descendants until the next field
push, so a record inside an array/map under any nullable `[null, T]` field is
name-checked while the same structure under a required field is not. Hoisting
name validation to the top-level/union call sites would fix the asymmetry and
let this key revert to a plain pair, but it changes verdicts for the
array-under-union case -- follow-up with sign-off, not this 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]