Skip to content

fix(nbcc): reject insert overwrite combined with non-blocking concurrency control - #19791

Open
Joy-2000 wants to merge 4 commits into
apache:masterfrom
Joy-2000:fix-insert-overwrite-with-nbcc
Open

fix(nbcc): reject insert overwrite combined with non-blocking concurrency control#19791
Joy-2000 wants to merge 4 commits into
apache:masterfrom
Joy-2000:fix-insert-overwrite-with-nbcc

Conversation

@Joy-2000

@Joy-2000 Joy-2000 commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Describe the issue this Pull Request addresses

Under NBCC, insert overwrite reuses the deterministic bucket file id, but the replace commit it generates records that same file id as replaced. Because the file system view hides a replaced file group by file id (ignoring the replace instant), the freshly-overwritten data becomes invisible → silent data loss.

Summary and Changelog

Reject insert overwrite when non-blocking concurrency control (NB-CC) is enabled, and fail early — before any instant is created.

Why this is unsafe. NB-CC requires a MOR table with a simple bucket index, where each bucket has one deterministic, fixed file id. insert overwrite reuses that same file id but generates a replacecommit that records the id as replaced. Because the file system view hides a replaced file group by file id (ignoring the replace instant), the freshly-overwritten data becomes invisible → silent data loss.

What this PR does:

  • Engine-agnostic backstop in BaseHoodieWriteClient#preWrite, the single chokepoint every overwrite entry point funnels through (Spark SQL, the DataSource writer, Flink, and Hudi Streamer). Keyed on isNonBlockingConcurrencyControl() && WriteOperationType.isOverwrite(writeOperationType). This closes the Hudi Streamer path, which bypasses the writer-level guards and previously only tripped the late partitioner check.
  • Fail-fast guards kept at the Spark (HoodieSparkSqlWriter) and Flink entry points for a friendlier early error. On Flink they're consolidated to a single call at the top of Pipelines#hoodieStreamWrite — the funnel for the table sink, HoodieFlinkStreamer --op insert_overwrite, and Sink V2 — replacing the duplicated checks in HoodieTableSink and PipelinesV2.
  • The rule is centralized in one validator, WriteConcurrencyMode.checkInsertOverwriteSupported(isNbcc, isOverwrite), which owns the message constant and throws a single exception type (HoodieException). All four throw sites route through it — preWrite, HoodieSparkSqlWriter, OptionsResolver, and the pre-existing BaseSparkBucketIndexBucketInfoGetter partitioner check — replacing the previous mix of HoodieException and IllegalArgumentException.
  • Row-writer bulk_insert overwrite and case-insensitive operation strings are handled via WriteOperationType.isOverwrite(...) + Locale.ROOT normalization in both sparkSqlInsertIntoOperation and deduceOverwriteConfig (the latter previously risked deleting the table path on an uppercase operation).

Relationship to the existing check. The Spark RDD path already rejected this since HUDI-8866 (BaseSparkBucketIndexBucketInfoGetter), but only after the requested/inflight replacecommit and the workload-profile shuffle. This PR adds (a) failing before initTable/startCommit, and (b) covering the row-writer bulk_insert overwrite path, which had no check — so the pre-existing partitioner check is superseded, not missed.

Underlying invariant (follow-up, not in this PR)

The root invariant is broader than insert overwrite: a fixed-id bucket file group must never be listed in a replacecommit. delete_partition followed by a re-insert into the same partition hits the identical trap. This PR is intentionally a narrow, safe guard for insert overwrite only (isOverwrite(...) excludes DELETE_PARTITION, since a standalone delete_partition under NB-CC is valid and loses no data). The root-cause fix — bumping the -0 generation in newBucketFileIdForNBCC on replace so re-inserts land on a fresh id — is deferred to a separate ISSUE for discussion.

Impact

Users combining insert overwrite with non-blocking concurrency control now get a clear error at submission time instead of silent data loss. No change to any existing valid write path.

Risk Level

low

Adds a validation that rejects a previously data-losing combination; existing valid writes are unaffected.

Documentation Update

