Skip to content

fix(utilities): count error-table records before committing the error table - #19940

Open
yihua wants to merge 3 commits into
apache:masterfrom
yihua:fix-error-table-status-recompute
Open

yihua wants to merge 3 commits into
apache:masterfrom
yihua:fix-error-table-status-recompute

Conversation

@yihua

@yihua yihua commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Describe the issue this Pull Request addresses

With hoodie.errortable.write.unification.enabled=true, StreamSync commits the error table and afterwards sums the error-table write statuses for the record counts. That ordering duplicates every error record.

The error-table write-status RDD is kept alive only by a Spark cache. The bulk insert persists it in BaseSparkCommitActionExecutor.updateIndex under HoodieDataCacheKey.of(basePath, instantTime), and the error table's own writeClient.commit releases it through releaseResources -> SparkReleaseResources.releaseCachedData. A real error-table writer typically also drops its cache of the upstream error events inside commit. So by the time the count runs, nothing backs the RDD, the aggregate re-evaluates its lineage, and the executors run the bulk insert a second time under an instant that has already completed. The re-run allocates fresh file ids, so the duplicates land in new file groups stamped with that instant and a snapshot read returns two rows per error record.

The counts themselves stay correct, which is why this is invisible from the outside: the recomputed statuses still describe one write's worth of records.

This arrived with #18750. That PR's first cut counted before committing the error table. A later revision hoisted the error-table commit ahead of the validators so error records would survive a validator abort, and the count moved behind it as a consequence. In the same round the data-table RDD was given a cache and a driver collect while the error-table RDD was left as a lazy aggregate, so the asymmetry went unnoticed.

Summary and Changelog

  • StreamSync.writeToSinkAndDoMetaSync counts records before committing the error table, restoring the ordering the pre-[RFC] Migrate HoodieStreamerWriteStatusValidator into the pre-commit validator framework #18750 HoodieStreamerWriteStatusValidator used. The error-table commit still runs ahead of the validators, so the property the reorder was introduced for is preserved. Steps renumbered accordingly.
  • ErrorTableCommitter and SuccessfulRecordCounter are unchanged. The error-table statuses are still summed on the executors with a single aggregate; nothing is materialized on the driver.
  • Tests: RddBackedErrorTableWriter is an error-table writer backed by a real SparkRDDWriteClient over a real Hudi table, whose commit releases the write statuses it is given and the upstream error events. The only BaseErrorTableWriter implementation in the tree is a stub that stores the RDD and releases nothing, so no existing test could observe this. TestErrorTableWriteOnce asserts the record keys the error table actually holds: one case on the fixed ordering, one pinning the previous ordering that wrote every record twice.

Ordering, for review

The whole change is the position of the counting block. ErrorTableCommitter and SuccessfulRecordCounter are byte-identical to the base branch.

Before, in writeToSinkAndDoMetaSync:

    writeStatusRDD.collect()                 data-table write runs
1   ErrorTableCommitter.commit(...)          error table commits, its write-status cache is released
2   runValidators(...)
3   SuccessfulRecordCounter.compute(...)     error-table statuses read HERE, after the release  <-- bug
4   write-error gate
5   writeClient.commit(...)

After:

    writeStatusRDD.collect()                 data-table write runs
1   SuccessfulRecordCounter.compute(...)     error-table statuses read while still cached
2   ErrorTableCommitter.commit(...)          error table commits, cache released, nothing reads it again
3   runValidators(...)
4   write-error gate
5   writeClient.commit(...)

This restores the order HoodieStreamerWriteStatusValidator used before #18750, where the same two operations ran inside the writeClient.commit callback rather than ahead of it. On release-1.2.0 that is StreamSync.HoodieStreamerWriteStatusValidator.validate: the error-table sum, then errorTableWriter.commit(...), then the write-error gate, and only then does SparkRDDWriteClient.commit proceed to commitStats.

before #18750 this PR
error-table statuses summed with one distributed aggregate yes yes
summed on executors, not collected to the driver yes yes
sum happens before the error-table commit yes yes
error-table commit before the write-error gate yes yes
data-table commit last yes yes

Step 2 stays ahead of the validators, so the property #18750 introduced the reorder for (error records survive a validator-driven abort) is unchanged.

