Skip to content

feat(spark): support standard writes and compaction for LSM tables - #19576

Open
cshuo wants to merge 1 commit into
apache:masterfrom
cshuo:spark-write-operation-lsm-e2e
Open

feat(spark): support standard writes and compaction for LSM tables#19576
cshuo wants to merge 1 commit into
apache:masterfrom
cshuo:spark-write-operation-lsm-e2e

Conversation

@cshuo

@cshuo cshuo commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Describe the issue this Pull Request addresses

Closes #19437.
Closes #19438.

Spark LSM tables require every base file and native log block to be ordered by partition path and record key using UTF-8 byte ordering. Standard write operations and compaction needed end-to-end coverage for that invariant, while bulk insert could still select partitioners that either used Java string ordering or did not guarantee ordering at all. The HoodieRecord and Dataset Row paths also needed consistent, table-aware partitioner selection so an LSM table could not bypass the required layout.

Summary and Changelog

  • Cover INSERT, INSERT_PREPPED, UPSERT, UPSERT_PREPPED, DELETE, DELETE_PREPPED, INSERT_OVERWRITE, INSERT_OVERWRITE_TABLE, and DELETE_PARTITION through the Spark RDD client for both COW and MOR LSM tables, including commit and replace-commit metadata validation.
  • Add DataFrame end-to-end coverage that verifies snapshot results and the physical UTF-8 record-key order of base files after standard writes, and verifies sorted output from MOR compaction and log compaction.
  • Add LSM-specific HoodieRecord bulk-insert partitioners for GLOBAL_SORT, PARTITION_SORT, and PARTITION_PATH_REPARTITION_AND_SORT. Global and repartition-and-sort modes use Spark sorting with a serializable UTF-8 comparator; partition sort preserves the existing coalesce plus in-memory per-partition sorting model while using the LSM ordering.
  • Reuse the existing Dataset Row global-sort and partition-sort implementations, and add an LSM row implementation for partition-path repartition-and-sort that orders by the metadata partition path and record key columns.
  • Make the public Spark bulk-insert partitioner factory entry points table-aware, so all supported call paths select LSM partitioners consistently. This does not add LSM clustering support.
  • Default LSM bulk insert to PARTITION_SORT when no sort mode is configured. Explicit NONE and PARTITION_PATH_REPARTITION modes, custom partitioners whose ordering cannot be verified, and the direct Dataset Row path without meta fields fail with clear errors; custom partitioners are rejected before the instant transitions to inflight.
  • Add DataFrame and Spark SQL coverage for HoodieRecord and Row writer bulk inserts, partition/table overwrite, snapshot reads, LSM table layout, and UTF-8 physical file ordering, including non-ASCII keys that distinguish UTF-8 ordering from Java UTF-16 ordering.

Impact

LSM Spark writes now consistently preserve the physical ordering required by the table layout across standard writes, overwrite operations, bulk insert, compaction, and log compaction. HoodieWriteConfig adds isLSMTreeStorageLayout(), and an LSM table whose bulk-insert sort mode is unset now defaults to PARTITION_SORT; an explicitly configured mode is still honored and rejected if it cannot guarantee ordering.

The sorting cost depends on the selected mode: global sort and partition-path repartition-and-sort introduce their expected shuffle, while partition sort coalesces and materializes each Spark partition for local sorting, with memory usage proportional to the largest resulting partition. Non-LSM partitioner behavior is unchanged.

Risk Level

Medium. The change touches Spark write partitioner selection and ordering for both HoodieRecord and Dataset Row paths. The risk is mitigated by unit coverage for factory selection, supported and rejected sort modes, meta-field validation, and UTF-8 ordering, plus end-to-end COW/MOR coverage for snapshots and physical LSM file layout. Targeted local runs completed for TestLSMBulkInsertPartitioner (13 tests), TestLSMDataSource, and TestInsertWithLSMLayout (3 tests); git diff --check also passes.

Documentation Update

None. LSM storage layout support is still under development, this change introduces no new configuration key, and the safe default is derived from the existing bulk-insert sort-mode configuration.

Contributor's checklist

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

@codecov-commenter

codecov-commenter commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.55556% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.50%. Comparing base (4853b57) to head (82b59e8).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
...LSMPartitionPathRepartitionAndSortPartitioner.java 86.66% 1 Missing and 1 partial ⚠️
...mit/BaseDatasetBulkInsertCommitActionExecutor.java 75.00% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19576      +/-   ##
============================================
+ Coverage     77.09%   77.50%   +0.41%     
- Complexity    32490    32817     +327     
============================================
  Files          2522     2527       +5     
  Lines        139112   139196      +84     
  Branches      16714    16723       +9     
============================================
+ Hits         107243   107889     +646     
+ Misses        24291    23740     -551     
+ Partials       7578     7567      -11     
Components Coverage Δ
hudi-common 83.26% <ø> (+0.01%) ⬆️
hudi-client 82.77% <97.56%> (+0.04%) ⬆️
hudi-flink 85.75% <ø> (+0.39%) ⬆️
hudi-spark-datasource 70.59% <75.00%> (-0.01%) ⬇️
hudi-utilities 73.67% <ø> (+0.04%) ⬆️
hudi-cli 15.32% <ø> (ø)
hudi-hadoop 68.94% <ø> (+5.44%) ⬆️
hudi-sync 75.14% <ø> (+0.02%) ⬆️
hudi-io 79.46% <ø> (ø)
hudi-timeline-service 83.44% <ø> (ø)
hudi-cloud 64.06% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 50.82% <7.77%> (+0.08%) ⬆️
flink-integration-tests 49.17% <100.00%> (+0.08%) ⬆️
hadoop-mr-java-client 43.72% <25.00%> (-0.01%) ⬇️
integration-tests 13.56% <4.44%> (-0.01%) ⬇️
spark-client-hadoop-common 50.33% <4.87%> (+0.71%) ⬆️
spark-java-tests 51.65% <95.55%> (+0.04%) ⬆️
spark-scala-tests 45.97% <40.00%> (-0.01%) ⬇️
utilities 36.56% <13.33%> (-0.02%) ⬇️

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

