Skip to content

[LAKE-6732] Spark 3.5: refuse to rewrite a table with duplicate data file registrations - #6

Open
abrarsher23 wants to merge 7 commits into
1.8.1-affirm-patchfrom
abrar/guard-duplicate-file-registrations
Open

abrarsher23 wants to merge 7 commits into
1.8.1-affirm-patchfrom
abrar/guard-duplicate-file-registrations

Conversation

@abrarsher23

@abrarsher23 abrarsher23 commented Sep 1, 2026 •

Copy link
Copy Markdown

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 a
WriteResult that 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 registrations
cannot be told apart:

  • Data side removes 1-of-2. DataFileSet keys on location(). Two scan tasks for one path
    collapse to a single DataFile carrying a single manifestLocation(); the other manifest is
    never opened.
  • Delete side removes 2-of-2. DeleteFileSet keys on (location, contentOffset, contentSizeInBytes) — identical across two registrations of one physical file. Removing one
    registration 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_files removed only the 93949 registration; remove_dangling_deletes then removed
both 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 registration
    at 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 a
small public commit class (DuplicateRegistrationRepair), because the public RewriteManifests
API cannot express this — its own contract forbids added/deleted files in a rewritten manifest.

Detection and repair live in a shared DuplicateFileRegistrationGuard, called from three
actions:

Action Why it needs the guard
RewriteDataFilesSparkAction the original path; the 1-of-2 data-side removal
RewritePositionDeleteFilesSparkAction run standalone from oncall/backfill notebooks, with no dependency on rewrite_data_files having run
RemoveDanglingDeletesSparkAction reproduces the delete-side 2-of-2 removal (see the A/B below)

RemoveDanglingDeletes takes a package-private skipDuplicateRegistrationCheck() so the
rewrite_data_files path, which already ran the guard, doesn't re-scan entries.

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

  • Repairs by default, throws on opt-out. A guard that's off by default wouldn't have caught
    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=false
    skips the check entirely; resolve-duplicate-file-registrations=false keeps detection but
    reverts to the original fail-fast behavior.
  • Rejects contradictory intent. A commit that both designates a survivor for a path AND
    separately asks for that same path to be deleted outright is rejected outright
    (validateNoContradictoryDuplicateRegistrationIntent), rather than letting the contradiction
    resolve silently in the destructive direction.
  • Retry-safe. If a concurrent commit changes the intended survivor's path between the
    detection scan and this repair's commit, the repair aborts loudly
    (validateDuplicateRegistrationSurvivorsPresent) rather than risking a silent drop of every
    live registration of that path. A subsequent retry re-scans against the latest state.
  • V3 deletion vectors are refused, not guessed at. A single Puffin file legitimately holds
    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 resolve option.
    Defence-in-depth rather than a live path: Iceberg's own BaseRowDelta#validate already refuses
    to add a second DV for a data file that already has one.
  • Known gap, tracked as a fast-follow: the only operational signal today is a single
    LOG.warn line — 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/filter scope is ignored by the guard, so partition-scoped compaction pays a full-table
    entries scan whenever it runs. Cost is unquantified; no metric exists yet for how often the
    guard actually fires in prod.

Relationship to #4

Complementary, not overlapping. #4 (already merged on this base) makes remove_dangling_deletes
err safe by retaining any delete file attached to a live FileScanTask — see the A/B below for
exactly how much that holds back on its own. It does not prevent the initial doubling, which
happens in rewrite_data_files before the dangling action ever runs. This PR stops the rewrite
from 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@x with x <= d, which forces min_data_seq <= d, while "dangling" requires d < min_data_seq; both cannot hold at once. The pre-#4 rule never wrongly removes a needed delete
on 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 argument
    validation, 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 the
    guard'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's
    fixture 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. Includes
    testRejectsDuplicateDataFileRegistrations, testRepairsDuplicateDataFileRegistrations,
    testRepairsDuplicateDeleteFileRegistrations, testRepairsMultipleDuplicateFileRegistrationsInOneBatch,
    testRepairsMixedDataAndDeleteFileRegistrationsTogether, testRepairsMoreThanTwoRegistrationsOfSamePath,
    testRepairThenCompactEndToEnd, testDuplicateDataFileRegistrationCheckCanBeDisabled,
    testAcceptsTableWithoutDuplicateRegistrations.
  • TestRewritePositionDeleteFilesAction (full suite): 19 tests, 0 failures. Includes standalone
    repair/reject tests (no prior rewrite_data_files call on the table) and a pinned test
    confirming Iceberg itself refuses to create a duplicate DV registration.

spotlessCheck and checkstyle clean on every file this PR touches.
iceberg-core:checkstyleTest currently fails on this branch, but only on
core/src/test/java/org/apache/iceberg/rest/* — unused imports and annotation style, pre-existing
since #1 merged and unrelated to this change.

Known limitations (tracked, not blocking)

  • The guard runs before the unknown-option check, so a typo'd option commits a repair before
    throwing.
  • Repair always targets main, ignoring toBranch(). Not reachable through
    table_compaction.py today (it never emits branch =>); live only if branch-scoped compaction
    is adopted later.
  • The survivor check can pass with zero surviving registrations (robustness, not correctness).
  • RemoveDanglingDeletes hardcodes the guard true,true with no opt-out reachable externally —
    RemoveDanglingDeleteFiles's API has no option-plumbing convention today, so exposing a toggle
    here means introducing that pattern for the first time, not reusing one.
  • A table that trips the DV-duplicate refusal is permanently un-compactable via auto-repair; no
    stored procedure exists to clear that state. validate=false is not a safe rollback for such a
    table — 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 been
raised repeatedly since 2021 (apache/iceberg#2435, #2608, #10445, #10784), all closed
unmerged 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 — a
same-named but functionally different problem. apache/iceberg#17198 added a "Content file
uniqueness" 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.5 counterpart of #8, which ported this same guard
onto 1.11.0-affirm-patch / spark/v4.0 and evolved it further (the three-action wiring, the
shared DuplicateFileRegistrationGuard class, the core TestDuplicateRegistrationRepair suite,
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:

abrarsher23 and others added 2 commits September 1, 2026 14:35
…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) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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();

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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 rayhondo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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("-", "");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Okay for now as all are on v2 but this needs to be addressed for v3

abrarsher23 added a commit that referenced this pull request Sep 3, 2026
- 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants