This is an automated email from the ASF dual-hosted git repository.
laskoviymishka pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push:
new c5964d1ba fix(table): validate DV replacement on add (#2054)
c5964d1ba is described below
commit c5964d1ba12897f3697fd36693edfd14b337a87f
Author: Andrei Tserakhau <[email protected]>
AuthorDate: Wed Oct 14 18:29:19 2026 +0200
fix(table): validate DV replacement on add (#2054)
Two paths still let a row delta commit more than one live deletion vector
for a single data file:
- Same-delta duplicate DVs on the fast-append path. findReplacedLiveDVs
collapses added DV references into a set, and when the data file has no
live DV yet the scan returns nothing; with no RemoveDeletes,
validateRemovedDeletes (which held the only duplicate-added-DV check)
never runs, so AddDeletes(dvA, dvB) for one data file committed two live
DVs. Hoist the duplicate-added-DV check into validateAddedDeletionVectors,
run unconditionally on every commit path.
- Concurrent first-DV additions across a commit retry. Two writers based on
a snapshot with no DV for a data file both pass findReplacedLiveDVs. After
one commits, the other's add-only delta is replayable: doCommit rebuilds
the manifest list against the refreshed parent but did not re-check DV
uniqueness, so the retry could inherit the concurrent DV and commit a
second one. Add validateNoConcurrentReplacedDVs to RowDelta.validate: on
retry it walks the concurrently committed delete entries and rejects a
live DV added for a data file this delta also adds a DV for. It is a no-op
on attempt 0 (no concurrent snapshots), and a removal-carrying delta is
non-replayable, so there are no removals to reconcile on retry.
Tests: TestRowDeltaFastAppendRejectsDuplicateAddedDVs covers the fast-append
duplicate, and TestRowDeltaRejectsConcurrentFirstDVOnRetry drives the
two-writer retry through the OCC scenario catalog and asserts the retry is
rejected before a second commit attempt.
---
table/row_delta.go | 184 +++++++++++++++++++++++++++++-------
table/row_delta_test.go | 243 +++++++++++++++++++++++++++++++++++++++---------
2 files changed, 350 insertions(+), 77 deletions(-)
diff --git a/table/row_delta.go b/table/row_delta.go
index a8fee000f..c19a331c2 100644
--- a/table/row_delta.go
+++ b/table/row_delta.go
@@ -222,6 +222,16 @@ func (rd *RowDelta) Commit(ctx context.Context) error {
}
}
+ // A data file may carry at most one live deletion vector, so a single
row
+ // delta may add at most one DV per referenced data file. This must be
+ // checked on every path: on the fast-append path no removals are
present,
+ // so validateRemovedDeletes (which also enforces it) never runs, and
two
+ // DVs added for a data file with no live DV yet would otherwise commit
two
+ // live DVs and make scans reject the table.
+ if err := rd.validateAddedDeletionVectors(); err != nil {
+ return err
+ }
+
fs, err := rd.txn.tbl.fsF(ctx)
if err != nil {
return err
@@ -234,6 +244,27 @@ func (rd *RowDelta) Commit(ctx context.Context) error {
op := rd.Operation()
+ // Find live DVs that would be superseded by added DVs, regardless of
+ // whether removals are present. If any such live DVs exist and are not
+ // being removed, the commit must fail.
+ replacedLive, err := rd.findReplacedLiveDVs(fs, meta)
+ if err != nil {
+ return err
+ }
+
+ // Validate that any live DV whose referenced data file is being
+ // superseded by an added DV is explicitly removed in this delta.
+ if len(replacedLive) > 0 && len(rd.removedDels) == 0 {
+ // No removals were registered, but there are live DVs that
would
+ // be superseded. Return an error with the same message format
as
+ // validateRemovedDeletes to guide the caller.
+ live := replacedLive[0]
+ ref := explicitReferencedDataFile(live)
+
+ return fmt.Errorf("cannot add a replacement deletion vector for
data file %s: live deletion vector %s is not removed by this row delta; the
superseded entry must be removed in the same snapshot",
+ ref, live.FilePath())
+ }
+
var producer *snapshotProducer
if len(rd.removedDels) > 0 {
// Resolve the removed files against the current snapshot's
@@ -245,7 +276,7 @@ func (rd *RowDelta) Commit(ctx context.Context) error {
// live DVs whose referenced data file this delta adds a
// replacement for; validateRemovedDeletes checks each is
// actually removed.
- resolvedRemovals, replacedLive, err :=
rd.resolveRemovedDeletes(fs, meta)
+ resolvedRemovals, err := rd.resolveRemovedDeletes(fs, meta)
if err != nil {
return err
}
@@ -338,13 +369,12 @@ func explicitReferencedDataFile(df iceberg.DataFile)
string {
// removals — otherwise the commit would leave both the old and the
// new DV live on one data file.
func (rd *RowDelta) validateRemovedDeletes(resolved, replacedLive
[]iceberg.DataFile) error {
+ // Duplicate added DVs (two references to one data file) are already
+ // rejected unconditionally by validateAddedDeletionVectors before this
+ // runs; here the set only pairs removals with their replacements.
addedDVs := make(map[string]struct{}, len(rd.delFiles))
for _, f := range rd.delFiles {
if ref := explicitReferencedDataFile(f); IsDeletionVector(f) &&
ref != "" {
- if _, ok := addedDVs[ref]; ok {
- return fmt.Errorf("multiple added deletion
vectors reference data file %s; at most one live deletion vector may exist per
data file",
- ref)
- }
addedDVs[ref] = struct{}{}
}
}
@@ -379,6 +409,66 @@ func (rd *RowDelta) validateRemovedDeletes(resolved,
replacedLive []iceberg.Data
return nil
}
+// validateAddedDeletionVectors rejects a row delta that adds more than one
+// deletion vector referencing the same data file. Two live DVs on one data
+// file violate the v3 spec and make scan planning reject the table. It runs on
+// every commit path; validateRemovedDeletes enforces the same rule but only
+// runs when removals are present, so the fast-append path relies on this.
+func (rd *RowDelta) validateAddedDeletionVectors() error {
+ addedDVs := make(map[string]struct{}, len(rd.delFiles))
+ for _, f := range rd.delFiles {
+ if ref := explicitReferencedDataFile(f); IsDeletionVector(f) &&
ref != "" {
+ if _, ok := addedDVs[ref]; ok {
+ return fmt.Errorf("multiple added deletion
vectors reference data file %s; at most one live deletion vector may exist per
data file",
+ ref)
+ }
+ addedDVs[ref] = struct{}{}
+ }
+ }
+
+ return nil
+}
+
+// findReplacedLiveDVs walks the current snapshot's delete manifests and
+// collects all live deletion vectors whose referenced data file this delta
+// adds a replacement for. This is used to enforce the v3 spec invariant
+// that at most one live DV can exist per data file: if any such live DVs
+// are found and they are not being explicitly removed, the commit must fail.
+func (rd *RowDelta) findReplacedLiveDVs(fs iceio.IO, meta *MetadataBuilder)
([]iceberg.DataFile, error) {
+ snap := rd.txn.planningSnapshot(meta)
+ if snap == nil {
+ // No existing snapshot means no live DVs to worry about.
+ return nil, nil
+ }
+
+ addedRefs := make(map[string]struct{}, len(rd.delFiles))
+ for _, f := range rd.delFiles {
+ if ref := explicitReferencedDataFile(f); IsDeletionVector(f) &&
ref != "" {
+ addedRefs[ref] = struct{}{}
+ }
+ }
+
+ // If no added DVs, there's nothing to replace.
+ if len(addedRefs) == 0 {
+ return nil, nil
+ }
+
+ var replacedLive []iceberg.DataFile
+ for entry, err := range snap.entries(fs,
iceberg.ManifestContentDeletes, true) {
+ if err != nil {
+ return nil, err
+ }
+ df := entry.DataFile()
+ if ref := explicitReferencedDataFile(df); IsDeletionVector(df)
&& ref != "" {
+ if _, ok := addedRefs[ref]; ok {
+ replacedLive = append(replacedLive, df)
+ }
+ }
+ }
+
+ return replacedLive, nil
+}
+
// resolveRemovedDeletes walks the current snapshot's delete manifests
// and resolves each removed file to its live manifest entry, returned
// in the order the removals were registered.
@@ -408,18 +498,10 @@ func (rd *RowDelta) validateRemovedDeletes(resolved,
replacedLive []iceberg.Data
// than one live entry at the path is rejected: the producer would
// tombstone every matching entry while the snapshot summary counts
// only one removal.
-//
-// The walk also collects replacedLive: every live deletion vector
-// whose referenced data file this delta adds a replacement DV for.
-// validateRemovedDeletes requires each to be among the removals, so a
-// delta cannot commit a replacement while leaving the superseded DV
-// live. This piggybacks on the manifest walk the removals already
-// need; deltas without removals do not pay for it (nor get it — see
-// AddDeletes).
-func (rd *RowDelta) resolveRemovedDeletes(fs iceio.IO, meta *MetadataBuilder)
(resolved, replacedLive []iceberg.DataFile, _ error) {
+func (rd *RowDelta) resolveRemovedDeletes(fs iceio.IO, meta *MetadataBuilder)
(resolved []iceberg.DataFile, _ error) {
snap := rd.txn.planningSnapshot(meta)
if snap == nil {
- return nil, nil, errors.New("cannot remove delete files from a
table without an existing snapshot")
+ return nil, errors.New("cannot remove delete files from a table
without an existing snapshot")
}
want := make(map[string]struct{}, len(rd.removedDels))
@@ -427,27 +509,15 @@ func (rd *RowDelta) resolveRemovedDeletes(fs iceio.IO,
meta *MetadataBuilder) (r
want[f.FilePath()] = struct{}{}
}
- addedRefs := make(map[string]struct{}, len(rd.delFiles))
- for _, f := range rd.delFiles {
- if ref := explicitReferencedDataFile(f); IsDeletionVector(f) &&
ref != "" {
- addedRefs[ref] = struct{}{}
- }
- }
-
liveByPath := make(map[string][]iceberg.DataFile, len(rd.removedDels))
for entry, err := range snap.entries(fs,
iceberg.ManifestContentDeletes, true) {
if err != nil {
- return nil, nil, err
+ return nil, err
}
df := entry.DataFile()
if _, ok := want[df.FilePath()]; ok {
liveByPath[df.FilePath()] =
append(liveByPath[df.FilePath()], df)
}
- if ref := explicitReferencedDataFile(df); IsDeletionVector(df)
&& ref != "" {
- if _, ok := addedRefs[ref]; ok {
- replacedLive = append(replacedLive, df)
- }
- }
}
seenKeys := make(map[pathRefKey]struct{}, len(rd.removedDels))
@@ -459,13 +529,13 @@ func (rd *RowDelta) resolveRemovedDeletes(fs iceio.IO,
meta *MetadataBuilder) (r
var live iceberg.DataFile
switch {
case len(candidates) == 0:
- return nil, nil, fmt.Errorf("cannot remove delete files
that do not belong to the table: %s", f.FilePath())
+ return nil, fmt.Errorf("cannot remove delete files that
do not belong to the table: %s", f.FilePath())
case len(candidates) == 1:
live = candidates[0]
default:
ref := explicitReferencedDataFile(f)
if ref == "" {
- return nil, nil, fmt.Errorf("ambiguous removal:
%d live delete entries share path %s; the removed file must declare a
referenced data file to identify one",
+ return nil, fmt.Errorf("ambiguous removal: %d
live delete entries share path %s; the removed file must declare a referenced
data file to identify one",
len(candidates), f.FilePath())
}
matches := 0
@@ -479,10 +549,10 @@ func (rd *RowDelta) resolveRemovedDeletes(fs iceio.IO,
meta *MetadataBuilder) (r
}
switch {
case matches == 0:
- return nil, nil, fmt.Errorf("cannot remove
delete file %s: no live delete entry references data file %s",
+ return nil, fmt.Errorf("cannot remove delete
file %s: no live delete entry references data file %s",
f.FilePath(), ref)
case matches > 1:
- return nil, nil, fmt.Errorf("found %d live
delete entries at %s referencing data file %s; the table has duplicate live
deletion vectors for one data file",
+ return nil, fmt.Errorf("found %d live delete
entries at %s referencing data file %s; the table has duplicate live deletion
vectors for one data file",
matches, f.FilePath(), ref)
}
}
@@ -490,7 +560,7 @@ func (rd *RowDelta) resolveRemovedDeletes(fs iceio.IO, meta
*MetadataBuilder) (r
liveRef := explicitReferencedDataFile(live)
key := pathRefKey{path: live.FilePath(), ref: liveRef}
if _, ok := seenKeys[key]; ok {
- return nil, nil, fmt.Errorf("removed delete files must
be unique: %s (referenced data file %q)",
+ return nil, fmt.Errorf("removed delete files must be
unique: %s (referenced data file %q)",
live.FilePath(), liveRef)
}
seenKeys[key] = struct{}{}
@@ -502,7 +572,7 @@ func (rd *RowDelta) resolveRemovedDeletes(fs iceio.IO, meta
*MetadataBuilder) (r
// error, so they are exempt here.
if liveRef != "" {
if prevPath, ok := seenRefs[liveRef]; ok {
- return nil, nil, fmt.Errorf("removed delete
files %s and %s both reference data file %q; the table has two live deletion
vectors for one data file",
+ return nil, fmt.Errorf("removed delete files %s
and %s both reference data file %q; the table has two live deletion vectors for
one data file",
prevPath, live.FilePath(), liveRef)
}
seenRefs[liveRef] = live.FilePath()
@@ -510,7 +580,7 @@ func (rd *RowDelta) resolveRemovedDeletes(fs iceio.IO, meta
*MetadataBuilder) (r
resolved[i] = live
}
- return resolved, replacedLive, nil
+ return resolved, nil
}
// validate is the client-side conflict check for a RowDelta commit. It
@@ -574,6 +644,15 @@ func (rd *RowDelta) validate(cc *conflictContext) error {
return nil
}
+ // Enforce the v3 single-live-DV invariant against concurrently
committed
+ // snapshots. findReplacedLiveDVs caught DVs already live in the base at
+ // Commit time; this catches a concurrent writer that added the first DV
+ // for the same data file after our base, visible once the branch is
+ // refreshed on retry.
+ if err := rd.validateNoConcurrentReplacedDVs(cc); err != nil {
+ return err
+ }
+
if len(referenced) > 0 {
if err := validateDataFilesExist(cc, referenced); err != nil {
return err
@@ -607,6 +686,41 @@ func (rd *RowDelta) validate(cc *conflictContext) error {
return nil
}
+// validateNoConcurrentReplacedDVs enforces the v3 single-live-DV invariant on
+// commit retries. findReplacedLiveDVs (run once at Commit time) rejects DVs
+// already live in the base snapshot, but two writers can each add the FIRST DV
+// for the same data file: both pass that scan against their shared base, and
an
+// add-only row delta is replayable. On retry cc carries the concurrently
+// committed snapshots, so a concurrent writer that added a DV for a data file
+// this delta also adds a DV for would strand two live DVs on one data file —
+// reject. A removal-carrying delta is non-replayable (see RemoveDeletes), so a
+// retry never has removals to reconcile against the concurrent DV here.
+func (rd *RowDelta) validateNoConcurrentReplacedDVs(cc *conflictContext) error
{
+ addedRefs := make(map[string]struct{}, len(rd.delFiles))
+ for _, f := range rd.delFiles {
+ if ref := explicitReferencedDataFile(f); IsDeletionVector(f) &&
ref != "" {
+ addedRefs[ref] = struct{}{}
+ }
+ }
+ if len(addedRefs) == 0 {
+ return nil
+ }
+
+ return cc.forEachAddedEntry(iceberg.ManifestContentDeletes, func(snap
Snapshot, entry iceberg.ManifestEntry) error {
+ df := entry.DataFile()
+ ref := explicitReferencedDataFile(df)
+ if !IsDeletionVector(df) || ref == "" {
+ return nil
+ }
+ if _, ok := addedRefs[ref]; ok {
+ return fmt.Errorf("cannot add a deletion vector for
data file %s: concurrent snapshot %d committed a live deletion vector %s for
it; reload the table and rewrite the DV to supersede the concurrent one,
removing the superseded DV in the same row delta",
+ ref, snap.SnapshotID, df.FilePath())
+ }
+
+ return nil
+ })
+}
+
// Operation returns the snapshot operation type that will be used when
// this RowDelta is committed:
// - data only → OpAppend
diff --git a/table/row_delta_test.go b/table/row_delta_test.go
index 237f0457c..86e4ddd73 100644
--- a/table/row_delta_test.go
+++ b/table/row_delta_test.go
@@ -963,7 +963,7 @@ func TestRowDeltaRemoveDeletesRequiresReplacement(t
*testing.T) {
// Assertion: Commit fails identifying the surviving live DV (a) or the
// duplicated reference (b).
func TestRowDeltaRemoveDeletesRejectsSurvivingLiveDV(t *testing.T) {
- t.Run("replacement added while live DV not removed", func(t *testing.T)
{
+ t.Run("replacement added while live DV not removed (with
RemoveDeletes)", func(t *testing.T) {
tbl, location, pathA, pathB, dvA, _ :=
newTableWithSharedPuffinDVs(t)
// Supersede A's DV, but also add a replacement for B without
@@ -980,6 +980,20 @@ func TestRowDeltaRemoveDeletesRejectsSurvivingLiveDV(t
*testing.T) {
assert.Contains(t, err.Error(), pathB)
})
+ t.Run("replacement added while live DV not removed (without
RemoveDeletes)", func(t *testing.T) {
+ tbl, location, pathA, _, _, _ := newTableWithSharedPuffinDVs(t)
+
+ // Add a replacement DV for A's live DV without removing the
live one.
+ // This should fail in the fast-append path (no RemoveDeletes
called).
+ dvA2 := writeDV(t, location, "dv-a2.puffin", pathA, []int64{0,
1})
+ rd := tbl.NewTransaction().NewRowDelta(nil).AddDeletes(dvA2)
+
+ err := rd.Commit(t.Context())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "is not removed by this row
delta")
+ assert.Contains(t, err.Error(), pathA)
+ })
+
t.Run("two replacements for one data file", func(t *testing.T) {
tbl, location, dataPath, dv1 := newTableWithLiveDV(t)
@@ -995,60 +1009,83 @@ func TestRowDeltaRemoveDeletesRejectsSurvivingLiveDV(t
*testing.T) {
})
}
-// Why: a table that already violates the one-live-DV-per-data-file
-// invariant must fail a supersession commit loudly — the producer keys
-// DV removals by referenced data file, so proceeding would either
-// tombstone several entries while counting one removal (same path) or
-// leave the sibling duplicate live next to the replacement (different
-// paths).
-// Condition: v3 tables corrupted through the unvalidated plain
-// AddDeletes path so one data file carries two live DV entries, first
-// as duplicate entries at one Puffin path, then at two distinct paths;
-// a RowDelta then supersedes one of them.
-// Assertion: Commit fails naming the duplicate entries rather than
-// committing.
+// Why: supersession in the fast-append path (no RemoveDeletes called)
+// must still enforce the one-live-DV-per-data-file invariant and require
+// explicit removal of the live DV.
+// Condition: a RowDelta adds a replacement DV for a data file with a live
+// DV, without calling RemoveDeletes.
+// Assertion: Commit fails with the same error as the RemoveDeletes case,
+// directing the caller to remove the superseded DV. With RemoveDeletes,
+// the same operation succeeds.
+func TestRowDeltaFastAppendRejectsDVSupersessionWithoutRemoval(t *testing.T) {
+ tbl, location, dataPath, dv1 := newTableWithLiveDV(t)
+
+ // Add a replacement DV without removing the live one.
+ rep := writeDV(t, location, "dv-002.puffin", dataPath, []int64{0, 1})
+ tx := tbl.NewTransaction()
+ rd := tx.NewRowDelta(nil).AddDeletes(rep)
+
+ err := rd.Commit(t.Context())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "is not removed by this row delta")
+ assert.Contains(t, err.Error(), "superseded entry must be removed in
the same snapshot")
+
+ // With RemoveDeletes, the same operation succeeds.
+ rep2 := writeDV(t, location, "dv-003.puffin", dataPath, []int64{0, 1})
+ tx2 := tbl.NewTransaction()
+ rd2 := tx2.NewRowDelta(nil).AddDeletes(rep2).RemoveDeletes(dv1)
+ require.NoError(t, rd2.Commit(t.Context()))
+
+ result, err := tx2.Commit(t.Context())
+ require.NoError(t, err)
+ snap := result.CurrentSnapshot()
+ require.NotNil(t, snap)
+
+ fs := iceio.LocalFS{}
+ live, removed := snapshotDVEntries(t, snap, fs)
+ assert.Equal(t, []string{rep2.FilePath()}, live)
+ assert.Equal(t, []string{dv1.FilePath()}, removed)
+}
+
+// Why: the fast-append path now prevents tables from being corrupted with
+// duplicate DVs for the same data file. Previously, duplicate entries could
+// be added through the unvalidated plain AddDeletes path, violating the
+// one-live-DV-per-data-file invariant. Now this is detected immediately.
+// Condition: a RowDelta attempts to add a replacement DV for a data file
+// that already has a live DV without removing the live one.
+// Assertion: Commit fails in the fast-append path itself, before any
+// corruption can occur.
func TestRowDeltaRemoveDeletesCorruptDuplicateLiveDVs(t *testing.T) {
- t.Run("duplicate entries at one path", func(t *testing.T) {
- tbl, location, dataPath, dv1 := newTableWithLiveDV(t)
+ t.Run("duplicate entries at one path prevented", func(t *testing.T) {
+ tbl, _, dataPath, dv1 := newTableWithLiveDV(t)
- // Corrupt the table: a second snapshot re-adds the same DV
- // entry (same path, same referenced data file).
+ // Attempt to re-add the same DV without removing the live one.
+ // This now fails in the fast-append validation, preventing
corruption.
tx := tbl.NewTransaction()
- require.NoError(t,
tx.NewRowDelta(nil).AddDeletes(dv1).Commit(t.Context()))
- tbl, err := tx.Commit(t.Context())
- require.NoError(t, err)
-
- replacement := writeDV(t, location, "dv-002.puffin", dataPath,
[]int64{0, 1})
- rd := tbl.NewTransaction().NewRowDelta(nil).
- AddDeletes(replacement).
- RemoveDeletes(dv1)
+ rd := tx.NewRowDelta(nil).AddDeletes(dv1)
- err = rd.Commit(t.Context())
+ err := rd.Commit(t.Context())
require.Error(t, err)
- assert.Contains(t, err.Error(), "duplicate live deletion
vectors")
+ assert.Contains(t, err.Error(), "is not removed by this row
delta",
+ "adding a duplicate DV for the same data file must fail
in the fast-append path")
+ assert.Contains(t, err.Error(), dataPath)
})
- t.Run("duplicate entries at two paths", func(t *testing.T) {
- tbl, location, dataPath, dv1 := newTableWithLiveDV(t)
+ t.Run("duplicate entries at two paths prevented", func(t *testing.T) {
+ tbl, location, dataPath, _ := newTableWithLiveDV(t)
- // Corrupt the table: a second live DV for the same data file
- // at a different Puffin path.
+ // Attempt to add a second live DV for the same data file
+ // at a different Puffin path, without removing the first.
+ // This now fails in the fast-append validation, preventing
corruption.
dv1b := writeDV(t, location, "dv-001b.puffin", dataPath,
[]int64{0})
tx := tbl.NewTransaction()
- require.NoError(t,
tx.NewRowDelta(nil).AddDeletes(dv1b).Commit(t.Context()))
- tbl, err := tx.Commit(t.Context())
- require.NoError(t, err)
+ rd := tx.NewRowDelta(nil).AddDeletes(dv1b)
- replacement := writeDV(t, location, "dv-002.puffin", dataPath,
[]int64{0, 1})
- rd := tbl.NewTransaction().NewRowDelta(nil).
- AddDeletes(replacement).
- RemoveDeletes(dv1)
-
- err = rd.Commit(t.Context())
+ err := rd.Commit(t.Context())
require.Error(t, err)
assert.Contains(t, err.Error(), "is not removed by this row
delta",
- "the sibling duplicate at the other path must be
flagged as surviving")
- assert.Contains(t, err.Error(), dv1b.FilePath())
+ "adding a second DV for the same data file must fail in
the fast-append path")
+ assert.Contains(t, err.Error(), dataPath)
})
}
@@ -1711,3 +1748,125 @@ func writtenManifests(t *testing.T, location string)
[]string {
return paths
}
+
+// Why: two DVs added in one row delta that reference the same data file would
+// commit two live DVs even when that data file has no live DV yet, so the
+// removal path's duplicate check never runs (no RemoveDeletes → fast-append).
+// The fast-append path must reject the duplicate directly.
+// Condition: AddDeletes(dvA, dvB) where both reference the same data file that
+// currently has no live DV, and no RemoveDeletes is called.
+// Assertion: Commit fails with the duplicate-added-DV error before committing.
+func TestRowDeltaFastAppendRejectsDuplicateAddedDVs(t *testing.T) {
+ tbl := newRowDeltaCommitTestTableVersion(t, 3)
+ location := tbl.Location()
+
+ arrowSc, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false,
false)
+ require.NoError(t, err)
+
+ dataPath := location + "/data/data-100.parquet"
+ writeParquetFile(t, dataPath, arrowSc, `[
+ {"id": 1, "data": "alpha"},
+ {"id": 2, "data": "beta"}
+ ]`)
+
+ tx := tbl.NewTransaction()
+ require.NoError(t, tx.AddFiles(t.Context(), []string{dataPath}, nil,
false))
+ tbl, err = tx.Commit(t.Context())
+ require.NoError(t, err)
+
+ // The data file has no live DV yet, and no RemoveDeletes is called, so
+ // only the fast-append path can catch the duplicate.
+ dvA := writeDV(t, location, "dv-a.puffin", dataPath, []int64{0})
+ dvB := writeDV(t, location, "dv-b.puffin", dataPath, []int64{1})
+
+ err = tbl.NewTransaction().NewRowDelta(nil).AddDeletes(dvA,
dvB).Commit(t.Context())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "multiple added deletion vectors
reference data file "+dataPath)
+}
+
+// Why: two writers that each add the FIRST deletion vector for the same data
+// file both pass the pre-commit scan against their shared base (no live DV
+// yet). After one commits, the other's add-only row delta is replayable, so
+// the retry must re-check against the refreshed head, or the table ends up
+// with two live DVs for one data file.
+// Condition: Writer A adds a DV for data file D; a concurrent writer commits
+// the first DV for D before A's commit lands, forcing A to retry.
+// Assertion: A's commit fails on the retry (after one refresh, before a second
+// commit attempt) with the concurrent-DV conflict error.
+func TestRowDeltaRejectsConcurrentFirstDVOnRetry(t *testing.T) {
+ localFS := func(context.Context) (iceio.IO, error) { return
iceio.LocalFS{}, nil }
+
+ location := filepath.ToSlash(t.TempDir())
+ schema := iceberg.NewSchema(0,
+ iceberg.NestedField{ID: 1, Name: "id", Type:
iceberg.PrimitiveTypes.Int64, Required: true},
+ iceberg.NestedField{ID: 2, Name: "data", Type:
iceberg.PrimitiveTypes.String},
+ )
+ props := iceberg.Properties{
+ table.PropertyFormatVersion: "3",
+ table.CommitMinRetryWaitMsKey: "0",
+ table.CommitMaxRetryWaitMsKey: "0",
+ table.CommitTotalRetryTimeoutMsKey: "60000",
+ table.CommitNumRetriesKey: "2",
+ }
+ meta, err := table.NewMetadata(schema, iceberg.UnpartitionedSpec,
table.UnsortedSortOrder, location, props)
+ require.NoError(t, err)
+
+ ident := table.Identifier{"db", "row_delta_concurrent_dv"}
+
+ // Base: one data file D with two rows and no deletion vector.
+ baseCat := &occScenarioCatalog{current: meta, location: location}
+ baseTbl := table.New(ident, meta,
location+"/metadata/v1.metadata.json", localFS, baseCat)
+
+ arrowSc, err := table.SchemaToArrowSchema(baseTbl.Schema(), nil, false,
false)
+ require.NoError(t, err)
+ dataPath := location + "/data/data-001.parquet"
+ writeParquetFile(t, dataPath, arrowSc, `[
+ {"id": 1, "data": "alpha"},
+ {"id": 2, "data": "beta"}
+ ]`)
+
+ tx := baseTbl.NewTransaction()
+ require.NoError(t, tx.AddFiles(t.Context(), []string{dataPath}, nil,
false))
+ _, err = tx.Commit(t.Context())
+ require.NoError(t, err)
+ metaBase := baseCat.current
+
+ // A concurrent writer commits the first DV for D just before A's
commit,
+ // advancing the head A will refresh onto.
+ addConcurrentDV := func(current table.Metadata) table.Metadata {
+ peerCat := &occScenarioCatalog{current: current, location:
location}
+ peerTbl := table.New(ident, current,
location+"/metadata/peer.metadata.json", localFS, peerCat)
+
+ dvC := writeDV(t, location, "dv-concurrent.puffin", dataPath,
[]int64{0})
+ ptx := peerTbl.NewTransaction()
+ require.NoError(t,
ptx.NewRowDelta(nil).AddDeletes(dvC).Commit(t.Context()))
+ _, perr := ptx.Commit(t.Context())
+ require.NoError(t, perr)
+
+ return peerCat.current
+ }
+
+ // Writer A starts from the DV-free base and adds its own first DV for
D.
+ // Its catalog conflicts once (landing the concurrent DV), then would
accept
+ // the retry — which the validator must reject before it commits.
+ catA := &occScenarioCatalog{
+ current: metaBase,
+ conflictsLeft: 1,
+ location: location,
+ onConflict: addConcurrentDV,
+ }
+ writerA := table.New(ident, metaBase,
location+"/metadata/base.metadata.json", localFS, catA)
+
+ dvA := writeDV(t, location, "dv-writer-a.puffin", dataPath, []int64{1})
+ txA := writerA.NewTransaction()
+ require.NoError(t,
txA.NewRowDelta(nil).AddDeletes(dvA).Commit(t.Context()))
+
+ _, err = txA.Commit(t.Context())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "concurrent snapshot")
+ assert.Contains(t, err.Error(), dataPath)
+ assert.Equal(t, int32(1), catA.loadTableCalls.Load(),
+ "the commit must refresh once for the retry before rejecting")
+ assert.Equal(t, int32(1), catA.commitTableCalls.Load(),
+ "the validator must reject on the refreshed retry, before a
second commit attempt")
+}