Files with missing lines Coverage Δ
...java/org/apache/hudi/config/HoodieWriteConfig.java 92.05% <100.00%> (+0.02%) ⬆️
...main/java/org/apache/hudi/io/BaseCreateHandle.java 88.29% <ø> (ø)
...un/strategy/MultipleSparkJobExecutionStrategy.java 96.11% <100.00%> (+0.03%) ⬆️
...lkinsert/BulkInsertInternalPartitionerFactory.java 83.33% <100.00%> (+8.33%) ⬆️
.../BulkInsertInternalPartitionerWithRowsFactory.java 83.33% <100.00%> (+13.33%) ⬆️
...xecution/bulkinsert/LSMBulkInsertRecordSorter.java 100.00% <100.00%> (ø)
...execution/bulkinsert/LSMGlobalSortPartitioner.java 100.00% <100.00%> (ø)
...tionPathRepartitionAndSortPartitionerWithRows.java 100.00% <100.00%> (ø)
...cution/bulkinsert/LSMPartitionSortPartitioner.java 100.00% <100.00%> (ø)
...e/action/commit/BaseSparkCommitActionExecutor.java 88.10% <ø> (ø)
... and 3 more

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

@cshuo
cshuo force-pushed the spark-write-operation-lsm-e2e branch from 8eb64dc to 0a965dc Compare August 11, 2026 02:15
@github-actions github-actions Bot added the size:XL PR with lines of changes > 1000 label Aug 11, 2026
@cshuo
cshuo force-pushed the spark-write-operation-lsm-e2e branch from 0a965dc to 82b59e8 Compare August 11, 2026 04:16
@hudi-bot

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

@cshuo
cshuo marked this pull request as ready for review August 11, 2026 08:34

@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 extends LSM-tree table support on Spark to standard writes and compaction, adds table-aware LSM bulk-insert partitioners (HoodieRecord and Row paths) with UTF-8 record-key ordering, and defaults LSM bulk insert to PARTITION_SORT. The partitioner ordering and the RDD/Row consistency look solid on close reading. A couple of cross-engine semantics questions are worth double-checking in the inline comments — particularly whether INSERT should be allowed on LSM at all. Please take a look at any inline comments, and this should be ready for a Hudi committer or PMC member to take it from here. A few small naming and readability nits below.

HoodieWriteMetadata result = new HoodieWriteMetadata();

boolean isLsmTable = table.getMetaClient().getTableConfig().isLSMTreeStorageLayout();
checkArgument(!isLsmTable || userDefinedBulkInsertPartitioner.isEmpty(),

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 guards the partitioner choice for LSM, but not duplicate record keys. Flink's HoodieTableFactory.checkStorageLayout hard-rejects INSERT on LSM "because they allow duplicate record keys", whereas this PR enables INSERT/bulk_insert on Spark LSM with no equivalent guard. The updated TestMORDataSource shows dup keys written via INSERT survive only until the first merge, then silently collapse to one row. Is allowing INSERT here intentional, and if so how do we avoid that silent data loss? @danny0405

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

return new RDDSimpleBucketBulkInsertPartitioner(table);
}
}
if (table.getMetaClient().getTableConfig().isLSMTreeStorageLayout()) {

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 BUCKET-index branch above returns before this LSM check, so an LSM table configured with hoodie.index.type=BUCKET would get the bucket partitioner and skip the LSM UTF-8 ordering entirely. Is that combination prevented at table creation, or could it silently produce base files that aren't record-key sorted and break the LSM reader? Same ordering applies in the WithRows factory.

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

}
return super.repartitionRecords(rows, outputSparkPartitions)
.sortWithinPartitions(functions.col(HoodieRecord.PARTITION_PATH_METADATA_FIELD), functions.col(HoodieRecord.RECORD_KEY_METADATA_FIELD));
}

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: this line is quite long — could you break the two functions.col(...) arguments onto separate lines to keep it within the usual column limit?

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

String expectedPartition) throws IOException {
HoodieInstant instant = findCompletedInstant(metaClient, instantTime);
assertEquals(HoodieTimeline.REPLACE_COMMIT_ACTION, instant.getAction());
HoodieReplaceCommitMetadata commitMetadata = metaClient.getActiveTimeline().readReplaceCommitMetadata(instant);

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 inner class name LsmTableTestContext uses mixed-case "Lsm" while everywhere else in this PR — and in the outer class name — the acronym is all-caps "LSM". Could you rename it to LSMTableTestContext for consistency?

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

HoodieWriteConfig config = createWriteConfig(sortMode, true);
assertEquals(expectedPartitionerClass,
BulkInsertInternalPartitionerFactory.get(lsmTable, config).getClass());
assertEquals(expectedPartitionerClass,

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: getRowPartitioner and getHoodieRecordPartitioner look unused — assertRowPartitionerSelection and assertHoodieRecordPartitionerSelection call the factories directly. Could you remove them to avoid dead code?

⚠️ 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:XL PR with lines of changes > 1000

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Spark][LSM] Support bulk insert and Row writer overwrite paths [Spark][LSM] Support standard write operations and compaction

4 participants