zeroshade commented on code in PR #1968:
URL: https://github.com/apache/iceberg-go/pull/1968#discussion_r3935766101


##########
table/scanner.go:
##########
@@ -1872,6 +1872,10 @@ type FileScanTask struct {
 // If an error is encountered, during the planning and setup then this will 
return the
 // error directly. If the error occurs while iterating the records, it will be 
returned
 // by the iterator.
+// Deletion-vector references are validated during setup. Deletion-vector 
Puffin files
+// are loaded lazily, so a Puffin read error is returned by the iterator when 
a task

Review Comment:
   **minor** — 'validated during setup' overstates what is checked eagerly
   
   collectUniqueDeletionVectors validates ref presence/non-emptiness, 
content_offset/content_size presence, and duplicate-DV conflicts. It does NOT 
check file_format or the content_size range — those live in dv.validateDVFile, 
which now only runs inside ReadDVs at demand time. So a structurally invalid DV 
entry (e.g. file_format=PARQUET) passes setup and, if a filter or row limit 
prunes away every task referencing it, is never validated at all, where main 
failed GetRecords unconditionally. Suggest softening to something like 
'Deletion-vector manifest references are validated during setup; format and 
blob-level validation happens with the Puffin read.' Same text at :1894 on 
ReadTasks.



##########
table/lazy_deletion_vector_test.go:
##########
@@ -0,0 +1,294 @@
+// 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"
+       "errors"
+       "os"
+       "path/filepath"
+       "strings"
+       "sync"
+       "sync/atomic"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/apache/iceberg-go/table/dv"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+func TestLazyDeletionVectorLoaderLoadsSharedPuffinGroupsOnDemand(t *testing.T) 
{
+       files := writeSharedDVPuffinFixture(t, 2)
+       refs := make([]string, len(files))
+       for i, file := range files {
+               require.NotNil(t, file.ReferencedDataFile())
+               refs[i] = *file.ReferencedDataFile()
+       }
+       tasks := []FileScanTask{
+               {DeletionVectorFiles: []iceberg.DataFile{files[0]}},
+               {DeletionVectorFiles: []iceberg.DataFile{files[1]}},
+       }
+       fs := &countingDVOpenIO{}
+
+       loader, err := newLazyDeletionVectorLoader(fs, tasks)
+       require.NoError(t, err)
+       require.Len(t, loader.byDataFile, 2)
+       assert.Zero(t, fs.opens.Load(), "indexing must not open a Puffin file")
+
+       bitmap, err := loader.load(t.Context(), refs[0])
+       require.NoError(t, err)
+       require.NotNil(t, bitmap)
+       assert.True(t, bitmap.Contains(0))
+       assert.True(t, bitmap.Contains(2))
+       assert.Equal(t, int64(1), fs.opens.Load())
+
+       bitmap, err = loader.load(t.Context(), refs[1])
+       require.NoError(t, err)
+       require.NotNil(t, bitmap)
+       assert.True(t, bitmap.Contains(1))
+       assert.True(t, bitmap.Contains(3))
+       assert.Equal(t, int64(1), fs.opens.Load(), "a shared Puffin group must 
be loaded once")
+}
+
+func TestLazyDeletionVectorLoaderRejectsEmptyReferencedDataFile(t *testing.T) {
+       dvFile := newDVMockDataFile("empty-ref.puffin", "", 0, 1, 1)
+
+       loader, err := newLazyDeletionVectorLoader(&countingDVOpenIO{}, 
[]FileScanTask{{
+               DeletionVectorFiles: []iceberg.DataFile{dvFile},
+       }})
+
+       assert.Nil(t, loader)
+       require.ErrorIs(t, err, dv.ErrInvalidDeletionVector)
+       assert.ErrorContains(t, err, "missing or empty referenced_data_file")
+}
+
+func TestReadTasksRejectsEmptyReferencedDataFile(t *testing.T) {
+       fs := iceio.LocalFS{}
+       tmp := t.TempDir()
+       tbl := buildDVScanTestTable(t, fs, tmp)
+       dataPath := filepath.Join(tmp, "data.parquet")
+       dataFile := writeIntParquetWithFieldID(t, fs, dataPath, 0, 1)
+       dvFile := newDVMockDataFile("empty-ref.puffin", "", 0, 1, 1)
+
+       _, _, err := tbl.Scan().ReadTasks(t.Context(), []FileScanTask{
+               {File: dataFile, DeletionVectorFiles: 
[]iceberg.DataFile{dvFile}},
+       })
+       require.ErrorIs(t, err, dv.ErrInvalidDeletionVector)
+}
+
+func TestLazyDeletionVectorLoaderSingleflightsConcurrentGroupLoads(t 
*testing.T) {
+       files := writeSharedDVPuffinFixture(t, 2)
+       refs := make([]string, len(files))
+       for i, file := range files {
+               require.NotNil(t, file.ReferencedDataFile())
+               refs[i] = *file.ReferencedDataFile()
+       }
+       tasks := []FileScanTask{{DeletionVectorFiles: files}}
+       fs := &countingDVOpenIO{}
+       loader, err := newLazyDeletionVectorLoader(fs, tasks)
+       require.NoError(t, err)
+
+       const callers = 32
+       bitmaps := make([]*dv.RoaringPositionBitmap, callers)
+       errs := make([]error, callers)
+       var wg sync.WaitGroup
+       wg.Add(callers)
+       for i := range callers {
+               go func(i int) {
+                       defer wg.Done()
+                       bitmaps[i], errs[i] = loader.load(t.Context(), 
refs[i%len(refs)])
+               }(i)
+       }
+       wg.Wait()
+
+       assert.Equal(t, int64(1), fs.opens.Load(), "concurrent callers must 
share one group read")
+       for i := range callers {
+               require.NoError(t, errs[i])
+               require.NotNil(t, bitmaps[i])
+       }
+}
+
+type failingLazyDeletionVectorIO struct {
+       opens atomic.Int64
+       err   error
+}
+
+func (f *failingLazyDeletionVectorIO) Open(string) (iceio.File, error) {
+       f.opens.Add(1)
+
+       return nil, f.err
+}
+
+func (f *failingLazyDeletionVectorIO) Remove(string) error { return nil }
+
+func TestLazyDeletionVectorLoaderCachesGroupErrors(t *testing.T) {
+       const dataFilePath = "file:///table/data/missing.parquet"
+       offset, size := int64(0), int64(1)
+       dvFile := newDVMockDataFile("missing.puffin", dataFilePath, offset, 
size, 1)
+       fs := &failingLazyDeletionVectorIO{err: errors.New("boom")}
+       loader, err := newLazyDeletionVectorLoader(fs,
+               []FileScanTask{{DeletionVectorFiles: 
[]iceberg.DataFile{dvFile}}})
+       require.NoError(t, err)
+
+       const callers = 8
+       errs := make([]error, callers)
+       var wg sync.WaitGroup
+       wg.Add(callers)
+       for i := range callers {
+               go func(i int) {
+                       defer wg.Done()
+                       _, errs[i] = loader.load(t.Context(), dataFilePath)
+               }(i)
+       }
+       wg.Wait()
+
+       assert.Equal(t, int64(1), fs.opens.Load())
+       for _, loadErr := range errs {
+               require.Error(t, loadErr)
+               assert.ErrorContains(t, loadErr, "read deletion vectors from 
missing.puffin")
+               assert.ErrorContains(t, loadErr, "boom")
+       }
+}
+
+type cancelOnOpenIO struct {
+       iceio.LocalFS
+       cancel context.CancelFunc
+       opens  atomic.Int64
+}
+
+func (f *cancelOnOpenIO) Open(name string) (iceio.File, error) {
+       file, err := f.LocalFS.Open(name)
+       if err == nil {
+               f.opens.Add(1)
+               f.cancel()
+       }
+
+       return file, err
+}
+
+func TestLazyDeletionVectorLoaderDoesNotCacheCallerCancellation(t *testing.T) {
+       files := writeSharedDVPuffinFixture(t, 1)
+       dataFilePath := *files[0].ReferencedDataFile()
+       ctx, cancel := context.WithCancel(t.Context())
+       fs := &cancelOnOpenIO{cancel: cancel}
+       loader, err := newLazyDeletionVectorLoader(fs, []FileScanTask{{
+               DeletionVectorFiles: files,
+       }})
+       require.NoError(t, err)
+
+       bitmap, err := loader.load(ctx, dataFilePath)
+       assert.ErrorIs(t, err, context.Canceled)
+       assert.Nil(t, bitmap)
+
+       bitmap, err = loader.load(context.Background(), dataFilePath)
+       require.NoError(t, err)
+       require.NotNil(t, bitmap)
+       assert.True(t, bitmap.Contains(0))
+       assert.Equal(t, int64(1), fs.opens.Load())
+}
+
+func TestLazyDeletionVectorLoaderSurfacesPuffinReadErrors(t *testing.T) {
+       const dataFilePath = "file:///table/data/truncated.parquet"
+       puffinPath, offset, length, card := writeDVPuffinFixture(t, 
[]uint64{1}, dataFilePath)
+       require.NoError(t, os.Truncate(puffinPath, 1))
+       dvFile := newDVMockDataFile(puffinPath, dataFilePath, offset, length, 
card)
+       loader, err := newLazyDeletionVectorLoader(iceio.LocalFS{}, 
[]FileScanTask{{
+               DeletionVectorFiles: []iceberg.DataFile{dvFile},
+       }})
+       require.NoError(t, err)
+
+       _, err = loader.load(t.Context(), dataFilePath)
+       require.Error(t, err)
+       assert.ErrorContains(t, err, "read deletion vectors from")
+}
+
+type countingPuffinOpenIO struct {
+       base  iceio.IO
+       opens atomic.Int64
+}
+
+func (f *countingPuffinOpenIO) Open(name string) (iceio.File, error) {
+       file, err := f.base.Open(name)
+       if err == nil && strings.HasSuffix(name, ".puffin") {
+               f.opens.Add(1)
+       }
+
+       return file, err
+}
+
+func (f *countingPuffinOpenIO) Remove(name string) error { return 
f.base.Remove(name) }
+
+func TestReadTasksDoesNotLoadDeletionVectorsBeforeIteration(t *testing.T) {
+       baseFS := iceio.LocalFS{}

Review Comment:
   **minor** — No test abandons a partially-consumed iterator
   
   TestReadTasksDoesNotLoadDeletionVectorsBeforeIteration covers the 
never-iterated case (opens==0) and the fully-drained case (opens==1), but 
nothing breaks out of an iterator that has already forced a Puffin load. That 
is the shape where a worker can be parked inside once.Do while the consumer 
walks away, so it is worth pinning. A Close-counting iceio.IO asserting 
opens==closes after an early break would cover it in ~20 lines.



##########
table/lazy_deletion_vector_bench_test.go:
##########
@@ -0,0 +1,157 @@
+// 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"
+       "path/filepath"
+       "sync/atomic"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/apache/iceberg-go/table/dv"
+       "golang.org/x/sync/errgroup"
+)
+
+const (
+       lazyDVBenchmarkGroupCount  = 100
+       lazyDVBenchmarkDVsPerGroup = 10
+       lazyDVBenchmarkTotalDVs    = lazyDVBenchmarkGroupCount * 
lazyDVBenchmarkDVsPerGroup
+)
+
+type lazyDVBenchmarkFixture struct {
+       tasks       []FileScanTask
+       groupStarts []string
+}
+
+type countingLazyDVOpenIO struct {
+       iceio.LocalFS
+       opens atomic.Int64
+}
+
+func (f *countingLazyDVOpenIO) Open(name string) (iceio.File, error) {
+       file, err := f.LocalFS.Open(name)
+       if err == nil {
+               f.opens.Add(1)
+       }
+
+       return file, err
+}
+
+func newLazyDVBenchmarkFixture(b *testing.B) lazyDVBenchmarkFixture {
+       b.Helper()
+
+       baseFS := iceio.LocalFS{}
+       root := b.TempDir()
+       fixture := lazyDVBenchmarkFixture{
+               tasks:       make([]FileScanTask, 0, lazyDVBenchmarkTotalDVs),
+               groupStarts: make([]string, 0, lazyDVBenchmarkGroupCount),
+       }
+
+       for groupIndex := range lazyDVBenchmarkGroupCount {
+               path := filepath.Join(root, fmt.Sprintf("dv-%03d.puffin", 
groupIndex))
+               writer := dv.NewDVWriter(baseFS, func(int32) 
*iceberg.PartitionSpec {
+                       return iceberg.UnpartitionedSpec
+               })
+
+               for offset := range lazyDVBenchmarkDVsPerGroup {
+                       dataIndex := groupIndex*lazyDVBenchmarkDVsPerGroup + 
offset
+                       ref := 
fmt.Sprintf("file:///benchmark/data/data-%04d.parquet", dataIndex)
+                       if offset == 0 {
+                               fixture.groupStarts = 
append(fixture.groupStarts, ref)
+                       }
+                       if err := writer.Add(ref, []int64{int64(offset)}, 0, 
nil); err != nil {
+                               b.Fatal(err)
+                       }
+               }
+
+               files, err := writer.Flush(context.Background(), path)
+               if err != nil {
+                       b.Fatal(err)
+               }
+               for _, file := range files {
+                       fixture.tasks = append(fixture.tasks, FileScanTask{
+                               DeletionVectorFiles: []iceberg.DataFile{file},
+                       })
+               }
+       }
+
+       return fixture
+}
+
+func BenchmarkLazyDeletionVectorLoading(b *testing.B) {
+       fixture := newLazyDVBenchmarkFixture(b)

Review Comment:
   **nit** — Description's benchmark table cites an 'eager all groups' row that 
is no longer reproducible
   
   BenchmarkLazyDeletionVectorLoading only defines lazy_unread / 
lazy_first_group / lazy_ten_groups / lazy_full_scan. The 'eager all groups' 
baseline came from BenchmarkReadAllDeletionVectorsSharedPuffin, deleted in 
17f771a. Worth a note in the PR body that the baseline row was measured against 
the pre-PR tree, so a reviewer does not go looking for the benchmark.



##########
table/arrow_scanner_test.go:
##########
@@ -430,7 +430,7 @@ func TestReleasePerFilePosDeletes(t *testing.T) {
        t.Run("nil chunk in slice does not panic", func(t *testing.T) {
                // Defensive: production code paths never insert a nil 
*arrow.Chunked
                // into the map. This subtest pins the guard so a future caller 
(the
-               // in-flight readAllDeletionVectors merger, or a refactor of 
readDeletes)
+               // in-flight delete-file merger, or a refactor of readDeletes)
                // can't silently NPE the cleanup path.

Review Comment:
   **nit** — Comment points at a plan that no longer exists
   
   The comment was rewritten from 'the in-flight readAllDeletionVectors merger' 
to 'the in-flight delete-file merger' when the eager loader was deleted. There 
is no in-flight merger any more; the sentence now names nothing. Suggest just 
describing the guard ('a future caller, or a refactor of readDeletes').



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to