Skip to content

refactor(schema): dissolve AvroSchemaUtils, dedupe compat - #19810

Open
voonhous wants to merge 6 commits into
apache:masterfrom
voonhous:16639-schema-utils-b
Open

refactor(schema): dissolve AvroSchemaUtils, dedupe compat#19810
voonhous wants to merge 6 commits into
apache:masterfrom
voonhous:16639-schema-utils-b

Conversation

@voonhous

@voonhous voonhous commented Sep 1, 2026

Copy link
Copy Markdown
Member

Describe the issue this Pull Request addresses

Closes #16639 (part 2; part 1 is #19809, merged as 93f1f71). Rebased onto master; the three commits are all new. Part 1 wrote down where schema helpers belong; this removes the class that has no place in that rule and the duplicated logic in the compatibility cluster.

Summary and Changelog

Behavior-preserving. One concern per commit; each compiles and reverts alone.

  • AvroSchemaUtils is gone: its three remaining uses were all inside HoodieAvroUtils, so the helpers move there unchanged; HoodieSchema.Blob builds its fields without it; the caller-less HoodieSchemaUtils#createNullableSchema shim goes.
  • HoodieSchemaUtils#asNullable runs the InternalSchema nullability update on the HoodieSchema directly instead of bouncing through Avro twice.
  • Compatibility cluster: lookupWriterField was implemented twice (facade now delegates); the checker's primitive-widening cases call HoodieSchemaTypePromotion#canPromote (same table, TIMESTAMP <- LONG / UUID <- STRING stay checker-only and explained); the 3-arg isSchemaCompatible named its parameters reader/writer while routing them prev/new, so they are renamed, not reordered.
Details per commit
  1. implement asNullable natively: old path was HoodieSchema -> Avro -> HoodieSchema -> InternalSchema -> HoodieSchema -> Avro -> HoodieSchema. Two details kept: Avro's Schema#isNullable is true for a bare NULL type while HoodieSchema#isNullable is not, so NULL-typed fields are excluded explicitly; the all-nullable case returns the input instance (was an equal fresh wrapper; the sole caller, Flink ClusteringOperator, does not compare identities). New tests pin the round trip's actual, unchanged behavior: per-field docs survive, record-level doc/props do not, a NULL-typed field beside a required one is rejected.
  2. dedupe compatibility helpers: lookupWriterField keeps the facade's stricter RECORD precondition then delegates. Checker cases LONG/FLOAT/DOUBLE/BYTES/STRING collapse into one canPromote call. TIMESTAMP <- LONG and UUID <- STRING must not become projection promotions or writer-schema deduction would silently drop the logical type; fix(schema): require a per-field override to promote a bare long to a timestamp logical type #19384 gates the deduction that produces that pair. Decimal widening untouched (the projection checker requires fixed-size parity, the compatibility checker does not; documented, not resolved). areSchemasCompatible documented: reader is the first argument, no name check, no missing-field check. Tests pin the promotion table from both sides, lookupWriterField (direct, alias, ambiguous, absent, non-record) and the argument order of areSchemasCompatible.
  3. dissolve AvroSchemaUtils: getNonNullTypeFromUnion public, isNullable private, createNewSchemaFromFieldsWithReference package-private, all verbatim. HoodieSchema.Blob uses a local nullable(Schema) (inputs are LONG, BYTES and a RECORD, so the old NULL-type guard was never exercised) and derives REFERENCE_FIELD_COUNT from a REFERENCE_SCHEMA constant instead of unwrapping the union around it. TestAvroSchemaUtils folds into TestHoodieAvroUtils; new tests pin the strict unwrap and the Blob shape (3 fields, 4 reference fields, null first in every nullable union).
  4. Self-review follow-ups, two commits: asNullable rejects non-RECORD input with IllegalArgumentException (was an Avro Not a record error from the deleted path, unreachable either way); tests pin the persisted Blob schema JSON literally, the five reverse numeric narrowings, the writer-side canPromote(LONG, DATE/TIME), and the three pre-existing losses of the asNullable round trip (non-null default, ENUM, null-last union) plus a VECTOR column surviving it; stale HoodieSchemaTypePromotion javadoc line dropped; test helper reuse.