Two things that are deliberately not restored here, both introduced by #18750 and out of scope: the sequence runs before writeClient.commit rather than inside its callback, so failures roll the inflight instant back explicitly; and the data-table statuses are collected in StreamSync as full WriteStatus objects rather than inside the write client as SlimWriteStats.

The error table is a separate table with its own write client, instant and HoodieDataCacheKey, so its commit releases only its own write statuses. The data-table RDD is unaffected either way, which is why only error-table rows were ever duplicated.

Impact

Error-table writes land once under write unification. No config, API or public behaviour change.

Risk Level

low. The change is a move of the counting block within one method; no helper signatures change. Red-then-green verified: with the count after the commit the new test fails with each of the 20 expected record keys appearing twice, and passes with the count before it.

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

…able commit

With error table write unification enabled, the streamer committed the error
table and afterwards summed the error-table write-status RDD for the record
counts. The commit releases that RDD, so the sum recomputed the bulk insert
and wrote every error record a second time under the committed instant.
@codecov-commenter

codecov-commenter commented Sep 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.31%. Comparing base (1f5a4fe) to head (9a5bd3d).
⚠️ Report is 3 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19940      +/-   ##
============================================
+ Coverage     80.23%   80.31%   +0.07%     
+ Complexity    34747    34743       -4     
============================================
  Files          2546     2546              
  Lines        142608   142515      -93     
  Branches      17362    17356       -6     
============================================
+ Hits         114419   114455      +36     
+ Misses        20277    20159     -118     
+ Partials       7912     7901      -11     
Components Coverage Δ
hudi-common 83.87% <ø> (+0.02%) ⬆️
hudi-client 83.31% <ø> (-0.03%) ⬇️
hudi-flink 85.56% <ø> (-0.01%) ⬇️
hudi-spark-datasource 73.77% <ø> (+0.51%) ⬆️
hudi-utilities 78.18% <100.00%> (-0.02%) ⬇️
hudi-cli 69.99% <ø> (ø)
hudi-hadoop 70.90% <ø> (+0.01%) ⬆️
hudi-sync 76.02% <ø> (ø)
hudi-io 81.61% <ø> (ø)
hudi-timeline-service 83.34% <ø> (ø)
hudi-cloud 80.99% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (-0.77%) ⬇️
Flag Coverage Δ
common-and-other-modules 52.06% <71.42%> (+0.02%) ⬆️
flink-integration-tests 49.06% <ø> (+0.01%) ⬆️
hadoop-mr-java-client 43.89% <ø> (-0.01%) ⬇️
integration-tests 13.46% <0.00%> (+<0.01%) ⬆️
spark-client-hadoop-common 38.55% <0.00%> (+0.01%) ⬆️
spark-java-tests 52.27% <0.00%> (+0.05%) ⬆️
spark-scala-tests 46.91% <0.00%> (+<0.01%) ⬆️
utilities 36.83% <100.00%> (+0.02%) ⬆️

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

Files with missing lines Coverage Δ
...org/apache/hudi/utilities/streamer/StreamSync.java 77.77% <100.00%> (ø)

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

…e regression test

The only error-table writer in the tree is a stub whose commit stores the
write-status RDD and releases nothing, so no test could observe the
recompute this PR fixes. RddBackedErrorTableWriter drives a real
SparkRDDWriteClient over a real Hudi table and releases both caches on
commit the way a production error-table writer does.

TestErrorTableWriteOnce asserts the record keys the error table holds, one
test on the committer's ordering and one pinning the previous ordering that
wrote every record twice.
@yihua
yihua marked this pull request as ready for review September 14, 2026 17:11

@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 materializes the error-table write statuses on the driver before the error-table commit releases the write-status RDD, so the later record count no longer re-evaluates the bulk-insert lineage and lands every error record a second time under the completed instant. I traced the persist/release mechanics through the write client and the fix ordering holds; one small question inline about the storage level of the fallback cache. 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 minor javadoc clarity nits; the core refactor (collect-then-commit, RDD→List propagation) is clean and well-tested.

cc @yihua