The @EnumFieldDescription on NON_BLOCKING_CONCURRENCY_CONTROL now states the restriction. No site docs change needed.

Contributor's checklist

  • Read through contributor's guide
  • Change Logs and Impact were stated clearly
  • Adequate tests were added (core unit test TestWriteConcurrencyMode; Spark positive + negative in TestInsertTable3; Flink in ITTestHoodieDataSource / TestOptionsResolver)
  • CI passes
@github-actions github-actions Bot added the size:M PR with lines of changes in (100, 300] label Aug 29, 2026
@codecov-commenter

codecov-commenter commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

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

Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19791      +/-   ##
============================================
- Coverage     78.14%   74.06%   -4.09%     
+ Complexity    33692    32040    -1652     
============================================
  Files          2540     2541       +1     
  Lines        141413   141588     +175     
  Branches      17123    17182      +59     
============================================
- Hits         110513   104864    -5649     
- Misses        23200    29482    +6282     
+ Partials       7700     7242     -458     
Components Coverage Δ
hudi-common 82.99% <100.00%> (-0.59%) ⬇️
hudi-client 82.19% <100.00%> (-0.95%) ⬇️
hudi-flink 85.66% <100.00%> (+<0.01%) ⬆️
hudi-spark-datasource 48.25% <100.00%> (-24.45%) ⬇️
hudi-utilities 74.55% <ø> (+0.04%) ⬆️
hudi-cli 15.06% <ø> (ø)
hudi-hadoop 69.96% <ø> (-0.15%) ⬇️
hudi-sync 75.51% <ø> (-0.03%) ⬇️
hudi-io 80.00% <ø> (+0.23%) ⬆️
hudi-timeline-service 83.74% <ø> (+0.19%) ⬆️
hudi-cloud 65.81% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 51.43% <45.45%> (-0.03%) ⬇️
flink-integration-tests 48.87% <70.00%> (+<0.01%) ⬆️
hadoop-mr-java-client 44.03% <50.00%> (+<0.01%) ⬆️
integration-tests 13.52% <13.63%> (+<0.01%) ⬆️
spark-client-hadoop-common 50.40% <57.14%> (+0.02%) ⬆️
spark-java-tests 52.19% <88.88%> (+0.03%) ⬆️
spark-scala-tests 33.91% <100.00%> (-12.86%) ⬇️
utilities 36.58% <61.11%> (+0.28%) ⬆️

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

Files with missing lines Coverage Δ
.../org/apache/hudi/client/BaseHoodieWriteClient.java 83.33% <100.00%> (-0.26%) ⬇️
...n/commit/BaseSparkBucketIndexBucketInfoGetter.java 100.00% <100.00%> (+5.26%) ⬆️
...apache/hudi/common/model/WriteConcurrencyMode.java 100.00% <100.00%> (ø)
...org/apache/hudi/configuration/OptionsResolver.java 91.35% <100.00%> (+0.14%) ⬆️
...ain/java/org/apache/hudi/sink/utils/Pipelines.java 93.75% <100.00%> (+0.02%) ⬆️
...n/scala/org/apache/hudi/HoodieSparkSqlWriter.scala 78.86% <100.00%> (+0.14%) ⬆️
...g/apache/spark/sql/hudi/ProvidesHoodieConfig.scala 78.23% <100.00%> (-6.58%) ⬇️

... and 258 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 commented Aug 29, 2026

Copy link
Copy Markdown
Member

+1 on this to fail loudly. IIUC, this is only relevant to simple bucket index (i.e. index based bucket number) right?

@Joy-2000

Copy link
Copy Markdown
Contributor Author

@voonhous Yeah, non-blocking concurrency control requires the MOR table with simple bucket index.