Left for later (not in this series)
  • HoodieSchemaUtils#projectSchema still round-trips through HoodieAvroUtils#projectSchema; a native version needs a nested-projection benchmark first.
  • HoodieSchemaUtils#asNullable still rewrites through the InternalSchema (HUDI-8841 reused ColumnUpdateChange because it existed); a field-wise HoodieSchema.createNullable wrap would keep non-null defaults, ENUM and union order, but that is a behavior change. The BLOB field-id collision the round trip exposes is [BUG] InternalSchema BLOB reference field ids collide with table field ids #19833.
  • HoodieAvroUtils#recordNeedsRewriteForExtendedAvroTypePromotion to HoodieSchema needs a logical-type accessor on HoodieSchema.
  • HoodieRealtimeRecordReaderUtils#generateProjectionSchema duplicates HoodieSchemaUtils#generateProjectionSchema (same algorithm and exception text).
  • HoodieSchemaRepair#hasTimestampMillisField vs HoodieTableMetadataUtil#isTimestampMillisField.
  • Decimal widening: the projection checker requires fixed-size parity, the compatibility checker does not; unifying is a behavior change.
  • AvroSchemaEvolutionUtils has no Avro in any signature; rename after the RFC-104 docs settle.
  • VariantSchemaUtils is HoodieSchema-typed but lives in common.avro; HoodieProjectionMask is a hudi-hadoop-mr concern in common.schema.
  • HoodieSchemaUtils#createNewSchemaField is an alias of HoodieSchemaField#of (13 files, several in active variant work).
  • HoodieMetadataPayload: deriving HOODIE_METADATA_AVRO_SCHEMA from HOODIE_METADATA_SCHEMA.toAvroSchema() would make the feat(schema): Phase 24 - Restore O(1) reference equality comparison i… #17672 fast path independent of interning order.

Impact

Internal API only, no behavior change. Removed: class AvroSchemaUtils, HoodieSchemaUtils#createNullableSchema (no callers). Moved: getNonNullTypeFromUnion -> HoodieAvroUtils (public; the other two become private/package-private there). Parameter names, not order, on the 3-arg HoodieSchemaCompatibility#isSchemaCompatible.

Risk Level

Low-medium. The checker change is on write-path validation; the collapsed cases match canPromote predicate by predicate and are pinned by tests in both directions. asNullable has one production caller and is pinned by tests.

Documentation Update

none

Contributor's checklist

  • Read through contributor's guide
  • Enough context is provided in the sections above
  • Adequate tests were added if applicable

@voonhous voonhous changed the title 16639 schema utils b refactor(schema): dissolve AvroSchemaUtils, dedupe compat Sep 1, 2026
@github-actions github-actions Bot added the size:XL PR with lines of changes > 1000 label Sep 1, 2026
@codecov-commenter

codecov-commenter commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.65079% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.33%. Comparing base (93f1f71) to head (875600c).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
...a/org/apache/hudi/common/avro/HoodieAvroUtils.java 88.88% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19810      +/-   ##
============================================
+ Coverage     78.32%   78.33%   +0.01%     
+ Complexity    33897    33896       -1     
============================================
  Files          2540     2539       -1     
  Lines        141763   141748      -15     
  Branches      17197    17191       -6     
============================================
+ Hits         111029   111038       +9     
+ Misses        23031    23013      -18     
+ Partials       7703     7697       -6     
Components Coverage Δ
hudi-common 83.75% <93.65%> (+0.06%) ⬆️
hudi-client 83.21% <ø> (+0.01%) ⬆️
hudi-flink 85.66% <ø> (+<0.01%) ⬆️
hudi-spark-datasource 73.24% <ø> (ø)
hudi-utilities 74.52% <ø> (-0.05%) ⬇️
hudi-cli 15.06% <ø> (-0.07%) ⬇️
hudi-hadoop 70.71% <ø> (ø)
hudi-sync 75.54% <ø> (-0.03%) ⬇️
hudi-io 79.98% <ø> (+0.04%) ⬆️
hudi-timeline-service 83.44% <ø> (ø)
hudi-cloud 65.81% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 51.47% <93.65%> (+0.02%) ⬆️
flink-integration-tests 48.82% <53.96%> (+<0.01%) ⬆️
hadoop-mr-java-client 44.08% <30.15%> (-0.01%) ⬇️
integration-tests 13.51% <0.00%> (-0.01%) ⬇️
spark-client-hadoop-common 50.56% <12.69%> (+<0.01%) ⬆️
spark-java-tests 52.26% <90.47%> (+0.03%) ⬆️
spark-scala-tests 46.98% <30.15%> (-0.02%) ⬇️
utilities 36.57% <26.98%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...va/org/apache/hudi/common/schema/HoodieSchema.java 87.41% <100.00%> (+0.04%) ⬆️
.../hudi/common/schema/HoodieSchemaCompatibility.java 80.00% <100.00%> (+2.58%) ⬆️
...ommon/schema/HoodieSchemaCompatibilityChecker.java 70.41% <100.00%> (+4.42%) ⬆️
.../hudi/common/schema/HoodieSchemaTypePromotion.java 79.16% <ø> (+4.16%) ⬆️
...g/apache/hudi/common/schema/HoodieSchemaUtils.java 86.82% <100.00%> (+1.45%) ⬆️
...a/org/apache/hudi/common/avro/HoodieAvroUtils.java 78.93% <88.88%> (+0.37%) ⬆️