JavaRDD<WriteStatus> writeStatusRDD = errorTableWriteStatusRDDOpt.get();
boolean cacheHere = writeStatusRDD.getStorageLevel().equals(StorageLevel.NONE());
if (cacheHere) {
writeStatusRDD.cache();

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.

🤖 cache() is MEMORY_ONLY, so a partition that doesn't fit (or gets evicted) is silently dropped and recomputed by the commit's evaluation — which is exactly the double write this PR is guarding against. Would persist(StorageLevel.MEMORY_AND_DISK()) (or the writer's hoodie.write.status.storage.level, default MEMORY_AND_DISK_SER) be a safer net here? I realize this mirrors the data-table cache() in StreamSync, so happy to leave it if you'd rather keep them consistent.

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

*
* @param dataTableWriteStatuses Pre-collected data-table write statuses. Must not be null.
* @param errorTableWriteStatusRDDOpt Optional error-table write status RDD; only consulted
* @param errorTableWriteStatuses Error-table write statuses collected before the error table

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 rewrapped javadoc now reads as one run-on sentence ("...or empty when none were written when unification is enabled") which is ambiguous about what depends on unification being enabled. Might be worth splitting back into two sentences like the original.

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

… table

Replaces the collect-based approach with the ordering the pre-apache#18750
validator used: sum the error-table write statuses on the executors while
the error table's own write still has them cached, then commit. Reverts
ErrorTableCommitter and SuccessfulRecordCounter to their previous shape.
@yihua yihua changed the title fix(utilities): collect error-table write statuses before the error table commit fix(utilities): count error-table records before committing the error table Sep 14, 2026
@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

@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 addressing the feedback! The PR now takes a simpler route: the record count moves ahead of the error-table commit and sums the write-status RDD directly via aggregate, so the count materializes the writer's cache and the commit reuses it, instead of collecting statuses on the driver through a separate collectAndCommit helper. Both of my prior comments are resolved — the javadoc for the error-table parameter is clear again, and the cache() storage-level question is moot since that fallback was removed along with collectAndCommit. That removal does raise one follow-on question inline: the new ordering relies on the pluggable writer's upsert returning a persisted RDD, which the BaseErrorTableWriter contract doesn't state, so a custom writer returning an unpersisted or derived RDD would still see the write evaluated twice. 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.

// statuses are held only by the cache that commit releases, so reading them afterwards
// re-runs the write and lands every error record twice.
SuccessfulRecordCounter.Counts counts = SuccessfulRecordCounter.compute(
writeStatuses, errorTableWriteStatusRDDOpt, isErrorTableWriteUnificationEnabled);

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 now relies on the writer's upsert handing back an RDD that is already persisted (the bulk insert's instant cache), but BaseErrorTableWriter#upsert is a pluggable EVOLVING API with no such contract. If a custom writer returns an unpersisted or derived RDD (e.g. a map over the bulk-insert result), aggregate evaluates the write once here and writeClient.commit evaluates it again — the same double-write this PR fixes. The previous revision's cacheHere fallback covered that; was dropping it intentional, or would a StorageLevel.NONE() check + persist around the count (like the data-table block just above) be worth keeping?

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

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

Reviewed the current diff and traced write-status persistence/release through the writer and commit path. The count-before-commit ordering addresses the reported recomputation for the persisted write-status RDD. One regression-test gap is noted inline. Validation: source inspection and current CI results; no local test execution.

Comment on lines +65 to +68
SuccessfulRecordCounter.Counts counts = SuccessfulRecordCounter.compute(
new ArrayList<>(), Option.of(writeStatusRDD), true);
assertTrue(ErrorTableCommitter.commit(writer, Option.of(writeStatusRDD), true,
BASE_TABLE_INSTANT, Option.empty()), "error table commit should succeed");

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.

[P2] Exercise the production ordering in the regression test

This test calls SuccessfulRecordCounter.compute and ErrorTableCommitter.commit directly in the desired order, but never invokes StreamSync. Both helpers are unchanged from the base branch, so reverting the entire production change in StreamSync would leave both new tests passing, including the test that explicitly expects duplicates. Could we add a test that runs a streamer batch with this RDD-backed writer and write unification enabled, then asserts each error-table key appears once? That assertion should fail when only the StreamSync reorder is reverted, so the suite catches a recurrence of the actual bug.

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.

5 participants