@voonhous voonhous left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Inline comments below. Two notes that do not anchor to a diff line:

  • The Spark RDD path was already rejected before this PR: BaseSparkBucketIndexBucketInfoGetter.java:40 has thrown this exact message since HUDI-8866 (a93c6eec6788). What the new Spark guard adds is failing before initTable/startCommit (the old check fires after the requested and inflight replacecommit and the workload-profile shuffle) and covering the row-writer bulk_insert overwrite path, which had no check. Could the description say so, so the partitioner check is not read as missed?
  • nit, optional: the @EnumFieldDescription on NON_BLOCKING_CONCURRENCY_CONTROL (WriteConcurrencyMode.java:42-46) states no restriction at all, and the PR marks "Documentation Update: none". Would one clause ("MOR with simple bucket index only; insert overwrite is not supported") be worth adding there? While in that enum, isNonBlockingConcurrencyControl(String) at :65 lacks the Locale.ROOT its sibling at :54 uses, and this PR now calls it on every Spark write.
  • FYI only, pre-existing and out of scope: with hoodie.datasource.write.row.writer.enable=false, insert overwrite + bulk_insert goes to plain client.bulkInsert (DataSourceUtils.java:192-195) and BULKINSERT_OVERWRITE_OPERATION_TYPE has no reader in hudi-client, so the overwrite is silently dropped.
@voonhous

voonhous commented Aug 29, 2026

Copy link
Copy Markdown
Member

@Joy-2000

The existing Spark check is a drive-by from HUDI-8866 (a93c6eec6788): once each NB-CC bucket got one fixed file id, overwrite had no valid choice (the fixed id collides with the replaced group, a random id splits the bucket), so it was rejected, untested. The invariant is broader than insert overwrite: delete_partition then re-insert (SparkDeletePartitionCommitActionExecutor.java:66) hides data the same way. Could the description state "a fixed-id file group must never be listed in a replacecommit", and is bumping the -0 generation in newBucketFileIdForNBCC on replace the root fix worth tracking instead of per-operation guards?

@danny0405 for a root cause fix discussion/visibility

@github-actions github-actions Bot added size:L PR with lines of changes in (300, 1000] and removed size:M PR with lines of changes in (100, 300] labels Aug 29, 2026
@Joy-2000

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review @voonhous — all points addressed. Summary below.

Engine-agnostic backstop. You're right that the per-engine guards left the Hudi Streamer path exposed. I moved the real safety net down to BaseHoodieWriteClient#preWrite — the single chokepoint every overwrite entry point funnels through — keyed on config.getWriteConcurrencyMode().isNonBlockingConcurrencyControl() && WriteOperationType.isOverwrite(writeOperationType). The per-engine checks (Spark HoodieSparkSqlWriter, Flink OptionsResolver) are kept only to fail fast with a friendlier message; they're no longer load-bearing.

Flink single funnel. Consolidated the check to the top of Pipelines#hoodieStreamWrite, which the table sink, the Flink Streamer (--op insert_overwrite), and Sink V2 all pass through, and removed the duplicated checks from HoodieTableSink and PipelinesV2. This closes the HoodieFlinkStreamer gap you flagged.

Message literal → one validator. Went a step further than hoisting the string: the whole rule now lives in one place — WriteConcurrencyMode.checkInsertOverwriteSupported(isNbcc, isOverwrite) — which owns both the message constant and the thrown type. All four sites route through it: preWrite, HoodieSparkSqlWriter, OptionsResolver, and the pre-existing BaseSparkBucketIndexBucketInfoGetter partitioner check. This also unifies the exception type to HoodieException everywhere — the Flink OptionsResolver and the partitioner previously threw IllegalArgumentException via ValidationUtils.checkArgument, which was the inconsistency you flagged. (Tests match on message, and the Flink IT / ITTestHoodieDataSource use findThrowableWithMessage, so they stay green; I updated the one TestOptionsResolver assertion that pinned IllegalArgumentException.) The call sites themselves can't be collapsed — bulkInsertAsRow calls writeClient.startCommit directly and bypasses preWrite, so each entry point still needs its own guard — but they now share a single source of truth for the rule.

Enum + Locale. Added the restriction clause to the @EnumFieldDescription on NON_BLOCKING_CONCURRENCY_CONTROL, and fixed isNonBlockingConcurrencyControl(String) to use Locale.ROOT like its sibling.

