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 84b84fb5f fix(table): check partial-progress batches against the
planning snapshot (#2302)
84b84fb5f is described below
commit 84b84fb5f7a5923ea909a931b2ce8ea005867be4
Author: Matt Topol <[email protected]>
AuthorDate: Wed Nov 4 10:40:13 2026 -0500
fix(table): check partial-progress batches against the planning snapshot
(#2302)
* fix(table): check partial-progress batches against the planning snapshot
Partial-progress compaction plans every group once, then commits batches
one by one, each starting from the previous batch's committed table. When a
batch's refresh-and-replay retry rebases onto a peer snapshot, that snapshot
becomes the next batch's base, so the next batch's commit-time validator never
sees it. If the peer added a position delete or deletion vector for a data file
in a later batch, that batch rewrote the file from tasks planned without the
delete, then CollectDeadPos [...]
Before rewriting a batch, run the rewrite conflict rules over every
snapshot committed since planning. A conflicting batch is recorded in
FailedGroups and counts toward MaxFailedCommits, like a failed commit, and
nothing is written for it.
* test(table): pin rejected batches against MaxFailedCommits
Address review: count planning-snapshot rejections toward
MaxFailedCommits in a test, and update the FailedGroups doc to cover
batches rejected before reaching the catalog. Rebase onto main
(AddFiles is now AddDataFilesByPath).
* refactor(table): read each snapshot once when checking partial-progress
batches
Collect the delete files committed since planning incrementally, so each
batch only reads snapshots committed since the previous batch instead of
rewalking the whole window. Document that partial-progress groups must be
planned from the transaction's table, and report batches rejected by the
planning check as rejected rather than committed.
* test(table): cover deletion vectors absorbed by an earlier
partial-progress batch
---
table/conflict_validation.go | 131 +++++++++++++++++++--------
table/rewrite_data_files.go | 114 ++++++++++++++++++++++--
table/rewrite_data_files_test.go | 186 +++++++++++++++++++++++++++++++++++++++
table/rewrite_files_test.go | 16 +++-
4 files changed, 400 insertions(+), 47 deletions(-)
diff --git a/table/conflict_validation.go b/table/conflict_validation.go
index 6ca0af9ce..9d0a4ca33 100644
--- a/table/conflict_validation.go
+++ b/table/conflict_validation.go
@@ -1044,54 +1044,115 @@ func validateNoNewDeletesForRewrittenFiles(ctx
*conflictContext, rewrittenFiles
return nil
}
- rewrittenPaths := make(map[string]struct{}, len(rewrittenFiles))
- rewrittenPartitions := make(map[string]struct{}, len(rewrittenFiles))
- for _, df := range rewrittenFiles {
- rewrittenPaths[df.FilePath()] = struct{}{}
+ rewritten, err := newRewrittenFileSet(rewrittenFiles)
+ if err != nil {
+ return err
+ }
+
+ return ctx.forEachAddedEntry(iceberg.ManifestContentDeletes, func(snap
Snapshot, e iceberg.ManifestEntry) error {
+ added, err := newAddedDelete(snap, e.DataFile())
+ if err != nil {
+ return err
+ }
+
+ return rewritten.conflict(added)
+ })
+}
+
+// addedDelete is a delete file added by a concurrent snapshot, reduced to
+// what rewrittenFileSet.conflict needs, so callers can keep it without
+// holding on to manifest entries.
+type addedDelete struct {
+ snapshotID int64
+ path string
+ specID int32
+ content iceberg.ManifestEntryContent
+ // refPath is the data file a position delete targets, or "" when the
+ // delete is partition-scoped.
+ refPath string
+ // partitionKey is set only for partition-scoped position deletes.
+ partitionKey string
+}
+
+func newAddedDelete(snap Snapshot, df iceberg.DataFile) (addedDelete, error) {
+ added := addedDelete{
+ snapshotID: snap.SnapshotID,
+ path: df.FilePath(),
+ specID: df.SpecID(),
+ content: df.ContentType(),
+ }
+ if added.content != iceberg.EntryContentPosDeletes {
+ return added, nil
+ }
+
+ if added.refPath = referencedDataFilePath(df); added.refPath != "" {
+ return added, nil
+ }
+
+ key, err := canonicalPartitionKey(df.SpecID(), dataFilePartition(df))
+ if err != nil {
+ return added, fmt.Errorf("building partition conflict key for
concurrent pos-delete %s (spec %d): %w",
+ df.FilePath(), df.SpecID(), err)
+ }
+ added.partitionKey = key
+
+ return added, nil
+}
+
+// rewrittenFileSet indexes the data files a rewrite replaces by path and by
+// (spec, partition).
+type rewrittenFileSet struct {
+ paths map[string]struct{}
+ partitions map[string]struct{}
+}
+
+func newRewrittenFileSet(files []iceberg.DataFile) (rewrittenFileSet, error) {
+ set := rewrittenFileSet{
+ paths: make(map[string]struct{}, len(files)),
+ partitions: make(map[string]struct{}, len(files)),
+ }
+ for _, df := range files {
+ set.paths[df.FilePath()] = struct{}{}
key, err := canonicalPartitionKey(df.SpecID(),
dataFilePartition(df))
if err != nil {
- return fmt.Errorf("building partition conflict key for
rewritten file %s (spec %d): %w",
+ return set, fmt.Errorf("building partition conflict key
for rewritten file %s (spec %d): %w",
df.FilePath(), df.SpecID(), err)
}
- rewrittenPartitions[key] = struct{}{}
+ set.partitions[key] = struct{}{}
}
- return ctx.forEachAddedEntry(iceberg.ManifestContentDeletes, func(snap
Snapshot, e iceberg.ManifestEntry) error {
- df := e.DataFile()
- switch df.ContentType() {
- case iceberg.EntryContentPosDeletes:
- if path := referencedDataFilePath(df); path != "" {
- if _, overlap := rewrittenPaths[path]; overlap {
- return fmt.Errorf("%w: snapshot %d
added pos-delete %s referencing rewritten file %s",
- ErrConflictingDeleteFiles,
snap.SnapshotID, df.FilePath(), path)
- }
-
- return nil
- }
+ return set, nil
+}
- key, err := canonicalPartitionKey(df.SpecID(),
dataFilePartition(df))
- if err != nil {
- return fmt.Errorf("building partition conflict
key for concurrent pos-delete %s (spec %d): %w",
- df.FilePath(), df.SpecID(), err)
- }
- if _, overlap := rewrittenPartitions[key]; overlap {
- return fmt.Errorf("%w: snapshot %d added
partition-scoped pos-delete %s overlapping rewritten partition (spec %d)",
- ErrConflictingDeleteFiles,
snap.SnapshotID, df.FilePath(), df.SpecID())
+// conflict returns ErrConflictingDeleteFiles when d could apply to a file in
+// the set.
+func (s rewrittenFileSet) conflict(d addedDelete) error {
+ switch d.content {
+ case iceberg.EntryContentPosDeletes:
+ if d.refPath != "" {
+ if _, overlap := s.paths[d.refPath]; overlap {
+ return fmt.Errorf("%w: snapshot %d added
pos-delete %s referencing rewritten file %s",
+ ErrConflictingDeleteFiles,
d.snapshotID, d.path, d.refPath)
}
return nil
+ }
- case iceberg.EntryContentEqDeletes:
- // Conservative: any concurrent eq-delete is a conflict
- // for a rewrite, because the predicate could cover a
- // rewritten file. Follow-up: accept partition-overlap
- // hints to narrow this.
- return fmt.Errorf("%w: snapshot %d added equality
delete %s during rewrite",
- ErrConflictingDeleteFiles, snap.SnapshotID,
df.FilePath())
+ if _, overlap := s.partitions[d.partitionKey]; overlap {
+ return fmt.Errorf("%w: snapshot %d added
partition-scoped pos-delete %s overlapping rewritten partition (spec %d)",
+ ErrConflictingDeleteFiles, d.snapshotID,
d.path, d.specID)
}
- return nil
- })
+ case iceberg.EntryContentEqDeletes:
+ // Conservative: any concurrent eq-delete is a conflict
+ // for a rewrite, because the predicate could cover a
+ // rewritten file. Follow-up: accept partition-overlap
+ // hints to narrow this.
+ return fmt.Errorf("%w: snapshot %d added equality delete %s
during rewrite",
+ ErrConflictingDeleteFiles, d.snapshotID, d.path)
+ }
+
+ return nil
}
// referencedDataFilePath resolves the single data file a pos-delete
diff --git a/table/rewrite_data_files.go b/table/rewrite_data_files.go
index e27933545..7d12e574f 100644
--- a/table/rewrite_data_files.go
+++ b/table/rewrite_data_files.go
@@ -74,8 +74,9 @@ type RewriteResult struct {
// partial-progress mode.
CompletedGroups []CompactionGroupResult
- // FailedGroups contains groups whose catalog commits failed. A partial
- // rewrite can return both completed and failed groups.
+ // FailedGroups contains groups whose batch failed to commit, or was
+ // rejected because a delete for one of its files was committed after
+ // planning. A partial rewrite can return both completed and failed
groups.
FailedGroups []CompactionGroupFailure
}
@@ -184,6 +185,11 @@ type RewriteDataFilesOptions struct {
// PartialProgress, when true, commits non-empty groups in durable
catalog
// batches. When false (the default), every group is staged in a single
// atomic rewrite snapshot.
+ //
+ // Partial progress checks each batch against the delete files committed
+ // since the transaction's starting snapshot, so the groups must be
planned
+ // from the transaction's table. Deletes committed between an older
+ // planning snapshot and that starting snapshot are not detected.
PartialProgress bool
// MaxCommits bounds the number of catalog snapshots produced in
@@ -192,10 +198,12 @@ type RewriteDataFilesOptions struct {
// effect for atomic rewrites.
MaxCommits int
- // MaxFailedCommits bounds catalog commit failures in partial-progress
mode.
- // Zero (the default) and negative values allow unlimited failures.
Failures
- // within the bound are returned in RewriteResult.FailedGroups without
making
- // the operation return an error.
+ // MaxFailedCommits bounds failed batches in partial-progress mode:
catalog
+ // commit failures, and batches rejected because a delete file for one
of
+ // their data files was committed after the groups were planned. Zero
(the
+ // default) and negative values allow unlimited failures. Failures
within
+ // the bound are returned in RewriteResult.FailedGroups without making
the
+ // operation return an error.
MaxFailedCommits int
// SnapshotProps are added to the rewrite snapshot's summary.
@@ -670,7 +678,9 @@ func allTasksHaveRowLineage(tasks []FileScanTask) bool {
// A batch is the atomic unit for both the MaxCommits bound and delete cleanup:
// classic position deletes are rechecked against the union of every old data
// file in the batch before they are removed. A later batch can fail without
-// rolling back snapshots already committed for earlier batches.
+// rolling back snapshots already committed for earlier batches. Each batch is
+// also checked against every delete committed since the groups were planned,
+// since an earlier batch's retry can make those snapshots part of its base.
func (t *Transaction) rewriteDataFilesPartial(ctx context.Context, groups
[]CompactionTaskGroup, opts RewriteDataFilesOptions) (*RewriteResult, error) {
if err := t.checkNotNil(); err != nil {
return nil, err
@@ -718,6 +728,7 @@ func (t *Transaction) rewriteDataFilesPartial(ctx
context.Context, groups []Comp
groupsPerCommit := (len(pendingGroups)-1)/maxCommits + 1
props := maps.Clone(opts.SnapshotProps)
current := t.tbl
+ sincePlanning := deletesSincePlanning{checked: t.tbl.Metadata(),
branch: t.branch}
failedCommits := 0
for batchStart := 0; batchStart < len(pendingGroups); batchStart +=
groupsPerCommit {
@@ -725,6 +736,7 @@ func (t *Transaction) rewriteDataFilesPartial(ctx
context.Context, groups []Comp
return result, err
}
+ batchNum := batchStart/groupsPerCommit + 1
batchEnd := min(batchStart+groupsPerCommit, len(pendingGroups))
batchGroups := pendingGroups[batchStart:batchEnd]
batchResults := make([]CompactionGroupResult, 0,
len(batchGroups))
@@ -734,6 +746,33 @@ func (t *Transaction) rewriteDataFilesPartial(ctx
context.Context, groups []Comp
if err != nil {
return result, fmt.Errorf("open table IO for partial
rewrite batch: %w", err)
}
+
+ // The groups were planned against the starting table, but an
earlier
+ // batch's refresh-and-replay retry can commit on top of peer
+ // snapshots. Those snapshots then form this batch's base, so
its
+ // commit-time validator never sees them, while its planned
tasks lack
+ // any delete files they added. Rewriting anyway would write
the deleted
+ // rows back and then drop those deletes as dead.
+ if err := sincePlanning.validate(current.Metadata(), fs,
batchGroups); err != nil {
+ if !errors.Is(err, ErrConflictingDeleteFiles) {
+ return result, fmt.Errorf("validate partial
rewrite batch %d against its planning snapshot: %w", batchNum, err)
+ }
+
+ failedCommits++
+ for _, group := range batchGroups {
+ result.FailedGroups =
append(result.FailedGroups, CompactionGroupFailure{
+ PartitionKey: group.PartitionKey,
+ Err: err,
+ })
+ }
+ if maxFailedCommits > 0 && failedCommits >
maxFailedCommits {
+ return result, fmt.Errorf("reject partial
rewrite batch %d: %w (maximum failed commits reached)",
+ batchNum, err)
+ }
+
+ continue
+ }
+
cleanupBatch := func(cause error, extras
...CompactionGroupResult) error {
results := batchResults
if len(extras) > 0 {
@@ -850,7 +889,7 @@ func (t *Transaction) rewriteDataFilesPartial(ctx
context.Context, groups []Comp
}
if maxFailedCommits > 0 && failedCommits >
maxFailedCommits {
return result, fmt.Errorf("commit partial
rewrite batch %d: %w (maximum failed commits reached)",
- batchStart/groupsPerCommit+1, err)
+ batchNum, err)
}
continue
@@ -947,6 +986,65 @@ func rewriteValidatorWithReferencedDataFiles(
}
}
+// deletesSincePlanning collects the delete files committed on a branch since
+// the groups were planned. Each validate call reads only the snapshots
+// committed since the previous call, so a partial rewrite reads every
+// snapshot's manifests once rather than once per batch.
+type deletesSincePlanning struct {
+ // checked is the metadata the collected deletes run up to. It starts as
+ // the metadata the groups were planned from.
+ checked Metadata
+ branch string
+ deletes []addedDelete
+}
+
+// validate collects the deletes committed between d.checked and current,
+// then returns ErrConflictingDeleteFiles when any delete committed since
+// planning could apply to a data file in groups.
+func (d *deletesSincePlanning) validate(current Metadata, fs iceio.IO, groups
[]CompactionTaskGroup) error {
+ // The rewrite validator binds no filters, so case sensitivity is moot.
+ cc, err := newConflictContext(d.checked, current, d.branch, fs, true)
+ if err != nil {
+ return err
+ }
+ deletes := d.deletes
+ err = cc.forEachAddedEntry(iceberg.ManifestContentDeletes, func(snap
Snapshot, e iceberg.ManifestEntry) error {
+ added, err := newAddedDelete(snap, e.DataFile())
+ if err != nil {
+ return err
+ }
+ deletes = append(deletes, added)
+
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+ d.checked, d.deletes = current, deletes
+
+ if len(d.deletes) == 0 {
+ return nil
+ }
+
+ var files []iceberg.DataFile
+ for _, group := range groups {
+ for _, task := range group.Tasks {
+ files = append(files, task.File)
+ }
+ }
+ rewritten, err := newRewrittenFileSet(files)
+ if err != nil {
+ return err
+ }
+ for _, added := range d.deletes {
+ if err := rewritten.conflict(added); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
// CollectSafePositionDeletes returns position delete files from the
// given tasks that are safe to remove during compaction.
//
diff --git a/table/rewrite_data_files_test.go b/table/rewrite_data_files_test.go
index ef2e168ae..4d91bd22b 100644
--- a/table/rewrite_data_files_test.go
+++ b/table/rewrite_data_files_test.go
@@ -23,6 +23,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "slices"
"strings"
"testing"
@@ -34,6 +35,7 @@ import (
iceio "github.com/apache/iceberg-go/io"
"github.com/apache/iceberg-go/table"
"github.com/apache/iceberg-go/table/compaction"
+ "github.com/apache/iceberg-go/table/dv"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -791,6 +793,190 @@ func
TestRewriteDataFiles_PartialProgressRetainsSharedPositionDeleteAfterFailure
"generated files from tolerated failed batches must be removed")
}
+// TestRewriteDataFiles_PartialProgressRejectsDeleteAbsorbedByEarlierBatch
+// lands a peer delete for a batch-2 data file just before batch 1 commits.
+// Batch 1's retry rebases onto the peer snapshot without conflict, since the
+// delete targets a file batch 1 does not rewrite. Batch 2's tasks were planned
+// before the delete existed and its transaction starts from batch 1's table,
+// so only a check back to the planning snapshot can stop it from rewriting the
+// file without the delete and then dropping the delete as dead, which would
+// bring the deleted row back. File-scoped position deletes and deletion
+// vectors take different paths through newAddedDelete, so both are covered.
+func TestRewriteDataFiles_PartialProgressRejectsDeleteAbsorbedByEarlierBatch(t
*testing.T) {
+ for _, tc := range []struct {
+ name string
+ kind absorbedDeleteKind
+ }{
+ {name: "position delete", kind: absorbedPositionDelete},
+ {name: "deletion vector", kind: absorbedDeletionVector},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ tbl, cat, groups, deletePaths :=
newAbsorbedDeleteFixture(t, tc.kind, 2, 1)
+
+ beforeFiles := parquetFiles(t, tbl.Location())
+ tx := tbl.NewTransaction()
+ result, err := tx.RewriteDataFiles(t.Context(), groups,
table.RewriteDataFilesOptions{
+ PartialProgress: true,
+ MaxCommits: 2,
+ })
+ require.NoError(t, err)
+ require.Nil(t, cat.beforeCommit, "the peer commit must
have landed during the rewrite")
+
+ require.Len(t, result.CompletedGroups, 1)
+ assert.Equal(t, "absorbed-group-0",
result.CompletedGroups[0].PartitionKey)
+ require.Len(t, result.FailedGroups, 1)
+ assert.Equal(t, "absorbed-group-1",
result.FailedGroups[0].PartitionKey)
+ assert.ErrorIs(t, result.FailedGroups[0].Err,
table.ErrConflictingDeleteFiles)
+ assert.Zero(t, result.RemovedPositionDeleteFiles)
+
+ final, err := cat.LoadTable(t.Context(),
tbl.Identifier())
+ require.NoError(t, err)
+ assertRowCount(t, final, 3)
+ finalTasks, err := final.Scan().PlanFiles(t.Context())
+ require.NoError(t, err)
+ var liveDeletes []string
+ for _, task := range finalTasks {
+ for _, df := range task.DeleteFiles {
+ liveDeletes = append(liveDeletes,
df.FilePath())
+ }
+ for _, df := range task.DeletionVectorFiles {
+ liveDeletes = append(liveDeletes,
df.FilePath())
+ }
+ }
+ assert.Equal(t, deletePaths, liveDeletes, "the peer's
delete must stay live")
+
+ // The rejected batch is detected before it writes
anything.
+ expectedFiles := beforeFiles
+ if tc.kind == absorbedPositionDelete {
+ expectedFiles =
append(slices.Clone(deletePaths), beforeFiles...)
+ }
+ for _, file := range
result.CompletedGroups[0].NewDataFiles {
+ expectedFiles = append(expectedFiles,
file.FilePath())
+ }
+ assert.ElementsMatch(t, expectedFiles, parquetFiles(t,
tbl.Location()),
+ "the rejected batch must not leave compacted
outputs behind")
+ })
+ }
+}
+
+//
TestRewriteDataFiles_PartialProgressRejectedBatchesCountTowardMaxFailedCommits
+// rejects two batches through the planning-snapshot check and asserts that
+// they count toward MaxFailedCommits like catalog commit failures do.
+func
TestRewriteDataFiles_PartialProgressRejectedBatchesCountTowardMaxFailedCommits(t
*testing.T) {
+ tbl, cat, groups, _ := newAbsorbedDeleteFixture(t,
absorbedPositionDelete, 3, 1, 2)
+
+ tx := tbl.NewTransaction()
+ result, err := tx.RewriteDataFiles(t.Context(), groups,
table.RewriteDataFilesOptions{
+ PartialProgress: true,
+ MaxCommits: 3,
+ MaxFailedCommits: 1,
+ })
+ require.Nil(t, cat.beforeCommit, "the peer commit must have landed
during the rewrite")
+ require.ErrorIs(t, err, table.ErrConflictingDeleteFiles)
+ assert.ErrorContains(t, err, "maximum failed commits reached")
+ require.Len(t, result.CompletedGroups, 1)
+ assert.Equal(t, "absorbed-group-0",
result.CompletedGroups[0].PartitionKey)
+ require.Len(t, result.FailedGroups, 2)
+}
+
+type absorbedDeleteKind int
+
+const (
+ absorbedPositionDelete absorbedDeleteKind = iota
+ absorbedDeletionVector
+)
+
+// newAbsorbedDeleteFixture writes n two-row data files, each committed on its
+// own, and returns one compaction group per file in path order. Before the
+// rewrite's first catalog commit, a peer commits one delete of row 0 for each
+// data file index in deleteTargets: a file-scoped position delete, or, on a v3
+// table, a deletion vector. Index 0's batch commits first, so targets must be
+// greater than 0 to be absorbed by an earlier batch. The returned paths are
+// the peer's delete files.
+func newAbsorbedDeleteFixture(t *testing.T, kind absorbedDeleteKind, n int,
deleteTargets ...int) (*table.Table, *concurrentTestCatalog,
[]table.CompactionTaskGroup, []string) {
+ t.Helper()
+ version := 2
+ if kind == absorbedDeletionVector {
+ version = 3
+ }
+ tbl, cat := newConcurrentRewriteTestTableVersion(t, version)
+ arrowSc, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false,
false)
+ require.NoError(t, err)
+
+ paths := make([]string, n)
+ for i := range paths {
+ paths[i] = tbl.Location() +
fmt.Sprintf("/data/absorbed-%d.parquet", i)
+ writeParquetFile(t, paths[i], arrowSc, fmt.Sprintf(
+ `[{"id": %d, "data": "a"}, {"id": %d, "data": "b"}]`,
i*2+1, i*2+2))
+
+ tx := tbl.NewTransaction()
+ require.NoError(t, tx.AddDataFilesByPath(t.Context(),
[]string{paths[i]}, nil, false))
+ tbl, err = tx.Commit(t.Context())
+ require.NoError(t, err)
+ }
+
+ tasks, err := tbl.Scan().PlanFiles(t.Context())
+ require.NoError(t, err)
+ require.Len(t, tasks, n)
+ taskByPath := make(map[string]table.FileScanTask, len(tasks))
+ for _, task := range tasks {
+ taskByPath[task.File.FilePath()] = task
+ }
+ groups := make([]table.CompactionTaskGroup, 0, n)
+ for i, path := range paths {
+ task := taskByPath[path]
+ groups = append(groups, table.CompactionTaskGroup{
+ PartitionKey: fmt.Sprintf("absorbed-group-%d", i),
+ Tasks: []table.FileScanTask{task},
+ TotalSizeBytes: task.File.FileSizeBytes(),
+ })
+ }
+
+ deletePaths := make([]string, len(deleteTargets))
+ for i, target := range deleteTargets {
+ ext := "parquet"
+ if kind == absorbedDeletionVector {
+ ext = "puffin"
+ }
+ deletePaths[i] = tbl.Location() +
fmt.Sprintf("/data/absorbed-peer-delete-%d.%s", target, ext)
+ }
+ cat.beforeCommit = func() {
+ peer, err := cat.LoadTable(t.Context(), tbl.Identifier())
+ require.NoError(t, err)
+ peerTx := peer.NewTransaction()
+ delta := peerTx.NewRowDelta(nil)
+ for i, target := range deleteTargets {
+ if kind == absorbedDeletionVector {
+ // A DV names its data file in
referenced_data_file, so the
+ // earlier batch's validator ignores it.
+ w := dv.NewDVWriter(iceio.LocalFS{},
unpartitionedSpecByID)
+ require.NoError(t, w.Add(paths[target],
[]int64{0}, 0, nil))
+ dvFiles, err := w.Flush(t.Context(),
deletePaths[i])
+ require.NoError(t, err)
+ delta = delta.AddDeletes(dvFiles...)
+
+ continue
+ }
+
+ // Equal file_path bounds make the delete file-scoped,
so the
+ // earlier batch's validator ignores it.
+ writeParquetFile(t, deletePaths[i],
table.PositionalDeleteArrowSchema,
+ fmt.Sprintf(`[{"file_path": "%s", "pos": 0}]`,
paths[target]))
+ builder, err := iceberg.NewDataFileBuilder(
+ *iceberg.UnpartitionedSpec,
iceberg.EntryContentPosDeletes,
+ deletePaths[i], iceberg.ParquetFile, nil, nil,
nil, 1, 128)
+ require.NoError(t, err)
+ bound := map[int][]byte{iceberg.DeleteFilePathFieldID:
[]byte(paths[target])}
+ delta =
delta.AddDeletes(builder.LowerBoundValues(bound).UpperBoundValues(bound).Build())
+ }
+ require.NoError(t, delta.Commit(t.Context()))
+ _, err = peerTx.Commit(t.Context())
+ require.NoError(t, err)
+ }
+
+ return tbl, cat, groups, deletePaths
+}
+
func TestRewriteDataFiles_PartialProgressCleansOutputsBeforeCommit(t
*testing.T) {
tbl, _ := newPartialProgressTestTable(t)
arrowSc, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false,
false)
diff --git a/table/rewrite_files_test.go b/table/rewrite_files_test.go
index df2c512e8..7f0acc1e0 100644
--- a/table/rewrite_files_test.go
+++ b/table/rewrite_files_test.go
@@ -48,11 +48,15 @@ import (
// whose AssertRefSnapshotID points at a stale snapshot fails its
// first attempt and triggers refresh-and-replay. attempts counts
// CommitTable invocations so tests can prove the retry boundary.
+// beforeCommit, when set, runs once at the start of the next
+// CommitTable, so a test can land a peer commit inside an operation
+// that commits more than once.
type concurrentTestCatalog struct {
- metadata table.Metadata
- location string
- fsF table.FSysF
- attempts atomic.Int32
+ metadata table.Metadata
+ location string
+ fsF table.FSysF
+ attempts atomic.Int32
+ beforeCommit func()
}
func (c *concurrentTestCatalog) LoadTable(_ context.Context, ident
table.Identifier) (*table.Table, error) {
@@ -61,6 +65,10 @@ func (c *concurrentTestCatalog) LoadTable(_ context.Context,
ident table.Identif
func (c *concurrentTestCatalog) CommitTable(_ context.Context, _
table.Identifier, reqs []table.Requirement, updates []table.Update)
(table.Metadata, string, error) {
c.attempts.Add(1)
+ if hook := c.beforeCommit; hook != nil {
+ c.beforeCommit = nil
+ hook()
+ }
for _, req := range reqs {
if err := req.Validate(c.metadata); err != nil {
return nil, "", fmt.Errorf("%w: %w",
table.ErrCommitFailed, err)