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 19c888720 feat(table): rewrite v2 position delete files (#2409)
19c888720 is described below

commit 19c888720daf8f3fd8d0a2bb14cd9e8e32ce0a51
Author: Minh Vu <[email protected]>
AuthorDate: Wed Nov 4 19:38:51 2026 +0100

    feat(table): rewrite v2 position delete files (#2409)
    
    * feat(table): rewrite position delete files
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * fix(table): preserve unscoped position deletes and validate positions
    
    * fix(table): skip ineligible position delete rewrites and clean partial 
outputs
    
    * test(table): cover no-op delete rewrites with read-only file IO
    
    * fix(table): repair delete rewrite lint and preserve no-op behavior
    
    * fix(table): compact position deletes across source sequences
    
    * test(table): cover source sequences in delete rewrite chunks
    
    * test(table): retain delete applicability across commit sequences
    
    * style(table): satisfy nlreturn in position delete rewrite
    
    * test(table): assert position delete rewrites stop on pinned ref conflict
    
    * fix(table): preserve cross-spec position deletes
    
    * refactor(table): stage position delete rewrites in transactions
    
    * fix(table): match location provider signature in position delete rewrite
    
    * style(table): fix position delete rewrite test formatting
    
    * style(table): fix position delete rewrite test formatting
    
    ---------
    
    Signed-off-by: Minh Vu <[email protected]>
---
 table/rewrite_position_delete_files.go             | 480 +++++++++++++++++++++
 ...write_position_delete_files_integration_test.go | 341 +++++++++++++++
 table/rewrite_position_delete_files_test.go        | 141 ++++++
 3 files changed, 962 insertions(+)

diff --git a/table/rewrite_position_delete_files.go 
b/table/rewrite_position_delete_files.go
new file mode 100644
index 000000000..f60696892
--- /dev/null
+++ b/table/rewrite_position_delete_files.go
@@ -0,0 +1,480 @@
+// 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
+
+import (
+       "context"
+       "fmt"
+       "maps"
+       "slices"
+       "strings"
+
+       "github.com/apache/arrow-go/v18/arrow"
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/arrow-go/v18/arrow/compute"
+       "github.com/apache/iceberg-go"
+       tblutils "github.com/apache/iceberg-go/table/internal"
+       "github.com/google/uuid"
+)
+
+// PositionDeleteRewriteOptions controls position delete compaction.
+// Zero values use a minimum of two files and a maximum of 128 MiB of input
+// file sizes per group. Decoded rows are held in memory for sorting.
+type PositionDeleteRewriteOptions struct {
+       MinInputFiles int
+       MaxInputBytes int64
+}
+
+type positionDeleteRewriteKey struct {
+       partition string
+}
+
+type positionDeleteRewriteGroup struct {
+       files       []iceberg.DataFile
+       partition   map[int]any
+       sequences   map[string]int64
+       maxSequence int64
+       bytes       int64
+}
+
+// A job owns a file subset that fits the input budget and minimum count.
+type positionDeleteRewriteJob struct {
+       key   positionDeleteRewriteKey
+       group *positionDeleteRewriteGroup
+}
+
+type positionDeleteLiveFile struct {
+       partition string
+       sequence  int64
+}
+
+type positionDeleteRow struct {
+       path     string
+       position int64
+}
+
+// PositionDeleteRewriteResult summarizes a position delete file rewrite.
+// Table is set when the table-level action has committed. Transaction callers
+// receive a nil Table until they commit their transaction.
+type PositionDeleteRewriteResult struct {
+       Table *Table
+
+       RemovedDeleteFiles int
+       AddedDeleteFiles   int
+       BytesBefore        int64
+       BytesAfter         int64
+}
+
+// RewritePositionDeleteFiles compacts compatible v2 position delete files on
+// main and commits the rewrite. Use [Transaction.RewritePositionDeleteFiles]
+// to target another branch or combine the rewrite with other updates.
+//
+// The action holds decoded delete rows in memory. A concurrent commit 
advancing
+// main invalidates the pinned planning snapshot and fails the whole commit
+// instead of retrying against the changed snapshot.
+func (t Table) RewritePositionDeleteFiles(
+       ctx context.Context, opts PositionDeleteRewriteOptions,
+) (*PositionDeleteRewriteResult, error) {
+       txn, err := t.NewTransactionOnBranch(MainBranch)
+       if err != nil {
+               return nil, err
+       }
+
+       result, err := txn.RewritePositionDeleteFiles(ctx, opts)
+       if err != nil {
+               return result, err
+       }
+
+       committed, err := txn.Commit(ctx)
+       if err == nil {
+               result.Table = committed
+       }
+
+       return result, err
+}
+
+// RewritePositionDeleteFiles stages a rewrite of compatible classic Parquet
+// position delete files without rewriting data files. It removes records
+// targeting data files that are no longer live in the target branch snapshot.
+//
+// Only v2 files in the current partition spec without optional deleted-row
+// payloads are selected. Groups never mix partition tuples. Each source's data
+// sequence controls its applicable rows; replacements inherit the maximum
+// source sequence. Physical source files remain available to older snapshots.
+//
+// The target branch snapshot is pinned. A concurrent commit advancing that
+// branch fails the eventual transaction commit rather than replaying the
+// rewrite. Decoded delete rows are kept in memory during sorting. Successfully
+// staged outputs must be retained until Transaction.Commit; abandoned
+// transactions may leave files for orphan cleanup.
+func (t *Transaction) RewritePositionDeleteFiles(
+       ctx context.Context, opts PositionDeleteRewriteOptions,
+) (*PositionDeleteRewriteResult, error) {
+       meta, err := t.txnMeta()
+       if err != nil {
+               return nil, err
+       }
+       if meta.formatVersion != 2 {
+               return nil, fmt.Errorf("%w: position delete rewrites require v2 
table format", iceberg.ErrNotImplemented)
+       }
+       if opts.MinInputFiles <= 0 {
+               opts.MinInputFiles = 2
+       }
+       if opts.MaxInputBytes <= 0 {
+               opts.MaxInputBytes = 128 << 20
+       }
+       snap := t.planningSnapshot(meta)
+       if snap == nil {
+               return &PositionDeleteRewriteResult{}, nil
+       }
+       spec, err := meta.CurrentSpec()
+       if err != nil {
+               return nil, err
+       }
+
+       fs, err := t.tbl.FS(ctx)
+       if err != nil {
+               return nil, err
+       }
+       manifests, err := snap.Manifests(fs)
+       if err != nil {
+               return nil, err
+       }
+
+       live := make(map[string]positionDeleteLiveFile)
+       // Build a snapshot-scoped live-file index. This is independent of
+       // removeDanglingDeleteFiles; the two features can merge in either 
order.
+       for _, manifest := range manifests {
+               if err := ctx.Err(); err != nil {
+                       return nil, err
+               }
+               if manifest.ManifestContent() != iceberg.ManifestContentData {
+                       continue
+               }
+               for entry, err := range manifest.Entries(fs, true) {
+                       if err != nil {
+                               return nil, err
+                       }
+                       df := entry.DataFile()
+                       if df.ContentType() != iceberg.EntryContentData {
+                               continue
+                       }
+                       partition, err := canonicalPartitionKey(df.SpecID(), 
dataFilePartition(df))
+                       if err != nil {
+                               return nil, fmt.Errorf("data file %s: %w", 
df.FilePath(), err)
+                       }
+                       if entry.DataSequenceNumber() < 0 {
+                               return nil, fmt.Errorf("%w: data file %s has 
unknown sequence number", ErrInvalidMetadata, df.FilePath())
+                       }
+                       live[df.FilePath()] = positionDeleteLiveFile{partition: 
partition, sequence: entry.DataSequenceNumber()}
+               }
+       }
+
+       groups := make(map[positionDeleteRewriteKey]*positionDeleteRewriteGroup)
+       seen := make(map[string]struct{})
+       for _, manifest := range manifests {
+               if err := ctx.Err(); err != nil {
+                       return nil, err
+               }
+               if manifest.ManifestContent() != iceberg.ManifestContentDeletes 
{
+                       continue
+               }
+               for entry, err := range manifest.Entries(fs, true) {
+                       if err != nil {
+                               return nil, err
+                       }
+                       df := entry.DataFile()
+                       if df.ContentType() != iceberg.EntryContentPosDeletes ||
+                               df.FileFormat() != iceberg.ParquetFile ||
+                               IsDeletionVector(df) || int(df.SpecID()) != 
spec.ID() ||
+                               entry.DataSequenceNumber() < 0 {
+                               continue
+                       }
+                       if _, ok := seen[df.FilePath()]; ok {
+                               continue
+                       }
+                       seen[df.FilePath()] = struct{}{}
+                       partition, err := canonicalPartitionKey(df.SpecID(), 
dataFilePartition(df))
+                       if err != nil {
+                               return nil, fmt.Errorf("position delete %s: 
%w", df.FilePath(), err)
+                       }
+                       key := positionDeleteRewriteKey{partition: partition}
+                       group := groups[key]
+                       if group == nil {
+                               group = &positionDeleteRewriteGroup{
+                                       partition: df.Partition(), sequences: 
make(map[string]int64),
+                               }
+                               groups[key] = group
+                       }
+                       group.files = append(group.files, df)
+                       group.sequences[df.FilePath()] = 
entry.DataSequenceNumber()
+                       group.maxSequence = max(group.maxSequence, 
entry.DataSequenceNumber())
+                       group.bytes += df.FileSizeBytes()
+               }
+       }
+
+       // Avoid opening a writer or requiring write-capable I/O when no group
+       // qualifies. Split before writing so the selected work is 
deterministic.
+       var jobs []positionDeleteRewriteJob
+       for key, group := range groups {
+               for _, chunk := range splitPositionDeleteRewriteGroup(group, 
opts.MaxInputBytes) {
+                       if len(chunk.files) < opts.MinInputFiles || chunk.bytes 
> opts.MaxInputBytes {
+                               continue
+                       }
+                       jobs = append(jobs, positionDeleteRewriteJob{key: key, 
group: chunk})
+               }
+       }
+       if len(jobs) == 0 {
+               return &PositionDeleteRewriteResult{}, nil
+       }
+       slices.SortFunc(jobs, func(a, b positionDeleteRewriteJob) int {
+               if order := strings.Compare(a.key.partition, b.key.partition); 
order != 0 {
+                       return order
+               }
+
+               return strings.Compare(a.group.files[0].FilePath(), 
b.group.files[0].FilePath())
+       })
+
+       wfs, err := requireWriteFileIO(fs)
+       if err != nil {
+               return nil, err
+       }
+
+       if err := t.AssertRefSnapshotID(""); err != nil {
+               return nil, err
+       }
+       rewrite := t.NewRewrite(nil)
+       props := maps.Clone(meta.props)
+       if props == nil {
+               props = make(iceberg.Properties)
+       }
+       props[WriteFormatDefaultKey] = string(iceberg.ParquetFile)
+       writer, err := newPositionDeleteWriter(meta.loc, wfs, meta, props)
+       if err != nil {
+               return nil, err
+       }
+
+       var produced []string
+       staged := false
+       defer func() {
+               if !staged {
+                       for _, path := range produced {
+                               _ = wfs.Remove(context.WithoutCancel(ctx), path)
+                       }
+               }
+       }()
+
+       result := &PositionDeleteRewriteResult{}
+       for _, job := range jobs {
+               if err := ctx.Err(); err != nil {
+                       return nil, err
+               }
+               key, group := job.key, job.group
+               supported := true
+               for _, df := range group.files {
+                       src, err := tblutils.GetFile(ctx, fs, df, true)
+                       if err != nil {
+                               return nil, err
+                       }
+                       rdr, err := src.GetReader(ctx)
+                       if err != nil {
+                               return nil, err
+                       }
+                       schema, schemaErr := rdr.Schema()
+                       closeErr := rdr.Close()
+                       if schemaErr != nil {
+                               return nil, schemaErr
+                       }
+                       if closeErr != nil {
+                               return nil, closeErr
+                       }
+                       // The two-column writer cannot preserve optional 
deleted rows.
+                       if schema.NumFields() != 2 ||
+                               len(schema.FieldIndices("file_path")) != 1 ||
+                               len(schema.FieldIndices("pos")) != 1 {
+                               supported = false
+
+                               break
+                       }
+               }
+               if !supported {
+                       continue
+               }
+
+               var rows []positionDeleteRow
+               crossSpecTarget := false
+               for _, df := range group.files {
+                       byPath, err := readDeletesForPaths(ctx, fs, df, nil)
+                       if err != nil {
+                               releasePosDeletes(byPath)
+
+                               return nil, fmt.Errorf("read position deletes 
from %s: %w", df.FilePath(), err)
+                       }
+                       for path, chunk := range byPath {
+                               if !rewritePositionDeletePathInScope(df, path) {
+                                       continue
+                               }
+                               data, ok := live[path]
+                               if !ok || data.sequence > 
group.sequences[df.FilePath()] {
+                                       continue
+                               }
+                               if data.partition != key.partition {
+                                       // A path-scoped delete can target data 
written with an
+                                       // older spec. The replacement may not 
retain that scope,
+                                       // so leave the entire source group 
untouched.
+                                       crossSpecTarget = true
+
+                                       break
+                               }
+                               for _, part := range chunk.Chunks() {
+                                       positions, ok := part.(*array.Int64)
+                                       if !ok {
+                                               releasePosDeletes(byPath)
+
+                                               return nil, fmt.Errorf("%w: 
non-int64 position column in %s", iceberg.ErrInvalidSchema, df.FilePath())
+                                       }
+                                       if err := 
validateRewritePositionDeletePositions(df.FilePath(), positions); err != nil {
+                                               releasePosDeletes(byPath)
+
+                                               return nil, err
+                                       }
+                                       for _, pos := range 
positions.Int64Values() {
+                                               rows = append(rows, 
positionDeleteRow{path: path, position: pos})
+                                       }
+                               }
+                       }
+                       releasePosDeletes(byPath)
+                       if crossSpecTarget {
+                               break
+                       }
+               }
+               if crossSpecTarget {
+                       continue
+               }
+
+               rows = sortUniquePositionDeleteRows(rows)
+               if len(rows) > 0 {
+                       b := array.NewRecordBuilder(compute.GetAllocator(ctx), 
PositionalDeleteArrowSchema)
+                       pathBuilder := b.Field(0).(*array.StringBuilder)
+                       posBuilder := b.Field(1).(*array.Int64Builder)
+                       for _, row := range rows {
+                               pathBuilder.Append(row.path)
+                               posBuilder.Append(row.position)
+                       }
+                       record := b.NewRecordBatch()
+                       b.Release()
+                       task := WriteTask{
+                               Uuid: uuid.New(), PartitionID: spec.ID(),
+                               FileCount: 1, Schema: 
iceberg.PositionalDeleteSchema,
+                               Batches: []arrow.RecordBatch{record},
+                       }
+                       // Track the intended output before writing. A failed 
writer
+                       // may have already created a partial file.
+                       produced = append(produced, writer.loc.NewDataLocation(
+                               
task.GenerateDataFileName(writer.invariants.extension), ""))
+                       newFile, err := writer.writeFile(ctx, group.partition, 
task)
+                       if err != nil {
+                               return nil, err
+                       }
+                       rewrite.AddDeleteFileWithDataSequenceNumber(newFile, 
group.maxSequence)
+                       result.AddedDeleteFiles++
+                       result.BytesAfter += newFile.FileSizeBytes()
+               }
+               for _, df := range group.files {
+                       rewrite.DeleteFile(df)
+                       result.RemovedDeleteFiles++
+                       result.BytesBefore += df.FileSizeBytes()
+               }
+       }
+       if result.RemovedDeleteFiles == 0 {
+               return result, nil
+       }
+       if err := rewrite.Commit(ctx); err != nil {
+               return nil, err
+       }
+
+       // This transaction still needs to commit. Do not delete staged output
+       // files when returning successfully to its caller.
+       staged = true
+
+       return result, nil
+}
+
+func splitPositionDeleteRewriteGroup(group *positionDeleteRewriteGroup, limit 
int64) []*positionDeleteRewriteGroup {
+       slices.SortFunc(group.files, func(a, b iceberg.DataFile) int { return 
strings.Compare(a.FilePath(), b.FilePath()) })
+       var groups []*positionDeleteRewriteGroup
+       var current *positionDeleteRewriteGroup
+       for _, file := range group.files {
+               size := file.FileSizeBytes()
+               if size < 0 || size > limit {
+                       continue
+               }
+               if current == nil || size > limit-current.bytes {
+                       current = &positionDeleteRewriteGroup{partition: 
group.partition, sequences: group.sequences}
+                       groups = append(groups, current)
+               }
+               current.files = append(current.files, file)
+               current.maxSequence = max(current.maxSequence, 
group.sequences[file.FilePath()])
+               current.bytes += size
+       }
+
+       return groups
+}
+
+func sortUniquePositionDeleteRows(rows []positionDeleteRow) 
[]positionDeleteRow {
+       slices.SortFunc(rows, func(a, b positionDeleteRow) int {
+               if a.path < b.path {
+                       return -1
+               }
+               if a.path > b.path {
+                       return 1
+               }
+               if a.position < b.position {
+                       return -1
+               }
+               if a.position > b.position {
+                       return 1
+               }
+
+               return 0
+       })
+
+       return slices.Compact(rows)
+}
+
+// A delete file's statistics can help planning, but do not establish an
+// explicit one-file scope for dropping rows during maintenance.
+func rewritePositionDeletePathInScope(df iceberg.DataFile, path string) bool {
+       target := explicitReferencedDataFile(df)
+
+       return target == "" || target == path
+}
+
+// Reject invalid positions before a rewrite could convert a null to zero or
+// carry a negative index into a replacement delete file.
+func validateRewritePositionDeletePositions(path string, positions 
*array.Int64) error {
+       if positions.NullN() != 0 {
+               return fmt.Errorf("%w: null position in delete file %s", 
iceberg.ErrInvalidSchema, path)
+       }
+       for _, pos := range positions.Int64Values() {
+               if pos < 0 {
+                       return fmt.Errorf("%w: negative position %d in delete 
file %s", iceberg.ErrInvalidSchema, pos, path)
+               }
+       }
+
+       return nil
+}
diff --git a/table/rewrite_position_delete_files_integration_test.go 
b/table/rewrite_position_delete_files_integration_test.go
new file mode 100644
index 000000000..80bf94ff0
--- /dev/null
+++ b/table/rewrite_position_delete_files_integration_test.go
@@ -0,0 +1,341 @@
+// 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 (
+       "context"
+       "fmt"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/apache/iceberg-go/table"
+       "github.com/stretchr/testify/require"
+)
+
+// Embedding only IO hides the writer methods provided by LocalFS.
+type readOnlyPositionDeleteIO struct{ iceio.IO }
+
+func TestRewritePositionDeleteFilesCommit(t *testing.T) {
+       tbl, cat := newConcurrentRewriteTestTable(t)
+       schema, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false, 
false)
+       require.NoError(t, err)
+       a, b := tbl.Location()+"/data/a.parquet", 
tbl.Location()+"/data/b.parquet"
+       writeParquetFile(t, a, schema, 
`[{"id":1,"data":"a"},{"id":2,"data":"b"}]`)
+       writeParquetFile(t, b, schema, `[{"id":3,"data":"c"}]`)
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.AddDataFilesByPath(t.Context(), []string{a, b}, 
nil, false))
+       tbl, err = tx.Commit(t.Context())
+       require.NoError(t, err)
+       var deletes []iceberg.DataFile
+       for i := range 2 {
+               path := fmt.Sprintf("%s/data/delete-%d.parquet", 
tbl.Location(), i)
+               // The second row is outside the file's referenced-data-file 
scope.
+               writeParquetFile(t, path, table.PositionalDeleteArrowSchema, 
fmt.Sprintf(`[{"file_path":%q,"pos":0},{"file_path":%q,"pos":0}]`, a, b))
+               builder, err := 
iceberg.NewDataFileBuilder(*iceberg.UnpartitionedSpec, 
iceberg.EntryContentPosDeletes, path, iceberg.ParquetFile, nil, nil, nil, 2, 
128)
+               require.NoError(t, err)
+               deletes = append(deletes, builder.ReferencedDataFile(a).Build())
+       }
+       tx = tbl.NewTransaction()
+       require.NoError(t, 
tx.NewRowDelta(nil).AddDeletes(deletes...).Commit(t.Context()))
+       tbl, err = tx.Commit(t.Context())
+       require.NoError(t, err)
+       sequence := currentManifestEntry(t, tbl, 
deletes[0].FilePath()).DataSequenceNumber()
+       require.ElementsMatch(t, []int64{2, 3}, scanIDs(t, tbl))
+
+       // When all inputs exceed the configured budget, the action is a no-op
+       // and must not require a filesystem that can create files.
+       readOnlyFS := func(context.Context) (iceio.IO, error) {
+               return readOnlyPositionDeleteIO{IO: iceio.LocalFS{}}, nil
+       }
+       readOnly := table.New(tbl.Identifier(), tbl.Metadata(), 
tbl.MetadataLocation(), readOnlyFS, nil)
+       noOp, err := readOnly.RewritePositionDeleteFiles(t.Context(), 
table.PositionDeleteRewriteOptions{
+               MinInputFiles: 1, MaxInputBytes: 1,
+       })
+       require.NoError(t, err)
+       require.Zero(t, noOp.RemovedDeleteFiles)
+       require.Equal(t, tbl.Metadata(), noOp.Table.Metadata())
+
+       result, err := tbl.RewritePositionDeleteFiles(t.Context(), 
table.PositionDeleteRewriteOptions{})
+       require.NoError(t, err)
+       updated := result.Table
+       require.Equal(t, 2, result.RemovedDeleteFiles)
+       require.Equal(t, 1, result.AddedDeleteFiles)
+       require.Equal(t, int64(256), result.BytesBefore)
+       require.Positive(t, result.BytesAfter)
+       require.ElementsMatch(t, []int64{2, 3}, scanIDs(t, updated))
+       tasks, err := updated.Scan().PlanFiles(t.Context())
+       require.NoError(t, err)
+       require.Len(t, tasks, 2)
+       var replacement iceberg.DataFile
+       for _, task := range tasks {
+               if task.File.FilePath() == a {
+                       require.Len(t, task.DeleteFiles, 1)
+                       replacement = task.DeleteFiles[0]
+               }
+       }
+       require.NotNil(t, replacement)
+       require.Equal(t, sequence, currentManifestEntry(t, updated, 
replacement.FilePath()).DataSequenceNumber())
+       require.Equal(t, int64(1), replacement.Count())
+
+       // Only one delete file remains, so the default minimum of two prevents
+       // a rewrite even if the table is exposed through read-only I/O.
+       readOnly = table.New(updated.Identifier(), updated.Metadata(), 
updated.MetadataLocation(), readOnlyFS, nil)
+       noOp, err = readOnly.RewritePositionDeleteFiles(t.Context(), 
table.PositionDeleteRewriteOptions{})
+       require.NoError(t, err)
+       require.Zero(t, noOp.RemovedDeleteFiles)
+       require.Equal(t, updated.Metadata(), noOp.Table.Metadata())
+
+       attemptsBefore := cat.attempts.Load()
+       _, err = tbl.RewritePositionDeleteFiles(t.Context(), 
table.PositionDeleteRewriteOptions{})
+       require.ErrorIs(t, err, table.ErrCommitFailed)
+       require.Equal(t, attemptsBefore+1, cat.attempts.Load(),
+               "the pinned planning snapshot must stop a stale delete rewrite 
without retrying")
+}
+
+func TestRewritePositionDeleteFilesDropsDanglingRowsFromUnscopedFiles(t 
*testing.T) {
+       tbl, _ := newConcurrentRewriteTestTable(t)
+       schema, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false, 
false)
+       require.NoError(t, err)
+       a := tbl.Location() + "/data/a.parquet"
+       b := tbl.Location() + "/data/b.parquet"
+       dangling := tbl.Location() + "/data/removed.parquet"
+       writeParquetFile(t, a, schema, 
`[{"id":1,"data":"a"},{"id":2,"data":"b"}]`)
+       writeParquetFile(t, b, schema, 
`[{"id":3,"data":"c"},{"id":4,"data":"d"}]`)
+
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.AddDataFilesByPath(t.Context(), []string{a, b}, 
nil, false))
+       tbl, err = tx.Commit(t.Context())
+       require.NoError(t, err)
+
+       var deletes []iceberg.DataFile
+       for i := range 2 {
+               path := fmt.Sprintf("%s/data/unscoped-delete-%d.parquet", 
tbl.Location(), i)
+               writeParquetFile(t, path, table.PositionalDeleteArrowSchema, 
fmt.Sprintf(
+                       
`[{"file_path":%q,"pos":1},{"file_path":%q,"pos":0},{"file_path":%q,"pos":0}]`, 
a, b, dangling))
+               builder, err := 
iceberg.NewDataFileBuilder(*iceberg.UnpartitionedSpec,
+                       iceberg.EntryContentPosDeletes, path, 
iceberg.ParquetFile, nil, nil, nil, 3, 128)
+               require.NoError(t, err)
+               deletes = append(deletes, builder.Build())
+       }
+
+       tx = tbl.NewTransaction()
+       require.NoError(t, 
tx.NewRowDelta(nil).AddDeletes(deletes...).Commit(t.Context()))
+       tbl, err = tx.Commit(t.Context())
+       require.NoError(t, err)
+       require.ElementsMatch(t, []int64{1, 4}, scanIDs(t, tbl))
+
+       result, err := tbl.RewritePositionDeleteFiles(t.Context(), 
table.PositionDeleteRewriteOptions{})
+       require.NoError(t, err)
+       updated := result.Table
+       require.Equal(t, 2, result.RemovedDeleteFiles)
+       require.Equal(t, 1, result.AddedDeleteFiles)
+       require.ElementsMatch(t, []int64{1, 4}, scanIDs(t, updated))
+
+       tasks, err := updated.Scan().PlanFiles(t.Context())
+       require.NoError(t, err)
+       require.Len(t, tasks, 2)
+       var replacement iceberg.DataFile
+       for _, task := range tasks {
+               require.Len(t, task.DeleteFiles, 1)
+               if replacement == nil {
+                       replacement = task.DeleteFiles[0]
+               }
+               require.Equal(t, replacement.FilePath(), 
task.DeleteFiles[0].FilePath())
+       }
+       require.Nil(t, replacement.ReferencedDataFile())
+       require.Equal(t, int64(2), replacement.Count(), "drop dangling and 
duplicate delete positions")
+
+       again, err := updated.RewritePositionDeleteFiles(t.Context(), 
table.PositionDeleteRewriteOptions{})
+       require.NoError(t, err)
+       require.Zero(t, again.RemovedDeleteFiles)
+       require.Equal(t, updated.Metadata(), again.Table.Metadata())
+}
+
+func TestRewritePositionDeleteFilesAcrossSequences(t *testing.T) {
+       ctx := t.Context()
+       tbl, _ := newConcurrentRewriteTestTable(t)
+       schema, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false, 
false)
+       require.NoError(t, err)
+       a, b := tbl.Location()+"/data/a.parquet", 
tbl.Location()+"/data/b.parquet"
+       writeParquetFile(t, a, schema, 
`[{"id":1,"data":"a"},{"id":2,"data":"b"}]`)
+       writeParquetFile(t, b, schema, 
`[{"id":3,"data":"c"},{"id":4,"data":"d"}]`)
+
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.AddDataFilesByPath(ctx, []string{a}, nil, false))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+
+       // The first delete predates file b. Its row targeting b must not
+       // become applicable after the rewrite raises the replacement sequence.
+       olderPath := tbl.Location() + "/data/older-delete.parquet"
+       writeParquetFile(t, olderPath, table.PositionalDeleteArrowSchema, 
fmt.Sprintf(
+               `[{"file_path":%q,"pos":0},{"file_path":%q,"pos":0}]`, a, b))
+       olderBuilder, err := 
iceberg.NewDataFileBuilder(*iceberg.UnpartitionedSpec,
+               iceberg.EntryContentPosDeletes, olderPath, iceberg.ParquetFile, 
nil, nil, nil, 2, 128)
+       require.NoError(t, err)
+       tx = tbl.NewTransaction()
+       require.NoError(t, 
tx.NewRowDelta(nil).AddDeletes(olderBuilder.Build()).Commit(ctx))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+
+       tx = tbl.NewTransaction()
+       require.NoError(t, tx.AddDataFilesByPath(ctx, []string{b}, nil, false))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+
+       newerPath := tbl.Location() + "/data/newer-delete.parquet"
+       writeParquetFile(t, newerPath, table.PositionalDeleteArrowSchema,
+               fmt.Sprintf(`[{"file_path":%q,"pos":1}]`, a))
+       newerBuilder, err := 
iceberg.NewDataFileBuilder(*iceberg.UnpartitionedSpec,
+               iceberg.EntryContentPosDeletes, newerPath, iceberg.ParquetFile, 
nil, nil, nil, 1, 128)
+       require.NoError(t, err)
+       newer := newerBuilder.Build()
+       tx = tbl.NewTransaction()
+       require.NoError(t, tx.NewRowDelta(nil).AddDeletes(newer).Commit(ctx))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+
+       maxSourceSequence := currentManifestEntry(t, tbl, 
newer.FilePath()).DataSequenceNumber()
+       require.ElementsMatch(t, []int64{3, 4}, scanIDs(t, tbl))
+
+       result, err := tbl.RewritePositionDeleteFiles(ctx, 
table.PositionDeleteRewriteOptions{})
+       require.NoError(t, err)
+       updated := result.Table
+       require.Equal(t, 2, result.RemovedDeleteFiles, "delete files from 
separate commits should compact")
+       require.Equal(t, 1, result.AddedDeleteFiles)
+       require.ElementsMatch(t, []int64{3, 4}, scanIDs(t, updated))
+
+       tasks, err := updated.Scan().PlanFiles(ctx)
+       require.NoError(t, err)
+       var replacement iceberg.DataFile
+       for _, task := range tasks {
+               if task.File.FilePath() == a {
+                       require.Len(t, task.DeleteFiles, 1)
+                       replacement = task.DeleteFiles[0]
+               }
+       }
+       require.NotNil(t, replacement)
+       require.Equal(t, maxSourceSequence,
+               currentManifestEntry(t, updated, 
replacement.FilePath()).DataSequenceNumber())
+       require.Equal(t, int64(2), replacement.Count(),
+               "stale positions must not gain applicability from the 
replacement sequence")
+}
+
+func TestRewritePositionDeleteFilesPreservesCrossSpecPathScopedDeletes(t 
*testing.T) {
+       ctx := t.Context()
+       tbl, _ := newConcurrentRewriteTestTable(t)
+       schema, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false, 
false)
+       require.NoError(t, err)
+
+       dataPath := tbl.Location() + "/data/old-spec.parquet"
+       writeParquetFile(t, dataPath, schema, 
`[{"id":1,"data":"a"},{"id":2,"data":"b"}]`)
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.AddDataFilesByPath(ctx, []string{dataPath}, nil, 
false))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+
+       // The data remains on the old unpartitioned spec, while the new delete
+       // files refer to its path from the evolved partition spec.
+       tx = tbl.NewTransaction()
+       require.NoError(t, tx.UpdateSpec(false).
+               AddField("id", iceberg.IdentityTransform{}, 
"id_identity").Commit())
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+       spec := tbl.Spec()
+       require.Equal(t, 1, spec.NumFields())
+       partition := map[int]any{spec.Field(0).FieldID: int64(1)}
+
+       var deletes []iceberg.DataFile
+       for i := range 2 {
+               path := fmt.Sprintf("%s/data/cross-spec-delete-%d.parquet", 
tbl.Location(), i)
+               writeParquetFile(t, path, table.PositionalDeleteArrowSchema,
+                       fmt.Sprintf(`[{"file_path":%q,"pos":0}]`, dataPath))
+               builder, buildErr := iceberg.NewDataFileBuilder(spec, 
iceberg.EntryContentPosDeletes,
+                       path, iceberg.ParquetFile, partition, nil, nil, 1, 128)
+               require.NoError(t, buildErr)
+               deletes = append(deletes, 
builder.ReferencedDataFile(dataPath).Build())
+       }
+       tx = tbl.NewTransaction()
+       require.NoError(t, 
tx.NewRowDelta(nil).AddDeletes(deletes...).Commit(ctx))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+       require.ElementsMatch(t, []int64{2}, scanIDs(t, tbl))
+
+       // Combining these sources can lose their path-based scope because the
+       // output may be partition-scoped. Keep the original files instead.
+       result, err := tbl.RewritePositionDeleteFiles(ctx, 
table.PositionDeleteRewriteOptions{})
+       require.NoError(t, err)
+       require.Zero(t, result.RemovedDeleteFiles)
+       require.Zero(t, result.AddedDeleteFiles)
+       require.Equal(t, tbl.Metadata(), result.Table.Metadata())
+       require.ElementsMatch(t, []int64{2}, scanIDs(t, result.Table))
+}
+
+func TestRewritePositionDeleteFilesStagesOnTargetBranch(t *testing.T) {
+       ctx := t.Context()
+       tbl, cat := newConcurrentRewriteTestTable(t)
+       schema, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false, 
false)
+       require.NoError(t, err)
+
+       dataPath := tbl.Location() + "/data/branch-data.parquet"
+       writeParquetFile(t, dataPath, schema, 
"[{\"id\":1,\"data\":\"a\"},{\"id\":2,\"data\":\"b\"}]")
+       txn := tbl.NewTransaction()
+       require.NoError(t, txn.AddDataFilesByPath(ctx, []string{dataPath}, nil, 
false))
+       tbl, err = txn.Commit(ctx)
+       require.NoError(t, err)
+
+       var deletes []iceberg.DataFile
+       for i := range 2 {
+               path := fmt.Sprintf("%s/data/branch-delete-%d.parquet", 
tbl.Location(), i)
+               writeParquetFile(t, path, table.PositionalDeleteArrowSchema,
+                       fmt.Sprintf("[{\"file_path\":%q,\"pos\":0}]", dataPath))
+               builder, buildErr := 
iceberg.NewDataFileBuilder(*iceberg.UnpartitionedSpec,
+                       iceberg.EntryContentPosDeletes, path, 
iceberg.ParquetFile, nil, nil, nil, 1, 128)
+               require.NoError(t, buildErr)
+               deletes = append(deletes, builder.Build())
+       }
+       txn, err = tbl.NewTransactionOnBranch("feature")
+       require.NoError(t, err)
+       require.NoError(t, 
txn.NewRowDelta(nil).AddDeletes(deletes...).Commit(ctx))
+       tbl, err = txn.Commit(ctx)
+       require.NoError(t, err)
+
+       require.ElementsMatch(t, []int64{1, 2}, idsOnRef(t, tbl, 
table.MainBranch))
+       require.ElementsMatch(t, []int64{2}, idsOnRef(t, tbl, "feature"))
+
+       txn, err = tbl.NewTransactionOnBranch("feature")
+       require.NoError(t, err)
+       commitsBefore := cat.attempts.Load()
+       result, err := txn.RewritePositionDeleteFiles(ctx, 
table.PositionDeleteRewriteOptions{})
+       require.NoError(t, err)
+       require.Nil(t, result.Table, "transaction rewrite must not commit 
itself")
+       require.Equal(t, 2, result.RemovedDeleteFiles)
+       require.Equal(t, 1, result.AddedDeleteFiles)
+       require.Equal(t, int64(256), result.BytesBefore)
+       require.Positive(t, result.BytesAfter)
+       require.Equal(t, commitsBefore, cat.attempts.Load(), "staging must not 
contact the catalog")
+
+       require.NoError(t, 
txn.SetProperties(iceberg.Properties{"test.position-delete-rewrite": "staged"}))
+       updated, err := txn.Commit(ctx)
+       require.NoError(t, err)
+       require.Equal(t, commitsBefore+1, cat.attempts.Load())
+       require.Equal(t, "staged", 
updated.Properties()["test.position-delete-rewrite"])
+       require.ElementsMatch(t, []int64{1, 2}, idsOnRef(t, updated, 
table.MainBranch))
+       require.ElementsMatch(t, []int64{2}, idsOnRef(t, updated, "feature"))
+}
diff --git a/table/rewrite_position_delete_files_test.go 
b/table/rewrite_position_delete_files_test.go
new file mode 100644
index 000000000..de9c38823
--- /dev/null
+++ b/table/rewrite_position_delete_files_test.go
@@ -0,0 +1,141 @@
+// 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
+
+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/stretchr/testify/require"
+)
+
+func TestSortUniquePositionDeleteRows(t *testing.T) {
+       rows := []positionDeleteRow{
+               {path: "b.parquet", position: 4},
+               {path: "a.parquet", position: 10},
+               {path: "a.parquet", position: 1},
+               {path: "b.parquet", position: 4},
+               {path: "a.parquet", position: 1},
+               {path: "b.parquet", position: 2},
+       }
+       got := sortUniquePositionDeleteRows(rows)
+       require.Equal(t, []positionDeleteRow{
+               {path: "a.parquet", position: 1},
+               {path: "a.parquet", position: 10},
+               {path: "b.parquet", position: 2},
+               {path: "b.parquet", position: 4},
+       }, got)
+}
+
+func TestSplitPositionDeleteRewriteGroup(t *testing.T) {
+       var files []iceberg.DataFile
+       for _, path := range []string{"d", "b", "a", "c"} {
+               b, err := 
iceberg.NewDataFileBuilder(*iceberg.UnpartitionedSpec, 
iceberg.EntryContentPosDeletes,
+                       path, iceberg.ParquetFile, nil, nil, nil, 1, 10)
+               require.NoError(t, err)
+               files = append(files, b.Build())
+       }
+       sequences := map[string]int64{"a": 2, "b": 9, "c": 5, "d": 7}
+       group := &positionDeleteRewriteGroup{files: files, sequences: sequences}
+       groups := splitPositionDeleteRewriteGroup(group, 20)
+       require.Len(t, groups, 2)
+       require.Equal(t, "a", groups[0].files[0].FilePath())
+       require.Equal(t, "b", groups[0].files[1].FilePath())
+       require.Equal(t, int64(9), groups[0].maxSequence)
+       require.Equal(t, int64(2), groups[0].sequences["a"])
+       require.Equal(t, "c", groups[1].files[0].FilePath())
+       require.Equal(t, "d", groups[1].files[1].FilePath())
+       require.Equal(t, int64(7), groups[1].maxSequence)
+       require.Equal(t, int64(5), groups[1].sequences["c"])
+       require.Empty(t, splitPositionDeleteRewriteGroup(group, 9))
+}
+
+func TestRewritePositionDeleteFilesEmptySnapshot(t *testing.T) {
+       schema := iceberg.NewSchema(0,
+               iceberg.NestedField{ID: 1, Name: "id", Type: 
iceberg.PrimitiveTypes.Int64, Required: true},
+       )
+       meta, err := NewMetadata(schema, iceberg.UnpartitionedSpec, 
UnsortedSortOrder, t.TempDir(), nil)
+       require.NoError(t, err)
+       tbl := New([]string{"db", "example"}, meta, "", nil, nil)
+       result, err := tbl.RewritePositionDeleteFiles(t.Context(), 
PositionDeleteRewriteOptions{})
+       require.NoError(t, err)
+       require.Zero(t, result.RemovedDeleteFiles)
+       require.Zero(t, result.AddedDeleteFiles)
+       require.Equal(t, tbl.Metadata(), result.Table.Metadata())
+
+       txn := tbl.NewTransaction()
+       staged, err := txn.RewritePositionDeleteFiles(t.Context(), 
PositionDeleteRewriteOptions{})
+       require.NoError(t, err)
+       require.Zero(t, staged.RemovedDeleteFiles)
+       require.Nil(t, staged.Table)
+}
+
+func TestRewritePositionDeleteFilesRejectsV3(t *testing.T) {
+       schema := iceberg.NewSchema(0,
+               iceberg.NestedField{ID: 1, Name: "id", Type: 
iceberg.PrimitiveTypes.Int64, Required: true},
+       )
+       meta, err := NewMetadata(schema, iceberg.UnpartitionedSpec, 
UnsortedSortOrder, t.TempDir(),
+               iceberg.Properties{PropertyFormatVersion: "3"})
+       require.NoError(t, err)
+       tbl := New([]string{"db", "example"}, meta, "", nil, nil)
+       _, err = tbl.RewritePositionDeleteFiles(t.Context(), 
PositionDeleteRewriteOptions{})
+       require.ErrorIs(t, err, iceberg.ErrNotImplemented)
+}
+
+func TestRewritePositionDeletePathInScope(t *testing.T) {
+       builder, err := iceberg.NewDataFileBuilder(*iceberg.UnpartitionedSpec,
+               iceberg.EntryContentPosDeletes, "delete.parquet", 
iceberg.ParquetFile,
+               nil, nil, nil, 2, 128)
+       require.NoError(t, err)
+       builder.LowerBoundValues(map[int][]byte{filePathFieldID: 
[]byte("old-metrics-path")})
+       builder.UpperBoundValues(map[int][]byte{filePathFieldID: 
[]byte("old-metrics-path")})
+       require.True(t, rewritePositionDeletePathInScope(builder.Build(), 
"live.parquet"),
+               "file_path statistics do not define the referenced_data_file 
scope")
+
+       builder.ReferencedDataFile("target.parquet")
+       require.True(t, rewritePositionDeletePathInScope(builder.Build(), 
"target.parquet"))
+       require.False(t, rewritePositionDeletePathInScope(builder.Build(), 
"other.parquet"))
+}
+
+func TestValidateRewritePositionDeletePositions(t *testing.T) {
+       for _, tc := range []struct {
+               name    string
+               values  []int64
+               valid   []bool
+               invalid bool
+       }{
+               {name: "valid", values: []int64{0, 42}},
+               {name: "negative", values: []int64{-1}, invalid: true},
+               {name: "null", values: []int64{0}, valid: []bool{false}, 
invalid: true},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       builder := 
array.NewInt64Builder(memory.DefaultAllocator)
+                       builder.AppendValues(tc.values, tc.valid)
+                       positions := builder.NewInt64Array()
+                       builder.Release()
+                       err := 
validateRewritePositionDeletePositions("delete.parquet", positions)
+                       positions.Release()
+                       if tc.invalid {
+                               require.ErrorIs(t, err, 
iceberg.ErrInvalidSchema)
+                       } else {
+                               require.NoError(t, err)
+                       }
+               })
+       }
+}

Reply via email to