Case sensitivity. Both sparkSqlInsertIntoOperation and deduceOverwriteConfig now lower-case the operation once with Locale.ROOT (the deduceOverwriteConfig one is the dangerous path you noted — uppercase could otherwise delete the table path).

scaladoc / ExceptionUtils. Restored the resolvePartitionWildcards summary line, and the Flink IT now uses ExceptionUtils.findThrowableWithMessage(...) instead of the hand-rolled walker.

Tests (moved to their conventional home in TestInsertTable3):

  • Positive routing: added an uppercase leg to Test Insert Overwrite Bucket Index Table, so BULK_INSERT still replaces rows instead of silently appending.
  • NBCC rejection: one consolidated test that explicitly pins hoodie.datasource.write.operation to each overwrite variant (insert_overwrite / insert_overwrite_table) × row-writer bulk_insert off/on, and asserts the active timeline is empty after failure — red against the old late partitioner check, proving we now fail before startCommit.
  • Dropped the redundant ITTestDataStreamV2Write mini-cluster test (the check moved to hoodieStreamWrite, and TestOptionsResolver already pins it).
  • Guard precision: added TestWriteConcurrencyMode in hudi-common — a direct unit test of the shared checkInsertOverwriteSupported validator, asserting both overwrite variants are rejected under NB-CC and every non-overwrite operation (e.g. insert into / upsert) still passes, so the guard can't over-block appends.

Description. Updated to state that the Spark RDD path already rejected this since HUDI-8866 (BaseSparkBucketIndexBucketInfoGetter), and that this PR adds (a) failing before initTable/startCommit, and (b) covering the row-writer bulk_insert overwrite path — so the partitioner check isn't read as missed.

On the broader invariant. Fully agree with you: the real invariant is a fixed-id file group must never be listed in a replacecommit, and delete_partition + re-insert hits the same trap. I've called this out in the description. I intentionally scoped this PR to insert overwrite as a narrow fail-early guard — isOverwrite(...) deliberately excludes DELETE_PARTITION, since a standalone delete_partition under NB-CC is valid and loses no data, so blanket-rejecting it would be a regression. The proper fix (bumping the -0 generation in newBucketFileIdForNBCC on replace, so re-inserts land on a fresh id) is a larger change with its own compatibility surface — I'd prefer to track it as a separate issue follow-up rather than stack more per-operation guards.

Also noting your FYI on the row.writer.enable=false silent-drop path (BULKINSERT_OVERWRITE_OPERATION_TYPE having no reader) — that's pre-existing and orthogonal to NB-CC (it produces no replacecommit, so no data-loss via the replaced-id mechanism);

@voonhous

voonhous commented Aug 29, 2026

Copy link
Copy Markdown
Member

@Joy-2000 verified all of the above at 7c36e9b and resolved the threads. Two notes:

  • CI validate-pr is failing as PR description is not in the correct format, please fix it.
  • The row writer does reach preWrite: bulkInsertAsRow calls startCommit, then executor.execute -> preExecute -> preWrite. So the backstop fires there too (after the instant is requested, which is why the early HoodieSparkSqlWriter guard is still worth keeping), and it now also catches BUCKET_RESCALE under NB-CC through the inherited INSERT_OVERWRITE op type.

Side note, could you please create an issue/dev-task for the root-cause fix by bumping the -0 generation in newBucketFileIdForNBCC on replace so re-inserts land on a fresh id if the intention is to defer it outside the scope of this PR?

@voonhous

Copy link
Copy Markdown
Member

Deferring this PR to @danny0405 now, thank you for. the contribution, no other comments from me.

@Joy-2000

Copy link
Copy Markdown
Contributor Author

@hudi-bot run azure

@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

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

A couple of suggestions on validating this unsupported option combination before write-client work begins. The Spark SQL placement after deduceOperation and before handleSaveModes already matches that goal, including the row-writer overwrite flag. The comments below concern the remaining Flink and Spark Streamer timing. These are based on tracing the current head; I have not run additional tests for this review.