... and 17 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@voonhous
voonhous force-pushed the 16639-schema-utils-b branch 3 times, most recently from a66245a to f8d7374 Compare September 3, 2026 07:50
voonhous added a commit that referenced this pull request Sep 3, 2026
refactor(schema): schema util contracts, dead API and folds

Part 1 of #16639. The HoodieSchema migration moved schema helpers out
of AvroSchemaUtils and HoodieAvroUtils but never wrote down what
belongs where, so helpers kept being re-implemented instead of found.
Behavior-preserving.

The routing rule, now in the class javadocs: HoodieSchema answers
questions about one schema and holds the factories; HoodieSchemaUtils
holds table-schema transforms and the Hudi record shapes;
HoodieSchemaCompatibility is the only compatibility and projection
entry point; HoodieAvroUtils holds Avro record and value operations;
common.schema.internal is the InternalSchema domain. Look-alike
helpers with different semantics cross-reference each other instead
of being merged.

Code changes to match it:
- HoodieSchemas folds into HoodieSchemaUtils (createDeleteLogSchema).
- AvroSchemaCache deleted: its one caller interned a generated-class
  static, a no-op since #18967 removed the last consumer.
- LocalHoodieSchemaCache moves from common.util to common.schema;
  getInstance() becomes create(), which is what it always did.
- convertBytesToBigDecimal(byte[], HoodieSchema) moves to
  HoodieAvroUtils; the (byte[], int, int) shim and the caller-less
  HoodieSchemaUtils.convertValueForSpecificDataTypes and
  hasSmallPrecisionDecimalField are deleted.
- HoodieAvroUtils.getRecordColumnValues takes HoodieSchema, since both
  callers unwrapped one to call it; it still interns through
  HoodieAvroSchemaCache and now reuses the static PROPERTIES and a
  fixed array instead of allocating per call.
- 13 caller-less members narrowed; test-only ones get
  @VisibleForTesting.
- Stale "equivalent to X" javadocs fixed, cross-references added.

