This is an automated email from the ASF dual-hosted git repository.
zeroshade 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 06cfb8cd4 fix(rowdelta): accept deletes for data files added in the
same commit (#2194)
06cfb8cd4 is described below
commit 06cfb8cd48e3820f17c60dc1b5bf549f0c5f6968
Author: Badal Prasad Singh <[email protected]>
AuthorDate: Tue Oct 20 22:43:26 2026 +0530
fix(rowdelta): accept deletes for data files added in the same commit
(#2194)
* fix: validation
Signed-off-by: badalprasadsingh <[email protected]>
* chore: minor
Signed-off-by: badalprasadsingh <[email protected]>
* chore: minor
Signed-off-by: badalprasadsingh <[email protected]>
* fix: check deletes against the files staged before them
Signed-off-by: badalprasadsingh <[email protected]>
---------
Signed-off-by: badalprasadsingh <[email protected]>
---
table/conflict_validation.go | 131 ++++++++++++-
table/mor_delete_conflict_test.go | 19 ++
table/producer_validate_test.go | 29 ++-
table/row_delta.go | 75 ++++---
table/row_delta_test.go | 400 ++++++++++++++++++++++++++++++++++++++
table/table.go | 6 +-
table/transaction.go | 14 +-
7 files changed, 629 insertions(+), 45 deletions(-)
diff --git a/table/conflict_validation.go b/table/conflict_validation.go
index 0ade50718..e8b56a159 100644
--- a/table/conflict_validation.go
+++ b/table/conflict_validation.go
@@ -154,9 +154,11 @@ var (
// writing or replacing.
ErrConflictingDeleteFiles = fmt.Errorf("%w: concurrent delete files
added", ErrCommitFailed)
- // ErrDataFilesMissing is returned when a concurrent commit deleted
- // data files the committer explicitly references (e.g. a position
- // delete pointing at a file that is no longer reachable).
+ // ErrDataFilesMissing is returned when data files the committer
+ // explicitly references (e.g. the target of a position delete) are
+ // not live: a concurrent commit removed them, or, when RowDelta.Commit
+ // stages the delete, an operation staged earlier in the same
+ // transaction did. A refresh does not help in the second case.
ErrDataFilesMissing = fmt.Errorf("%w: referenced data files missing",
ErrCommitFailed)
)
@@ -411,8 +413,9 @@ func (c *conflictContext) manifestsFor(snap Snapshot)
([]iceberg.ManifestFile, e
// partition-spec projection resolves the same column identifiers.
//
// When base and current point at the same snapshot on the branch, the
-// returned context has no concurrent snapshots and validators
-// short-circuit to nil. When the base snapshot is no longer on the
+// returned context has no concurrent snapshots and validators that
+// inspect them short-circuit to nil; validateDataFilesExist still reads
+// the branch head. When the base snapshot is no longer on the
// branch (divergent commit, expired base), newConflictContext returns
// ErrCommitDiverged; the commit cannot be safely revalidated without
// a refresh-and-rebuild.
@@ -511,6 +514,8 @@ func (c *conflictContext) forEachAddedEntry(content
iceberg.ManifestContent, vis
// validateDataFilesExist verifies that every file path in
// referencedPaths is still a live data file on the current branch head.
+// Files the transaction stages are not on the head yet, so callers pass
+// only the paths Transaction.headDataFiles returns when the delete is staged.
// This is the check a position-delete commit performs to prove the
// files it references have not been removed by a concurrent commit
// (at which point the pos-delete would apply to rewritten data and
@@ -580,13 +585,127 @@ func validateDataFilesExist(ctx *conflictContext,
referencedPaths []string) erro
for p := range needed {
missing = append(missing, p)
}
+
+ return dataFilesMissingError(missing, "missing")
+}
+
+// headDataFiles returns the paths in referenced that a delete staged next
must still
+// find live on the branch head when the transaction commits
(validateDataFilesExist).
+// It replays, oldest first, the data files added and removed by the staged
snapshots
+// the delete is layered on (its parent and the parent's staged ancestors, a
chain a
+// rollback can shorten), then sorts each referenced file into one case:
+// - added by those snapshots or in added (the delete's own data files), and
not
+// removed since: left out, as no concurrent commit can remove an
uncommitted file;
+// - removed by those snapshots: rejected with ErrDataFilesMissing, as it
would be
+// across separate commits;
+// - anything else: returned for the head check, which also rejects a file
that only
+// a later snapshot adds, since the delete would carry the lower sequence
number.
+//
+// A retry sees the same changes: it reuses the staged snapshots' added
manifests and
+// fails if a file one of them removes is gone from the fresh parent
(checkRemovedFiles).
+func (t *Transaction) headDataFiles(fs iceio.IO, referenced []string, added
[]iceberg.DataFile) ([]string, error) {
+ if len(referenced) == 0 {
+ return nil, nil
+ }
+ meta, err := t.txnMeta()
+ if err != nil {
+ return nil, err
+ }
+
+ staged := make(map[int64]*addSnapshotUpdate)
+ for _, u := range meta.updates {
+ if su, ok := u.(*addSnapshotUpdate); ok {
+ staged[su.Snapshot.SnapshotID] = su
+ }
+ }
+ // A parent is always added before its child, so the walk ends at the
first
+ // snapshot this transaction did not stage.
+ var chain []*addSnapshotUpdate
+ if parent := t.planningSnapshot(meta); parent != nil {
+ for su, ok := staged[parent.SnapshotID]; ok; {
+ chain = append(chain, su)
+ if su.Snapshot.ParentSnapshotID == nil {
+ break
+ }
+ su, ok = staged[*su.Snapshot.ParentSnapshotID]
+ }
+ }
+ slices.Reverse(chain)
+
+ live := make(map[string]struct{}, len(added))
+ removed := make(map[string]struct{})
+ for _, su := range chain {
+ id := su.Snapshot.SnapshotID
+ manifests, err := su.Snapshot.Manifests(fs)
+ if err != nil {
+ return nil, fmt.Errorf("iterating data files removed by
staged snapshot %d: %w", id, err)
+ }
+ for _, mf := range manifests {
+ // Only data manifests this snapshot wrote can hold its
tombstones; inherited
+ // ones restate the head (re-read at commit) and the
snapshots replayed before it.
+ if mf.ManifestContent() != iceberg.ManifestContentData
|| mf.SnapshotID() != id || mf.DeletedDataFiles() == 0 {
+ continue
+ }
+ for entry, err := range mf.Entries(fs, false) {
+ if err != nil {
+ return nil, fmt.Errorf("iterating data
files removed by staged snapshot %d: %w", id, err)
+ }
+ if entry.Status() == iceberg.EntryStatusDELETED
&& entry.SnapshotID() == id {
+ path := entry.DataFile().FilePath()
+ delete(live, path)
+ removed[path] = struct{}{}
+ }
+ }
+ }
+
+ for _, mf := range su.ownManifests {
+ if mf.ManifestContent() != iceberg.ManifestContentData {
+ continue
+ }
+ for entry, err := range mf.Entries(fs, true) {
+ if err != nil {
+ return nil, fmt.Errorf("iterating data
files added by staged snapshot %d: %w", id, err)
+ }
+ path := entry.DataFile().FilePath()
+ live[path] = struct{}{}
+ }
+ }
+ }
+ for _, df := range added {
+ live[df.FilePath()] = struct{}{}
+ }
+
+ head := make([]string, 0, len(referenced))
+ var missing []string
+ for _, path := range referenced {
+ // Not committed yet, so no concurrent commit can remove it.
+ if _, ok := live[path]; ok {
+ continue
+ }
+ if _, ok := removed[path]; ok {
+ missing = append(missing, path)
+
+ continue
+ }
+ head = append(head, path)
+ }
+ if len(missing) > 0 {
+ return nil, dataFilesMissingError(missing, "removed earlier in
this transaction")
+ }
+
+ return head, nil
+}
+
+// dataFilesMissingError reports the referenced data files a delete cannot
apply to,
+// naming the first few in path order; state says what became of them.
+func dataFilesMissingError(missing []string, state string) error {
sort.Strings(missing)
sample := missing
if len(sample) > 5 {
sample = sample[:5]
}
- return fmt.Errorf("%w: %d files missing, e.g. %v", ErrDataFilesMissing,
len(missing), sample)
+ return fmt.Errorf("%w: %d files %s, e.g. %v", ErrDataFilesMissing,
len(missing), state, sample)
}
// validateAddedDataFilesMatchingFilter returns an error if any
diff --git a/table/mor_delete_conflict_test.go
b/table/mor_delete_conflict_test.go
index 44e6fc737..84778c39b 100644
--- a/table/mor_delete_conflict_test.go
+++ b/table/mor_delete_conflict_test.go
@@ -128,3 +128,22 @@ func TestMergeOnReadDeleteConflict_NoConflictCommits(t
*testing.T) {
require.Equal(t, []int64{1, 2, 3, 4, 5, 6, 8, 9, 10}, idsInTable(t,
tbl))
}
+
+// TestMergeOnReadDeleteOfDataFileAddedInSameTransaction proves a merge-on-read
+// DELETE may target a data file added earlier in the same transaction:
+// the file is not on the branch head yet, but it lands atomically with the
deletion vector.
+func TestMergeOnReadDeleteOfDataFileAddedInSameTransaction(t *testing.T) {
+ ctx := context.Background()
+ tbl := appendTenRows(t, newV3MoRConflictTestTable(t))
+
+ path := tbl.Location() + "/data/added-in-txn.parquet"
+ writeRowDeltaDataFile(t, tbl, path, 11, 12)
+
+ txn := tbl.NewTransaction()
+ require.NoError(t, txn.AddFiles(ctx, []string{path}, nil, false))
+ require.NoError(t, txn.Delete(ctx,
iceberg.EqualTo(iceberg.Reference("id"), int64(11)), nil))
+ tbl, err := txn.Commit(ctx)
+ require.NoError(t, err)
+
+ require.Equal(t, []int64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12},
idsInTable(t, tbl))
+}
diff --git a/table/producer_validate_test.go b/table/producer_validate_test.go
index 594e74af3..6bee44b36 100644
--- a/table/producer_validate_test.go
+++ b/table/producer_validate_test.go
@@ -224,19 +224,32 @@ func TestRowDelta_ValidateNoDeletes(t *testing.T) {
cc := newEmptyConflictContext(t)
txn := newValidateTestTxn(t, nil)
rd := &RowDelta{txn: txn}
- require.NoError(t, rd.validate(cc))
+ require.NoError(t, rd.validate(cc, nil))
}
// TestRowDelta_ValidatePosDeleteWithoutReference covers the realistic
// case where the pos-delete file does not record its referenced data
-// file (ReferencedDataFile returns nil). validate() must skip the
-// referenced-file check, matching Java's behavior.
+// file (ReferencedDataFile returns nil). referencedDataFiles() must
+// leave it out so validate() skips the referenced-file check.
func TestRowDelta_ValidatePosDeleteWithoutReference(t *testing.T) {
cc := newEmptyConflictContext(t)
txn := newValidateTestTxn(t, nil)
posDelete := newTestPosDeleteFile(t, "pos-1.parquet", nil)
rd := &RowDelta{txn: txn, delFiles: []iceberg.DataFile{posDelete}}
- require.NoError(t, rd.validate(cc))
+ require.NoError(t, rd.validate(cc, rd.referencedDataFiles()))
+}
+
+// TestRowDelta_ReferencedDataFilesAreDistinct pins what Commit checks:
+// each referenced data file once, skipping position deletes that record none
and equality deletes.
+func TestRowDelta_ReferencedDataFilesAreDistinct(t *testing.T) {
+ ref := "data.parquet"
+ rd := &RowDelta{delFiles: []iceberg.DataFile{
+ newTestPosDeleteFile(t, "pos-1.parquet", &ref),
+ newTestPosDeleteFile(t, "pos-2.parquet", nil),
+ newTestEqDeleteFile(t, "eq-1.parquet", []int{1}),
+ newTestPosDeleteFile(t, "pos-3.parquet", &ref),
+ }}
+ assert.Equal(t, []string{ref}, rd.referencedDataFiles())
}
// TestRowDelta_ValidateEqDeleteIsolationGates proves the isolation
@@ -253,7 +266,7 @@ func TestRowDelta_ValidateEqDeleteIsolationGates(t
*testing.T) {
WriteDeleteIsolationLevelKey: string(level),
})
rd := &RowDelta{txn: txn, delFiles:
[]iceberg.DataFile{eqDelete}}
- require.NoError(t, rd.validate(cc), "%s + no concurrent should
be nil", level)
+ require.NoError(t, rd.validate(cc, nil), "%s + no concurrent
should be nil", level)
}
}
@@ -267,7 +280,7 @@ func TestRowDelta_ValidateInvalidIsolationLevel(t
*testing.T) {
})
rd := &RowDelta{txn: txn, delFiles: []iceberg.DataFile{eqDelete}}
- assert.ErrorIs(t, rd.validate(cc), ErrInvalidIsolationLevel)
+ assert.ErrorIs(t, rd.validate(cc, nil), ErrInvalidIsolationLevel)
}
func TestRowDelta_ValidateInvalidIsolationLevelWithPosDeletesOnly(t
*testing.T) {
@@ -278,7 +291,7 @@ func
TestRowDelta_ValidateInvalidIsolationLevelWithPosDeletesOnly(t *testing.T)
})
rd := &RowDelta{txn: txn, delFiles: []iceberg.DataFile{posDelete}}
- assert.ErrorIs(t, rd.validate(cc), ErrInvalidIsolationLevel)
+ assert.ErrorIs(t, rd.validate(cc, nil), ErrInvalidIsolationLevel)
}
func TestRowDelta_ValidateInvalidIsolationLevelWithNilContext(t *testing.T) {
@@ -287,7 +300,7 @@ func
TestRowDelta_ValidateInvalidIsolationLevelWithNilContext(t *testing.T) {
})
rd := &RowDelta{txn: txn}
- assert.ErrorIs(t, rd.validate(nil), ErrInvalidIsolationLevel)
+ assert.ErrorIs(t, rd.validate(nil, nil), ErrInvalidIsolationLevel)
}
func TestRewriteValidator_Smokes(t *testing.T) {
diff --git a/table/row_delta.go b/table/row_delta.go
index c19a331c2..600204185 100644
--- a/table/row_delta.go
+++ b/table/row_delta.go
@@ -46,7 +46,8 @@ import (
// Client-side conflict validation runs before the commit is sent to
// the catalog:
// - Position deletes: referenced data files must still be reachable
-// from the current branch head (validateDataFilesExist).
+// from the current branch head (validateDataFilesExist), unless
+// this delta or an operation staged before it in the transaction adds
them.
// - Equality deletes under write.delete.isolation-level=serializable
// (the default): concurrent data files in the same partition(s) as
// the equality deletes are rejected. For partitioned tables an
@@ -60,8 +61,9 @@ import (
// The pre-flight runs before cat.CommitTable on every commit attempt.
// On the first attempt the writer's view and the catalog state are the
// same, so there are no concurrent snapshots to inspect and the checks
-// short-circuit; on retries doCommit refreshes the catalog state and
-// the checks run against the freshly loaded branch head.
+// against them short-circuit, while the position-delete check still
+// reads the branch head; on retries doCommit refreshes the catalog state
+// and the checks run against the freshly loaded branch head.
//
// Usage:
//
@@ -149,8 +151,10 @@ func (rd *RowDelta) RemoveDeletes(files
...iceberg.DataFile) *RowDelta {
// deletion vector lacks a replacement or is not referenced by the
// current snapshot, if the delta would leave two live deletion vectors
// on one data file (a replacement added while the superseded live DV
-// is not removed, or two added replacements for one data file), or if
-// the table format version does not support delete files.
+// is not removed, or two added replacements for one data file), if a
+// position delete references a data file that an operation staged
+// earlier in the transaction removed (ErrDataFilesMissing), or if the
+// table format version does not support delete files.
// Deletes require v2 or later, DVs require v3 or later.
//
// With removals present the staged commit is non-replayable: an
@@ -242,6 +246,11 @@ func (rd *RowDelta) Commit(ctx context.Context) error {
return err
}
+ referenced, err := rd.txn.headDataFiles(fs, rd.referencedDataFiles(),
rd.dataFiles)
+ if err != nil {
+ return err
+ }
+
op := rd.Operation()
// Find live DVs that would be superseded by added DVs, regardless of
@@ -321,11 +330,38 @@ func (rd *RowDelta) Commit(ctx context.Context) error {
// suppressed above; RowDelta semantics (pos-delete references,
// eq-delete predicate) require a dedicated check that
// snapshot_producers does not know about.
- rd.txn.addValidator(rd.validate)
+ rd.txn.addValidator(func(cc *conflictContext) error {
+ return rd.validate(cc, referenced)
+ })
return rd.txn.apply(updates, reqs)
}
+// referencedDataFiles returns the distinct data files that this delta's
+// position deletes name in referenced_data_file. A position delete that
+// leaves the field unset, such as a v2 file covering several data files,
+// names its targets only in its file_path column, which is not read here,
+// so those targets go unchecked.
+func (rd *RowDelta) referencedDataFiles() []string {
+ var referenced []string
+ seen := make(map[string]struct{})
+ for _, f := range rd.delFiles {
+ if f.ContentType() != iceberg.EntryContentPosDeletes {
+ continue
+ }
+ ref := iceberginternal.BorrowedDataFileReferencedDataFile(f)
+ if ref == nil || *ref == "" {
+ continue
+ }
+ if _, ok := seen[*ref]; !ok {
+ seen[*ref] = struct{}{}
+ referenced = append(referenced, *ref)
+ }
+ }
+
+ return referenced
+}
+
// pathRefKey is the removal identity of a delete-manifest entry: its
// file path plus the data file its deletion vector references (""
// for delete files that record none).
@@ -589,10 +625,12 @@ func (rd *RowDelta) resolveRemovedDeletes(fs iceio.IO,
meta *MetadataBuilder) (r
// reflects the freshly refreshed branch state. Two invariants are
// enforced:
//
-// - Every data file referenced by a position-delete in this RowDelta
-// must still be reachable from the branch head. A concurrent
-// compaction or overwrite that rewrote a referenced file would
-// orphan this pos-delete and produce incorrect results — reject.
+// - Every data file in referenced, the position-delete targets that
+// Commit did not find among the transaction's staged files
+// (Transaction.headDataFiles), must still be reachable from the
+// branch head. A concurrent compaction or overwrite that rewrote a
+// referenced file would orphan this pos-delete and produce incorrect
+// results — reject.
// Always runs, no isolation gating.
//
// - When any equality-delete is included and isolation is
@@ -608,7 +646,7 @@ func (rd *RowDelta) resolveRemovedDeletes(fs iceio.IO, meta
*MetadataBuilder) (r
//
// Fast appends alongside a RowDelta see no validators from RowDelta:
// data-only commits are as safe as a fastAppend.
-func (rd *RowDelta) validate(cc *conflictContext) error {
+func (rd *RowDelta) validate(cc *conflictContext, referenced []string) error {
meta, err := rd.txn.txnMeta()
if err != nil {
return err
@@ -620,22 +658,9 @@ func (rd *RowDelta) validate(cc *conflictContext) error {
return err
}
- // Collect every data-file path the pos-deletes in this delta
- // reference. A nil ReferencedDataFile means the pos-delete does
- // not record its target — we cannot check it here; the file is
- // still present in the per-row position_delete_file column and
- // would apply correctly regardless of concurrent removals,
- // matching Java's behavior when the referenced-file column is
- // unset.
- var referenced []string
var eqDeleteFiles []iceberg.DataFile
for _, f := range rd.delFiles {
- switch f.ContentType() {
- case iceberg.EntryContentPosDeletes:
- if ref :=
iceberginternal.BorrowedDataFileReferencedDataFile(f); ref != nil && *ref != ""
{
- referenced = append(referenced, *ref)
- }
- case iceberg.EntryContentEqDeletes:
+ if f.ContentType() == iceberg.EntryContentEqDeletes {
eqDeleteFiles = append(eqDeleteFiles, f)
}
}
diff --git a/table/row_delta_test.go b/table/row_delta_test.go
index 86e4ddd73..86d025758 100644
--- a/table/row_delta_test.go
+++ b/table/row_delta_test.go
@@ -20,6 +20,7 @@ package table_test
import (
"context"
"iter"
+ "os"
"path/filepath"
"strconv"
"strings"
@@ -1870,3 +1871,402 @@ func TestRowDeltaRejectsConcurrentFirstDVOnRetry(t
*testing.T) {
assert.Equal(t, int32(1), catA.commitTableCalls.Load(),
"the validator must reject on the refreshed retry, before a
second commit attempt")
}
+
+func writeRowDeltaDataFile(t *testing.T, tbl *table.Table, path string, ids
...int64) iceberg.DataFile {
+ t.Helper()
+
+ arrowSc, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false,
false)
+ require.NoError(t, err)
+
+ rows := make([]string, 0, len(ids))
+ for _, id := range ids {
+ row := `{"id": ` + strconv.FormatInt(id, 10) + `}`
+ rows = append(rows, row)
+ }
+
+ jsonData := "[" + strings.Join(rows, ",") + "]"
+ writeParquetFile(t, path, arrowSc, jsonData)
+
+ info, err := os.Stat(path)
+ require.NoError(t, err)
+
+ b, err := iceberg.NewDataFileBuilder(
+ *iceberg.UnpartitionedSpec, iceberg.EntryContentData, path,
iceberg.ParquetFile,
+ nil, nil, nil, int64(len(ids)), info.Size())
+ require.NoError(t, err)
+
+ return b.Build()
+}
+
+func seedRowDeltaDataFile(t *testing.T, tbl *table.Table, path string, ids
...int64) *table.Table {
+ t.Helper()
+
+ writeRowDeltaDataFile(t, tbl, path, ids...)
+ tx := tbl.NewTransaction()
+ require.NoError(t, tx.AddFiles(t.Context(), []string{path}, nil, false))
+
+ tbl, err := tx.Commit(t.Context())
+ require.NoError(t, err)
+ require.NotNil(t, tbl.CurrentSnapshot())
+
+ return tbl
+}
+
+func writeRowDeltaPositionDelete(t *testing.T, location, name, dataPath
string, positions []int64) iceberg.DataFile {
+ t.Helper()
+
+ rows := make([]string, 0, len(positions))
+ for _, pos := range positions {
+ row := `{"file_path": "` + dataPath + `", "pos": ` +
strconv.FormatInt(pos, 10) + `}`
+ rows = append(rows, row)
+ }
+ path := location + "/data/" + name
+ jsonData := "[" + strings.Join(rows, ",") + "]"
+ writeParquetFile(t, path, table.PositionalDeleteArrowSchema, jsonData)
+
+ info, err := os.Stat(path)
+ require.NoError(t, err)
+
+ b, err := iceberg.NewDataFileBuilder(
+ *iceberg.UnpartitionedSpec, iceberg.EntryContentPosDeletes,
path, iceberg.ParquetFile,
+ nil, nil, nil, int64(len(positions)), info.Size())
+ require.NoError(t, err)
+
+ return b.ReferencedDataFile(dataPath).Build()
+}
+
+// addedDataFileStages stage a RowDelta that adds dataB with deletes,
+// either in the same RowDelta or in a later one of the same transaction.
+var addedDataFileStages = []struct {
+ name string
+ stage func(t *testing.T, tx *table.Transaction, dataB iceberg.DataFile,
deletes ...iceberg.DataFile)
+}{
+ {
+ name: "same row delta",
+ stage: func(t *testing.T, tx *table.Transaction, dataB
iceberg.DataFile, deletes ...iceberg.DataFile) {
+ require.NoError(t,
tx.NewRowDelta(nil).AddRows(dataB).AddDeletes(deletes...).Commit(t.Context()))
+ },
+ },
+ {
+ name: "earlier row delta in the transaction",
+ stage: func(t *testing.T, tx *table.Transaction, dataB
iceberg.DataFile, deletes ...iceberg.DataFile) {
+ require.NoError(t,
tx.NewRowDelta(nil).AddRows(dataB).Commit(t.Context()))
+ require.NoError(t,
tx.NewRowDelta(nil).AddDeletes(deletes...).Commit(t.Context()))
+ },
+ },
+}
+
+// A data file added by the commit is not on the branch head yet.
+//
+// Condition: one commit adds file B and deletes for head file A and for B,
+// via one RowDelta or two in one transaction, as v2 position deletes or DVs.
+// Assertion: the commit succeeds and each delete hides only its own row.
+func TestRowDeltaDeletesDataFileAddedInSameCommit(t *testing.T) {
+ formats := []struct {
+ name string
+ formatVersion int
+ ext string
+ writeDelete func(t *testing.T, location, name, dataPath
string, positions []int64) iceberg.DataFile
+ }{
+ {name: "v2 position delete", formatVersion: 2, ext: ".parquet",
writeDelete: writeRowDeltaPositionDelete},
+ {name: "v3 deletion vector", formatVersion: 3, ext: ".puffin",
writeDelete: writeDV},
+ }
+ for _, format := range formats {
+ for _, tt := range addedDataFileStages {
+ t.Run(format.name+"/"+tt.name, func(t *testing.T) {
+ tbl := newRowDeltaCommitTestTableVersion(t,
format.formatVersion)
+ location := tbl.Location()
+ pathA := location + "/data/data-a.parquet"
+ pathB := location + "/data/data-b.parquet"
+ tbl = seedRowDeltaDataFile(t, tbl, pathA, 1, 2,
3)
+
+ dataB := writeRowDeltaDataFile(t, tbl, pathB,
4, 5, 6)
+ deleteA := format.writeDelete(t, location,
"delete-a"+format.ext, pathA, []int64{0})
+ deleteB := format.writeDelete(t, location,
"delete-b"+format.ext, pathB, []int64{2})
+
+ tx := tbl.NewTransaction()
+ tt.stage(t, tx, dataB, deleteA, deleteB)
+ tbl, err := tx.Commit(t.Context())
+ require.NoError(t, err)
+
+ assert.Equal(t, []int64{2, 3, 4, 5},
idsInTable(t, tbl))
+ })
+ }
+ }
+}
+
+// A delete-file rewrite may target a data file added earlier in the same
transaction.
+//
+// Condition: a RowDelta adds B with a DV for it, then a rewrite replaces that
DV.
+// Assertion: the commit succeeds and only the replacement DV hides B's rows.
+func TestRewriteFilesReplacesDVOfDataFileAddedInSameTransaction(t *testing.T) {
+ tbl := newRowDeltaCommitTestTableVersion(t, 3)
+ location := tbl.Location()
+ pathB := location + "/data/data-b.parquet"
+ tbl = seedRowDeltaDataFile(t, tbl, location+"/data/data-a.parquet", 1,
2, 3)
+
+ dataB := writeRowDeltaDataFile(t, tbl, pathB, 4, 5, 6)
+ dvB := writeDV(t, location, "dv-b.puffin", pathB, []int64{0})
+ replacement := writeDV(t, location, "dv-b-replacement.puffin", pathB,
[]int64{0, 2})
+
+ tx := tbl.NewTransaction()
+ require.NoError(t,
tx.NewRowDelta(nil).AddRows(dataB).AddDeletes(dvB).Commit(t.Context()))
+ require.NoError(t,
tx.NewRewrite(nil).DeleteFile(dvB).AddDeleteFile(replacement).Commit(t.Context()))
+ tbl, err := tx.Commit(t.Context())
+ require.NoError(t, err)
+
+ assert.Equal(t, []int64{1, 2, 3, 5}, idsInTable(t, tbl))
+ assert.Equal(t, 1, liveDVCount(t, tbl))
+}
+
+// Exempting files the transaction adds must not waive the check for others.
+//
+// Condition: a DV targets a file that is neither on the head nor added when
the DV is staged:
+// an unknown file beside a DV for B, B added only by a later RowDelta, which
would leave
+// the DV with the lower data sequence number, or B added by a RowDelta rolled
back before it.
+// Assertion: ErrDataFilesMissing names only that file
+// and the catalog never receives a commit.
+func TestRowDeltaRejectsDeleteForDataFileNotLiveWhenStaged(t *testing.T) {
+ tests := []struct {
+ name string
+ stage func(t *testing.T, tx *table.Transaction, headID
int64, dataB, dvB, dvUnknown iceberg.DataFile)
+ wantMissing string
+ }{
+ {
+ name: "unknown file beside a file the row delta adds",
+ stage: func(t *testing.T, tx *table.Transaction, _
int64, dataB, dvB, dvUnknown iceberg.DataFile) {
+ require.NoError(t,
tx.NewRowDelta(nil).AddRows(dataB).AddDeletes(dvB,
dvUnknown).Commit(t.Context()))
+ },
+ wantMissing: "unknown.parquet",
+ },
+ {
+ name: "file a later row delta adds",
+ stage: func(t *testing.T, tx *table.Transaction, _
int64, dataB, dvB, _ iceberg.DataFile) {
+ require.NoError(t,
tx.NewRowDelta(nil).AddDeletes(dvB).Commit(t.Context()))
+ require.NoError(t,
tx.NewRowDelta(nil).AddRows(dataB).Commit(t.Context()))
+ },
+ wantMissing: "data-b.parquet",
+ },
+ {
+ name: "file a rolled back row delta adds",
+ stage: func(t *testing.T, tx *table.Transaction, headID
int64, dataB, dvB, _ iceberg.DataFile) {
+ require.NoError(t,
tx.NewRowDelta(nil).AddRows(dataB).Commit(t.Context()))
+ require.NoError(t,
tx.RollbackToSnapshot(headID))
+ require.NoError(t,
tx.NewRowDelta(nil).AddDeletes(dvB).Commit(t.Context()))
+ },
+ wantMissing: "data-b.parquet",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tbl, cat := newConcurrentRewriteTestTableVersion(t, 3)
+ location := tbl.Location()
+ pathB := location + "/data/data-b.parquet"
+ unknownPath := location + "/data/unknown.parquet"
+
+ tbl = seedRowDeltaDataFile(t, tbl,
location+"/data/data-a.parquet", 1, 2)
+ headBefore := tbl.CurrentSnapshot().SnapshotID
+ attemptsBefore := cat.attempts.Load()
+
+ dataB := writeRowDeltaDataFile(t, tbl, pathB, 3, 4)
+ dvB := writeDV(t, location, "dv-b.puffin", pathB,
[]int64{0})
+ dvUnknown := writeDV(t, location, "dv-unknown.puffin",
unknownPath, []int64{0})
+
+ tx := tbl.NewTransaction()
+ tt.stage(t, tx, headBefore, dataB, dvB, dvUnknown)
+ _, err := tx.Commit(t.Context())
+ require.ErrorIs(t, err, table.ErrDataFilesMissing)
+ assert.ErrorContains(t, err, "1 files missing")
+ assert.ErrorContains(t, err,
location+"/data/"+tt.wantMissing)
+ assert.Equal(t, attemptsBefore, cat.attempts.Load())
+ require.NotNil(t, cat.metadata.CurrentSnapshot())
+ assert.Equal(t, headBefore,
cat.metadata.CurrentSnapshot().SnapshotID)
+ })
+ }
+}
+
+// Rolling back a staged removal makes the file live again for later deletes.
+//
+// Condition: a rewrite removes head file A, the transaction rolls back to the
head,
+// then a RowDelta adds a DV for A.
+// Assertion: the commit succeeds, the DV applies to A, and A carries the only
live DV.
+func TestRowDeltaDeletesDataFileAfterRollingBackItsRemoval(t *testing.T) {
+ tbl := newRowDeltaCommitTestTableVersion(t, 3)
+ location := tbl.Location()
+ pathA := location + "/data/data-a.parquet"
+ tbl = seedRowDeltaDataFile(t, tbl, pathA, 1, 2, 3)
+ headID := tbl.CurrentSnapshot().SnapshotID
+
+ tasks, err := tbl.Scan().PlanFiles(t.Context())
+ require.NoError(t, err)
+ require.Len(t, tasks, 1)
+ dvA := writeDV(t, location, "dv-a.puffin", pathA, []int64{0})
+
+ tx := tbl.NewTransaction()
+ require.NoError(t,
tx.NewRewrite(nil).DeleteFile(tasks[0].File).Commit(t.Context()))
+ require.NoError(t, tx.RollbackToSnapshot(headID))
+ require.NoError(t,
tx.NewRowDelta(nil).AddDeletes(dvA).Commit(t.Context()))
+ tbl, err = tx.Commit(t.Context())
+ require.NoError(t, err)
+
+ assert.Equal(t, []int64{2, 3}, idsInTable(t, tbl))
+ assert.Equal(t, 1, liveDVCount(t, tbl))
+}
+
+// A retry re-checks the refreshed head, which still lacks the added B.
+//
+// Condition: a transaction adds B with DVs for head file A and for B, in one
RowDelta
+// or two, and a concurrent append or removal of A commits first.
+// Assertion: the append retries with both DVs; the removal fails naming A.
+func TestRowDeltaDeletesDataFileAddedInSameCommitOnRetry(t *testing.T) {
+ tests := []struct {
+ name string
+ concurrent func(t *testing.T, tbl *table.Table, dataA
iceberg.DataFile)
+ wantIDs []int64
+ wantErr bool
+ }{
+ {
+ name: "concurrent append",
+ concurrent: func(t *testing.T, tbl *table.Table, _
iceberg.DataFile) {
+ seedRowDeltaDataFile(t, tbl,
tbl.Location()+"/data/data-c.parquet", 7)
+ },
+ wantIDs: []int64{2, 3, 4, 5, 7},
+ },
+ {
+ name: "concurrent removal of a head file",
+ concurrent: func(t *testing.T, tbl *table.Table, dataA
iceberg.DataFile) {
+ tx := tbl.NewTransaction()
+ require.NoError(t,
tx.NewRewrite(nil).DeleteFile(dataA).Commit(t.Context()))
+ _, err := tx.Commit(t.Context())
+ require.NoError(t, err)
+ },
+ wantErr: true,
+ },
+ }
+
+ for _, stage := range addedDataFileStages {
+ for _, tt := range tests {
+ t.Run(stage.name+"/"+tt.name, func(t *testing.T) {
+ tbl, cat :=
newConcurrentRewriteTestTableVersion(t, 3)
+ location := tbl.Location()
+ pathA := location + "/data/data-a.parquet"
+ pathB := location + "/data/data-b.parquet"
+ tbl = seedRowDeltaDataFile(t, tbl, pathA, 1, 2,
3)
+
+ tasks, err := tbl.Scan().PlanFiles(t.Context())
+ require.NoError(t, err)
+ require.Len(t, tasks, 1)
+
+ dataB := writeRowDeltaDataFile(t, tbl, pathB,
4, 5, 6)
+ dvA := writeDV(t, location, "dv-a.puffin",
pathA, []int64{0})
+ dvB := writeDV(t, location, "dv-b.puffin",
pathB, []int64{2})
+ tx := tbl.NewTransaction()
+ stage.stage(t, tx, dataB, dvA, dvB)
+
+ tt.concurrent(t, tbl, tasks[0].File)
+ require.NotNil(t,
cat.metadata.CurrentSnapshot())
+ headBefore :=
cat.metadata.CurrentSnapshot().SnapshotID
+ attemptsBefore := cat.attempts.Load()
+
+ committed, err := tx.Commit(t.Context())
+ if tt.wantErr {
+ require.ErrorIs(t, err,
table.ErrDataFilesMissing)
+ assert.ErrorContains(t, err, "1 files
missing")
+ assert.ErrorContains(t, err, pathA)
+ assert.Equal(t, int32(1),
cat.attempts.Load()-attemptsBefore,
+ "the retry must be rejected
before reaching the catalog")
+ assert.Equal(t, headBefore,
cat.metadata.CurrentSnapshot().SnapshotID)
+
+ return
+ }
+
+ require.NoError(t, err)
+ assert.Equal(t, int32(2),
cat.attempts.Load()-attemptsBefore, "the commit must conflict once and retry")
+ assert.Equal(t, tt.wantIDs, idsInTable(t,
committed))
+ })
+ }
+ }
+}
+
+// A rewrite in the same transaction removes the data file a DV targets.
+//
+// Condition: the rewrite removes head file A, or B added earlier in the
transaction,
+// before the RowDelta that adds the DV beside a DV for the other, still live
file,
+// or after the DV together with it.
+// Assertion: only the DV staged after the removal fails, naming that file
alone and writing
+// nothing, as across separate commits; either way the table ends without the
file and
+// without a live DV.
+func TestRowDeltaDeleteForDataFileRemovedInSameTransaction(t *testing.T) {
+ files := []struct {
+ name string
+ pick func(dataA, dataB iceberg.DataFile) (target, other
iceberg.DataFile)
+ wantIDs []int64
+ }{
+ {
+ name: "head file",
+ pick: func(dataA, dataB iceberg.DataFile)
(iceberg.DataFile, iceberg.DataFile) { return dataA, dataB },
+ wantIDs: []int64{4, 5, 6},
+ },
+ {
+ name: "file added earlier in the transaction",
+ pick: func(dataA, dataB iceberg.DataFile)
(iceberg.DataFile, iceberg.DataFile) { return dataB, dataA },
+ wantIDs: []int64{1, 2, 3},
+ },
+ }
+ orders := []struct {
+ name string
+ stage func(t *testing.T, tbl *table.Table, tx
*table.Transaction, target, dv, otherDV iceberg.DataFile)
+ }{
+ {
+ name: "removed before the delete",
+ stage: func(t *testing.T, tbl *table.Table, tx
*table.Transaction, target, dv, otherDV iceberg.DataFile) {
+ before := metadataFileCount(t, tbl)
+ require.NoError(t,
tx.NewRewrite(nil).DeleteFile(target).Commit(t.Context()))
+ staged := metadataFileCount(t, tbl)
+ require.Greater(t, staged, before, "the probe
must see the files a staged operation writes")
+
+ err := tx.NewRowDelta(nil).AddDeletes(otherDV,
dv).Commit(t.Context())
+ require.ErrorIs(t, err,
table.ErrDataFilesMissing)
+ assert.ErrorContains(t, err, "1 files removed
earlier in this transaction")
+ assert.ErrorContains(t, err, target.FilePath())
+ assert.Equal(t, staged, metadataFileCount(t,
tbl), "the rejected row delta must write nothing")
+ },
+ },
+ {
+ name: "removed after the delete",
+ stage: func(t *testing.T, _ *table.Table, tx
*table.Transaction, target, dv, _ iceberg.DataFile) {
+ require.NoError(t,
tx.NewRowDelta(nil).AddDeletes(dv).Commit(t.Context()))
+ require.NoError(t,
tx.NewRewrite(nil).DeleteFile(target).DeleteFile(dv).Commit(t.Context()))
+ },
+ },
+ }
+
+ for _, file := range files {
+ for _, order := range orders {
+ t.Run(file.name+"/"+order.name, func(t *testing.T) {
+ tbl := newRowDeltaCommitTestTableVersion(t, 3)
+ location := tbl.Location()
+ tbl = seedRowDeltaDataFile(t, tbl,
location+"/data/data-a.parquet", 1, 2, 3)
+
+ tasks, err := tbl.Scan().PlanFiles(t.Context())
+ require.NoError(t, err)
+ require.Len(t, tasks, 1)
+
+ dataB := writeRowDeltaDataFile(t, tbl,
location+"/data/data-b.parquet", 4, 5, 6)
+ target, other := file.pick(tasks[0].File, dataB)
+ dv := writeDV(t, location, "dv-target.puffin",
target.FilePath(), []int64{0})
+ otherDV := writeDV(t, location,
"dv-other.puffin", other.FilePath(), []int64{0})
+
+ tx := tbl.NewTransaction()
+ require.NoError(t,
tx.NewRowDelta(nil).AddRows(dataB).Commit(t.Context()))
+ order.stage(t, tbl, tx, target, dv, otherDV)
+ tbl, err = tx.Commit(t.Context())
+ require.NoError(t, err)
+
+ assert.Equal(t, file.wantIDs, idsInTable(t,
tbl))
+ assert.Zero(t, liveDVCount(t, tbl))
+ })
+ }
+ }
+}
diff --git a/table/table.go b/table/table.go
index 1d3189024..ab06c9c35 100644
--- a/table/table.go
+++ b/table/table.go
@@ -744,9 +744,9 @@ func (t Table) doCommit(ctx context.Context, updates
[]Update, reqs []Requiremen
// peers (partition-filter overlap, referenced-file removal)
// even when the catalog-side AssertRefSnapshotID would accept
// them. On attempt 0 base == current → no concurrent
- // snapshots → validators short-circuit. Real divergence
- // detection fires on attempts > 0 once `current` is the
- // post-conflict state.
+ // snapshots → validators that inspect them short-circuit, but
+ // validateDataFilesExist still reads the branch head. Real
+ // divergence detection fires on attempts > 0 once `current` is
the post-conflict state.
//
// Skipped when the branch does not exist on `current` — that
// always means "the committer is creating this branch" (e.g.
diff --git a/table/transaction.go b/table/transaction.go
index 3e9bac41f..0966c86b5 100644
--- a/table/transaction.go
+++ b/table/transaction.go
@@ -2137,6 +2137,9 @@ func (t *Transaction) replaceFiles(ctx context.Context,
dataFilesToDelete, dataF
referencedDataFilePaths =
append(referencedDataFilePaths, ref)
}
slices.Sort(referencedDataFilePaths)
+ if referencedDataFilePaths, err = t.headDataFiles(fs,
referencedDataFilePaths, nil); err != nil {
+ return err
+ }
}
if !cfg.skipAutoNameMapping {
@@ -2612,8 +2615,13 @@ func (t *Transaction) performMergeOnReadDeletion(ctx
context.Context, snapshotPr
// validates partition-filter overlap; without this check a
concurrent
// commit that removed a referenced data file would silently
orphan the
// deletes (they would apply to rewritten data or dangle).
Mirrors
- // RowDelta.validate: collect referenced data-file paths and run
- // validateDataFilesExist unconditionally (no isolation gating).
+ // RowDelta: collect referenced data-file paths, leave out the
ones
+ // staged earlier in the transaction, and run
validateDataFilesExist
+ // unconditionally (no isolation gating).
+ referenced, err = t.headDataFiles(fs, referenced, nil)
+ if err != nil {
+ return nil, err
+ }
if len(referenced) > 0 {
t.addValidator(func(cc *conflictContext) error {
return validateDataFilesExist(cc, referenced)
@@ -3291,7 +3299,7 @@ func (t *Transaction) rewriteSingleFile(ctx
context.Context, args rewriteSingleF
// files that match filter, appends them to updater, and returns the distinct
// data-file paths those deletes reference. A position-delete that does not
// record its referenced data file is omitted from the returned paths (it
-// cannot be existence-checked), matching RowDelta.validate and Java.
+// cannot be existence-checked), matching RowDelta.referencedDataFiles.
func (t *Transaction) writePositionDeletesForFiles(ctx context.Context, fs
io.IO, updater *snapshotProducer, dvs liveDeletionVectors, files
[]iceberg.DataFile, filter iceberg.BooleanExpression, caseSensitive bool,
concurrency int, commitUUID uuid.UUID) ([]string, error) {
meta, err := t.txnMeta()
if err != nil {