You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by ze...@apache.org on 2026/10/19 15:36:35 UTC

(iceberg-go) branch main updated: feat(table): set referenced_data_file on single-target v2 position deletes (#2189)

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 28ff8d85c feat(table): set referenced_data_file on single-target v2 position deletes (#2189)
28ff8d85c is described below

commit 28ff8d85cc187292c23a2e6db904d12050995bcd
Author: Revanth <re...@gmail.com>
AuthorDate: Mon Oct 19 10:36:29 2026 -0500

    feat(table): set referenced_data_file on single-target v2 position deletes (#2189)
---
 table/internal/parquet_files.go  | 39 ++++++++++++++++++--
 table/pos_delete_metrics_test.go | 77 ++++++++++++++++++++++++++++++++++++++++
 2 files changed, 114 insertions(+), 2 deletions(-)

diff --git a/table/internal/parquet_files.go b/table/internal/parquet_files.go
index f8ee6b4f7..9805878dd 100644
--- a/table/internal/parquet_files.go
+++ b/table/internal/parquet_files.go
@@ -1263,7 +1263,7 @@ func (w *ParquetFileWriter) Close() (_ iceberg.DataFile, err error) {
 		return nil, err
 	}
 
-	return stats.ToDataFile(DataFileOpts{
+	opts := DataFileOpts{
 		Schema:          w.info.FileSchema,
 		Spec:            w.info.Spec,
 		Path:            w.info.FileName,
@@ -1272,7 +1272,42 @@ func (w *ParquetFileWriter) Close() (_ iceberg.DataFile, err error) {
 		FileSize:        w.counter.Count,
 		PartitionValues: w.partition,
 		SortOrderID:     w.info.SortOrderID,
-	}), nil
+	}
+	if w.info.Content == iceberg.EntryContentPosDeletes {
+		opts.ReferencedDataFile = singleReferencedDataFile(stats)
+	}
+
+	return stats.ToDataFile(opts), nil
+}
+
+// posDeleteFilePathFieldID is the reserved field ID of the file_path column
+// in a position delete file.
+const posDeleteFilePathFieldID = 2147483546
+
+// singleReferencedDataFile returns the data file path when every delete in a
+// position delete file targets that one file, or nil otherwise. It compares
+// the untruncated file_path min and max, which position delete writers always
+// collect (see table.positionDeleteMetricsProps). Java's v2
+// PositionDeleteWriter leaves the field unset; the spec allows it on position
+// delete files with a single target.
+func singleReferencedDataFile(stats *DataFileStatistics) *string {
+	agg, ok := stats.ColAggs[posDeleteFilePathFieldID]
+	if !ok {
+		return nil
+	}
+
+	lower, ok := agg.Min().(iceberg.StringLiteral)
+	if !ok || lower == "" {
+		return nil
+	}
+	upper, ok := agg.Max().(iceberg.StringLiteral)
+	if !ok || lower != upper {
+		return nil
+	}
+
+	path := string(lower)
+
+	return &path
 }
 
 func (w *ParquetFileWriter) Abort() error {
diff --git a/table/pos_delete_metrics_test.go b/table/pos_delete_metrics_test.go
index 258a1c42e..ed03a6f9c 100644
--- a/table/pos_delete_metrics_test.go
+++ b/table/pos_delete_metrics_test.go
@@ -130,3 +130,80 @@ func TestPositionDeleteFilesKeepFullFilePathBounds(t *testing.T) {
 		}
 	}
 }
+
+// TestPositionDeleteFilesSetReferencedDataFile pins #2167: a v2 position
+// delete file whose deletes all target one data file records that file in
+// referenced_data_file, and one spanning several data files leaves it nil.
+// PlanFiles reads the delete files back from v2 manifests, so the field must
+// also survive the manifest round trip.
+func TestPositionDeleteFilesSetReferencedDataFile(t *testing.T) {
+	tests := []struct {
+		name      string
+		filter    iceberg.BooleanExpression
+		wantSaved bool
+	}{
+		{"single target", iceberg.EqualTo(iceberg.Reference("id"), int64(2)), true},
+		{"multiple targets", iceberg.NewOr(
+			iceberg.EqualTo(iceberg.Reference("id"), int64(2)),
+			iceberg.EqualTo(iceberg.Reference("id"), int64(4))), false},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+
+			location := filepath.ToSlash(t.TempDir())
+			schema := iceberg.NewSchema(0,
+				iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
+			)
+			meta, err := table.NewMetadata(schema, iceberg.UnpartitionedSpec, table.UnsortedSortOrder, location,
+				iceberg.Properties{
+					table.PropertyFormatVersion: "2",
+					table.WriteDeleteModeKey:    table.WriteModeMergeOnRead,
+				})
+			require.NoError(t, err)
+
+			metaLoc := location + "/metadata/v1.metadata.json"
+			fsF := func(context.Context) (iceio.IO, error) { return iceio.LocalFS{}, nil }
+			cat := &concurrentTestCatalog{metadata: meta, location: metaLoc, fsF: fsF}
+			tbl := table.New(table.Identifier{"db", "pos_delete_ref"}, meta, metaLoc, fsF, cat)
+
+			arrowSchema := arrow.NewSchema([]arrow.Field{
+				{Name: "id", Type: arrow.PrimitiveTypes.Int64, Nullable: false},
+			}, nil)
+			for _, rows := range []string{`[{"id":1},{"id":2}]`, `[{"id":3},{"id":4}]`} {
+				data, err := array.TableFromJSON(memory.DefaultAllocator, arrowSchema, []string{rows})
+				require.NoError(t, err)
+				rdr := array.NewTableReader(data, -1)
+				tbl, err = tbl.Append(ctx, rdr, nil)
+				rdr.Release()
+				data.Release()
+				require.NoError(t, err)
+			}
+
+			tbl, err = tbl.Delete(ctx, tc.filter, nil)
+			require.NoError(t, err)
+
+			tasks, err := tbl.Scan().PlanFiles(ctx)
+			require.NoError(t, err)
+
+			deleteFiles := make(map[string]iceberg.DataFile)
+			for _, task := range tasks {
+				for _, df := range task.DeleteFiles {
+					require.Equal(t, iceberg.EntryContentPosDeletes, df.ContentType())
+					deleteFiles[df.FilePath()] = df
+					if tc.wantSaved {
+						require.NotNil(t, df.ReferencedDataFile())
+						require.Equal(t, task.File.FilePath(), *df.ReferencedDataFile())
+					}
+				}
+			}
+			require.Len(t, deleteFiles, 1)
+			if !tc.wantSaved {
+				for _, df := range deleteFiles {
+					require.Nil(t, df.ReferencedDataFile())
+				}
+			}
+		})
+	}
+}