Tests pin delete-log field order and logical types, case-insensitive
projection, nested column values, the HoodieMetadataPayload
reference-equality fast path, the getRecordColumnValues intern, and
LocalHoodieSchemaCache (test restored from #17740).

Pre-existing bugs found on the way are filed, not fixed here:
#19823, #19825, #19826.

Part 2 (#19810) implements asNullable natively, dissolves
AvroSchemaUtils and dedupes the compatibility cluster.

Closes #15908
HoodieSchemaUtils#asNullable unwrapped its argument to Avro, called
AvroSchemaUtils#asNullable, which immediately re-wrapped it as a
HoodieSchema to run the InternalSchema column-nullability update, then
flattened the result back to Avro for the caller to wrap once more.
Run the same pipeline on the HoodieSchema directly: collect the
required top-level fields, convert to InternalSchema, apply a
ColumnUpdateChange that marks them nullable, convert back under the
original full name.

Two details preserved on purpose: Avro's Schema#isNullable is true for a
bare NULL type while HoodieSchema#isNullable is not, so the filter
excludes NULL-typed fields explicitly; and when every field is already
nullable the input instance is returned (the old path returned an equal
fresh wrapper; the sole caller, Flink ClusteringOperator, does not
compare identities).

Tests pin the actual behavior of the InternalSchema round trip, which
is unchanged by this commit: per-field docs, names, namespace and field
order survive; record-level doc and custom props do not; a NULL-typed
field next to a required field is rejected by the converter.

AvroSchemaUtils#asNullable is now unused and is removed with its class
in the next commit.

Part of apache#16639.
- HoodieSchemaCompatibility#lookupWriterField re-implemented the
  checker's lookupWriterField (direct name, then reader aliases, throw
  on more than one match). Keep the facade's RECORD precondition and
  delegate to the checker.
- HoodieSchemaCompatibilityChecker restated the primitive widening
  table (LONG<-INT, FLOAT<-INT/LONG, DOUBLE<-INT/LONG/FLOAT,
  BYTES<-STRING, STRING<-BYTES/numeric) that HoodieSchemaTypePromotion
  already encodes; the five cases now call canPromote. TIMESTAMP<-LONG
  and UUID<-STRING stay as explicit checker-only cases with a comment
  on why they must not become projection promotions (apache#19384 gates the
  deduction that produces that pair). checkDecimalWidening untouched.
- The 3-arg isSchemaCompatible named its parameters readerSchema and
  writerSchema but forwards them as prevSchema and newSchema, where the
  reader is the second argument. Rename the parameters and rewrite the
  javadoc; no reordering, callers already pass (prev, new).
  areSchemasCompatible gets a javadoc stating that its reader is the
  first argument, names are not checked and no missing-field check runs.

Tests pin the promotion table from both sides (logical-type pairs
accepted by the checker and rejected by canPromote), lookupWriterField
(direct, alias, ambiguous, absent, non-record) and the argument order
of areSchemasCompatible.

Part of apache#16639.
AvroSchemaUtils had no callers outside hudi-common, a HoodieSchema twin
for every method, and survived only as a delegate target. Its three
remaining production uses were all inside HoodieAvroUtils, so the
helpers move there with their bodies unchanged: getNonNullTypeFromUnion
stays public (HoodieSchema.Blob and a hudi-hadoop-common test use the
strict unwrap; its javadoc states the two-branch contract and points at
the lenient siblings), isNullable becomes private and
createNewSchemaFromFieldsWithReference package-private.

HoodieSchema.Blob no longer reaches into a util class from its static
initializer: a local nullable(Schema) builds the [null, X] unions
(inputs are LONG, BYTES and a RECORD, so the old NULL-type guard was
never exercised) and REFERENCE_FIELD_COUNT is derived from a
REFERENCE_SCHEMA constant instead of unwrapping the union around it.

HoodieSchemaUtils#createNullableSchema, a caller-less shim over the
deleted method, is removed; HoodieSchema#createNullable is the
idempotent native equivalent.

TestAvroSchemaUtils folds into TestHoodieAvroUtils. New tests pin the
strict unwrap (non-union as-is, both branch orders, three-branch and
null-less unions rejected) and the Blob shape (3 fields, 4 reference
fields, null first in every nullable union).

Closes the AvroSchemaUtils half of apache#16639.
@voonhous
voonhous force-pushed the 16639-schema-utils-b branch from f8d7374 to cd15b3f Compare September 3, 2026 10:33
@github-actions github-actions Bot added size:L PR with lines of changes in (300, 1000] and removed size:XL PR with lines of changes > 1000 labels Sep 3, 2026

@voonhous voonhous left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review of the three commits. Old and new asNullable were run side by side on 20 schemas (nested records, non-null defaults, null-last unions, logical types, enum/fixed, VARIANT/VECTOR/BLOB, non-record) with identical output, and the persisted Blob schema JSON is byte-identical before and after. One pre-existing bug surfaced on the way and is filed as #19833 (BLOB reference field ids collide with table field ids); it is deliberately not fixed here so the PR stays a pure refactor.

nit: feel free to ignore. The InternalSchema route inside asNullable was reuse rather than design (HUDI-8841 picked ColumnUpdateChange because it existed); a field-wise HoodieSchema.createNullable wrap would be lossless but is a behaviour change. Could the "Left for later" list in the description gain that line?

return schema;
}

InternalSchema internalSchema = InternalSchemaConverter.convert(schema);

@voonhous voonhous Sep 3, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

