Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -346,8 +346,7 @@ func (s *Schema) MarshalJSON() ([]byte, error) {

type Alias Schema

aliasCopy := *(*Alias)(s)
aliasCopy.IdentifierFieldIDs = ids
aliasCopy := Alias{ID: s.ID, IdentifierFieldIDs: ids}

return json.Marshal(struct {
Type string `json:"type"`
Expand Down
46 changes: 46 additions & 0 deletions schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"path/filepath"
"runtime"
"strings"
"sync"
"testing"

"github.com/apache/iceberg-go"
Expand Down Expand Up @@ -2293,3 +2294,48 @@ func TestVisitGeoSchemaWithSchemaVisitorPerPrimitiveType(t *testing.T) {
assert.Equal(t, 1, v.geometryCalls)
assert.Equal(t, 1, v.geographyCalls)
}

func TestSchemaMarshalJSONConcurrentLazyLookups(t *testing.T) {
for range 32 {
schema := iceberg.NewSchemaWithIdentifiers(17, nil,
iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
iceberg.NestedField{ID: 2, Name: "data", Type: iceberg.PrimitiveTypes.String},
)
start := make(chan struct{})
var wg sync.WaitGroup
for range 8 {
wg.Go(func() {
<-start
for range 8 {
_, err := json.Marshal(schema)
assert.NoError(t, err)
}
})
wg.Go(func() {
<-start
_, found := schema.FindFieldByID(1)
assert.True(t, found)
_, found = schema.FindFieldByName("data")
assert.True(t, found)
_, found = schema.FindFieldByNameCaseInsensitive("DATA")
assert.True(t, found)
name, found := schema.FindColumnName(2)
assert.True(t, found)
assert.Equal(t, "data", name)
})
}
close(start)
wg.Wait()

data, err := json.Marshal(schema)
require.NoError(t, err)
assert.JSONEq(t, `{
"type": "struct", "schema-id": 17, "identifier-field-ids": [],
"fields": [
{"id": 1, "name": "id", "type": "long", "required": true},
{"id": 2, "name": "data", "type": "string", "required": false}
]
}`, string(data))
assert.Nil(t, schema.IdentifierFieldIDs)
}
}
260 changes: 199 additions & 61 deletions table/arrow_scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,120 @@ func readAllDeleteFiles(ctx context.Context, fs iceio.IO, tasks []FileScanTask,
return deletesPerFile, nil
}

// lazyPositionDeleteLoader indexes positional-delete metadata for a scan, but
// waits to open each delete file until a worker reaches a task that references
// it. A delete file can apply to more than one data file, so the cache keeps
// the complete grouped result for the delete file rather than caching only one
// task's positions.
//
// The grouped Arrow chunks are owned by the loader until release. The iterator
// calls release after all workers have stopped, which keeps shared chunks alive
// while multiple tasks use them and also covers early iterator termination.
type lazyPositionDeleteLoader struct {
fs iceio.IO
files map[string]*lazyPositionDeleteFile

releaseOnce sync.Once
}

type lazyPositionDeleteFile struct {
dataFile iceberg.DataFile

once sync.Once
deletes map[string]*arrow.Chunked
err error
}

func newLazyPositionDeleteLoader(fs iceio.IO, tasks []FileScanTask) *lazyPositionDeleteLoader {
loader := &lazyPositionDeleteLoader{
fs: fs,
files: make(map[string]*lazyPositionDeleteFile),
}

for _, task := range tasks {
for _, deleteFile := range task.DeleteFiles {
if deleteFile.ContentType() != iceberg.EntryContentPosDeletes {
continue
}

path := deleteFile.FilePath()
if _, ok := loader.files[path]; !ok {
loader.files[path] = &lazyPositionDeleteFile{dataFile: deleteFile}
}
}
}

return loader
}

func (l *lazyPositionDeleteLoader) load(ctx context.Context, task FileScanTask) (positionDeletes, error) {
if len(task.DeleteFiles) == 0 {
return nil, nil
}

targetPath := task.File.FilePath()
deletes := make(positionDeletes, 0, len(task.DeleteFiles))
// Most scan tasks carry one positional delete file. Avoid allocating a
// deduplication map unless there can actually be duplicate entries.
var seen map[string]struct{}
if len(task.DeleteFiles) > 1 {
seen = make(map[string]struct{}, len(task.DeleteFiles))
}
for _, deleteFile := range task.DeleteFiles {
if deleteFile.ContentType() != iceberg.EntryContentPosDeletes {
continue
}

path := deleteFile.FilePath()
if seen != nil {
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
}

cached, ok := l.files[path]
if !ok {
// The loader is normally built from the same task slice supplied to
// this method. Keep this guard so a malformed caller cannot panic a
// scan if it changes a task after loader construction.
continue
}

cached.once.Do(func() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This caches whatever readDeletes returns, including context.Canceled, for the life of the loader, so a later load() with a fresh context still gets the stale error. That's fine today because the loader is built per GetRecords and every worker shares scanCtx, but nothing in the type says so.

I'd add a sentence to lazyPositionDeleteFile (or load) spelling out that the loader lives for exactly one scan and that any error, including transient context errors, is locked in for all callers regardless of their own context. If we ever reuse a loader across retries the once.Do would need to become cancellation-aware, and I'd rather that be written down before someone hits it. wdyt?

cached.deletes, cached.err = readDeletes(ctx, l.fs, cached.dataFile)
if cached.err != nil {
// readDeletes currently returns nil on errors. Release defensively
// in case a future reader returns partial Arrow ownership.
releasePosDeletes(cached.deletes)
cached.deletes = nil
}
})
if cached.err != nil {
return nil, cached.err

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When readDeletes fails we return the raw error without the delete-file path, so the caller sees something like file not found with no clue which file. readAllDeletionVectors already wraps with the puffin path; I'd match it here, something like fmt.Errorf("read position deletes from %s: %w", cached.dataFile.FilePath(), cached.err) inside the once.Do so the path travels with the cached error.

}

if chunk := cached.deletes[targetPath]; chunk != nil {
deletes = append(deletes, chunk)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These chunks are borrowed from the loader without a Retain(), so multiple workers hold the same *arrow.Chunked while the loader still owns it. It's safe only because release() runs strictly after wg.Wait(), so nothing reads a freed chunk, but that ordering is the entire thing keeping it correct and it isn't visible from the types.

release() also nils cached.deletes, so a second range over the returned iter.Seq2 would hit the done once, read nil, and silently yield zero positional deletes. I'd keep the nil-write (dropping it turns a second range into a use-after-free on released chunks, which is worse) and add a one-line comment that the iterator is single-use. If we'd rather not lean on the ordering invariant at all, Retain() on append plus Release() after collectPosDeletePositions makes it self-contained. Either way, I'd make it explicit.

}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor — Lazy loader narrows positional-delete scope from global to task-scoped, changing scan results

The eager path built perFilePosDeletes keyed by every data-file path found inside each delete file (arrow_scanner.go:134-138), so a delete file referenced by one task also applied to any other task's data file it happened to mention. load() instead consults only the delete files listed on the task itself and looks up cached.deletes[targetPath] (:252-254). The new semantics match Java's task-scoped DeleteFileIndex and are, I believe, the correct ones - but this is a result-set change in a PR whose description says only error timing moves, and no test pins it. Planner-built tasks should not hit it; Scan.ReadTasks accepts caller-supplied tasks and can. Suggest a regression test asserting a delete file is applied only to tasks that reference it, plus a line in the PR description. Flagging for your judgement rather than blocking.

}

return deletes, nil
}

func (l *lazyPositionDeleteLoader) release() {
if l == nil {
return
}

l.releaseOnce.Do(func() {
for _, cached := range l.files {
releasePosDeletes(cached.deletes)
cached.deletes = nil
}
})
}

// perFileDVBitmaps maps each data-file path to the deletion-vector bitmap
// that applies to it. Kept separate from perFilePosDeletes so the row-filter
// pipeline can use compute.Filter on a Boolean mask built from Contains()
Expand Down Expand Up @@ -1702,11 +1816,15 @@ func (as *arrowScan) producePosDeletesFromTask(ctx context.Context, task tblutil
}

func createIterator(ctx context.Context, numWorkers uint, records <-chan enumeratedRecord, deletesPerFile perFilePosDeletes, cancel context.CancelCauseFunc, rowLimit int64) iter.Seq2[arrow.RecordBatch, error] {
return createIteratorWithCleanup(ctx, numWorkers, records, deletesPerFile, cancel, rowLimit, nil)
}

func createIteratorWithCleanup(ctx context.Context, numWorkers uint, records <-chan enumeratedRecord, deletesPerFile perFilePosDeletes, cancel context.CancelCauseFunc, rowLimit int64, cleanup func()) iter.Seq2[arrow.RecordBatch, error] {
isBeforeAny := func(batch enumeratedRecord) bool {
return batch.Task.Index < 0
}

sequenced := tblutils.MakeSequencedChan(uint(numWorkers), records,
sequenced := tblutils.MakeSequencedChanWithDiscard(uint(numWorkers), records,
func(left, right *enumeratedRecord) bool {
switch {
case isBeforeAny(*left):
Expand All @@ -1732,7 +1850,11 @@ func createIterator(ctx context.Context, numWorkers uint, records <-chan enumera
return next.Task.Index == prev.Task.Index+1 &&
prev.Record.Last && next.Record.Index == 0
}
}, enumeratedRecord{Task: tblutils.Enumerated[FileScanTask]{Index: -1}})
}, enumeratedRecord{Task: tblutils.Enumerated[FileScanTask]{Index: -1}}, func(rec enumeratedRecord) {
if rec.Record.Value != nil {
rec.Record.Value.Release()
}
})

totalRowCount := int64(0)

Expand All @@ -1745,6 +1867,9 @@ func createIterator(ctx context.Context, numWorkers uint, records <-chan enumera
}

releasePerFilePosDeletes(deletesPerFile)
if cleanup != nil {
cleanup()
}
}()

defer cancel(nil)
Expand Down Expand Up @@ -1799,59 +1924,83 @@ func createIterator(ctx context.Context, numWorkers uint, records <-chan enumera
}
}

func (as *arrowScan) recordBatchesFromTasksAndDeletes(ctx context.Context, tasks []FileScanTask, deletesPerFile perFilePosDeletes, dvBitmaps perFileDVBitmaps, eqDeleteSets map[int][]*equalityDeleteSet, invariants *arrowScanInvariants) iter.Seq2[arrow.RecordBatch, error] {
extSet := substrait.NewExtensionSet()

ctx, cancel := context.WithCancelCause(exprs.WithExtensionIDSet(ctx, extSet))
taskChan := make(chan tblutils.Enumerated[FileScanTask], len(tasks))

// numWorkers := 1
numWorkers := min(as.concurrency, len(tasks))
records := make(chan enumeratedRecord, numWorkers)

var wg sync.WaitGroup
wg.Add(numWorkers)
for range numWorkers {
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case task, ok := <-taskChan:
if !ok {
func (as *arrowScan) recordBatchesFromTasksAndDeletes(ctx context.Context, tasks []FileScanTask, positionDeleteLoader *lazyPositionDeleteLoader, dvBitmaps perFileDVBitmaps, eqDeleteSets map[int][]*equalityDeleteSet, invariants *arrowScanInvariants) iter.Seq2[arrow.RecordBatch, error] {
return func(yield func(arrow.RecordBatch, error) bool) {
extSet := substrait.NewExtensionSet()
scanCtx, cancel := context.WithCancelCause(exprs.WithExtensionIDSet(ctx, extSet))
numWorkers := min(as.concurrency, len(tasks))
taskChan := make(chan tblutils.Enumerated[FileScanTask], len(tasks))
records := make(chan enumeratedRecord, numWorkers)

var wg sync.WaitGroup
wg.Add(numWorkers)
for range numWorkers {
go func() {
defer wg.Done()
for {
select {
case <-scanCtx.Done():
return
case task, ok := <-taskChan:
if !ok {
return
}
if scanCtx.Err() != nil {
return
}

filePath := task.Value.File.FilePath()
var positionalDeletes positionDeletes
if positionDeleteLoader != nil {
var err error
positionalDeletes, err = positionDeleteLoader.load(scanCtx, task.Value)
if err != nil {
records <- enumeratedRecord{Task: task, Err: err}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Deferred positional-delete errors can leak out-of-order Arrow batches. A later task can emit a batch while an earlier task is loading its delete file. If the earlier load then fails here, the sequencer emits the error but abandons batches still held in its priority queue when records closes; iterator cleanup never sees those records to release them. A checked-allocator probe with task 1 queued ahead of task 0’s error reproduced a 128-byte Arrow leak. Please give the sequencer an error/close discard path that releases queued record batches, with a deterministic two-worker regression test.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This send is unconditional while the receive side can stall. MakeSequencedChanWithDiscard drains records, but if sequenced fills first (buffer is numWorkers) the helper blocks on out <- *previous until the consumer reads, which delays it reading records, which delays this error send, which delays this worker returning, which delays wg.Wait() and the records close.

It's bounded backpressure rather than a deadlock, but under a slow consumer and high concurrency it stretches shutdown, and that's exactly what the 500ms deadline in TestArrowScanPreCancelledIteratorTearsDownProducer is up against, so it's a plausible CI flake. I'd make it a select { case records <- ...: case <-scanCtx.Done(): return }, matching the feeder. wdyt?

cancel(err)

return
}
}

if err := as.recordsFromTask(scanCtx, task, records,
positionalDeletes,
dvBitmaps[filePath],
eqDeleteSets[task.Index],
invariants); err != nil {
cancel(err)

return
}
}
}
}()
}

filePath := task.Value.File.FilePath()
if err := as.recordsFromTask(ctx, task, records,
deletesPerFile[filePath],
dvBitmaps[filePath],
eqDeleteSets[task.Index],
invariants); err != nil {
cancel(err)
go func() {
defer func() {
close(taskChan)
wg.Wait()
close(records)
}()

return
}
for i, t := range tasks {
select {
case <-scanCtx.Done():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Cancellation can deadlock iteration indefinitely. This return bypasses close(taskChan), wg.Wait(), and close(records). createIteratorWithCleanup then cancels and drains the sequenced channel, but MakeSequencedChan cannot close because its records source remains open. I reproduced this with one valid task and an already-canceled context: iteration failed to return within 200 ms on the first attempt. Please make producer teardown unconditional (for example, defer closing taskChan, waiting for workers, and closing records) and add pre-canceled and early-termination regression tests.

return
case taskChan <- tblutils.Enumerated[FileScanTask]{
Value: t, Index: i, Last: i == len(tasks)-1,
}:
}
}
}()
}

go func() {
for i, t := range tasks {
taskChan <- tblutils.Enumerated[FileScanTask]{
Value: t, Index: i, Last: i == len(tasks)-1,
}
var cleanup func()
if positionDeleteLoader != nil {
cleanup = positionDeleteLoader.release
}
close(taskChan)

wg.Wait()
close(records)
}()

return createIterator(ctx, uint(numWorkers), records, deletesPerFile,
cancel, as.rowLimit)
createIteratorWithCleanup(scanCtx, uint(numWorkers), records, nil,
cancel, as.rowLimit, cleanup)(yield)
}
}

func (as *arrowScan) GetRecords(ctx context.Context, tasks []FileScanTask) (*arrow.Schema, iter.Seq2[arrow.RecordBatch, error], error) {
Expand Down Expand Up @@ -1884,36 +2033,25 @@ func (as *arrowScan) GetRecords(ctx context.Context, tasks []FileScanTask) (*arr
return nil, nil, err
}

deletesPerFile, err := readAllDeleteFiles(ctx, as.fs, tasks, as.concurrency)
if err != nil {
// readAllDeleteFiles can return a partially-populated map alongside
// the error if some goroutines completed before the failure.
releasePerFilePosDeletes(deletesPerFile)

return nil, nil, err
}

// DV bitmaps stay in their native form rather than being materialized
// into int64 positions and merged with the Parquet pos-delete map.
// filterByDeletionVector applies the bitmap to each batch via a Boolean
// keep-mask + compute.Filter — O(1) Contains lookups, vectorized Filter,
// no intermediate position set.
dvBitmaps, err := readAllDeletionVectors(ctx, as.fs, tasks, as.concurrency)
if err != nil {
releasePerFilePosDeletes(deletesPerFile)

return nil, nil, err
}

eqDeleteSets, err := readAllEqualityDeleteFiles(ctx, as.fs,
invariants.tableSchema, invariants.nameMapping, tasks, as.concurrency)
if err != nil {
// Positional deletes were fully loaded; release them before aborting.
releasePerFilePosDeletes(deletesPerFile)

return nil, nil, err
}
addEqualityDeleteFieldIDs(invariants, eqDeleteSets)

return resultSchema, as.recordBatchesFromTasksAndDeletes(ctx, tasks, deletesPerFile, dvBitmaps, eqDeleteSets, invariants), nil
positionDeleteLoader := newLazyPositionDeleteLoader(as.fs, tasks)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the lazy loader, an unreadable positional delete file no longer fails GetRecords; the error now surfaces mid-iteration as enumeratedRecord.Err. A caller doing schema, iter, err := GetRecords(...); if err != nil { return } and then consuming will miss delete-file errors unless they also check the per-item error.

DV and equality-delete errors still surface eagerly from GetRecords, so the two now behave differently. I'd add a godoc line on GetRecords noting that positional-delete read errors are delivered through the iterator while DV and equality errors surface before it returns, and probably a CHANGELOG note since it's a caller-visible behavioral change. TestArrowScanDefersPositionDeleteReadsUntilIteration already pins the new behavior, so this is just documenting it.


return resultSchema, as.recordBatchesFromTasksAndDeletes(ctx, tasks,
positionDeleteLoader, dvBitmaps, eqDeleteSets, invariants), nil
}
Loading
Loading