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 1df76d8c9 fix(table): apply existing deletes when cow rewrites a data
file (#2046)
1df76d8c9 is described below
commit 1df76d8c901f63b22be4d6e98fbc8414880ced9a
Author: Badal Prasad Singh <[email protected]>
AuthorDate: Thu Oct 15 23:51:23 2026 +0530
fix(table): apply existing deletes when cow rewrites a data file (#2046)
* fix: apply existing deletes
Signed-off-by: badalprasadsingh <[email protected]>
* minor
Signed-off-by: badalprasadsingh <[email protected]>
* minor
Signed-off-by: badalprasadsingh <[email protected]>
* fix: merge
Signed-off-by: badalprasadsingh <[email protected]>
* minor
Signed-off-by: badalprasadsingh <[email protected]>
* minor
Signed-off-by: badalprasadsingh <[email protected]>
* minor
Signed-off-by: badalprasadsingh <[email protected]>
* fix: minor
Signed-off-by: badalprasadsingh <[email protected]>
---------
Signed-off-by: badalprasadsingh <[email protected]>
---
table/cow_rewrite_deletes_test.go | 457 ++++++++++++++++++++++++++++++++++++++
table/mor_delete_pruning_test.go | 4 +-
table/no_replay_removals_test.go | 52 +++++
table/table.go | 16 ++
table/transaction.go | 335 ++++++++++++++++------------
5 files changed, 719 insertions(+), 145 deletions(-)
diff --git a/table/cow_rewrite_deletes_test.go
b/table/cow_rewrite_deletes_test.go
new file mode 100644
index 000000000..aafad6493
--- /dev/null
+++ b/table/cow_rewrite_deletes_test.go
@@ -0,0 +1,457 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table_test
+
+import (
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow/array"
+ "github.com/apache/arrow-go/v18/arrow/memory"
+ "github.com/apache/iceberg-go"
+ "github.com/apache/iceberg-go/table"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type cowRewriteOp struct {
+ name string
+ run func(*testing.T, *table.Table) *table.Table
+ wantIDs []int64
+ wantRecords int64
+}
+
+// TestCoWRewriteKeepsRowsDeleted rewrites a data file that already has a
delete on id=1.
+// Once the file is replaced that delete no longer reaches the copied rows, so
the rewrite itself must drop id=1.
+func TestCoWRewriteKeepsRowsDeleted(t *testing.T) {
+ deleteID1 := func(t *testing.T, tbl *table.Table) *table.Table {
+ t.Helper()
+
+ return mergeOnReadDelete(t, tbl, 1)
+ }
+ equalityDeleteID1 := func(t *testing.T, tbl *table.Table) *table.Table {
+ t.Helper()
+
+ return appendEqualityDelete(t, tbl, []int{1}, `[{"id": 1}]`)
+ }
+
+ deletes := []struct {
+ name string
+ formatVersion string
+ seed func(*testing.T, *table.Table) *table.Table
+ seededDVs int
+ }{
+ {name: "position delete", formatVersion: "2", seed: deleteID1},
+ {name: "deletion vector", formatVersion: "3", seed: deleteID1,
seededDVs: 1},
+ {name: "equality delete on v2", formatVersion: "2", seed:
equalityDeleteID1},
+ {name: "equality delete on v3", formatVersion: "3", seed:
equalityDeleteID1},
+ }
+
+ matchID2 := iceberg.EqualTo(iceberg.Reference("id"), int64(2))
+ operations := []cowRewriteOp{
+ {
+ name: "copy-on-write delete",
+ run: func(t *testing.T, tbl *table.Table)
*table.Table { return copyOnWriteDelete(t, tbl, matchID2) },
+ wantIDs: []int64{3, 4, 5},
+ wantRecords: 3,
+ },
+ {
+ name: "filtered overwrite",
+ run: func(t *testing.T, tbl *table.Table) *table.Table {
+ return filteredOverwrite(t, tbl, matchID2,
`[{"id": 6}]`)
+ },
+ wantIDs: []int64{3, 4, 5, 6},
+ wantRecords: 4,
+ },
+ }
+
+ for _, del := range deletes {
+ for _, op := range operations {
+ t.Run(del.name+"/"+op.name, func(t *testing.T) {
+ tbl := newMergeOnReadTestTableVersion(t,
del.formatVersion)
+ tbl = appendRowsOnRef(t, tbl, table.MainBranch,
1, 2, 3, 4, 5)
+ tbl = del.seed(t, tbl)
+ require.Equal(t, []int64{2, 3, 4, 5},
idsInTable(t, tbl))
+ require.Equal(t, del.seededDVs, liveDVCount(t,
tbl))
+
+ var rowIDsBefore map[int64]int64
+ if del.formatVersion == "3" {
+ rowIDsBefore = readRowIDsByID(t,
t.Context(), tbl)
+ require.Len(t, rowIDsBefore, 4)
+ }
+
+ tbl = op.run(t, tbl)
+
+ assert.Equal(t, op.wantIDs, idsInTable(t, tbl),
"id=1 was deleted before the rewrite and must stay deleted")
+ assert.Equal(t, op.wantRecords,
liveDataRecordCount(t, tbl), "the rewritten file must not carry the
already-deleted row")
+ assert.Zero(t, liveDVCount(t, tbl), "the
deletion vector of the replaced file must be removed with it")
+
+ if rowIDsBefore != nil {
+ rowIDsAfter := readRowIDsByID(t,
t.Context(), tbl)
+ for _, id := range []int64{3, 4, 5} {
+ assert.Equal(t,
rowIDsBefore[id], rowIDsAfter[id],
+ "id=%d must keep its
_row_id through the rewrite", id)
+ }
+ }
+ })
+ }
+ }
+}
+
+// TestCoWRewriteKeepsEachFilesOwnRowsDeleted rewrites two partitioned files
in one operation.
+// Each carrying a deletion vector on a different row position.
+// Every rewritten file must drop the row its own vector removed, and no other.
+func TestCoWRewriteKeepsEachFilesOwnRowsDeleted(t *testing.T) {
+ matchID2Or5 := iceberg.NewOr(
+ iceberg.EqualTo(iceberg.Reference("id"), int64(2)),
+ iceberg.EqualTo(iceberg.Reference("id"), int64(5)),
+ )
+
+ operations := []cowRewriteOp{
+ {
+ name: "copy-on-write delete",
+ run: func(t *testing.T, tbl *table.Table)
*table.Table { return copyOnWriteDelete(t, tbl, matchID2Or5) },
+ wantIDs: []int64{3, 4},
+ wantRecords: 2,
+ },
+ {
+ name: "filtered overwrite",
+ run: func(t *testing.T, tbl *table.Table) *table.Table {
+ return filteredOverwrite(t, tbl, matchID2Or5,
`[{"id": 7, "category": "x"}]`)
+ },
+ wantIDs: []int64{3, 4, 7},
+ wantRecords: 3,
+ },
+ }
+
+ recordsJSON := `[
+ {"id": 1, "category": "x"}, {"id": 2, "category": "x"}, {"id":
3, "category": "x"},
+ {"id": 4, "category": "y"}, {"id": 5, "category": "y"}, {"id":
6, "category": "y"}
+ ]`
+
+ for _, op := range operations {
+ t.Run(op.name, func(t *testing.T) {
+ tbl := newPartitionedMergeOnReadTestTable(t)
+ tbl = appendRows(t, tbl, recordsJSON)
+
+ // Distinct positions per file: id=1 is first in its
file, id=6 is last in its own.
+ // So applying one file's vector to the other changes
the result.
+ tbl = mergeOnReadDelete(t, tbl, 1)
+ tbl = mergeOnReadDelete(t, tbl, 6)
+ require.Equal(t, 2, liveDVCount(t, tbl))
+ require.Equal(t, []int64{2, 3, 4, 5}, idsInTable(t,
tbl))
+
+ tbl = op.run(t, tbl)
+
+ assert.Equal(t, op.wantIDs, idsInTable(t, tbl), "each
rewritten file must drop only the row its own deletion vector removed")
+ assert.Equal(t, op.wantRecords, liveDataRecordCount(t,
tbl))
+ assert.Zero(t, liveDVCount(t, tbl), "both replaced
files must take their deletion vectors with them")
+ })
+ }
+}
+
+func TestCoWRewriteAppliesStackedDeletes(t *testing.T) {
+ matchID2 := iceberg.EqualTo(iceberg.Reference("id"), int64(2))
+ operations := []cowRewriteOp{
+ {
+ name: "copy-on-write delete",
+ run: func(t *testing.T, tbl *table.Table)
*table.Table { return copyOnWriteDelete(t, tbl, matchID2) },
+ wantIDs: []int64{4, 5},
+ wantRecords: 2,
+ },
+ {
+ name: "filtered overwrite",
+ run: func(t *testing.T, tbl *table.Table) *table.Table {
+ return filteredOverwrite(t, tbl, matchID2,
`[{"id": 6}]`)
+ },
+ wantIDs: []int64{4, 5, 6},
+ wantRecords: 3,
+ },
+ }
+
+ for _, op := range operations {
+ t.Run(op.name, func(t *testing.T) {
+ tbl := newMergeOnReadTestTableVersion(t, "3")
+ tbl = appendRowsOnRef(t, tbl, table.MainBranch, 1, 2,
3, 4, 5)
+ tbl = mergeOnReadDelete(t, tbl, 1)
+ tbl = appendEqualityDelete(t, tbl, []int{1}, `[{"id":
3}]`)
+ require.Equal(t, 1, liveDVCount(t, tbl))
+ require.Equal(t, []int64{2, 4, 5}, idsInTable(t, tbl))
+
+ tbl = op.run(t, tbl)
+
+ assert.Equal(t, op.wantIDs, idsInTable(t, tbl), "id=1
and id=3 were deleted before the rewrite and must stay deleted")
+ assert.Equal(t, op.wantRecords, liveDataRecordCount(t,
tbl), "the rewritten file must carry neither already-deleted row")
+ assert.Zero(t, liveDVCount(t, tbl))
+ })
+ }
+}
+
+func TestDroppedFilesTakeTheirDeletionVectors(t *testing.T) {
+ matchY := iceberg.EqualTo(iceberg.Reference("category"), "y")
+ matchYOrID2 := iceberg.NewOr(matchY,
iceberg.EqualTo(iceberg.Reference("id"), int64(2)))
+ mergeOnReadDeleteWhere := func(filter iceberg.BooleanExpression)
func(*testing.T, *table.Table) *table.Table {
+ return func(t *testing.T, tbl *table.Table) *table.Table {
+ tbl, err := tbl.Delete(t.Context(), filter, nil)
+ require.NoError(t, err)
+
+ return tbl
+ }
+ }
+
+ cases := []struct {
+ name string
+ run func(*testing.T, *table.Table) *table.Table
+ wantIDs []int64
+ wantDVs int
+ }{
+ {
+ name: "copy-on-write delete drops a file",
+ run: func(t *testing.T, tbl *table.Table)
*table.Table { return copyOnWriteDelete(t, tbl, matchY) },
+ wantIDs: []int64{2, 3},
+ wantDVs: 1,
+ },
+ {
+ name: "filtered overwrite drops a file",
+ run: func(t *testing.T, tbl *table.Table) *table.Table {
+ return filteredOverwrite(t, tbl, matchY,
`[{"id": 7, "category": "y"}]`)
+ },
+ wantIDs: []int64{2, 3, 7},
+ wantDVs: 1,
+ },
+ {
+ name: "copy-on-write delete drops a file and
rewrites another",
+ run: func(t *testing.T, tbl *table.Table)
*table.Table { return copyOnWriteDelete(t, tbl, matchYOrID2) },
+ wantIDs: []int64{3},
+ wantDVs: 0,
+ },
+ {
+ name: "filtered overwrite drops a file and rewrites
another",
+ run: func(t *testing.T, tbl *table.Table) *table.Table {
+ return filteredOverwrite(t, tbl, matchYOrID2,
`[{"id": 7, "category": "y"}]`)
+ },
+ wantIDs: []int64{3, 7},
+ wantDVs: 0,
+ },
+ {
+ name: "merge-on-read delete drops a file",
+ run: mergeOnReadDeleteWhere(matchY),
+ wantIDs: []int64{2, 3},
+ wantDVs: 1,
+ },
+ {
+ // The partially deleted file's vector is replaced by a
merged one, so one DV stays live.
+ name: "merge-on-read delete drops a file and deletes
rows from another",
+ run: mergeOnReadDeleteWhere(matchYOrID2),
+ wantIDs: []int64{3},
+ wantDVs: 1,
+ },
+ }
+
+ recordsJSON := `[
+ {"id": 1, "category": "x"}, {"id": 2, "category": "x"}, {"id":
3, "category": "x"},
+ {"id": 4, "category": "y"}, {"id": 5, "category": "y"}, {"id":
6, "category": "y"}
+ ]`
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ tbl := newPartitionedMergeOnReadTestTable(t)
+ tbl = appendRows(t, tbl, recordsJSON)
+ tbl = mergeOnReadDelete(t, tbl, 1)
+ tbl = mergeOnReadDelete(t, tbl, 6)
+ require.Equal(t, 2, liveDVCount(t, tbl))
+ require.Equal(t, []int64{2, 3, 4, 5}, idsInTable(t,
tbl))
+
+ tbl = tc.run(t, tbl)
+
+ assert.Equal(t, tc.wantIDs, idsInTable(t, tbl))
+ assert.Equal(t, tc.wantDVs, liveDVCount(t, tbl), "the
deletion vector of every dropped file must be removed with it")
+ })
+ }
+}
+
+// TestCoWRewriteToEmptyWritesNoDataFile rewrites a file whose only surviving
row is the one the operation removes,
+// so the rewrite has no rows to write.
+// No empty data file may stay live, and the file's deletion vector must go
too.
+func TestCoWRewriteToEmptyWritesNoDataFile(t *testing.T) {
+ matchID2 := iceberg.EqualTo(iceberg.Reference("id"), int64(2))
+ cases := []struct {
+ name string
+ run func(*testing.T, *table.Table) *table.Table
+ wantIDs []int64
+ wantDataFiles int
+ }{
+ {
+ name: "copy-on-write delete",
+ run: func(t *testing.T, tbl *table.Table)
*table.Table { return copyOnWriteDelete(t, tbl, matchID2) },
+ wantIDs: nil,
+ wantDataFiles: 0,
+ },
+ {
+ name: "filtered overwrite",
+ run: func(t *testing.T, tbl *table.Table) *table.Table {
+ return filteredOverwrite(t, tbl, matchID2,
`[{"id": 6}]`)
+ },
+ wantIDs: []int64{6},
+ wantDataFiles: 1,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ tbl := newMergeOnReadTestTableVersion(t, "3")
+ tbl = appendRowsOnRef(t, tbl, table.MainBranch, 1, 2)
+ tbl = mergeOnReadDelete(t, tbl, 1)
+ require.Equal(t, 1, liveDVCount(t, tbl))
+ require.Equal(t, []int64{2}, idsInTable(t, tbl))
+
+ tbl = tc.run(t, tbl)
+
+ assert.Equal(t, tc.wantIDs, idsInTable(t, tbl))
+ dataPaths, _ := liveFilesOnRef(t, tbl, table.MainBranch)
+ assert.Len(t, dataPaths, tc.wantDataFiles, "a rewrite
with no surviving rows must not leave an empty data file live")
+ assert.Zero(t, liveDVCount(t, tbl))
+ })
+ }
+}
+
+// TestCoWRewriteSkipsDeletesThatDoNotApply re-inserts id=1 after an equality
delete on it.
+// The delete has a lower sequence number than the new file, so rewriting that
file must keep the row,
+// while the untouched older file must keep its own id=1 hidden.
+func TestCoWRewriteSkipsDeletesThatDoNotApply(t *testing.T) {
+ matchID4 := iceberg.EqualTo(iceberg.Reference("id"), int64(4))
+ operations := []cowRewriteOp{
+ {
+ name: "copy-on-write delete",
+ run: func(t *testing.T, tbl *table.Table)
*table.Table { return copyOnWriteDelete(t, tbl, matchID4) },
+ wantIDs: []int64{1, 2, 3, 5},
+ wantRecords: 5,
+ },
+ {
+ name: "filtered overwrite",
+ run: func(t *testing.T, tbl *table.Table) *table.Table {
+ return filteredOverwrite(t, tbl, matchID4,
`[{"id": 6}]`)
+ },
+ wantIDs: []int64{1, 2, 3, 5, 6},
+ wantRecords: 6,
+ },
+ }
+
+ for _, op := range operations {
+ t.Run(op.name, func(t *testing.T) {
+ tbl := newMergeOnReadTestTableVersion(t, "2")
+ tbl = appendRowsOnRef(t, tbl, table.MainBranch, 1, 2, 3)
+ tbl = appendEqualityDelete(t, tbl, []int{1}, `[{"id":
1}]`)
+ tbl = appendRowsOnRef(t, tbl, table.MainBranch, 1, 4, 5)
+ require.Equal(t, []int64{1, 2, 3, 4, 5}, idsInTable(t,
tbl))
+
+ tbl = op.run(t, tbl)
+
+ assert.Equal(t, op.wantIDs, idsInTable(t, tbl), "the
re-inserted id=1 is newer than the equality delete and must survive the
rewrite")
+ assert.Equal(t, op.wantRecords, liveDataRecordCount(t,
tbl), "only the file holding id=4 is rewritten; the other keeps all 3 of its
rows")
+ })
+ }
+}
+
+// mergeOnReadDelete deletes id on a table configured for merge-on-read
deletes.
+func mergeOnReadDelete(t *testing.T, tbl *table.Table, id int64) *table.Table {
+ t.Helper()
+
+ tbl, err := tbl.Delete(t.Context(),
iceberg.EqualTo(iceberg.Reference("id"), id), nil)
+ require.NoError(t, err)
+
+ return tbl
+}
+
+// copyOnWriteDelete switches the table to copy-on-write in its own commit,
then deletes the rows matching filter.
+func copyOnWriteDelete(t *testing.T, tbl *table.Table, filter
iceberg.BooleanExpression) *table.Table {
+ t.Helper()
+
+ tx := tbl.NewTransaction()
+ require.NoError(t,
tx.SetProperties(iceberg.Properties{table.WriteDeleteModeKey:
table.WriteModeCopyOnWrite}))
+ tbl, err := tx.Commit(t.Context())
+ require.NoError(t, err)
+
+ tbl, err = tbl.Delete(t.Context(), filter, nil)
+ require.NoError(t, err)
+
+ return tbl
+}
+
+// filteredOverwrite replaces the rows matching filter with the given records.
+func filteredOverwrite(t *testing.T, tbl *table.Table, filter
iceberg.BooleanExpression, recordsJSON string) *table.Table {
+ t.Helper()
+
+ rdr := rowsReader(t, tbl, recordsJSON)
+ defer rdr.Release()
+
+ tbl, err := tbl.Overwrite(t.Context(), rdr, nil,
table.WithOverwriteFilter(filter))
+ require.NoError(t, err)
+
+ return tbl
+}
+
+func appendRows(t *testing.T, tbl *table.Table, recordsJSON string)
*table.Table {
+ t.Helper()
+
+ rdr := rowsReader(t, tbl, recordsJSON)
+ defer rdr.Release()
+
+ tbl, err := tbl.Append(t.Context(), rdr, nil)
+ require.NoError(t, err)
+
+ return tbl
+}
+
+func rowsReader(t *testing.T, tbl *table.Table, recordsJSON string)
array.RecordReader {
+ t.Helper()
+
+ arrowSchema, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false,
false)
+ require.NoError(t, err)
+
+ data, err := array.TableFromJSON(memory.DefaultAllocator, arrowSchema,
[]string{recordsJSON})
+ require.NoError(t, err)
+ t.Cleanup(data.Release)
+
+ return array.NewTableReader(data, -1)
+}
+
+// liveDataRecordCount sums record_count over the live data files of the
current snapshot,
+// including rows that deletes hide from a scan.
+func liveDataRecordCount(t *testing.T, tbl *table.Table) int64 {
+ t.Helper()
+
+ fs, err := tbl.FS(t.Context())
+ require.NoError(t, err)
+ snapshot := tbl.CurrentSnapshot()
+ require.NotNil(t, snapshot)
+ manifests, err := snapshot.Manifests(fs)
+ require.NoError(t, err)
+
+ var total int64
+ for _, m := range manifests {
+ if m.ManifestContent() != iceberg.ManifestContentData {
+ continue
+ }
+ for entry, err := range m.Entries(fs, true) {
+ require.NoError(t, err)
+ total += entry.DataFile().Count()
+ }
+ }
+
+ return total
+}
diff --git a/table/mor_delete_pruning_test.go b/table/mor_delete_pruning_test.go
index 01dc5585d..b538a02e8 100644
--- a/table/mor_delete_pruning_test.go
+++ b/table/mor_delete_pruning_test.go
@@ -147,7 +147,7 @@ func TestV3MergeOnReadDoubleDeleteMergesDeletionVector(t
*testing.T) {
// TestV3MergeOnReadTripleDeleteMergesDeletionVector extends the double-delete
// guard to a third delete. The second delete supersedes DV #1, leaving it as a
// DELETED-status entry in the deleted-files manifest against the same data
file.
-// The third delete's collectExistingDVs must skip that ghost entry; otherwise
it
+// The third delete's readPlanningDeletes must skip that ghost entry;
otherwise it
// seeds the merged DV from the stale first bitmap (resurrecting id=4, removed
by
// the second delete) and tries to remove a DV that no longer exists. See
#1372.
func TestV3MergeOnReadTripleDeleteMergesDeletionVector(t *testing.T) {
@@ -175,7 +175,7 @@ func TestV3MergeOnReadTripleDeleteMergesDeletionVector(t
*testing.T) {
require.NoError(t, err)
require.Equal(t, 1, liveDVCount(t, tbl), "second delete must merge into
the single DV")
- // Third delete: DV #1 is now a superseded DELETED entry.
collectExistingDVs
+ // Third delete: DV #1 is now a superseded DELETED entry.
readPlanningDeletes
// must ignore it and seed only from the live merged DV.
tbl, err = tbl.Delete(ctx, iceberg.EqualTo(iceberg.Reference("id"),
int64(1)), nil)
require.NoError(t, err)
diff --git a/table/no_replay_removals_test.go b/table/no_replay_removals_test.go
index ec8de8074..34e3b181b 100644
--- a/table/no_replay_removals_test.go
+++ b/table/no_replay_removals_test.go
@@ -297,3 +297,55 @@ func TestReplaceFilesDataOnlyStillReplays(t *testing.T) {
assert.Equal(t, []int64{1, 2, 100}, idsInTable(t, committed),
"the replay must inherit the peer's append alongside the
compacted file")
}
+
+// A copy-on-write delete that removes a deletion vector must fail on a
concurrent append instead of retrying.
+// Without a deletion vector, the same delete still retries and both writes
land.
+func TestCoWDeleteRemovingDVFailsInsteadOfReplaying(t *testing.T) {
+ cases := []struct {
+ name string
+ seededDVs int
+ wantAttempts int32
+ }{
+ {name: "file with a deletion vector fails on the first
conflict", seededDVs: 1, wantAttempts: 1},
+ {name: "file without a deletion vector still retries",
seededDVs: 0, wantAttempts: 2},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx := t.Context()
+ tbl, cat := newNoReplayV3Table(t)
+ tbl = appendTenRows(t, tbl)
+ if tc.seededDVs > 0 {
+ tbl = mergeOnReadDelete(t, tbl, 7)
+ }
+ require.Equal(t, tc.seededDVs, liveDVCount(t, tbl))
+
+ tx := tbl.NewTransaction()
+ require.NoError(t,
tx.SetProperties(iceberg.Properties{table.WriteDeleteModeKey:
table.WriteModeCopyOnWrite}))
+ tbl, err := tx.Commit(ctx)
+ require.NoError(t, err)
+
+ staleTxn := tbl.NewTransaction()
+ require.NoError(t, staleTxn.Delete(ctx,
iceberg.EqualTo(iceberg.Reference("id"), int64(2)), nil))
+
+ appendRowsOnRef(t, tbl, table.MainBranch, 100)
+
+ attemptsBefore := cat.attempts.Load()
+ _, err = staleTxn.Commit(ctx)
+ assert.Equal(t, attemptsBefore+tc.wantAttempts,
cat.attempts.Load())
+
+ committed, loadErr := cat.LoadTable(ctx,
table.Identifier{"db", "no_replay_removals"})
+ require.NoError(t, loadErr)
+ if tc.seededDVs > 0 {
+ require.Error(t, err)
+ assert.ErrorIs(t, err, table.ErrCommitFailed)
+ assert.Equal(t, []int64{1, 2, 3, 4, 5, 6, 8, 9,
10, 100}, idsInTable(t, committed),
+ "only the peer's append landed; the
stale delete of id 2 must not have")
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, []int64{1, 3, 4, 5, 6, 7, 8, 9,
10, 100}, idsInTable(t, committed),
+ "the delete replays over the peer's
append, so both writers land")
+ }
+ })
+ }
+}
diff --git a/table/table.go b/table/table.go
index 27950c12c..1d3189024 100644
--- a/table/table.go
+++ b/table/table.go
@@ -310,6 +310,11 @@ func (t Table) Append(ctx context.Context, rdr
array.RecordReader, snapshotProps
//
// New data from the provided table is written to the table regardless of the
filter.
//
+// On a v3 table, every data file the overwrite removes or rewrites takes its
deletion vector with it.
+// A commit that removes a deletion vector is not replayed: if another writer
commits first,
+// it fails with ErrCommitFailed instead of refreshing and retrying.
+// Reload the table and run the overwrite again.
+//
// The batchSize parameter refers to the batch size for reading the input
data, not the batch size for writes.
// The concurrency parameter controls the level of parallelism for manifest
processing and file rewriting and
// can be overridden using the WithOverwriteConcurrency option. Defaults to
runtime.GOMAXPROCS(0).
@@ -338,6 +343,11 @@ func (t Table) OverwriteTable(ctx context.Context, tbl
arrow.Table, batchSize in
//
// New data from the provided RecordReader is written to the table regardless
of the filter.
//
+// On a v3 table, every data file the overwrite removes or rewrites takes its
deletion vector with it.
+// A commit that removes a deletion vector is not replayed: if another writer
commits first,
+// it fails with ErrCommitFailed instead of refreshing and retrying.
+// Reload the table and run the overwrite again.
+//
// The concurrency parameter controls the level of parallelism for manifest
processing and file rewriting and
// can be overridden using the WithOverwriteConcurrency option. Defaults to
runtime.GOMAXPROCS(0).
func (t Table) Overwrite(ctx context.Context, rdr array.RecordReader,
snapshotProps iceberg.Properties, opts ...OverwriteOption) (*Table, error) {
@@ -364,6 +374,12 @@ func (t Table) Overwrite(ctx context.Context, rdr
array.RecordReader, snapshotPr
// - Strict evaluator determines if all rows in a file must match the filter
// - Files that pass inclusive but not strict evaluation are rewritten with
filtered data
//
+// On a v3 table, every data file the delete removes or rewrites takes its
deletion vector with it.
+// A merge-on-read delete replaces the existing deletion vector of a file it
deletes rows from.
+//
+// A commit that removes a deletion vector is not replayed: if another writer
commits first,
+// it fails with ErrCommitFailed instead of refreshing and retrying, so reload
the table and run the delete again.
+//
// The concurrency parameter controls the level of parallelism for manifest
processing and file rewriting and
// can be overridden using the WithOverwriteConcurrency option. Defaults to
runtime.GOMAXPROCS(0).
func (t Table) Delete(ctx context.Context, filter iceberg.BooleanExpression,
snapshotProps iceberg.Properties, opts ...DeleteOption) (*Table, error) {
diff --git a/table/transaction.go b/table/transaction.go
index 3edadcf89..103ed5ba7 100644
--- a/table/transaction.go
+++ b/table/transaction.go
@@ -25,7 +25,6 @@ import (
"fmt"
"iter"
"log/slog"
- "maps"
"runtime"
"slices"
"strconv"
@@ -2310,6 +2309,11 @@ func (t *Transaction) AddFiles(ctx context.Context,
filePaths []string, snapshot
//
// New data from the provided table is written to the table regardless of the
filter.
//
+// On a v3 table, every data file the overwrite removes or rewrites takes its
deletion vector with it.
+// A commit that removes a deletion vector is not replayed: if another writer
commits first,
+// it fails with ErrCommitFailed instead of refreshing and retrying.
+// Reload the table and run the overwrite again.
+//
// The batchSize parameter refers to the batch size for reading the input
data, not the batch size for writes.
// The concurrency parameter controls the level of parallelism for manifest
processing and file rewriting and
// can be overridden using the WithOverwriteConcurrency option.
@@ -2376,6 +2380,11 @@ func WithOverwriteCaseInsensitive() OverwriteOption {
//
// New data from the provided RecordReader is written to the table regardless
of the filter.
//
+// On a v3 table, every data file the overwrite removes or rewrites takes its
deletion vector with it.
+// A commit that removes a deletion vector is not replayed: if another writer
commits first,
+// it fails with ErrCommitFailed instead of refreshing and retrying.
+// Reload the table and run the overwrite again.
+//
// The concurrency parameter controls the level of parallelism for manifest
processing and file rewriting and
// can be overridden using the WithOverwriteConcurrency option.
// If concurrency <= 0, defaults to runtime.GOMAXPROCS(0).
@@ -2475,17 +2484,40 @@ func (t *Transaction) performCopyOnWriteDeletion(ctx
context.Context, operation
updater := t.updateSnapshot(wfs, snapshotProps,
operation).mergeOverwrite(&commitUUID, filter)
updater.setManifestConcurrency(concurrency)
- filesToDelete, filesToRewrite, fileSeqByPath, err :=
t.classifyFilesForDeletions(ctx, fs, filter, caseSensitive, concurrency)
+ filesToDelete, entriesToRewrite, err :=
t.classifyFilesForDeletions(ctx, fs, filter, caseSensitive, concurrency)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // SetProperties above may have staged a name mapping, which replaces
t.meta, so re-read it.
+ meta, err = t.txnMeta()
if err != nil {
return nil, nil, err
}
+ // Rewrites need the live deletes to drop rows already deleted, and
every removed file must take its deletion vector with it.
+ // DVs only exist from v3, so a v2 delete that only drops whole files
skips the read.
+ var (
+ deletes planningDeletes
+ builtMeta Metadata
+ )
+ if len(entriesToRewrite) > 0 || (len(filesToDelete) > 0 &&
meta.formatVersion >= 3) {
+ builtMeta, err = meta.Build()
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to build metadata:
%w", err)
+ }
+ deletes, err = t.readPlanningDeletes(fs, builtMeta)
+ if err != nil {
+ return nil, nil, err
+ }
+ }
+
for _, df := range filesToDelete {
- updater.deleteDataFile(df)
+ deletes.removeDataFile(updater, df)
}
- if len(filesToRewrite) > 0 {
- if err := t.rewriteFilesWithFilter(ctx, fs, updater,
filesToRewrite, fileSeqByPath, filter, caseSensitive, concurrency); err != nil {
+ if len(entriesToRewrite) > 0 {
+ if err := t.rewriteFilesWithFilter(ctx, fs, updater, builtMeta,
deletes, entriesToRewrite, filter, caseSensitive, concurrency); err != nil {
return nil, nil, err
}
}
@@ -2500,7 +2532,10 @@ func (t *Transaction) performCopyOnWriteDeletion(ctx
context.Context, operation
// shared with RewriteDataFiles via rewriteValidator, so changes there
// apply here too. A concurrent removal of one of these files is caught
// earlier, by the retry rebuild's checkRemovedFiles
(ErrCommitDiverged).
- removed := slices.Concat(filesToDelete, filesToRewrite)
+ removed := slices.Clip(filesToDelete)
+ for _, entry := range entriesToRewrite {
+ removed = append(removed, entry.DataFile())
+ }
if len(removed) > 0 {
t.addValidator(rewriteValidator(removed))
}
@@ -2538,17 +2573,41 @@ func (t *Transaction) performMergeOnReadDeletion(ctx
context.Context, snapshotPr
updater := t.updateSnapshot(wfs, snapshotProps,
OpDelete).mergeOverwrite(&commitUUID, filter)
updater.setManifestConcurrency(concurrency)
- filesToDelete, withPartialDeletions, _, err :=
t.classifyFilesForDeletions(ctx, fs, filter, caseSensitive, concurrency)
+ filesToDelete, withPartialDeletions, err :=
t.classifyFilesForDeletions(ctx, fs, filter, caseSensitive, concurrency)
+ if err != nil {
+ return nil, err
+ }
+
+ // SetProperties above may have staged a name mapping, which replaces
t.meta, so re-read it.
+ meta, err = t.txnMeta()
if err != nil {
return nil, err
}
+ // On v3 every removed file takes its deletion vector with it, and a
partially deleted file's vector is
+ // merged into its replacement. One read serves both. DVs only exist
from v3, so v2 skips it.
+ var deletes planningDeletes
+ if meta.formatVersion >= 3 && (len(filesToDelete) > 0 ||
len(withPartialDeletions) > 0) {
+ builtMeta, err := meta.Build()
+ if err != nil {
+ return nil, fmt.Errorf("failed to build metadata: %w",
err)
+ }
+ deletes, err = t.readPlanningDeletes(fs, builtMeta)
+ if err != nil {
+ return nil, err
+ }
+ }
+
for _, df := range filesToDelete {
- updater.deleteDataFile(df)
+ deletes.removeDataFile(updater, df)
}
if len(withPartialDeletions) > 0 {
- referenced, err := t.writePositionDeletesForFiles(ctx, fs,
updater, withPartialDeletions, filter, caseSensitive, concurrency, commitUUID)
+ partialFiles := make([]iceberg.DataFile, 0,
len(withPartialDeletions))
+ for _, entry := range withPartialDeletions {
+ partialFiles = append(partialFiles, entry.DataFile())
+ }
+ referenced, err := t.writePositionDeletesForFiles(ctx, fs,
updater, deletes.dvs, partialFiles, filter, caseSensitive, concurrency,
commitUUID)
if err != nil {
return nil, err
}
@@ -2614,6 +2673,12 @@ func WithDeleteCaseInsensitive() DeleteOption {
// - Strict evaluator determines if all rows in a file must match the filter
// - Files that pass inclusive but not strict evaluation are rewritten with
filtered data
//
+// On a v3 table, every data file the delete removes or rewrites takes its
deletion vector with it.
+// A merge-on-read delete replaces the existing deletion vector of a file it
deletes rows from.
+//
+// A commit that removes a deletion vector is not replayed: if another writer
commits first,
+// it fails with ErrCommitFailed instead of refreshing and retrying, so reload
the table and run the delete again.
+//
// The concurrency parameter controls the level of parallelism for manifest
processing and file rewriting and
// can be overridden using the WithOverwriteConcurrency option. Defaults to
runtime.GOMAXPROCS(0).
func (t *Transaction) Delete(ctx context.Context, filter
iceberg.BooleanExpression, snapshotProps iceberg.Properties, opts
...DeleteOption) (err error) {
@@ -2660,31 +2725,30 @@ func (t *Transaction) Delete(ctx context.Context,
filter iceberg.BooleanExpressi
}
// classifyFilesForDeletions classifies existing data files based on the
provided filter.
-// Returns files to delete completely, files to rewrite partially, the
-// per-file file_sequence_number map for rewrite candidates (used to
-// synthesize row-lineage columns when reading), and any error.
-func (t *Transaction) classifyFilesForDeletions(ctx context.Context, fs io.IO,
filter iceberg.BooleanExpression, caseSensitive bool, concurrency int)
(filesToDelete, filesWithPartialDeletions []iceberg.DataFile, fileSeqByPath
map[string]*int64, err error) {
+// Returns files to delete completely, the manifest entries of files that
partially match, and any error.
+// Partial matches stay entries because their sequence numbers decide which
deletes apply and seed row-lineage synthesis.
+func (t *Transaction) classifyFilesForDeletions(ctx context.Context, fs io.IO,
filter iceberg.BooleanExpression, caseSensitive bool, concurrency int)
(filesToDelete []iceberg.DataFile, filesWithPartialDeletions
[]iceberg.ManifestEntry, err error) {
meta, err := t.txnMeta()
if err != nil {
- return nil, nil, nil, err
+ return nil, nil, err
}
s := t.planningSnapshot(meta)
if s == nil {
- return nil, nil, nil, nil
+ return nil, nil, nil
}
if filter == nil || filter.Equals(iceberg.AlwaysTrue{}) {
for df, err := range s.dataFiles(fs, nil) {
if err != nil {
- return nil, nil, nil, err
+ return nil, nil, err
}
if df.ContentType() == iceberg.EntryContentData {
filesToDelete = append(filesToDelete, df)
}
}
- return filesToDelete, filesWithPartialDeletions, nil, nil
+ return filesToDelete, nil, nil
}
return t.classifyFilesForFilteredDeletions(ctx, fs, filter,
caseSensitive, concurrency)
@@ -2717,23 +2781,22 @@ func (t *fileClassificationTask)
buildPartitionProjection(specID int) (iceberg.B
}
// classifyFilesForFilteredDeletions classifies files for filtered overwrite
operations.
-// Returns files to delete completely, files to rewrite partially, the
-// per-file file_sequence_number map for rewrite candidates, and any error.
-func (t *Transaction) classifyFilesForFilteredDeletions(ctx context.Context,
fs io.IO, filter iceberg.BooleanExpression, caseSensitive bool, concurrency
int) (filesToDelete, filesWithPartialDeletes []iceberg.DataFile, fileSeqByPath
map[string]*int64, err error) {
+// Returns files to delete completely, the manifest entries of files that
partially match, and any error.
+func (t *Transaction) classifyFilesForFilteredDeletions(ctx context.Context,
fs io.IO, filter iceberg.BooleanExpression, caseSensitive bool, concurrency
int) (filesToDelete []iceberg.DataFile, filesWithPartialDeletes
[]iceberg.ManifestEntry, err error) {
meta, err := t.txnMeta()
if err != nil {
- return nil, nil, nil, err
+ return nil, nil, err
}
schema := meta.CurrentSchema()
builtMeta, err := meta.Build()
if err != nil {
- return nil, nil, nil, err
+ return nil, nil, err
}
inclusiveEvaluator, err := newInclusiveMetricsEvaluator(schema, filter,
caseSensitive, false)
if err != nil {
- return nil, nil, nil, fmt.Errorf("failed to create inclusive
metrics evaluator: %w", err)
+ return nil, nil, fmt.Errorf("failed to create inclusive metrics
evaluator: %w", err)
}
// A file is deleted as a whole only if the filter is true for all of
its
@@ -2742,12 +2805,12 @@ func (t *Transaction)
classifyFilesForFilteredDeletions(ctx context.Context, fs
// negation of the rows the rewrite keeps instead, so that both paths
agree.
notTrue, err := isNotTrueExpr(filter)
if err != nil {
- return nil, nil, nil, fmt.Errorf("failed to build negated
filter: %w", err)
+ return nil, nil, fmt.Errorf("failed to build negated filter:
%w", err)
}
strictEvaluator, err := newStrictMetricsEvaluator(schema,
iceberg.NewNot(notTrue), caseSensitive, false)
if err != nil {
- return nil, nil, nil, fmt.Errorf("failed to create strict
metrics evaluator: %w", err)
+ return nil, nil, fmt.Errorf("failed to create strict metrics
evaluator: %w", err)
}
classificationTask := newFileClassificationTask(builtMeta, filter,
caseSensitive)
@@ -2758,12 +2821,11 @@ func (t *Transaction)
classifyFilesForFilteredDeletions(ctx context.Context, fs
if s != nil {
manifests, err = s.Manifests(fs)
if err != nil {
- return nil, nil, nil, fmt.Errorf("failed to get
manifests: %w", err)
+ return nil, nil, fmt.Errorf("failed to get manifests:
%w", err)
}
}
var mu sync.Mutex
- fileSeqByPath = make(map[string]*int64)
g, _ := errgroup.WithContext(ctx)
g.SetLimit(min(concurrency, len(manifests)))
@@ -2783,8 +2845,7 @@ func (t *Transaction)
classifyFilesForFilteredDeletions(ctx context.Context, fs
}
localDelete := make([]iceberg.DataFile, 0)
- localRewrite := make([]iceberg.DataFile, 0)
- localSeqByPath := make(map[string]*int64)
+ localRewrite := make([]iceberg.ManifestEntry, 0)
for entry, err := range manifest.Entries(fs, false) {
if err != nil {
@@ -2816,20 +2877,7 @@ func (t *Transaction)
classifyFilesForFilteredDeletions(ctx context.Context, fs
if strict {
localDelete = append(localDelete, df)
} else {
- localRewrite = append(localRewrite, df)
- // Capture the entry's data sequence
number so the
- // rewrite path can synthesize
- // _last_updated_sequence_number for
source rows that
- // have a null value (or no column) in
the file. Per
- // spec the synthesized value is the
manifest entry's
- // sequence_number (field id 3, the
data sequence
- // number) — not file_sequence_number
(field id 4) —
- // so back-dated EXISTING entries
assign the correct
- // value to surviving rows.
- if seq := entry.SequenceNum(); seq >= 0
{
- s := seq
- localSeqByPath[df.FilePath()] =
&s
- }
+ localRewrite = append(localRewrite,
entry)
}
}
@@ -2837,7 +2885,6 @@ func (t *Transaction)
classifyFilesForFilteredDeletions(ctx context.Context, fs
mu.Lock()
filesToDelete = append(filesToDelete,
localDelete...)
filesWithPartialDeletes =
append(filesWithPartialDeletes, localRewrite...)
- maps.Copy(fileSeqByPath, localSeqByPath)
mu.Unlock()
}
@@ -2846,17 +2893,78 @@ func (t *Transaction)
classifyFilesForFilteredDeletions(ctx context.Context, fs
}
if err := g.Wait(); err != nil {
- return nil, nil, nil, err
+ return nil, nil, err
+ }
+
+ return filesToDelete, filesWithPartialDeletes, nil
+}
+
+// planningDeletes indexes the live delete files of the planning snapshot the
same way scan planning indexes them.
+type planningDeletes struct {
+ positional *positionalDeleteIndex
+ dvs map[string]iceberg.ManifestEntry
+ equality *equalityDeleteIndex
+}
+
+// readPlanningDeletes reads and indexes every live delete file of the
planning snapshot.
+//
+// Delete manifests are not pruned by the row filter: with filter id=2 the
rewrite keeps id=1,
+// so a delete on id=1 must still be applied.
+//
+// Known limitation (#2160): every live delete manifest of the snapshot is
read on each call,
+// with no partition pruning, so a narrow delete or rewrite on a delete-heavy
table pays for all of them.
+func (t *Transaction) readPlanningDeletes(fs io.IO, builtMeta Metadata)
(planningDeletes, error) {
+ meta, err := t.txnMeta()
+ if err != nil {
+ return planningDeletes{}, err
+ }
+
+ var liveDeletes []iceberg.ManifestEntry
+ if s := t.planningSnapshot(meta); s != nil {
+ // Skip DELETED entries: a replaced DV stays behind as one, and
merging it
+ // into a new DV would bring back deleted rows. See #1372.
+ for entry, err := range s.entries(fs,
iceberg.ManifestContentDeletes, true) {
+ if err != nil {
+ return planningDeletes{}, fmt.Errorf("failed to
read delete manifests: %w", err)
+ }
+ liveDeletes = append(liveDeletes, entry)
+ }
+ }
+
+ classified, err := classifyManifestEntries(liveDeletes)
+ if err != nil {
+ return planningDeletes{}, err
+ }
+ positional, err :=
buildPositionalDeleteIndex(classified.positionalDeleteEntries)
+ if err != nil {
+ return planningDeletes{}, err
+ }
+ dvs, err := buildDVIndex(classified.dvEntries)
+ if err != nil {
+ return planningDeletes{}, err
+ }
+ equality, err :=
buildEqualityDeleteIndex(classified.equalityDeleteEntries, builtMeta,
meta.CurrentSchema())
+ if err != nil {
+ return planningDeletes{}, err
}
- return filesToDelete, filesWithPartialDeletes, fileSeqByPath, nil
+ return planningDeletes{positional: positional, dvs: dvs, equality:
equality}, nil
+}
+
+// removeDataFile removes df and, as the spec requires, the deletion vector
that references it.
+// Removing a deletion vector makes the commit non-replayable (see
snapshotProducer.commitManifests).
+// Position and equality delete files are left alone, since they can still
apply to other files.
+func (d planningDeletes) removeDataFile(updater *snapshotProducer, df
iceberg.DataFile) {
+ updater.deleteDataFile(df)
+ if dv, ok := d.dvs[df.FilePath()]; ok {
+ updater.removeDeletionVector(dv.DataFile())
+ }
}
// rewriteFilesWithFilter rewrites data files by preserving only rows that do
NOT match the filter.
-// fileSeqByPath maps each file's path to its file_sequence_number from the
source manifest entry,
-// used to synthesize _last_updated_sequence_number when the source row's
value is null. The
-// filter binding and substrait conversion are computed once here and reused
across every file.
-func (t *Transaction) rewriteFilesWithFilter(ctx context.Context, fs io.IO,
updater *snapshotProducer, files []iceberg.DataFile, fileSeqByPath
map[string]*int64, filter iceberg.BooleanExpression, caseSensitive bool,
concurrency int) error {
+// Rows already removed by a delete are dropped too, since those deletes stop
applying once the file is replaced.
+// The filter binding and substrait conversion are computed once here and
reused across every file.
+func (t *Transaction) rewriteFilesWithFilter(ctx context.Context, fs io.IO,
updater *snapshotProducer, builtMeta Metadata, deletes planningDeletes, entries
[]iceberg.ManifestEntry, filter iceberg.BooleanExpression, caseSensitive bool,
concurrency int) error {
meta, err := t.txnMeta()
if err != nil {
return err
@@ -2876,13 +2984,18 @@ func (t *Transaction) rewriteFilesWithFilter(ctx
context.Context, fs io.IO, upda
return err
}
- for _, originalFile := range files {
+ for _, entry := range entries {
+ task, err := fileScanTaskForDataEntry(entry,
deletes.positional, deletes.dvs, deletes.equality)
+ if err != nil {
+ return err
+ }
+
// Use a separate UUID for rewrite operations to avoid filename
collisions with new data files
rewriteUUID := uuid.New()
args := rewriteSingleFileArgs{
fs: fs,
- originalFile: originalFile,
- fileSeqNum: fileSeqByPath[originalFile.FilePath()],
+ builtMeta: builtMeta,
+ task: task,
filter: complementFilter,
postFilter: postFilter,
caseSensitive: caseSensitive,
@@ -2891,10 +3004,10 @@ func (t *Transaction) rewriteFilesWithFilter(ctx
context.Context, fs io.IO, upda
}
rewrittenFiles, err := t.rewriteSingleFile(ctx, args)
if err != nil {
- return fmt.Errorf("failed to rewrite file %s: %w",
originalFile.FilePath(), err)
+ return fmt.Errorf("failed to rewrite file %s: %w",
task.File.FilePath(), err)
}
- updater.deleteDataFile(originalFile)
+ deletes.removeDataFile(updater, task.File)
for _, rewrittenFile := range rewrittenFiles {
updater.appendDataFile(rewrittenFile)
}
@@ -2992,12 +3105,13 @@ func (isNotTrueVisitor) VisitBound(pred
iceberg.BoundPredicate) truthExprs {
// — there are several same-typed fields (two filter values, plus a UUID and an
// int) so positional ordering offers no compile-time order protection.
type rewriteSingleFileArgs struct {
- fs io.IO
- originalFile iceberg.DataFile
- // fileSeqNum is the source file's data sequence number from its
- // manifest entry; required to synthesize _last_updated_sequence_number
- // for rows whose value is null in the source file.
- fileSeqNum *int64
+ fs io.IO
+ // builtMeta is the transaction's metadata, built once for the whole
rewrite.
+ builtMeta Metadata
+ // task is the source file as planned by [fileScanTaskForDataEntry].
+ // Its deletes must be applied, and its sequence number seeds
_last_updated_sequence_number
+ // for rows whose value is null in the file.
+ task FileScanTask
// filter is the per-row complement (rows that survive the rewrite).
filter iceberg.BooleanExpression
// postFilter is the pre-bound, pre-substrait-converted version of
@@ -3019,12 +3133,6 @@ func (t *Transaction) rewriteSingleFile(ctx
context.Context, args rewriteSingleF
return nil, err
}
- scanTask := &FileScanTask{
- File: args.originalFile,
- Start: 0,
- Length: args.originalFile.FileSizeBytes(),
- }
-
// Preserve row lineage for v3 tables: include _row_id and
// _last_updated_sequence_number in the scan projection so they are read
// from the source file (or synthesized from file metadata when null)
and
@@ -3042,7 +3150,7 @@ func (t *Transaction) rewriteSingleFile(ctx
context.Context, args rewriteSingleF
// scanner's row filter stays AlwaysTrue. Pruning happens before rows
are
// materialized, so _row_id synthesis still sees the original positions
of
// every surviving row group and the post-synthesis filter remains
correct.
- _, originalFirstRowID, _, _, _ :=
iceberginternal.BorrowedDataFilePointers(args.originalFile)
+ _, originalFirstRowID, _, _, _ :=
iceberginternal.BorrowedDataFilePointers(args.task.File)
preserveRowLineage := meta.formatVersion >= 3 && originalFirstRowID !=
nil
projectedSchema := meta.CurrentSchema()
var factoryOpts []writerFactoryOption
@@ -3055,13 +3163,6 @@ func (t *Transaction) rewriteSingleFile(ctx
context.Context, args rewriteSingleF
projectedSchema = iceberg.SchemaWithRowLineage(projectedSchema)
factoryOpts = append(factoryOpts,
withFactoryFileSchema(projectedSchema))
- scanTask.FirstRowID = args.originalFile.FirstRowID()
- // fileSeqNum drives _last_updated_sequence_number synthesis for
- // source rows whose value is null in the file (or for source
- // files written before row lineage existed). When the column
- // is non-null the existing per-row value wins, preserving the
- // original update sequence across the rewrite.
- scanTask.DataSequenceNumber = args.fileSeqNum
}
// When preserving row lineage, the bound scan-time filter is replaced
@@ -3076,13 +3177,8 @@ func (t *Transaction) rewriteSingleFile(ctx
context.Context, args rewriteSingleF
scanFilter = boundFilter
}
- builtMeta, err := meta.Build()
- if err != nil {
- return nil, fmt.Errorf("failed to build metadata: %w", err)
- }
-
scanner := &arrowScan{
- metadata: builtMeta,
+ metadata: args.builtMeta,
fs: args.fs,
scanSchema: meta.CurrentSchema(),
projectedSchema: projectedSchema,
@@ -3093,7 +3189,7 @@ func (t *Transaction) rewriteSingleFile(ctx
context.Context, args rewriteSingleF
concurrency: args.concurrency,
}
- arrowSchema, recordIter, err := scanner.GetRecords(ctx,
[]FileScanTask{*scanTask})
+ arrowSchema, recordIter, err := scanner.GetRecords(ctx,
[]FileScanTask{args.task})
if err != nil {
return nil, fmt.Errorf("failed to get records from original
file: %w", err)
}
@@ -3104,7 +3200,7 @@ func (t *Transaction) rewriteSingleFile(ctx
context.Context, args rewriteSingleF
if preserveRowLineage {
arrowSchema, err =
SchemaToArrowSchemaWithOptions(projectedSchema, ArrowSchemaOptions{
IncludeFieldIDs: true,
- TableProperties: builtMeta.Properties(),
+ TableProperties: args.builtMeta.Properties(),
})
if err != nil {
return nil, fmt.Errorf("failed to build arrow schema
with field IDs: %w", err)
@@ -3174,7 +3270,7 @@ func (t *Transaction) rewriteSingleFile(ctx
context.Context, args rewriteSingleF
// 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.
-func (t *Transaction) writePositionDeletesForFiles(ctx context.Context, fs
io.IO, updater *snapshotProducer, files []iceberg.DataFile, filter
iceberg.BooleanExpression, caseSensitive bool, concurrency int, commitUUID
uuid.UUID) ([]string, error) {
+func (t *Transaction) writePositionDeletesForFiles(ctx context.Context, fs
io.IO, updater *snapshotProducer, dvs map[string]iceberg.ManifestEntry, files
[]iceberg.DataFile, filter iceberg.BooleanExpression, caseSensitive bool,
concurrency int, commitUUID uuid.UUID) ([]string, error) {
meta, err := t.txnMeta()
if err != nil {
return nil, err
@@ -3200,24 +3296,20 @@ func (t *Transaction) writePositionDeletesForFiles(ctx
context.Context, fs io.IO
// into the new DV (the incremental deletes) and the superseded DVs are
// removed. Without this a second delete on the same file would write a
// second live DV, corrupting the table (buildDVIndex rejects it).
- var (
- existingDVs map[string]iceberg.DataFile
- existingDVBitmaps map[string]*dv.RoaringPositionBitmap
- )
- if meta.formatVersion >= 3 {
- existingDVs, err = t.collectExistingDVs(fs, files)
- if err != nil {
- return nil, err
+ // dvs holds the live DVs keyed by data file path, and is empty on v2.
+ existingDVs := make(map[string]iceberg.DataFile)
+ existingDVBitmaps := make(map[string]*dv.RoaringPositionBitmap)
+ for _, df := range files {
+ entry, ok := dvs[df.FilePath()]
+ if !ok {
+ continue
}
-
- existingDVBitmaps = make(map[string]*dv.RoaringPositionBitmap,
len(existingDVs))
- for ref, dvFile := range existingDVs {
- bitmap, err := dv.ReadDV(fs, dvFile)
- if err != nil {
- return nil, fmt.Errorf("failed to read existing
deletion vector for %s: %w", ref, err)
- }
- existingDVBitmaps[ref] = bitmap
+ bitmap, err := dv.ReadDV(fs, entry.DataFile())
+ if err != nil {
+ return nil, fmt.Errorf("failed to read existing
deletion vector for %s: %w", df.FilePath(), err)
}
+ existingDVs[df.FilePath()] = entry.DataFile()
+ existingDVBitmaps[df.FilePath()] = bitmap
}
posDeleteFiles := positionDeleteRecordsToDataFiles(ctx,
t.tbl.Location(), meta, partitionContextByFilePath, recordWritingArgs{
@@ -3252,49 +3344,6 @@ func (t *Transaction) writePositionDeletesForFiles(ctx
context.Context, fs io.IO
return referenced, nil
}
-// collectExistingDVs returns the live deletion vectors that reference any of
-// files, keyed by the referenced data file path. Used by the v3 merge-on-read
-// delete path to fold prior deletes into a new DV and remove the superseded
-// entries, preserving the spec's one-DV-per-data-file invariant.
-func (t *Transaction) collectExistingDVs(fs io.IO, files []iceberg.DataFile)
(map[string]iceberg.DataFile, error) {
- meta, err := t.txnMeta()
- if err != nil {
- return nil, err
- }
-
- s := t.planningSnapshot(meta)
- if s == nil {
- return nil, nil
- }
-
- wanted := make(map[string]struct{}, len(files))
- for _, df := range files {
- wanted[df.FilePath()] = struct{}{}
- }
-
- result := make(map[string]iceberg.DataFile)
- // Skip DELETED entries: an old, replaced DV stays behind as one.
Reading it
- // would build the new DV from old data and bring back deleted rows.
See #1372.
- for entry, err := range s.entries(fs, iceberg.ManifestContentDeletes,
true) {
- if err != nil {
- return nil, fmt.Errorf("scanning existing deletion
vectors: %w", err)
- }
- df := entry.DataFile()
- if !IsDeletionVector(df) {
- continue
- }
- ref := iceberginternal.BorrowedDataFileReferencedDataFile(df)
- if ref == nil {
- continue
- }
- if _, ok := wanted[*ref]; ok {
- result[*ref] = df
- }
- }
-
- return result, nil
-}
-
func (t *Transaction) makePositionDeleteRecordsForFilter(ctx context.Context,
fs io.IO, files []iceberg.DataFile, filter iceberg.BooleanExpression,
caseSensitive bool, concurrency int) (seq2 iter.Seq2[arrow.RecordBatch, error],
err error) {
meta, err := t.txnMeta()
if err != nil {