Skip to content

refactor(schema): schema util contracts, dead API and folds - #19809

Merged
voonhous merged 20 commits into
apache:masterfrom
voonhous:16639-schema-utils-a
Sep 3, 2026
Merged

refactor(schema): schema util contracts, dead API and folds#19809
voonhous merged 20 commits into
apache:masterfrom
voonhous:16639-schema-utils-a

Conversation

@voonhous

@voonhous voonhous commented Sep 1, 2026

Copy link
Copy Markdown
Member

Describe the issue this Pull Request addresses

Part 1 of #16639. The HoodieSchema migration moved the schema helpers out of AvroSchemaUtils/HoodieAvroUtils but never wrote down what belongs where, so helpers keep being re-implemented instead of found (472bc0c48564, e3f93425cd3b, #19212). Absorbs/Closes #15908.

Summary and Changelog

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

  • 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/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.
  • The code made to match it: two value-level helpers move out of HoodieSchemaUtils, HoodieSchemas folds into it, AvroSchemaCache is deleted, LocalHoodieSchemaCache moves to common.schema, getRecordColumnValues takes HoodieSchema, caller-less members are narrowed or deleted, stale "equivalent to X" javadocs are fixed.
  • Part 2 (stacked, separate PR): native asNullable, dissolve AvroSchemaUtils, dedupe the compatibility cluster.
