Skip to content

feat(spark): enable format-aware sort ordering and LSM reading for Spark - #19502

Merged
voonhous merged 3 commits into
apache:masterfrom
cshuo:spark-lsm-reader-ordering
Aug 7, 2026
Merged

feat(spark): enable format-aware sort ordering and LSM reading for Spark#19502
voonhous merged 3 commits into
apache:masterfrom
cshuo:spark-lsm-reader-ordering

Conversation

@cshuo

@cshuo cshuo commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Describe the issue this Pull Request addresses

Closes #19436.

Spark LSM data-table reads need to use the LSM file-group reader and the same record-key ordering as the physical base-file format. HFile therefore uses unsigned UTF-8 byte ordering to match its physical key order, while Parquet and ORC retain Java String.compareTo (UTF-16 code-unit) ordering. Applying UTF-8 ordering to every format would add unnecessary comparison overhead, especially for keys with long common prefixes. See #19436 for the detailed analysis and benchmark results.

Summary and Changelog

  • Select HoodieLsmFileGroupReader for eligible Spark snapshot/MOR reads of LSM data tables.
  • Preserve metadata-table behavior and the existing fallback for skip-merge reads or unsupported log-file types.
  • Add a base-file-format-aware record-key comparator:
    • HFile: unsigned UTF-8 byte ordering.
    • Parquet/ORC: Java String.compareTo ordering.
  • Apply the comparator consistently to the LSM loser tree, sorted file-group record buffer, create handles, and sorted/LSM merge handles.
  • Preserve the base-file-only fast path so physical duplicate keys are returned directly; file slices entering the merge path continue to merge equal keys as one logical record.
  • Propagate the configured table storage layout during Spark SQL table initialization.
  • Add coverage using U+E000 and U+20000 to distinguish UTF-8 and UTF-16 ordering.

Impact

Spark can read eligible LSM-layout data tables end to end. Reader and writer ordering now agree with each base-file format without imposing UTF-8 comparison overhead on Parquet and ORC sorting. No new public API or user-facing configuration is introduced.

Risk Level

Medium. This changes Spark MOR reader selection for LSM-layout data tables and comparator selection on sorted-run paths. The change retains existing fallbacks and is covered by targeted common reader/ordering tests, Spark datasource E2E coverage, Spark client compilation, and Spark 4 packaging verification.

Documentation Update

None. No configuration or public API is added or changed.

Contributor's checklist

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

@cshuo cshuo changed the title [Spark][LSM] Enable format-aware LSM reading and sorted-run ordering feat(spark): enable format-aware sort ordering and LSM reading for Spark Aug 4, 2026
@github-actions github-actions Bot added the size:L PR with lines of changes in (300, 1000] label Aug 4, 2026
@cshuo
cshuo force-pushed the spark-lsm-reader-ordering branch 2 times, most recently from 94ce9af to 6942f82 Compare August 4, 2026 04:05
@cshuo
cshuo marked this pull request as ready for review August 4, 2026 07:15

@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! The PR wires up format-aware record-key ordering (UTF-8 for HFile, Java String/UTF-16 for Parquet/ORC) across the write and LSM read paths and enables the LSM file-group reader for eligible Spark snapshot/MOR reads of LSM data tables. I traced the writer/reader ordering consistency, comparator serializability, stream reuse, and reader selection gating, and the change is internally consistent. One non-blocking compatibility question is worth double-checking in the inline comment. Please take a look, and this should be ready for a Hudi committer or PMC member to take it from here. A couple of readability nits below, mostly around duplicated builder calls.

