Repository navigation
[LAKE-6732] Spark 3.5: refuse to rewrite a table with duplicate data file registrations - #6
abrarsher23 wants to merge 7 commits into
Conversation
…file registrations A data file registered at two data sequence numbers cannot be compacted safely. File identity in the rewrite path is keyed on location alone, so the two registrations are indistinguishable: the data side removes one of them while the delete side removes both corresponding delete-file registrations. The surviving data registration is then left with no delete file covering it and previously suppressed rows become visible. Adds validate-duplicate-file-registrations (default true), checked before planFileGroups so the action fails before making any commit.
For each duplicated path found, keeps the lowest live data sequence number and drops every other live registration -- for both data and delete files. Keeping the lowest is load-bearing, not arbitrary: any delete file committed between two duplicate registrations has a sequence number greater than the lower one, so it continues to cover a registration kept at the lower sequence number. Keeping the higher one would silently drop that coverage and reproduce the defect this guards against. Adds resolve-duplicate-file-registrations (default true). The existing validate-duplicate-file-registrations guard's public API (RewriteManifests) cannot express a delete: its own contract forbids added/deleted files in a rewritten manifest. The actual delete machinery (ManifestFilterManager) is package-private and had no per-entry, sequence-number-aware predicate, so this adds one (dropDuplicateRegistrations) alongside the existing deleteByRowFilter/dropPartition/dropDeleteFilesOlderThan predicates, routed through both of MergingSnapshotProducer's filter managers so data and delete duplicates are repaired independently. A small new commit-operation class (DuplicateRegistrationRepair) exposes this publicly, modeled on StreamingDelete. Also fixes a startingSnapshotId ordering bug: it was captured before the duplicate-registration check ran, so a repair's new snapshot would have been silently ignored by the subsequent scan and commit. Tests: extends the existing data-file duplicate-registration coverage with a repair-path test, and adds a delete-file duplicate-registration repair test (v2 position deletes; v3 deletion vectors have different per-data-file uniqueness semantics not exercised here). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
- Fix NPE: the two new repair tests called validateAndInitOptions() directly without ever setting rewriter, which that method dereferences immediately. Add .binPack() before the direct call in both, and in the two new batch/multi-path tests below. - Fix ManifestFilterManager.canContainDroppedFiles(): was an else-if chain, so a populated deleteFiles predicate on the same instance would shadow the duplicate-registration check and silently skip a manifest that needed opening. Not reachable via the current single caller, but a landmine in shared, reusable core internals. - Replace magic number content == 0 with FileContent.DATA.id(), matching this codebase's existing convention (BaseEntriesTable). - Clarify the LOG.warn message so the property-name parenthetical reads as an instruction, not a dangling reference. - Strengthen DuplicateRegistrationRepair's keepDataFile/keepDeleteFile javadoc to state the "must be the true minimum" contract is caller-enforced only, plus basic argument validation. - testRepairsDuplicateDeleteFileRegistrations now asserts which sequence number survives (the lower one), not just that dedup happened -- a bug that kept the higher one would have passed before. - Add coverage the self-review flagged as missing: multiple distinct duplicated paths repaired in one batch/commit, a duplicated data file and a duplicated delete file repaired together (content-type routing), 3+ registrations of the same path, and a full repair-then-compact execute() path that verifies actual row data (not just metadata) is correct afterward. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
| // AFFIRM: true if this exact live registration (this path at this data sequence number) is a | ||
| // duplicate that must be dropped, i.e. a *different* sequence number was chosen to survive for | ||
| // this same path. | ||
| private boolean isDuplicateRegistrationToDrop(F file, ManifestEntry<F> entry) { |
There was a problem hiding this comment.
The actual repair mechanism. Everything else in this diff is plumbing to get a path -> sequence number to keep map to this point and commit the result.
Why a new predicate was necessary rather than reusing delete(CharSequence)/delete(F file) on this same class: both existing predicates match by location only — for a path with two live registrations, either one would delete both, not the one you want to drop. This is the only predicate in the class that's sequence-number-aware.
Why this isn't built on the public RewriteManifests API instead: that interface's own contract says "the added manifest cannot contain new or deleted files" — it can reshuffle which manifest an entry lives in, but it structurally cannot change an entry's live/dead status. That ruled it out.
| @@ -400,9 +455,19 @@ private boolean canContainDroppedPartitions(ManifestFile manifest) { | |||
| } | |||
|
|
|||
| private boolean canContainDroppedFiles(ManifestFile manifest) { | |||
There was a problem hiding this comment.
Fixed during self-review: this was originally an if / else if chain. If deleteFiles were ever populated on the same ManifestFilterManager instance as duplicateRegistrationKeepSequence (not possible today — the only caller, DuplicateRegistrationRepair, calls nothing else — but this class is shared/reusable core internals), the deleteFiles partition-overlap check would short-circuit past a manifest that actually needed opening for a duplicate registration, silently under-scanning it. Made the checks independent instead of else-if so this can't happen regardless of what future caller combines with this predicate.
| // repairDuplicateFileRegistrations() committed a fix above, both the file-group scan below | ||
| // and the final rewrite commit must start from that corrected snapshot -- capturing this | ||
| // earlier would silently plan against the pre-repair snapshot and defeat the repair. | ||
| long startingSnapshotId = table.currentSnapshot().snapshotId(); |
There was a problem hiding this comment.
This capture point matters and used to be wrong. startingSnapshotId is what planFileGroups() scans as-of a few lines down, and what the final compaction commit uses as its base. It used to be captured before the duplicate-registration check ran (top of execute()). If a repair commits a new snapshot, capturing this first would mean the scan and the final commit both silently plan against the pre-repair snapshot — the repair would run, but have zero effect on the compaction that immediately follows it. Moved to after the check for that reason.
| DuplicateRegistrationRepair repair = | ||
| new DuplicateRegistrationRepair(table.name(), ((HasTableOperations) table).operations()); | ||
|
|
||
| for (Row row : duplicates) { |
There was a problem hiding this comment.
Routes each duplicated path to keepDataFile/keepDeleteFile based on content, then a single repair.commit() below covers the whole batch — multiple distinct duplicated paths found in one scan land in one snapshot, not one commit per path (see testRepairsMultipleDuplicateFileRegistrationsInOneBatch). table.refresh() right after is what makes the startingSnapshotId reordering fix (further up in execute()) actually take effect.
| * only the sequence number to KEEP per path -- every other live registration of that path is | ||
| * dropped from the new snapshot. | ||
| */ | ||
| public class DuplicateRegistrationRepair |
There was a problem hiding this comment.
Modeled directly on StreamingDelete (same three overrides: self()/operation()/validate(), nothing else). One deliberate difference: this constructor is public, where StreamingDelete's is protected — StreamingDelete is only ever constructed from inside org.apache.iceberg (e.g. BaseTable.newDelete()), but this needs to be instantiated directly from RewriteDataFilesSparkAction in the spark.actions package, via ((HasTableOperations) table).operations() — same pattern already used by RewriteManifestsSparkAction.
| * @param sequenceNumber the lowest live data sequence number registered for that path | ||
| * @return this for method chaining | ||
| */ | ||
| public DuplicateRegistrationRepair keepDataFile(String path, long sequenceNumber) { |
There was a problem hiding this comment.
The one invariant that matters for correctness here: sequenceNumber MUST be the lowest live one for path, not an arbitrary choice. Any delete file committed between two duplicate registrations has a sequence number greater than the lower one, so it still covers a registration kept at the lower sequence number. Keeping the higher one instead silently drops that delete coverage and reproduces the exact incident this PR fixes (resurrected rows that were correctly suppressed).
Not validated here — the only caller (RewriteDataFilesSparkAction) computes this via SQL min(sequence_number), so it's correct by construction today, but nothing in this class or ManifestFilterManager would catch a future caller passing a non-minimum value. Flagged in the javadoc as a caller-enforced-only contract.
…eleteFile The repair's keep-sequence-number map is computed once from a scan that happens before repair.commit() is called. MergingSnapshotProducer's optimistic-concurrency retry loop re-runs validate()/apply() -- and therefore manifest filtering -- against a freshly refreshed base on every CAS conflict, but does not recompute that map. If a concurrent commit changed the exact path being repaired in the window between the original scan and a retry, the stale keep-decision could cause every live registration of that path to be dropped -- the survivor because it no longer exists at that sequence number, everything else because it doesn't match. That's silent data loss with the commit reporting success. Fix: track, per filterManifests() call, which duplicated paths were actually observed live at their designated keep-sequence-number. After filtering, if any path's survivor wasn't found, throw rather than let the commit proceed -- converting a silent drop into a loud commit failure, mirroring the existing validateRequiredDeletes pattern this class already uses for a similar guarantee. The tracking set is cleared at the start of every filterManifests() call so a survivor observed during a prior, superseded attempt can't satisfy a later retry's check. Also collapses keepDataFile/keepDeleteFile's identical validation into one private helper (code-review DRY finding). Known gap: no test reproduces the actual concurrent-commit race (would need to inject a commit between this repair's scan and its own commit attempt) -- the fix is verified by tracing the retry control flow, not by a reproduction test. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
… run createTablePartitioned() returns a Table handle obtained before the Spark write it triggers; without an explicit refresh(), currentSnapshot() is null and TestHelpers.dataFiles() sees zero files. Every test in this file that already calls shouldHaveFiles() (which refreshes internally) was fine; every one that reads the table directly without an intervening refresh was not -- including testDuplicateDataFileRegistrationCheckCanBeDisabled, which predates this branch and was part of the original guard commit. Confirms none of PR#6's original tests had ever actually been run before this Thor build. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
createTablePartitioned's data write is the table's first commit, so a delete file added afterward is registered at sequence 2, not 1 -- the test asserted 1. Found by the same Thor test run once the stale- Table-handle fix above let the test actually execute far enough to reach this assertion. Capture the real first-commit sequence number instead of hardcoding one, so this can't silently drift again if createTablePartitioned's commit count ever changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
rayhondo
left a comment
There was a problem hiding this comment.
Reviewed against the linked incident doc ("Anatomy of one duplicate row") and stress-tested the repair logic for concurrent-write and format-version edge cases. The fix correctly closes the mechanism the incident traced through rewrite_data_files on V2 tables. Left inline notes on gaps found during that pass -- scoped down after confirming actual call-site usage in all-the-things (V1 and standalone remove_dangling_deletes/COW-DML concerns don't apply to how these tables are actually used; the rewrite_position_delete_files and batch-repair-atomicity notes do).
| validateAndInitOptions(); | ||
|
|
||
| if (validateDuplicateFileRegistrations) { | ||
| validateNoDuplicateFileRegistrations(); |
There was a problem hiding this comment.
This guard is scoped to RewriteDataFilesSparkAction only. RewritePositionDeleteFilesSparkAction hits the identical bug -- DeleteFileSet collapses duplicate registrations by location() the same way -- and it's not just theoretically reachable: we already run it standalone in production oncall/backfill notebooks, unconnected to any rewrite_data_files call (e.g. an executed CALL prod__lake__us.system.rewrite_position_delete_files(...) against chrono.ml_logs_model_run). We don't run COW DML against these tables and don't invoke remove_dangling_deletes standalone (it's always an option flag inside rewrite_data_files), so those aren't live concerns -- but oncall will keep reaching for other cleanup procs by hand, and none of them are protected today. Recommend pulling the detection+repair into a shared entry point (e.g. a helper both actions call, or a check inside ManifestFilterManager/MergingSnapshotProducer itself) rather than duplicating this guard per-action, or at minimum a fast-follow ticket to wire RewritePositionDeleteFilesSparkAction into the same check.
| * instead means the whole commit attempt aborts; a subsequent retry (or a fresh invocation of | ||
| * the guard) re-evaluates against the latest state rather than acting on stale intent. | ||
| */ | ||
| private void validateDuplicateRegistrationSurvivorsPresent() { |
There was a problem hiding this comment.
The repair folds every duplicated path from one scan into a single commit, and this method aborts the entire commit if even one path's intended survivor isn't observed live on a CAS retry (e.g. a legitimate concurrent write touched just that one path in the meantime). On a table with many duplicated paths, one unrelated concurrent write blocks repair of every other still-valid path in that batch, forcing rewrite_data_files to fail outright instead of repairing what it safely can. Worth considering whether the repair should drop the affected path from this attempt and retry, rather than failing the whole batch.
| // validateDuplicateRegistrationSurvivorsPresent for why: a commit retry against a base a | ||
| // concurrent writer already changed must not silently drop every registration of a path whose | ||
| // survivor vanished out from under it). | ||
| private boolean isDuplicateRegistrationToDrop(F file, ManifestEntry<F> entry) { |
There was a problem hiding this comment.
This method's "mark path as seen" side effect (duplicateRegistrationSurvivorsSeen.add(path)) only fires if it's actually evaluated -- callers use it inside a || chain in manifestHasDeletedFiles/filterManifestWithDeletedFiles, so if deletePaths/deleteFiles/dropPartitions already matches the same entry earlier in that chain, Java short-circuits and this side effect never runs. Not reachable via DuplicateRegistrationRepair today (it never populates those other fields on the same instance), but it's a landmine for the next caller that combines dropDuplicateRegistrations() with an ordinary delete()/dropPartition() targeting the same path -- that commit would wrongly throw ValidationException claiming the survivor was never observed. Worth a comment or an assertion guarding this invariant so it doesn't surprise the next person.
| duplicateRegistrationKeepSequence.keySet(), duplicateRegistrationSurvivorsSeen) | ||
| .immutableCopy(); | ||
| if (!missing.isEmpty()) { | ||
| throw new ValidationException( |
There was a problem hiding this comment.
Minor: hand-rolls if (!missing.isEmpty()) { throw new ValidationException(...); } instead of this file's existing ValidationException.check(condition, msg, args) helper used elsewhere (e.g. validateRequiredDeletes). No functional difference, just a second idiom for the same check-and-throw pattern in a file that already has one convention.
| */ | ||
| private List<Row> findDuplicateFileRegistrations() { | ||
| String tempView = | ||
| "affirm_duplicate_registrations_" + UUID.randomUUID().toString().replace("-", ""); |
There was a problem hiding this comment.
Minor: reinvents entries-table grouping via a UUID-named temp view + raw SQL string. The sibling RemoveDanglingDeletesSparkAction (lines ~120-130) already does the identical "group by key, take min" shape via the Dataset API (groupBy(...).agg(min(...))) -- no temp view, compile-time column safety, no extra catalog round-trip.
| public void testRepairsDuplicateDeleteFileRegistrations() { | ||
| // Restricted to v2 position deletes: v3 deletion vectors have different per-data-file | ||
| // uniqueness semantics not exercised by this scenario. | ||
| assumeThat(formatVersion).isEqualTo(2); |
There was a problem hiding this comment.
Delete-file duplicate-registration repair is only tested for format version 2 (assumeThat(formatVersion).isEqualTo(2) here and at line 2401). DeleteFileSet keys equality on location() + contentOffset() + contentSizeInBytes() for v3 deletion vectors, but the repair's own matching (ManifestFilterManager.isDuplicateRegistrationToDrop) keys only on location(). We're V2-only today but expect to move up -- this needs either v3 coverage before that migration or an explicit tracking ticket, since right now it's genuinely unverified for v3, not just an untested-but-equivalent path.
There was a problem hiding this comment.
Okay for now as all are on v2 but this needs to be addressed for v3
- Shared duplicate-registration guard (DuplicateFileRegistrationGuard) so RewritePositionDeleteFilesSparkAction gets the same protection as RewriteDataFilesSparkAction, since it is run standalone in production oncall/backfill notebooks with no dependency on rewrite_data_files. - Refuse to auto-repair a duplicated delete-file registration on a table that may carry V3+ deletion vectors: a single Puffin file legitimately holds multiple distinct delete files sharing one location, which this guard's location-only matching cannot tell apart from an actual duplicate. - Fixed a short-circuit landmine: isDuplicateRegistrationToDrop's survivor-tracking side effect is now hoisted into a local variable at both call sites, so it always evaluates regardless of || short-circuiting. - Switched findDuplicateFileRegistrations from a temp-view + raw SQL string to the same groupBy/agg Dataset API RemoveDanglingDeletesSparkAction already uses. - Style: use ValidationException.check() instead of a hand-rolled if/throw. - Documented the batch-atomicity tradeoff (one poisoned path fails the whole repair batch) as a deliberate, accepted design choice rather than leaving it unaddressed.
…tes; extract shared DuplicateFileRegistrationGuard Ports the additional scope #8 built while retargeting this guard to the 1.11 base, back onto this branch: - Extracts the detection/repair logic this branch already had inline in RewriteDataFilesSparkAction into a shared DuplicateFileRegistrationGuard, used by three actions instead of one. remove_dangling_deletes reproduces the delete-side "2-of-2 removal" half of the original incident on its own (DeleteFileSet's identity key is (location, contentOffset, contentSizeInBytes), identical across two live registrations of one physical file), and rewrite_position_delete_files is run standalone from oncall/backfill notebooks with no dependency on rewrite_data_files ever having run -- both needed the identical guard, not a second drifting copy of this logic. - New core test suite, TestDuplicateRegistrationRepair (66 tests): argument validation, the keep-lowest-sequence-number invariant, 3+ registrations collapsing to one, delete-file repair, that repairing a data file doesn't strip its own delete coverage, and retry safety against a concurrent commit changing the intended survivor between scan and commit. - New ManifestFilterManager checks: validateNoContradictoryDuplicateRegistrationIntent (rejects a commit that both keeps a survivor and separately deletes that same path outright -- the two instructions silently resolved in the destructive direction before this), and hoists isDuplicateRegistrationToDrop's two call sites out of their `||` chains into local variables, since Java's short-circuiting could skip the survivor-recording side effect when an earlier term in the same chain already matched the entry. - TestRemoveDanglingDeleteAction: one existing test's fixture (testPartitionedDeletesWithDanglingDvs) incidentally re-registers two data files at a second sequence number, which the guard now repairs before the dangling-delete pass runs -- updated its post-repair assertions accordingly, and added a new test driving the "delete side" defect deliberately rather than incidentally. - TestRewriteDataFilesAction: narrowed testDuplicateDataFileRegistrationCheckCanBeDisabled's assertion to just "the opt-out doesn't throw this guard's exception" -- two Thor runs disagreed on what happens downstream of the opt-out (planner bin-packing determines whether the duplicate lands in a selected file group), so asserting no exception at all was asserting a coin flip. - Fixed a pre-existing checkstyle violation in RemoveDanglingDeletesSparkAction (new ArrayList<>() instead of Lists.newArrayList()) while already touching that file -- present since #4 merged, apparently never caught by CI until now. TestRewritePositionDeleteFilesAction's new testIcebergItselfRefusesToCreateADuplicateDvRegistration had to be adapted rather than ported verbatim: it relied on rewrite_position_delete_files converting V2 position deletes to V3 DVs, a capability this branch's RewritePositionDeleteFilesSparkAction doesn't have (it hard-refuses to run against a V3 table at all here). Generates the DV directly via FileGenerationUtil instead, matching TestRemoveDanglingDeleteAction's existing pattern -- same assertion, without depending on a feature this base doesn't have. Also found and fixed, independent of the port: DuplicateFileRegistrationGuard's validateNoContradictoryDuplicateRegistrationIntent on the 1.11 branch has a comment stating it must read callerDeletedFilePaths (not deleteFiles, which the filtering loop also writes to and never clears between commit attempts) but the code still reads deleteFiles -- this port implements what the comment says, not what the code currently does. Worth a look on #8 too. Tested: TestDuplicateRegistrationRepair (66 tests, 0 failures), TestDuplicateFileRegistrationGuard (5 tests, 0 failures), TestRemoveDanglingDeleteAction (12 tests, 2 skipped, 0 failures), TestRewriteDataFilesAction (full suite, 122 tests, 2 skipped, 0 failures), TestRewritePositionDeleteFilesAction (full suite, 19 tests, 0 failures). spotlessCheck and checkstyle clean on every file this commit touches -- iceberg-core:checkstyleTest still fails on this branch, but only on core/src/test/java/org/apache/iceberg/rest/* (unused imports, annotation style), pre-existing since PR #1 merged and unrelated to this change.
Problem
A file — data or delete — registered at two data sequence numbers cannot be compacted
safely. This is not something a healthy writer produces: it arises when a commit is retried after
its outcome became unknown (
CommitStateUnknownException) and the retry re-registers aWriteResultthat had in fact already been applied server-side. Root cause of INC-7208 (SEV1).File identity in the rewrite path is keyed on location alone, while a file's data sequence
number lives on the manifest entry rather than the
ContentFile, so the two registrationscannot be told apart:
DataFileSetkeys onlocation(). Two scan tasks for one pathcollapse to a single
DataFilecarrying a singlemanifestLocation(); the other manifest isnever opened.
DeleteFileSetkeys on(location, contentOffset, contentSizeInBytes)— identical across two registrations of one physical file. Removing oneregistration removes its sibling too.
Net effect: a surviving data registration left with no delete coverage, and rows that were
correctly suppressed become visible.
Observed in production: one physical file, 30 records, registered at seq 93949 and seq 93950.
rewrite_data_filesremoved only the 93949 registration;remove_dangling_deletesthen removedboth registrations of both its delete files — including a position delete its own test
correctly judged not dangling. 23 rows across 6 keys surfaced.
Change
Two properties, both default
true:validate-duplicate-file-registrations— detect any live file path (data or delete)registered at more than one distinct data sequence number.
resolve-duplicate-file-registrations— repair it instead of failing: keep the registrationat the LOWEST live data sequence number per duplicated path, drop every other, as one
metadata-only commit, then continue.
Implemented as a new primitive on
ManifestFilterManager(dropDuplicateRegistrations) plus asmall public commit class (
DuplicateRegistrationRepair), because the publicRewriteManifestsAPI cannot express this — its own contract forbids added/deleted files in a rewritten manifest.
Detection and repair live in a shared
DuplicateFileRegistrationGuard, called from threeactions:
RewriteDataFilesSparkActionRewritePositionDeleteFilesSparkActionrewrite_data_fileshaving runRemoveDanglingDeletesSparkActionRemoveDanglingDeletestakes a package-privateskipDuplicateRegistrationCheck()so therewrite_data_filespath, which already ran the guard, doesn't re-scanentries.Keeping the lowest sequence number is not an arbitrary choice: any delete file committed between
two duplicate registrations has a sequence number greater than the lower one, so it continues to
cover a registration kept at the lower sequence number. Keeping the higher one instead would
silently drop that coverage and reproduce the exact defect this repairs.
Design notes
this. Repair defaults to on so an affected table self-heals instead of blocking every subsequent
scheduled compaction run. Escape hatches retained:
validate-duplicate-file-registrations=falseskips the check entirely;
resolve-duplicate-file-registrations=falsekeeps detection butreverts to the original fail-fast behavior.
separately asks for that same path to be deleted outright is rejected outright
(
validateNoContradictoryDuplicateRegistrationIntent), rather than letting the contradictionresolve silently in the destructive direction.
detection scan and this repair's commit, the repair aborts loudly
(
validateDuplicateRegistrationSurvivorsPresent) rather than risking a silent drop of everylive registration of that path. A subsequent retry re-scans against the latest state.
several distinct DVs sharing one on-disk path; this guard's matching is location-only, so it
cannot always tell that apart from a genuine duplicate registration. Refuses to auto-repair the
whole batch when a duplicated path might be a DV, regardless of the caller's
resolveoption.Defence-in-depth rather than a live path: Iceberg's own
BaseRowDelta#validatealready refusesto add a second DV for a data file that already has one.
LOG.warnline — there is no metric/alert distinguishing "repaired N paths" from "found none,"and no way to notice a table repairing on every run (which would mean a writer bug is still
actively producing duplicates, not a one-off). File a follow-up to add a counter/alert on repair
frequency per table before this sees wide rollout.
where/filterscope is ignored by the guard, so partition-scoped compaction pays a full-tableentriesscan whenever it runs. Cost is unquantified; no metric exists yet for how often theguard actually fires in prod.
Relationship to #4
Complementary, not overlapping. #4 (already merged on this base) makes
remove_dangling_deleteserr safe by retaining any delete file attached to a live
FileScanTask— see the A/B below forexactly how much that holds back on its own. It does not prevent the initial doubling, which
happens in
rewrite_data_filesbefore the dangling action ever runs. This PR stops the rewritefrom starting at all, and repairs the underlying cause instead of only refusing to proceed.
A/B: does #4 fix the delete-side half on its own? — Partially
A duplicated delete-file registration (this PR's target) is not the same failure mode as a
duplicated data-file registration alone. On a fixture with a duplicated data registration but no
duplicated delete, #4's scan-based dangling-delete detection and the old min-sequence heuristic
come out identical — neither wrongly drops a needed delete, because a needed delete requires some
live
X@xwithx <= d, which forcesmin_data_seq <= d, while "dangling" requiresd < min_data_seq; both cannot hold at once. The pre-#4 rule never wrongly removes a needed deleteon its own — the trigger requires a duplicated delete registration specifically, which is
exactly the "delete side removes 2-of-2" scenario this PR's guard targets in
RemoveDanglingDeletesSparkAction.Testing
TestDuplicateRegistrationRepair(new, core-level): 66 tests, 0 failures. Covers argumentvalidation, the keep-lowest-sequence-number invariant (including a test pinning that the
primitive honours a wrong caller choice verbatim rather than silently correcting it — that
is the caller's contract by design), 3+ registrations collapsing to one, delete-file repair,
that repairing a data file does not strip its own delete coverage, and retry safety against a
concurrent commit changing the intended survivor between scan and commit.
TestDuplicateFileRegistrationGuard(new): 5 tests, 0 failures. Direct unit coverage of theguard's V3-deletion-vector refusal decision function — this branch can't be driven end-to-end
through any action, since Iceberg itself refuses to create the state it defends against (a
second live DV for a data file that already has one).
TestRemoveDanglingDeleteAction: 12 tests, 2 skipped, 0 failures. One pre-existing test'sfixture incidentally re-registers two data files at a second sequence number; the guard now
repairs that before the dangling-delete pass runs, so its post-repair assertions were updated
accordingly. Added a new test (
testDoesNotDropADuplicatedDeleteFileThatStillCoversLiveData)driving the delete-side defect deliberately rather than incidentally.
TestRewriteDataFilesAction(full suite): 122 tests, 2 skipped, 0 failures. IncludestestRejectsDuplicateDataFileRegistrations,testRepairsDuplicateDataFileRegistrations,testRepairsDuplicateDeleteFileRegistrations,testRepairsMultipleDuplicateFileRegistrationsInOneBatch,testRepairsMixedDataAndDeleteFileRegistrationsTogether,testRepairsMoreThanTwoRegistrationsOfSamePath,testRepairThenCompactEndToEnd,testDuplicateDataFileRegistrationCheckCanBeDisabled,testAcceptsTableWithoutDuplicateRegistrations.TestRewritePositionDeleteFilesAction(full suite): 19 tests, 0 failures. Includes standalonerepair/reject tests (no prior
rewrite_data_filescall on the table) and a pinned testconfirming Iceberg itself refuses to create a duplicate DV registration.
spotlessCheckand checkstyle clean on every file this PR touches.iceberg-core:checkstyleTestcurrently fails on this branch, but only oncore/src/test/java/org/apache/iceberg/rest/*— unused imports and annotation style, pre-existingsince #1 merged and unrelated to this change.
Known limitations (tracked, not blocking)
throwing.
main, ignoringtoBranch(). Not reachable throughtable_compaction.pytoday (it never emitsbranch =>); live only if branch-scoped compactionis adopted later.
RemoveDanglingDeleteshardcodes the guardtrue,truewith no opt-out reachable externally —RemoveDanglingDeleteFiles's API has no option-plumbing convention today, so exposing a togglehere means introducing that pattern for the first time, not reusing one.
stored procedure exists to clear that state.
validate=falseis not a safe rollback for such atable — it re-enables the exact pre-PR code path that caused the incident, on a table now
known to have a live duplicate registration.
Upstream
Not fixed upstream for this defect.
RepairManifests/RepairTable-shaped proposals have beenraised repeatedly since 2021 (
apache/iceberg#2435,#2608,#10445,#10784), all closedunmerged or still open.
apache/iceberg#17399("API: Define RepairManifests action interface")merged into the 1.12.0 milestone, but scoped to file-metric/stat repair (
record_count,file_size_in_bytes, bounds/null counts), not duplicate sequence-number registrations — asame-named but functionally different problem.
apache/iceberg#17198added a "Content fileuniqueness" spec section stating duplicate live entries are undefined behavior and that writers
are not required to validate uniqueness at commit time — consistent with this being a
read/compaction-layer repair, not a write-time one.
Relationship to #8
This is the
1.8.1-affirm-patch/spark/v3.5counterpart of #8, which ported this same guardonto
1.11.0-affirm-patch/spark/v4.0and evolved it further (the three-action wiring, theshared
DuplicateFileRegistrationGuardclass, the coreTestDuplicateRegistrationRepairsuite,and several self-review rounds) while this branch sat untouched. This PR ports that evolution back
here rather than leaving the two forks to drift, adapting for real differences between the two
bases:
RewritePositionDeleteFilesSparkActionrefuses to run against a V3 table at all(no V2→V3 DV-rewrite capability here), so
TestRewritePositionDeleteFilesAction'sDV-duplicate-refusal test generates the DV directly via
FileGenerationUtilinstead of via arewrite, matching
TestRemoveDanglingDeleteAction's existing pattern — same assertion, withoutdepending on a feature this base doesn't have.
DuplicateFileRegistrationGuardon the 1.11 branch has a comment stating
validateNoContradictoryDuplicateRegistrationIntentmust read
callerDeletedFilePaths(notdeleteFiles, which the filtering loop also writes toand never clears between commit attempts) — but the code there still reads
deleteFiles. Thisport implements what the comment specifies, not what Spark 4.0: refuse-or-repair duplicate data file registrations before compaction #8's code currently does. Worth a look on
Spark 4.0: refuse-or-repair duplicate data file registrations before compaction #8 too.