major: pre-existing, not introduced by this rewrite: the deleted AvroSchemaUtils#asNullable path gives the identical output. InternalSchemaConverter.buildBlobInternalRecordType() gives the blob's nested reference fields ids 0..3 and InternalSchema.buildIdToField is one flat last-put-wins map, so on {id: int, b: nullable BLOB} this call returns a record whose first field is external_path instead of id; a required BLOB throws Cannot update nullability for column 'b'. Reachable from Flink ClusteringOperator.open() on any BLOB table. Could we keep the fix out of this PR so the conversion stays as on master and the PR remains a pure refactor?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed as #19833 with the repro, will leave this comment unresolved, but will keep it as a separate fix.

Comment thread hudi-common/src/test/java/org/apache/hudi/common/schema/TestHoodieSchema.java Outdated
asNullable failed on a non-record input with whatever
HoodieSchema#getFields throws (an IllegalStateException; the deleted
Avro path threw AvroRuntimeException). Make the RECORD-only contract
explicit with a checkArgument and say so in the javadoc, which also
now names the three pre-existing losses of the InternalSchema round
trip: non-null defaults, ENUM, and null-last union order.

HoodieSchemaTypePromotion's closing javadoc line still said the class
is used only by HoodieSchemaProjectionChecker, which the header edited
in the previous commit contradicts. Drop it.
- TestHoodieSchema: the Blob schema is persisted by every BLOB table,
  so testCreateBlob pins its serialized form literally instead of
  checking counts and nullability field by field.
- TestHoodieSchemaCompatibility: assert the five reverse numeric
  narrowings the checker must reject; the hudi-spark guard for the
  reversed-argument bug class (HUDI-1493) has never executed. Reuse
  HoodieSchemaTestUtils.createRecord instead of a local twin; note
  the end-to-end alias coverage.
- TestHoodieSchemaTypePromotion: writer-side canPromote(LONG, DATE)
  and (LONG, TIME) are rejected.
- TestHoodieSchemaUtils: a non-null default, an ENUM and a null-last
  union come back changed from the asNullable round trip, a VECTOR
  column survives, and a non-record input is rejected.
@voonhous

voonhous commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

@wombatu-kun The second part of the Avro Utils refactoring is ready for review! Can you please help to review it? Thank you!

assertCompatible/assertIncompatible take (reader, writer), the
reverse of the (prev, new) order isSchemaCompatible expects, so
the argument order is easy to misread at the call sites. Spell
out the direction being asserted, the reader-first convention,
and the single-field record wrapping.
@apache apache deleted a comment from hudi-bot Sep 3, 2026
@hudi-bot

hudi-bot commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

CI report:

Bot commands @hudi-bot supports the following commands:
  • @hudi-bot run azure re-run the last Azure build

@hudi-agent hudi-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 🤖 This review was generated by an AI agent and may contain mistakes. Please verify any suggestions before applying.

Thanks for working on this! This PR dissolves AvroSchemaUtils (moving its three remaining helpers into HoodieAvroUtils), implements HoodieSchemaUtils#asNullable natively over HoodieSchema/InternalSchema instead of round-tripping through Avro, and dedupes the compatibility cluster (lookupWriterField delegation, the primitive-widening cases routed through HoodieSchemaTypePromotion#canPromote, and a parameter rename on the 3-arg isSchemaCompatible). I traced each dedup to confirm the behavior-preserving claim: the canPromote collapse only runs in the different-types branch (so its equal-type short-circuit is unreachable and the widening table matches exactly), the two lookupWriterField bodies are behaviorally equivalent, the Blob static-init ordering is sound, and the asNullable NULL-type handling and RECORD precondition are safe for the sole caller. No correctness issues found. A few style/readability suggestions in the inline comments. Please take a look, and this should be ready for a Hudi committer or PMC member to take it from here. One minor naming nit on a private helper; the rest of the refactor is clean.

cc @yihua

* Wraps the given schema into the canonical Avro nullable union {@code [null, schema]}. None of the blob
* field types is NULL, so no further validation is needed here.
*/
private static Schema nullable(Schema schema) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 nit: nullable reads as an adjective (a property check) rather than a factory — could you rename it to wrapNullable or createNullableUnion so call sites like nullable(bytesField) are self-explanatory?

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L PR with lines of changes in (300, 1000]

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Evaluate Avro schema util classes

4 participants