Comparator<Tuple2<HoodieKey, Option<HoodieRecordLocation>>> comparator = (Comparator<Tuple2<HoodieKey, Option<HoodieRecordLocation>>> & Serializable) (t1, t2) -> {
HoodieKey key1 = t1._1;
HoodieKey key2 = t2._1;
return StringUtils.compareUtf8Bytes(key1.getRecordKey(), key2.getRecordKey());

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.

For Hudi’s new LSM layout, use unsigned lexicographic UTF-8 byte order as the table-level record-key ordering contract.

This is the stronger choice than Java String.compareTo() because:

The current LSM implementation is inconsistent. These two paths still use Java UTF-16 ordering:

They should use:

Comparator.comparing(
    HoodieRecord::getRecordKey,
    StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR)

and:

int keyCompare = StringUtils.compareUtf8Bytes(
    left.current.getRecordKey(),
    right.current.getRecordKey());

I would define the invariant as:

Every LSM run is strictly ordered by the unsigned UTF-8 representation of _hoodie_record_key; all writers, range partitioners, merge readers, compaction paths, and key-range metadata must use that same ordering.

Also audit the Spark bulk-insert partitioners: several still use String.compareTo(), including the bucket-index path. For composite routing, compare (partition path/file group, record key) as a tuple, applying UTF-8 comparison to each string component—don’t sort a concatenated string.

Add an end-to-end test using keys whose orders differ:

U+E000
U+20000

Expected UTF-8/Parquet order:

U+E000 < U+20000

That test should cover writing separate L0 runs, LSM k-way merge, compaction into L1, and reading the compacted result.

@cshuo cshuo Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks for the detailed review. Two points:

  1. Using UTF-8 consistently would be the ideal ordering contract, especially for cross-engine compatibility. The current format-aware design is a performance trade-off: HFile requires UTF-8 byte ordering, while UTF-8 comparison of Java String record keys is slower than String.compareTo, particularly for long common prefixes. Details and benchmarks are in [Spark][LSM] Enable format-aware LSM reading and sorted-run ordering #19436. If this sorting regression is acceptable, using UTF-8 ordering consistently would be an optimal option.

  2. Bulk insert and other LSM write paths are follow-up work. They are tracked by [Spark][LSM] Support standard write operations and compaction #19437, [Spark][LSM] Support bulk insert and Row writer overwrite paths #19438, [Spark][LSM] Support bucket index write paths #19439, and [Spark][LSM] Support clustering and bucket rescale #19440. The relevant partitioners and ordering requirements will be handled in those PRs.

@cshuo
cshuo force-pushed the spark-lsm-reader-ordering branch from 6942f82 to 9988740 Compare August 5, 2026 08:59

@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 routes eligible Spark LSM data-table reads through HoodieLsmFileGroupReader, aligns the LSM loser tree and merge handle record-key ordering to unsigned UTF-8 byte order, extracts shouldUseLsmReader into a shared LsmReaderUtils, and propagates the table storage layout on Spark SQL table init. I traced the write and read paths and reader-selection edge cases (skip-merge fallback, metadata table, base-file-only duplicate preservation, incremental filtering, non-splittable MOR); the ordering is consistent end-to-end (UTF-8) and effectively fixes a prior read/write comparator mismatch. 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 Hudi committer or PMC member can take it from here for a final review.
. A couple of readability nits around duplicated builder blocks in the Spark reader selection paths.

cc @yihua

.withStart(file.start)
.withLength(baseFileLength)
.build()
} else {

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 two builder branches share ~12 identical .withX() calls and only differ in .withShouldUseRecordPosition(...). Could you extract the shared setup into a local helper or a partially-constructed builder, so a future author adding a new withX() option doesn't have to remember to update both branches?

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

.withInternalSchemaOpt(HOption.ofNullable(tableSchema.internalSchema.orNull))
.build()
} else {
HoodieFileGroupReader.builder[InternalRow]()

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: same pattern as HoodieFileGroupReaderBasedFileFormat — both reader branches duplicate all builder options. It might be worth consolidating the shared lines to make the if/else diff obvious at a glance.

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

@cshuo
cshuo force-pushed the spark-lsm-reader-ordering branch from 9988740 to f12ca2c Compare August 6, 2026 01:46

@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 wires Spark snapshot/MOR reads onto the LSM file-group reader for LSM-layout data tables and consolidates record-key ordering on the UTF-8 byte comparator across the write and read paths, while preserving metadata-table behavior and the skip-merge fallback. I traced the write/read ordering contract end to end on the PR branch — because requireSortedRecords() is true for LSM layout, the write path sorts LSM base/log runs with the same UTF-8 comparator the reader's loser tree and sorted buffer now use, so the ordering is internally consistent. Stream reuse (fresh getLogFiles() streams), null-safe mergeType handling, skip-merge routing via props, and preserved MDT behavior all check out. 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 maintainability nit on the duplicated builder chains; everything else looks clean.

cc @yihua

.withLength(baseFileLength)
.withShouldUseRecordPosition(shouldUseRecordPosition)
.build()
val reader: HoodieRecordReader[InternalRow] =

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 two branches of this if/else share ~10 identical .withX() builder calls and diverge only on reader type and withShouldUseRecordPosition. Could you extract the shared builder setup into a small helper (e.g. baseReaderBuilder(readerContext, ...)) so a future option added to one branch isn't silently missed in the other? Same pattern appears in HoodieMergeOnReadRDDV2.scala.

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

@codecov-commenter

codecov-commenter commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 38.70968% with 38 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.99%. Comparing base (4a9cef8) to head (f7fc4a8).
⚠️ Report is 8 commits behind head on master.

Files with missing lines Patch % Lines
...scala/org/apache/hudi/HoodieMergeOnReadRDDV2.scala 0.00% 23 Missing ⚠️
...parquet/HoodieFileGroupReaderBasedFileFormat.scala 51.72% 13 Missing and 1 partial ⚠️
...n/scala/org/apache/hudi/HoodieSparkSqlWriter.scala 50.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19502      +/-   ##
============================================
- Coverage     76.40%   73.99%   -2.42%     
+ Complexity    32398    31152    -1246     
============================================
  Files          2520     2521       +1     
  Lines        138985   139077      +92     
  Branches      16695    16750      +55     
============================================
- Hits         106189   102906    -3283     
- Misses        25166    28419    +3253     
- Partials       7630     7752     +122     
Components Coverage Δ
hudi-common 81.74% <100.00%> (-0.61%) ⬇️
hudi-client 77.46% <ø> (-4.53%) ⬇️
hudi-flink 83.97% <100.00%> (+<0.01%) ⬆️
hudi-spark-datasource 63.99% <29.62%> (-6.63%) ⬇️
hudi-utilities 73.67% <ø> (ø)
hudi-cli 15.32% <ø> (ø)
hudi-hadoop 60.44% <ø> (-3.06%) ⬇️
hudi-sync 70.97% <ø> (-0.03%) ⬇️
hudi-io 79.31% <ø> (-0.15%) ⬇️
hudi-timeline-service 77.57% <ø> (-5.88%) ⬇️
hudi-cloud 64.06% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 49.83% <12.90%> (+0.26%) ⬆️
flink-integration-tests 48.77% <100.00%> (-0.03%) ⬇️
hadoop-mr-java-client 43.74% <28.57%> (-0.03%) ⬇️
integration-tests 13.57% <3.22%> (-0.01%) ⬇️
spark-client-hadoop-common 49.62% <28.57%> (-0.01%) ⬇️
spark-java-tests 32.63% <29.50%> (-18.62%) ⬇️
spark-scala-tests 45.97% <29.50%> (-0.04%) ⬇️
utilities 36.58% <29.50%> (-0.02%) ⬇️

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

Files with missing lines Coverage Δ
...he/hudi/io/LsmFileGroupReaderBasedMergeHandle.java 90.47% <ø> (+11.30%) ⬆️
...mon/table/read/lsm/LsmFileGroupRecordIterator.java 94.97% <100.00%> (+0.02%) ⬆️
...che/hudi/common/table/read/lsm/LsmReaderUtils.java 100.00% <100.00%> (ø)
...pache/hudi/metadata/HoodieBackedTableMetadata.java 94.07% <100.00%> (ø)
...java/org/apache/hudi/table/format/FormatUtils.java 82.89% <100.00%> (-0.86%) ⬇️
...n/scala/org/apache/hudi/HoodieSparkSqlWriter.scala 59.64% <50.00%> (-18.74%) ⬇️
...parquet/HoodieFileGroupReaderBasedFileFormat.scala 73.66% <51.72%> (-7.72%) ⬇️
...scala/org/apache/hudi/HoodieMergeOnReadRDDV2.scala 66.00% <0.00%> (-5.74%) ⬇️

... and 358 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 continued work on this! This PR wires up LSM file-group-reader selection for Spark snapshot/MOR reads and aligns the read-path record-key ordering (UTF-8 byte order) with the write path. I traced the ordering invariant end to end — requireSortedRecords() returns true for any LSM-layout table, so base and log sorted runs are all written in UTF-8 order, and the loser-tree/record-buffer read paths now compare with compareUtf8Bytes to match. Reader selection, merge-type gating (including skip-merge routing through the RDD path), stream consumption, and the storage-layout propagation all check out. 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 builder-duplication suggestions in the Scala reader-selection paths; otherwise clean.

cc @yihua

@hudi-bot

hudi-bot commented Aug 7, 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 49fade8 into apache:master Aug 7, 2026
23 checks passed
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.

[Spark][LSM] Enable format-aware LSM reading and sorted-run ordering

6 participants