Per-commit changelog
  1. narrow unused schema util API: members with no caller outside their class become private/package-private; test-only public members get @VisibleForTesting. Nothing deleted, no bodies changed.
  2. move LocalHoodieSchemaCache: common.util -> common.schema; getInstance() -> create() (it returns a new instance per call). Sole caller RecordContext.
  3. delete AvroSchemaCache: its one caller interned a generated-class static, a no-op; HoodieAvroSchemaCache is the live Avro-keyed cache. See the feat(schema): Phase 24 - Restore O(1) reference equality comparison i… #17672 section below.
  4. move value helpers to avro utils: HoodieSchemaUtils.convertValueForSpecificDataTypes (no production callers) deleted; convertBytesToBigDecimal(byte[], HoodieSchema) moved to HoodieAvroUtils, the (byte[], int, int) shim deleted. Tests moved, two added.
  5. column values take HoodieSchema: HoodieAvroUtils#getRecordColumnValues took Schema and re-wrapped it while both callers unwrapped a HoodieSchema to call it. Test added.
  6. fold HoodieSchemas into utils: createDeleteLogSchema moves next to getRecordKeySchema; class deleted; six callers updated. Tests added.
  7. document schema util contracts: the rule above, class javadocs for HoodieSchemaRepair, HoodieAvroWrapperUtils, HoodieSchemaTypePromotion, stale references fixed, cross-references added.
  8. Self-review follow-ups, seven commits: hasSmallPrecisionDecimalField deleted with its test (no production caller since refactor: Update ParquetWriteSupport for Rows to match Avro writer behavior #13882); getRecordColumnValues interns through HoodieAvroSchemaCache as before (identity-keyed front), with a test that fails without it; stale javadoc fixed and the case-insensitive projection contract documented; tests pin delete-log field order and logical types, case-insensitive projection, a missing column, the null-schema guard, and LocalHoodieSchemaCache (test restored from feat(schema): Migrate json and proto converters to use HoodieSchema #17740). Two pre-existing bugs found on the way are filed, not fixed here: [BUG] HoodieSchemaUtils.hasDecimalField recurses forever on a union with two or more non-null branches #19825, [BUG] generateProjectionSchema lowercases with the default locale while HiveHoodieReaderContext uses Locale.ROOT #19826.
  9. Review follow-ups, three commits: convertBytesToBigDecimal(byte[], Decimal) and the 4- and 6-arg convertToRecord narrowed to package-private (callers: this class and same-package tests); rewritePrimaryType and the 7-arg convertToRecord marked @VisibleForTesting (test callers in hudi-hadoop-mr and hudi-spark); HoodieSchemaUtils class javadoc lists the public API it skipped and files getFieldSchema/getNestedField as facades over HoodieSchema; projectSchema and the avroToJson comparison dropped from HoodieAvroUtils javadocs; test that LocalHoodieSchemaCache.create() returns independent caches.
  10. Self-review round 5, two commits: getRecordColumnValues reuses the static PROPERTIES and a fixed array instead of allocating Properties + ArrayList per call (pre-existing, on the line this PR rewrote; it runs per comparison in RDDBucketIndexPartitioner's sort comparator); a second stale "cached Avro schema reference" comment in HoodieMetadataPayload#getInsertValue reworded; tests pin the getInsertValue reference-equality fast path (class schema returns HoodieMetadataRecord, an equal-but-distinct schema returns GenericData.Record) and a real nested value plus null / non-record intermediates in getRecordColumnValues.

Kept on purpose after re-checking callers: the 5-arg HoodieSchemaUtils.createNewSchemaField (ExpressionPayload.mergeSchema).

Why deleting AvroSchemaCache leaves the #17672 fast path unchanged

#17672 restored the == check in HoodieMetadataPayload#getInsertValue with two halves: pre-populating HoodieSchemaCache (wrapper identity) and pre-populating AvroSchemaCache so raw Avro schemas interned by HoodieAvroDataBlock and six other call sites would resolve to HOODIE_METADATA_AVRO_SCHEMA. The migration removed every one of those consumers (#17743, #17763, #17772, #17952, last #18967), so since June the cache held one entry nothing looked up; intern(SCHEMA$) on that empty identity-loader cache returned SCHEMA$, which is what the field now holds directly. The HoodieSchemaCache half still does the work (HoodieAvroDataBlock interns header schemas by value). Init-order hardening left for later: HOODIE_METADATA_AVRO_SCHEMA = HOODIE_METADATA_SCHEMA.toAvroSchema().

Impact

Internal API only, no behavior change. Removed: HoodieSchemas, AvroSchemaCache, HoodieSchemaUtils#convertValueForSpecificDataTypes, HoodieSchemaUtils#convertBytesToBigDecimal(byte[], int, int), HoodieSchemaUtils#hasSmallPrecisionDecimalField. Moved: LocalHoodieSchemaCache (and getInstance() -> create()), convertBytesToBigDecimal(byte[], HoodieSchema) -> HoodieAvroUtils. Signature: HoodieAvroUtils#getRecordColumnValues takes HoodieSchema. Visibility narrowed on 13 caller-less members.

Risk Level

Low. Moves, visibility changes, caller-less deletions and javadoc only.

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

Reduce the visibility of schema-util members that have no callers
outside their own class, so the public surface of HoodieAvroUtils,
HoodieAvroWrapperUtils, HoodieSchemaUtils and
HoodieSchemaCompatibilityChecker only lists what other code uses.
No bodies or signatures change.

HoodieAvroUtils: indexedRecordToBytesStream, avroToJsonString,
wrapNullable, getNullableValAsString and gteqAvro1_12 become private;
convertBytesToFixed and gteqAvro1_10 become package-private (their
only callers are same-package tests); makeFieldNonNull, rewriteRecords
and gteqAvro1_9 stay public but are marked @VisibleForTesting because
their only callers are tests in other modules.

HoodieSchemaUtils: hasSmallPrecisionDecimalField becomes
package-private; addMetadataColumnTypes stays public and is marked
@VisibleForTesting (callers are hudi-hadoop-mr and client-common
tests).

HoodieAvroWrapperUtils: wrapArray and the 2-arg
unwrapAvroValueWrapper become private.
HoodieSchemaCompatibilityChecker: READER_WRITER_COMPATIBLE_MESSAGE
becomes private.

Kept as-is after re-checking callers: convertBytesToBigDecimal(byte[],
Decimal) (TestMercifulJsonConverter) and the 5-arg
HoodieSchemaUtils.createNewSchemaField with HoodieFieldOrder
(ExpressionPayload.mergeSchema in hudi-spark-common).

Part of apache#16639; absorbs apache#15908.
Move LocalHoodieSchemaCache from common.util to common.schema, next to
the other schema caches (HoodieSchemaCache, HoodieAvroSchemaCache), and
rename its factory from getInstance() to create(): it returns a new,
empty cache on every call, so the old name suggested a singleton it is
not. RecordContext is the only caller.

Note: RecordContext is Serializable and holds the cache as a
non-transient field, so the Java-serialized form now names the new
package. RecordContext is per-task state and is never persisted.

Part of apache#16639.
AvroSchemaCache (Schema -> Schema interning) had a single call site:
HoodieMetadataPayload interning HoodieMetadataRecord.getClassSchema(),
a generated-class static that is already a singleton. Because that was
the only intern() call in the repo, the cache was always empty when the
static initializer ran and intern() necessarily returned its argument,
so the identity check in HoodieMetadataPayload#getInsertValue is
unaffected. HoodieAvroSchemaCache (Schema -> HoodieSchema) is the live
Avro-keyed cache and stays.

Also drop two unused Scala imports of the deleted class and repoint the
HoodieInternalRowUtils scaladoc at HoodieAvroSchemaCache.

Part of apache#16639.
HoodieSchemaUtils is the home of table-schema transforms, but it
carried two value-level helpers that only forwarded to HoodieAvroUtils:

- convertValueForSpecificDataTypes(HoodieSchema, Object, boolean) had
  no production callers (HoodieAvroUtils#getNestedFieldVal calls its own
  Schema-typed overload) and is deleted; its ten unit tests move to
  TestHoodieAvroUtils against the Avro overload with the assertions
  unchanged.
- convertBytesToBigDecimal(byte[], HoodieSchema) moves next to the
  other decimal decoders in HoodieAvroUtils and now calls the
  (byte[], int, int) overload directly; the HoodieSchemaUtils
  (byte[], int, int) shim is deleted. DecimalLogicalTypeProcessor and
  TestJsonKafkaSource switch to the new home.

TestDataSourceDefaults passes the raw Avro field schema instead of
wrapping it only to unwrap it again. Two tests pin the moved decimal
decoder (bytes-backed value; non-DECIMAL schema rejected).

Part of apache#16639.
HoodieAvroUtils#getRecordColumnValues took an Avro Schema and
immediately re-wrapped it through HoodieAvroSchemaCache.intern to call
HoodieRecord#toIndexedRecord(HoodieSchema, ...), while both callers
(HoodieAvroRecord#getColumnValues, HoodieTableMetadataUtil) already
held a HoodieSchema and unwrapped it with toAvroSchema() just to
satisfy the signature. Take HoodieSchema directly, like the sibling
getSortColumnValuesWithPartitionPathAndRecordKey already does, and drop
the round trip; HoodieAvroUtils no longer needs HoodieAvroSchemaCache.

Adds a direct unit test for getRecordColumnValues (there was none).

Part of apache#16639.
HoodieSchemas ("Factory class for HoodieSchema") held a single method,
createDeleteLogSchema, and gave contributors a third place to look for
schema helpers next to HoodieSchema's own factories and
HoodieSchemaUtils. Move the method, verbatim, into HoodieSchemaUtils
beside the other well-known Hudi record shapes (getRecordKeySchema,
getRecordKeyPartitionPathSchema) and delete the class. Six call sites
switch to the new home.

Adds unit tests for createDeleteLogSchema (there were none): record
name, record-key field first and non-nullable, ordering field made
nullable with a null default and its doc preserved, unknown ordering
field rejected.

Part of apache#16639.
Write down where schema helpers belong so the next contributor finds
the existing one instead of adding a twin (the failure mode apache#16639
describes; see 472bc0c, e3f9342 and apache#19212 for past
instances):

- HoodieAvroUtils: operations on Avro records and values; raw Schema
  helpers only in service of those. Lists the union-unwrapping family
  (unwrapNullable, getActualSchemaFromUnion,
  AvroSchemaUtils#getNonNullTypeFromUnion, HoodieSchema#getNonNullType,
  HoodieSchemaUtils#resolveUnionSchema,
  AvroOrcUtils#getActualSchemaType)
  with their differing contracts, which apache#19212 kept apart on purpose.
- HoodieSchemaUtils: HoodieSchema-typed structural transforms of table
  schemas and the well-known Hudi record shapes; no value or record
  operations. Replaces a javadoc that claimed every method delegates
  to Avro (7 of 32 do) and names the delegations still to be retired.
- HoodieSchemaRepair, HoodieAvroWrapperUtils: class javadocs (had none).
- HoodieSchemaTypePromotion: the single primitive-widening table used
  by the projection checker; explains why TIMESTAMP<-LONG and
  UUID<-STRING are accepted by the compatibility checker but are not
  projection promotions, and records the decimal fixed-size-parity
  difference between the two checkers instead of resolving it.
- AvroSchemaUtils: marked as being retired; do not add methods.

Fix stale references: eight "equivalent to X" javadocs in
HoodieSchemaUtils where X no longer exists, the
AvroSchemaUtils.isProjectionOfInternal mention in
InternalSchemaConverter, the AvroSchemaUtils.getAvroRecordQualifiedName
mention in HoodieSchemaConversionUtils, and the "only used in tests"
note on toJavaDate/fromJavaDate (HoodieArrayWritableSchemaUtils calls
them). Cross-reference the three projection helpers, getNestedField vs
findNestedField, mergeSchemas vs AvroSchemaEvolutionUtils, and the
createNewSchemaField aliases of HoodieSchemaField.of.

Javadoc and comments only.

Part of apache#16639.
@github-actions github-actions Bot added the size:L PR with lines of changes in (300, 1000] label Sep 1, 2026
@voonhous voonhous changed the title 16639 schema utils a refactor(schema): schema util contracts, dead API and folds Sep 1, 2026

@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 is part of the #16639 schema-utility consolidation: it reroutes callers off the HoodieSchemas/HoodieSchemaUtils wrappers to the corresponding HoodieAvroUtils methods, retires AvroSchemaCache and HoodieSchemas, relocates LocalHoodieSchemaCache into common.schema (with getInstance()create()), switches getRecordColumnValues to take HoodieSchema directly, and tightens visibility on now-internal helpers alongside javadoc cleanup. The behavioral changes trace out as behavior-preserving and all affected callers are internal or same-package tests. 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. A couple of small readability issues: a stale caching comment after the cache was removed, and @VisibleForTesting applied inconsistently to a public method.

cc @yihua

@@ -110,7 +109,7 @@ public class HoodieMetadataPayload implements HoodieRecordPayload<HoodieMetadata
private static final HoodieSchema HOODIE_METADATA_SCHEMA = HoodieSchemaCache.intern(
HoodieSchema.fromAvroSchema(HoodieMetadataRecord.getClassSchema()));
// Cache the Avro schema reference for O(1) equality checks during Avro.Schema -> HoodieSchema migration

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: the comment above still says 'Cache the Avro schema reference for O(1) equality checks' but AvroSchemaCache.intern() was removed on the next line — could you update or drop it so it matches what the code actually does?

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

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.

Reworded in 557a274. The field still anchors the == fast path in getInsertValue (line 428); HoodieMetadataRecord.getClassSchema() returns the generated class's SCHEMA$ singleton, so the reference is stable without interning.

return rewriteRecordWithNewSchema(oldRecord, newSchema, Collections.EMPTY_MAP, validate);
}

@VisibleForTesting

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: @VisibleForTesting on a public method is a bit contradictory — the sibling gteqAvro1_10 was correctly narrowed to package-private to match the annotation's intent. Could gteqAvro1_9 follow the same treatment, or if it has real external callers, drop the annotation?

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

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.

Keeping it as is. gteqAvro1_9 has test callers outside the package and outside the module (TestHoodieLogFormat in hudi-hadoop-common, TestObjectSizeCalculator in common.util), so package-private is not an option, and the annotation is what records that the method is public only for tests -- same pattern as makeFieldNonNull and rewriteRecords in this class. gteqAvro1_10 and gteqAvro1_12 could be narrowed because their only callers are in-package or in-class.

The comment on HOODIE_METADATA_AVRO_SCHEMA still described the field as
an interned cache after b00fc5b dropped AvroSchemaCache. The field
remains the anchor for the == fast path in getInsertValue;
HoodieMetadataRecord.getClassSchema() returns the generated class's
SCHEMA$ singleton, so the reference is stable without interning. Say
that instead.
@codecov-commenter

codecov-commenter commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.32%. Comparing base (c59987a) to head (f25cd74).
⚠️ Report is 6 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff            @@
##             master   #19809   +/-   ##
=========================================
  Coverage     78.31%   78.32%           
- Complexity    33888    33904   +16     
=========================================
  Files          2541     2540    -1     
  Lines        141728   141763   +35     
  Branches      17182    17237   +55     
=========================================
+ Hits         111001   111042   +41     
- Misses        23017    23021    +4     
+ Partials       7710     7700   -10     
Components Coverage Δ
hudi-common 83.68% <100.00%> (+0.03%) ⬆️
hudi-client 83.21% <100.00%> (-0.01%) ⬇️
hudi-flink 85.67% <ø> (-0.04%) ⬇️
hudi-spark-datasource 73.24% <ø> (+0.03%) ⬆️
hudi-utilities 74.52% <ø> (-0.03%) ⬇️
hudi-cli 15.13% <ø> (+0.06%) ⬆️
hudi-hadoop 70.72% <ø> (ø)
hudi-sync 75.56% <ø> (ø)
hudi-io 79.93% <ø> (-0.05%) ⬇️
hudi-timeline-service 83.44% <ø> (ø)
hudi-cloud 65.81% <ø> (ø)
hudi-kafka-connect 53.96% <ø> (+0.76%) ⬆️
Flag Coverage Δ
common-and-other-modules 51.46% <93.93%> (+0.01%) ⬆️
flink-integration-tests 48.82% <39.39%> (-0.02%) ⬇️
hadoop-mr-java-client 44.09% <45.45%> (+0.01%) ⬆️
integration-tests 13.51% <27.27%> (+<0.01%) ⬆️
spark-client-hadoop-common 50.55% <57.57%> (+<0.01%) ⬆️
spark-java-tests 52.24% <100.00%> (+0.02%) ⬆️
spark-scala-tests 46.97% <57.57%> (+0.09%) ⬆️
utilities 36.56% <57.57%> (-0.01%) ⬇️

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

Files with missing lines Coverage Δ
...pache/hudi/io/cdc/HoodieNativeLogFormatWriter.java 89.78% <100.00%> (ø)
.../org/apache/hudi/HoodieSchemaConversionUtils.scala 72.41% <ø> (ø)
.../org/apache/spark/sql/HoodieInternalRowUtils.scala 64.62% <ø> (ø)
...a/org/apache/hudi/common/avro/AvroSchemaUtils.java 80.95% <ø> (ø)
...a/org/apache/hudi/common/avro/HoodieAvroUtils.java 78.55% <100.00%> (+1.00%) ⬆️
...pache/hudi/common/avro/HoodieAvroWrapperUtils.java 83.66% <ø> (ø)
...n/avro/processors/DecimalLogicalTypeProcessor.java 95.83% <100.00%> (ø)
...a/org/apache/hudi/common/engine/RecordContext.java 84.41% <100.00%> (ø)
...org/apache/hudi/common/model/HoodieAvroRecord.java 55.81% <100.00%> (ø)
...ommon/schema/HoodieSchemaCompatibilityChecker.java 65.99% <ø> (ø)
... and 10 more

... and 31 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.

@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 the contribution! This PR documents the routing contracts between the schema utility classes and shifts code to match them — folding HoodieSchemas into HoodieSchemaUtils, deleting the dead AvroSchemaCache, relocating LocalHoodieSchemaCache and the convertValueForSpecificDataTypes/convertBytesToBigDecimal helpers, retyping getRecordColumnValues to take HoodieSchema, and narrowing caller-less members. The substantive changes trace as behavior-preserving, with reference-equality fast paths retained and the interning removal affecting only a perf micro-optimization. No issues flagged from this automated pass — a Hudi committer or PMC member can take it from here for a final review.

cc @yihua

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

Two text-only notes, no code change:

  • Impact says "Visibility narrowed on 12 caller-less members"; 119e1af narrows 11 (7 private, 3 package-private, 1 constant) and adds @VisibleForTesting to 4 that stay public.
  • 5be7b58's message says the eight "equivalent to X" javadocs in HoodieSchemaUtils named an X that no longer exists; four of those X's still exist (removeFields, createNewSchemaField, createNullableSchema, asNullable). "stale or imprecise" would be accurate on the squash message.

List<HoodieSchemaField> fields = Stream.concat(
Stream.of(createNewSchemaField(
HoodieRecord.RECORD_KEY_METADATA_FIELD, HoodieSchema.create(HoodieSchemaType.STRING), null, null)),
orderingFieldNames.stream().map(orderingFieldName -> tableSchema.getField(orderingFieldName)

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 move. tableSchema.getField(name) is an exact top-level lookup (HoodieSchema.java:1215), so a nested ordering field such as nested_record.level throws IllegalArgumentException here on the first delete of a v10 native-log table: nested precombine is supported (HUDI-4051) and HoodieNativeLogAppendHandle.java:93 passes the names through verbatim. Out of scope for a behavior-preserving PR; should the fix (writer createDeleteLogFieldValues and reader getValue aligned on a flat field name) go in a separate PR, with a note on #16639 meanwhile?

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 an issue for. this here, will fix it outside of this PR and keep this PR purely for refactoring.
#19823

Comment thread hudi-common/src/main/java/org/apache/hudi/common/avro/AvroSchemaUtils.java Outdated
hasSmallPrecisionDecimalField and isSmallPrecisionDecimalField have
had no production caller since apache#13882 removed canUseRowWriter; only
their own test called them. Delete both and the test, and fold
hasDecimalWithCondition into hasDecimalField now that the predicate
has a single value.
The HoodieSchema signature change dropped the HoodieAvroSchemaCache
intern the Avro-typed version did, so the schema instance reaching
BaseAvroPayload#getRecord was no longer canonical and its identity
fast path could miss for callers that parse their own copy. Intern
through HoodieSchemaCache, which is the same canonical instance the
old path resolved to.
safeAvroToJsonString pointed at avroToJsonString, which is private
now; link the public avroToJson instead. HoodieSchemaTypePromotion
said it was used by the compatibility checkers while the paragraph
above explains why they deliberately do not use it. AvroSchemaUtils
claimed a HoodieSchema twin for every method, which does not hold
for the strict getNonNullTypeFromUnion. getCachedSchema's scaladoc
still named the Avro Schema type and the Avro-keyed cache.
createDeleteLogSchema: every case used one LONG field, so an
implementation keeping only the first field would pass. Cover two
ordering fields in caller order, no ordering fields, and an
already-nullable non-null-first union, whose default is dropped by
HoodieSchemaField.of.

generateProjectionSchema: pin case-insensitive matching, which
HiveHoodieReaderContext relies on.

getRecordColumnValues: the test was a copy of the sort-column test;
use the nested schema and pin a nested column and a missing one.

convertBytesToBigDecimal: cover the null-schema guard.

TestLsmFileGroupRecordIterator: drop the delete-log schema test,
now a subset of TestHoodieSchemaUtils#testCreateDeleteLogSchema.

TestLocalHoodieSchemaCache: restore the two tests deleted with the
rename in apache#17740; the class had no direct test since.
@github-actions github-actions Bot added size:XL PR with lines of changes > 1000 and removed size:L PR with lines of changes in (300, 1000] labels Sep 2, 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.

Two pre-existing bugs surfaced (hasDecimalField union recursion, default-locale lowercasing in generateProjectionSchema); both left for separate PRs to keep this one behavior-preserving, issues linked in the threads.

return hasDecimalField(schema.getValueType());
case UNION:
return hasDecimalWithCondition(schema.getNonNullType(), condition);
return hasDecimalField(schema.getNonNullType());

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 fold: master has the same arm (HoodieSchemaUtils.java:799-800, since #17600). getNonNullType() returns this for a union without a null branch (HoodieSchema.java:1397-1399) and ["null","string","int"] reduces to exactly that shape (line 1413), so this recurses to StackOverflowError. SourceFormatAdapter.java:261 calls it on every JSON source schema; HoodieSchemaRepair.hasTimestampMillisField:249 has the identical arm. Out of scope for a behavior-preserving PR; should the getTypes().stream().anyMatch(...) fix go in a separate PR, with an issue linked here meanwhile?

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 #19825 (with the HoodieSchemaRepair.hasTimestampMillisField twin); fix goes in a separate PR, this one keeps the arm as on master.

assertTrue(fieldNames1.contains("timestamp"));

// Field names are matched case-insensitively; HiveHoodieReaderContext lowercases names before calling this.
HoodieSchema schema2 = HoodieSchemaUtils.generateProjectionSchema(originalSchema, Arrays.asList("_ROW_KEY"));

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.

minor: not blocking, pre-existing. This pins case-insensitive matching, but generateProjectionSchema lowercases with the default locale (HoodieSchemaUtils.java:475,478) while its only production caller uses Locale.ROOT (HiveHoodieReaderContext.java:236); under tr_TR an ID column lowercases to dotless-i on one side and id on the other, and the projection throws "Field id not found in log schema". _ROW_KEY has no I, so this case cannot catch it. Should the Locale.ROOT fix and an I-bearing name (PII_COL) go in a separate PR, with an issue linked here?

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 #19826; fix in a separate PR. Correction to the claim above: generateProjectionSchema has six callers, HiveHoodieReaderContext is the one that pre-lowercases (with Locale.ROOT); the others lowercase both sides through this call and stay consistent.

Comment thread hudi-common/src/main/java/org/apache/hudi/common/avro/HoodieAvroUtils.java Outdated
Comment thread hudi-common/src/main/java/org/apache/hudi/common/avro/HoodieAvroUtils.java Outdated
Comment thread hudi-common/src/test/java/org/apache/hudi/common/avro/TestHoodieAvroUtils.java Outdated
HoodieSchemaCache is value-keyed, so a caller whose schema instance
is not the stored key pays a deep Schema.equals on every call; the
sort comparator calls twice per comparison and col-stats once per
record per column. Master interned through HoodieAvroSchemaCache,
whose weak-key front is an identity lookup, so go back to exactly
that path.

Add a test that fails without the intern: a record held by a
BaseAvroPayload on the interned Avro schema, read with a freshly
parsed equal HoodieSchema, comes back as String only when the
payload's identity fast path hits (a miss re-serializes and yields
Utf8). Drop the nested column the sibling getNestedFieldVal test
already pins on the same fixture.
generateProjectionSchema matched names case-insensitively without
saying so; the reason lived only on the hadoop-mr twin (Hive
lowercases column projections). Port it, note that the projected
field keeps the schema's casing and that fields differing only in
case cannot be projected. The sort-column sibling's @PARAM still
named the Avro Schema type.
createDeleteLogSchema: cover a timestamp-millis and a decimal
ordering field, the branch with the delete path's history (apache#13998,
apache#13163, apache#12006); assert the [long, null] branch order directly
instead of only through the dropped default; drop an assertion the
field-name list already covers.

TestHoodieAvroUtils: the moved convertValueForSpecificDataTypes
tests reuse the class's DATE/TS_MILLIS/TS_MICROS schema constants.

TestJsonKafkaSource: import HoodieAvroUtils instead of the
fully-qualified name left over from the old target.
@voonhous

voonhous commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

@wombatu-kun Can you please help to review this when you are free?

* {@code addHoodieKeyToRecord}, {@code getRecordColumnValues}, {@code createHoodieRecordFromAvro}</li>
* </ul>
*
* <p>Raw {@link Schema} helpers live here only when they serve a record operation on this class (for example

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.

projectSchema is given here as a raw-Schema helper that serves a record operation on this class, but nothing on this class calls it and its only production caller is HoodieSchemaUtils.projectSchema, which the sibling javadoc lists as a delegation being retired. Could it be dropped from the example list, leaving createNewSchemaField and unwrapNullable?

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.

Dropped in 173cef4; the example list is now createNewSchemaField, unwrapNullable.

/**
* Convert a given avro record to a JSON string. If the record contents are invalid, return the record.toString().
* Use this method over {@link HoodieAvroUtils#avroToJsonString} when simply trying to print the record contents without any guarantees around their correctness.
* Use this method over {@link #avroToJson(GenericRecord, boolean)} when simply trying to print the record contents without any guarantees around their correctness.

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.

avroToJson returns byte[] while safeAvroToJsonString returns String, so it is not an alternative a caller would weigh against this method - the sibling it actually wraps is the now-private avroToJsonString. Could the comparison just be dropped, since the preceding sentence already states the fallback behaviour?

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.

Dropped in 173cef4.

* This is also what Conversions.DecimalConversion.toBytes() outputs inside a byte buffer
*/
public static Object convertBytesToFixed(byte[] bytes, Schema schema) {
@VisibleForTesting

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.

convertBytesToBigDecimal(byte[], LogicalTypes.Decimal) is in the same position as convertBytesToFixed - its only caller outside this class is TestMercifulJsonConverter, in this package - but stayed public and unannotated. rewritePrimaryType and the convertToRecord overloads are the same shape with test-only callers in other modules: could all four get the narrowing treatment this PR describes?

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.

Done in 0b0c95e. convertBytesToBigDecimal(byte[], Decimal) and the 4- and 6-arg convertToRecord are package-private with @VisibleForTesting (callers: this class plus TestHoodieAvroUtils / TestMercifulJsonConverter in the same package). rewritePrimaryType and the 7-arg convertToRecord keep public with @VisibleForTesting because their test callers are in other modules (TestHoodieArrayWritableSchemaUtils in hudi-hadoop-mr, TestColStatsRecordWithMetadataRecord in hudi-spark). PR body updated: 13 narrowed, and the "kept on purpose" line now names only the 5-arg createNewSchemaField.

*
* <p>This class provides HoodieSchema equivalents of operations found in AvroSchemaUtils
* and HoodieAvroUtils, focusing on table schema management rather than record-level operations.</p>
* <p>What lives here:</p>

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.

The list omits about a third of the public API - all three createNewSchemaField overloads, getFieldSchema, createHoodieWriteSchema, hasDecimalField, createNewSchemaFromFieldsWithReference and toJavaDefaultValue - and getNestedField is filed under "needs more than HoodieSchema offers on its own" although its own javadoc in this PR calls it a null-checking facade over HoodieSchema#getNestedField. Could the missing ones that fit an existing bullet be added, and getNestedField move to the "questions about a single schema" line under "Not here"?

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.

Reworked in 173cef4: added createHoodieWriteSchema, createNewSchemaFromFieldsWithReference, the createNewSchemaField copy factory (with the two aliases noted), toJavaDefaultValue, getRecordQualifiedName and hasDecimalField; getNestedField and getFieldSchema now sit under "questions about a single schema" as facades over the HoodieSchema instance methods. Still unlisted on purpose: addMetadataColumnTypes (test-only), createSchemaErrorString, and createNullableSchema, which the delegation paragraph already covers.


@Test
public void testBasicCacheUsage() {
LocalHoodieSchemaCache schemaCache = LocalHoodieSchemaCache.create();

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.

Neither test creates two caches, so both still pass if create() returned a shared singleton - the one property the rename and its new javadoc assert. Could one case call create() twice and assert the two id spaces are independent?

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.

Added testCreateReturnsIndependentCaches in 7eb2cbf: two caches, the first cache's id misses in the second, and the same id resolves to a different schema in each.

convertBytesToBigDecimal(byte[], Decimal) and the 4- and 6-arg
convertToRecord overloads are called only from this class and from
tests in the same package, so they become package-private with
@VisibleForTesting, like convertBytesToFixed. rewritePrimaryType and
the 7-arg convertToRecord keep public because their test callers are
in other modules (hudi-hadoop-mr, hudi-spark); the annotation records
that this is the only reason.
HoodieSchemaUtils: list the public API the class javadoc skipped
(createHoodieWriteSchema, createNewSchemaFromFieldsWithReference, the
createNewSchemaField copy factory, toJavaDefaultValue,
getRecordQualifiedName, hasDecimalField) and file getFieldSchema and
getNestedField as facades over HoodieSchema instance methods instead
of as lookups of their own.

HoodieAvroUtils: projectSchema serves no record operation on the
class, so it leaves the raw-Schema example list; the
safeAvroToJsonString javadoc compared against avroToJson, which
returns bytes, so the comparison goes.
The two existing cases use one cache each, so a shared singleton
would still pass them. Create two caches, check the first cache's id
misses in the second and that the same id resolves to a different
schema in each.
getRecordColumnValues built a new Properties and an ArrayList on every
call while its sibling getSortColumnValuesWithPartitionPathAndRecordKey
reuses the static PROPERTIES and a fixed array. The method runs once
per comparison in RDDBucketIndexPartitioner's sort comparator and once
per record per column in collectColumnRangeMetadata, so the two
allocations were paid N log N times per partition. Pre-existing, on
the line this PR already rewrote.

Also names the two callers the intern defends instead of the general
"sort path" claim, and rewords the second stale "cached Avro schema
reference" comment in HoodieMetadataPayload#getInsertValue.
testGetRecordColumnValues asserted null for a nested column on an empty
nested record, which a broken traversal would also return; it now reads
a real nested value and covers a null and a non-record intermediate.

HoodieMetadataPayload#getInsertValue's reference-equality fast path had
no discriminating test: the class schema singleton must return the
generated HoodieMetadataRecord, while an equal but distinct schema
instance rebuilds a GenericData.Record.
@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

@voonhous
voonhous merged commit 93f1f71 into apache:master Sep 3, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL PR with lines of changes > 1000

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor HoodieAvroUtils with remote unused method

5 participants