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 26e566b90 fix(table): keep full file_path bounds on v2 position delete 
files (#2164)
26e566b90 is described below

commit 26e566b902ebcad7cd9d0cc1c808204af80bbbce
Author: Revanth <[email protected]>
AuthorDate: Sun Oct 11 13:17:57 2026 -0500

    fix(table): keep full file_path bounds on v2 position delete files (#2164)
---
 table/cow_delete_conflict_test.go |  42 ++++++------
 table/pos_delete_metrics_test.go  | 132 ++++++++++++++++++++++++++++++++++++++
 table/rolling_data_writer.go      |   2 +-
 table/writer.go                   |  30 ++++++++-
 table/writer_test.go              |  22 +++++++
 5 files changed, 206 insertions(+), 22 deletions(-)

diff --git a/table/cow_delete_conflict_test.go 
b/table/cow_delete_conflict_test.go
index 693681be3..4a11118f2 100644
--- a/table/cow_delete_conflict_test.go
+++ b/table/cow_delete_conflict_test.go
@@ -177,27 +177,29 @@ func TestCopyOnWriteConflict_ConcurrentAppendCommits(t 
*testing.T) {
 // TestCopyOnWriteConflict_ConcurrentDeleteOnUntouchedFileCommits pins the
 // validator's scope: a concurrent delete against a data file this commit does
 // not remove must not reject it. v3 deletion vectors carry
-// referenced_data_file, so the delete resolves to its exact target; v2 
position
-// deletes written here are partition-scoped and conservatively conflict.
+// referenced_data_file, and v2 position deletes keep full file_path bounds
+// (#2141), so either resolves to its exact target.
 func TestCopyOnWriteConflict_ConcurrentDeleteOnUntouchedFileCommits(t 
*testing.T) {
-       for _, isolation := range cowIsolations {
-               for _, op := range cowOps {
-                       t.Run(fmt.Sprintf("v3/%s/%s", isolation, op), func(t 
*testing.T) {
-                               ctx := context.Background()
-                               tbl := appendTenRows(t, 
newCoWConflictTestTable(t, "3", isolation))
-                               // A second data file that the id==2 removal 
does not touch.
-                               tbl, err := tbl.Append(ctx, cowTestRecords(t, 
`[{"id":11,"data":"k"},{"id":12,"data":"l"}]`), nil)
-                               require.NoError(t, err)
-
-                               txn := stageCopyOnWrite(t, tbl, op, 
iceberg.EqualTo(iceberg.Reference("id"), int64(2)))
-
-                               _, err = tbl.Delete(ctx, 
iceberg.EqualTo(iceberg.Reference("id"), int64(12)), nil)
-                               require.NoError(t, err)
-
-                               committed, err := txn.Commit(ctx)
-                               require.NoError(t, err)
-                               require.Equal(t, []int64{1, 3, 4, 5, 6, 7, 8, 
9, 10, 11}, idsInTable(t, committed))
-                       })
+       for _, version := range cowFormatVersions {
+               for _, isolation := range cowIsolations {
+                       for _, op := range cowOps {
+                               t.Run(fmt.Sprintf("v%s/%s/%s", version, 
isolation, op), func(t *testing.T) {
+                                       ctx := context.Background()
+                                       tbl := appendTenRows(t, 
newCoWConflictTestTable(t, version, isolation))
+                                       // A second data file that the id==2 
removal does not touch.
+                                       tbl, err := tbl.Append(ctx, 
cowTestRecords(t, `[{"id":11,"data":"k"},{"id":12,"data":"l"}]`), nil)
+                                       require.NoError(t, err)
+
+                                       txn := stageCopyOnWrite(t, tbl, op, 
iceberg.EqualTo(iceberg.Reference("id"), int64(2)))
+
+                                       _, err = tbl.Delete(ctx, 
iceberg.EqualTo(iceberg.Reference("id"), int64(12)), nil)
+                                       require.NoError(t, err)
+
+                                       committed, err := txn.Commit(ctx)
+                                       require.NoError(t, err)
+                                       require.Equal(t, []int64{1, 3, 4, 5, 6, 
7, 8, 9, 10, 11}, idsInTable(t, committed))
+                               })
+                       }
                }
        }
 }
diff --git a/table/pos_delete_metrics_test.go b/table/pos_delete_metrics_test.go
new file mode 100644
index 000000000..258a1c42e
--- /dev/null
+++ b/table/pos_delete_metrics_test.go
@@ -0,0 +1,132 @@
+// 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"
+       "maps"
+       "path/filepath"
+       "testing"
+
+       "github.com/apache/arrow-go/v18/arrow"
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/arrow-go/v18/arrow/memory"
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/apache/iceberg-go/table"
+       "github.com/stretchr/testify/require"
+)
+
+// TestPositionDeleteFilesKeepFullFilePathBounds pins #2141: v2 position delete
+// files keep exact file_path bounds and pos bounds whatever the table metrics
+// settings are, as Java's MetricsConfig.forPositionDelete does. Data file
+// paths are far longer than 16 characters, so the table-defaults case would
+// truncate them under the default truncate(16) mode, and the override case
+// asks for no file_path or pos metrics at all.
+func TestPositionDeleteFilesKeepFullFilePathBounds(t *testing.T) {
+       identity := iceberg.NewPartitionSpec(iceberg.PartitionField{
+               SourceIDs: []int{2}, FieldID: 1000, Transform: 
iceberg.IdentityTransform{}, Name: "data",
+       })
+
+       specs := []struct {
+               name string
+               spec *iceberg.PartitionSpec
+       }{
+               {"unpartitioned", iceberg.UnpartitionedSpec},
+               {"partitioned", &identity},
+       }
+
+       metricsConfigs := []struct {
+               name  string
+               props iceberg.Properties
+       }{
+               {"table defaults", nil},
+               {"file_path and pos none", iceberg.Properties{
+                       table.MetricsModeColumnConfPrefix + ".file_path": 
"none",
+                       table.MetricsModeColumnConfPrefix + ".pos":       
"none",
+               }},
+       }
+
+       filePathField, ok := 
iceberg.PositionalDeleteSchema.FindFieldByName("file_path")
+       require.True(t, ok)
+       posField, ok := iceberg.PositionalDeleteSchema.FindFieldByName("pos")
+       require.True(t, ok)
+
+       for _, tc := range specs {
+               for _, mc := range metricsConfigs {
+                       t.Run(tc.name+"/"+mc.name, func(t *testing.T) {
+                               ctx := context.Background()
+
+                               location := filepath.ToSlash(t.TempDir())
+                               props := iceberg.Properties{
+                                       table.PropertyFormatVersion: "2",
+                                       table.WriteDeleteModeKey:    
table.WriteModeMergeOnRead,
+                               }
+                               maps.Copy(props, mc.props)
+                               schema := iceberg.NewSchema(0,
+                                       iceberg.NestedField{ID: 1, Name: "id", 
Type: iceberg.PrimitiveTypes.Int64, Required: true},
+                                       iceberg.NestedField{ID: 2, Name: 
"data", Type: iceberg.PrimitiveTypes.String, Required: true},
+                               )
+                               meta, err := table.NewMetadata(schema, tc.spec, 
table.UnsortedSortOrder, location, props)
+                               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_metrics"}, meta, metaLoc, fsF, cat)
+
+                               arrowSchema := arrow.NewSchema([]arrow.Field{
+                                       {Name: "id", Type: 
arrow.PrimitiveTypes.Int64, Nullable: false},
+                                       {Name: "data", Type: 
arrow.BinaryTypes.String, Nullable: false},
+                               }, nil)
+                               data, err := 
array.TableFromJSON(memory.DefaultAllocator, arrowSchema, []string{
+                                       
`[{"id":1,"data":"a"},{"id":2,"data":"a"},{"id":3,"data":"b"},{"id":4,"data":"b"}]`,
+                               })
+                               require.NoError(t, err)
+                               defer data.Release()
+
+                               rdr := array.NewTableReader(data, -1)
+                               defer rdr.Release()
+
+                               tbl, err = tbl.Append(ctx, rdr, nil)
+                               require.NoError(t, err)
+                               tbl, err = tbl.Delete(ctx, 
iceberg.EqualTo(iceberg.Reference("id"), int64(2)), nil)
+                               require.NoError(t, err)
+
+                               tasks, err := tbl.Scan().PlanFiles(ctx)
+                               require.NoError(t, err)
+
+                               deleteFiles := 0
+                               for _, task := range tasks {
+                                       dataPath := task.File.FilePath()
+                                       require.Greater(t, len(dataPath), 16)
+                                       for _, df := range task.DeleteFiles {
+                                               require.Equal(t, 
iceberg.EntryContentPosDeletes, df.ContentType())
+                                               deleteFiles++
+
+                                               require.Equal(t, 
[]byte(dataPath), df.LowerBoundValues()[filePathField.ID])
+                                               require.Equal(t, 
[]byte(dataPath), df.UpperBoundValues()[filePathField.ID])
+                                               require.NotEmpty(t, 
df.LowerBoundValues()[posField.ID])
+                                               require.NotEmpty(t, 
df.UpperBoundValues()[posField.ID])
+                                       }
+                               }
+                               require.Equal(t, 1, deleteFiles)
+                       })
+               }
+       }
+}
diff --git a/table/rolling_data_writer.go b/table/rolling_data_writer.go
index e431d0f2b..e87d6fc3d 100644
--- a/table/rolling_data_writer.go
+++ b/table/rolling_data_writer.go
@@ -240,7 +240,7 @@ func newWriterFactory(rootLocation string, args 
recordWritingArgs, meta *Metadat
                f.variantFieldIDs = 
tblutils.VariantFieldIDsFromSchema(f.fileSchema)
        }
 
-       f.statsCols, err = computeStatsPlan(f.fileSchema, meta.props)
+       f.statsCols, err = computeStatsPlan(f.fileSchema, 
metricsPropsFor(f.content, meta.props))
        if err != nil {
                stopCount()
 
diff --git a/table/writer.go b/table/writer.go
index 9246d7fdd..16b32cefa 100644
--- a/table/writer.go
+++ b/table/writer.go
@@ -138,7 +138,7 @@ func newDataFileWriter(rootLocation string, fs 
io.WriteFileIO, meta *MetadataBui
                apply(&w)
        }
 
-       statsCols, err := computeStatsPlan(w.fileSchema, meta.props)
+       statsCols, err := computeStatsPlan(w.fileSchema, 
metricsPropsFor(w.content, meta.props))
        if err != nil {
                return nil, err
        }
@@ -239,6 +239,34 @@ type dataFileWriter interface {
        writeFile(ctx context.Context, partitionValues map[int]any, task 
WriteTask) (iceberg.DataFile, error)
 }
 
+// positionDeleteMetricsProps mirrors Java's MetricsConfig.forPositionDelete:
+// position delete files keep full file_path and pos bounds whatever the table
+// metrics settings are, so readers and conflict checks can resolve the data
+// file a delete file targets from equal file_path bounds.
+//
+// The default mode is none on purpose, as in Java: any column besides
+// file_path and pos, such as a future row column, gets no value counts, null
+// counts, column sizes or bounds. The table's write.metadata.metrics.*
+// settings do not apply to position delete files.
+//
+// The map is shared and handed to computeStatsPlan as is; treat it as
+// read-only.
+var positionDeleteMetricsProps = iceberg.Properties{
+       DefaultWriteMetricsModeKey:                 
string(tblutils.MetricModeNone),
+       MetricsModeColumnConfPrefix + ".file_path": 
string(tblutils.MetricModeFull),
+       MetricsModeColumnConfPrefix + ".pos":       
string(tblutils.MetricModeFull),
+}
+
+// metricsPropsFor returns the properties that drive column metrics for files
+// of the given content type.
+func metricsPropsFor(content iceberg.ManifestEntryContent, props 
iceberg.Properties) iceberg.Properties {
+       if content == iceberg.EntryContentPosDeletes {
+               return positionDeleteMetricsProps
+       }
+
+       return props
+}
+
 func newPositionDeleteWriter(rootLocation string, fs io.WriteFileIO, meta 
*MetadataBuilder, props iceberg.Properties, opts ...dataFileWriterOption) 
(*defaultDataFileWriter, error) {
        // Always enforce the file schema to be the Positional Delete Schema by 
appending the option at the very end
        return newDataFileWriter(rootLocation, fs, meta, props, append(opts, 
withFileSchema(iceberg.PositionalDeleteSchema), 
withContent(iceberg.EntryContentPosDeletes))...)
diff --git a/table/writer_test.go b/table/writer_test.go
index 6a2891842..eac6c2d71 100644
--- a/table/writer_test.go
+++ b/table/writer_test.go
@@ -265,3 +265,25 @@ func TestWriteFileRejectsPosDeleteSortOrderClaim(t 
*testing.T) {
        assert.Nil(t, df)
        assert.Contains(t, err.Error(), "position delete")
 }
+
+func TestMetricsPropsFor(t *testing.T) {
+       tableProps := iceberg.Properties{
+               DefaultWriteMetricsModeKey:                 "counts",
+               MetricsModeColumnConfPrefix + ".file_path": "none",
+       }
+
+       tests := []struct {
+               content iceberg.ManifestEntryContent
+               want    iceberg.Properties
+       }{
+               {iceberg.EntryContentData, tableProps},
+               {iceberg.EntryContentEqDeletes, tableProps},
+               {iceberg.EntryContentPosDeletes, positionDeleteMetricsProps},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.content.String(), func(t *testing.T) {
+                       assert.Equal(t, tt.want, metricsPropsFor(tt.content, 
tableProps))
+               })
+       }
+}

Reply via email to