Comment on lines +503 to +505
// Validate the finalized write operation (after overwrite/static-partition resolution) at the
// single point all production callers (table sink, flink streamer, sink v2) funnel through.
OptionsResolver.checkNonBlockingConcurrencyControl(conf);

@danny0405 danny0405 Aug 31, 2026

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.

Could we also call OptionsResolver.checkNonBlockingConcurrencyControl(conf) at the very beginning of HoodieTableSink.getSinkRuntimeProvider(), before returning the DataStreamSinkProviderAdapter lambda?

By that point, the planner has applied applyOverwrite() and applyStaticPartition(), so conf contains the resolved operation. This gives the Table/SQL path a single early validation point for both explicit write.operation options and SQL INSERT OVERWRITE, without adding separate checks in HoodieTableFactory.sanityCheck() and applyOverwrite().

The placement before the lambda matters: the lambda performs client-ID setup and calls StreamerUtil.initTableFromClientIfNecessary() before reaching this pipeline guard. With incremental job-graph initialization or partition-level simple bucket indexing, the latter can create the table before the invalid combination is rejected.

Please keep this shared pipeline guard as well, since HoodieFlinkStreamer and direct pipeline callers bypass HoodieTableSink. A sink-level test could assert that getSinkRuntimeProvider() itself rejects the resolved overwrite operation under NBCC, without invoking the returned provider.

Updated to recommend this single sink-level check instead of my earlier factory-plus-applyOverwrite() suggestion.

Comment on lines +590 to +595
// Engine-agnostic backstop rejecting insert overwrite under non-blocking concurrency control.
// Complements the engine-specific guards by covering entry points that bypass them (e.g. Hudi
// Streamer, which reaches the write client directly).
WriteConcurrencyMode.checkInsertOverwriteSupported(
config.getWriteConcurrencyMode().isNonBlockingConcurrencyControl(),
WriteOperationType.isOverwrite(writeOperationType));

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.

Could we add an early Spark HoodieStreamer option check alongside this backstop? StreamSync.writeToSinkAndDoMetaSync calls startCommit before writeToSink dispatches to insertOverwrite/insertOverwriteTable, and those client methods call initTable before reaching preWrite. This guard prevents the overwrite, but the invalid NBCC configuration has already caused client initialization and commit-instant creation, unlike the Spark SQL guard. StreamSync.getHoodieClientConfigAndWriterSchema already has the built HoodieWriteConfig and cfg.operation; calling the shared validator immediately after builder.build() would reject both overwrite variants before constructing the write client. Please use cfg.operation rather than assuming the datasource operation property is present. Keep this preWrite check as protection for direct client callers, and add a Streamer test asserting that rejection creates no new timeline instant.

@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 rejects insert overwrite under non-blocking concurrency control (which reuses the deterministic bucket file id but emits a replacecommit that hides the freshly-written data by file id → silent data loss), centralizing the rule in WriteConcurrencyMode.checkInsertOverwriteSupported and wiring it into an engine-agnostic preWrite backstop plus fail-fast guards at the Spark and Flink entry points. I traced the overwrite paths across Spark SQL, the DataSource writer, the row-writer bulk_insert path, and Flink, and the guard placement lines up precisely with where the replacecommit hazard occurs; the DELETE_PARTITION and delete-and-recreate paths are correctly left untouched. No new issues flagged from this automated pass beyond what other reviewers have already raised — a Hudi committer or PMC member can take it from here for a final review. A couple of small naming and comment-formatting nits below.

cc @yihua


/**
* Returns whether the operation is INSERT OVERWRITE dynamic partition.
*/

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: could you rename this to something like checkInsertOverwriteSupported(Configuration conf) to mirror the underlying WriteConcurrencyMode method? checkNonBlockingConcurrencyControl reads more like a predicate on the mode itself and doesn't hint at what the check is actually validating.

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

+ "by the query reader and the compactor. Insert overwrite is not supported in this mode.")
NON_BLOCKING_CONCURRENCY_CONTROL;

//Error message thrown when insert overwrite is combined with non-blocking concurrency control.

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: missing space after ////Error message should be // Error message to match the surrounding comment style.

⚠️ 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]

6 participants