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 61318c413 perf(table): avoid cloning ReadTasks for already-bound
residuals (#2106)
61318c413 is described below
commit 61318c41303d3968f4a846e1703297e8eb3a68d8
Author: Minh Vu <[email protected]>
AuthorDate: Sun Oct 11 20:30:34 2026 +0200
perf(table): avoid cloning ReadTasks for already-bound residuals (#2106)
---
table/arrow_scanner.go | 32 +-
table/arrow_scanner_bench_test.go | 58 +++
table/arrow_scanner_pruning_internal_test.go | 18 +-
table/incremental_append_scan.go | 2 +-
table/incremental_changelog_scan.go | 2 +-
table/readtasks_residual_binding_internal_test.go | 426 ++++++++++++++++++++++
table/scanner.go | 97 +++--
7 files changed, 593 insertions(+), 42 deletions(-)
diff --git a/table/arrow_scanner.go b/table/arrow_scanner.go
index c1ebe9fd4..a482a2964 100644
--- a/table/arrow_scanner.go
+++ b/table/arrow_scanner.go
@@ -1248,6 +1248,9 @@ type arrowScan struct {
filterPlanCache compiledFileFilterPlanCache
fileReadPlanCache preparedFileReadPlanCache
cacheFileReadPlan bool
+ // taskResidualsBound means every non-nil residual passed to GetRecords
was
+ // bound and validated against filterSchema, so rowFilterForTask can
skip binding.
+ taskResidualsBound bool
useLargeTypes bool
concurrency int
@@ -1694,42 +1697,55 @@ func validateBoundFilter(schema *iceberg.Schema, filter
iceberg.BooleanExpressio
return validationErr
}
-func bindTaskFilter(schema *iceberg.Schema, filter iceberg.BooleanExpression,
caseSensitive bool) (iceberg.BooleanExpression, error) {
+// bindTaskFilter returns changed=true only when binding an unbound residual
+// produces a new expression. When changed is false, the returned expression is
+// the exact input value, after validating any already-bound predicates.
+func bindTaskFilter(schema *iceberg.Schema, filter iceberg.BooleanExpression,
caseSensitive bool) (iceberg.BooleanExpression, bool, error) {
if filter == nil {
- return nil, nil
+ return nil, false, nil
}
state, err := iceberg.VisitExpr(filter, filterBindingVisitor{})
if err != nil {
- return nil, err
+ return nil, false, err
}
if state.hasBound && state.hasUnbound {
- return nil, fmt.Errorf("%w: scan task residual mixes bound and
unbound predicates", iceberg.ErrInvalidArgument)
+ return nil, false, fmt.Errorf("%w: scan task residual mixes
bound and unbound predicates", iceberg.ErrInvalidArgument)
}
if !state.hasUnbound {
if state.hasBound {
if err := validateBoundFilter(schema, filter); err !=
nil {
- return nil, err
+ return nil, false, err
}
}
- return filter, nil
+ return filter, false, nil
+ }
+
+ bound, err := iceberg.BindExpr(schema, filter, caseSensitive)
+ if err != nil {
+ return nil, false, err
}
- return iceberg.BindExpr(schema, filter, caseSensitive)
+ return bound, true, nil
}
func (as *arrowScan) rowFilterForTask(task FileScanTask)
(iceberg.BooleanExpression, error) {
if task.Residual == nil {
return as.boundRowFilter, nil
}
+ if as.taskResidualsBound {
+ return task.Residual, nil
+ }
filterSchema := as.scanSchema
if as.filterSchema != nil {
filterSchema = as.filterSchema
}
- return bindTaskFilter(filterSchema, task.Residual, as.caseSensitive)
+ bound, _, err := bindTaskFilter(filterSchema, task.Residual,
as.caseSensitive)
+
+ return bound, err
}
// fieldIndexByID returns the index of the field carrying fieldID in its Arrow
diff --git a/table/arrow_scanner_bench_test.go
b/table/arrow_scanner_bench_test.go
index f3a1e5aa3..b5c5dcc20 100644
--- a/table/arrow_scanner_bench_test.go
+++ b/table/arrow_scanner_bench_test.go
@@ -687,3 +687,61 @@ func benchmarkTaskResidualJSON(rowCount int) string {
return result.String()
}
+
+func BenchmarkArrowScanReadTasksResidualBinding(b *testing.B) {
+ schema := iceberg.NewSchema(1,
+ iceberg.NestedField{ID: 1, Name: "id", Type:
iceberg.PrimitiveTypes.Int64, Required: true},
+ )
+ metadata, err := NewMetadata(
+ schema, iceberg.UnpartitionedSpec, UnsortedSortOrder,
"mem://benchmark/read-tasks", nil,
+ )
+ if err != nil {
+ b.Fatal(err)
+ }
+ fs := iceio.NewMemFS()
+ scan := New(Identifier{"benchmark", "read_tasks"}, metadata,
"metadata.json",
+ func(context.Context) (iceio.IO, error) { return fs, nil },
nil).Scan()
+ boundResidual, err := iceberg.BindExpr(schema,
+ iceberg.GreaterThan(iceberg.Reference("id"), int64(1)), true)
+ if err != nil {
+ b.Fatal(err)
+ }
+ unboundResidual := iceberg.GreaterThan(iceberg.Reference("id"),
int64(1))
+
+ for _, workload := range []struct {
+ taskCount int
+ name string
+ residual iceberg.BooleanExpression
+ }{
+ {taskCount: 1_000, name: "bound", residual: boundResidual},
+ {taskCount: 10_000, name: "bound", residual: boundResidual},
+ {taskCount: 100_000, name: "bound", residual: boundResidual},
+ {taskCount: 100_000, name: "nil"},
+ {taskCount: 100_000, name: "unbound", residual:
unboundResidual},
+ {taskCount: 100_000, name: "late_unbound", residual:
boundResidual},
+ } {
+ b.Run(fmt.Sprintf("tasks=%d/residual=%s", workload.taskCount,
workload.name), func(b *testing.B) {
+ tasks := make([]FileScanTask, workload.taskCount)
+ for i := range tasks {
+ tasks[i].Residual = workload.residual
+ }
+ if workload.name == "late_unbound" {
+ tasks[len(tasks)-1].Residual = unboundResidual
+ }
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ _, records, err := scan.ReadTasks(b.Context(),
tasks)
+ if err != nil {
+ b.Fatal(err)
+ }
+ if records == nil {
+ b.Fatal("ReadTasks returned a nil
iterator")
+ }
+ // Do not drain the iterator: this benchmark
isolates setup and residual binding.
+ }
+ b.ReportMetric(float64(len(tasks)), "tasks/op")
+ })
+ }
+}
diff --git a/table/arrow_scanner_pruning_internal_test.go
b/table/arrow_scanner_pruning_internal_test.go
index 007e3d0d2..b34e6d1b8 100644
--- a/table/arrow_scanner_pruning_internal_test.go
+++ b/table/arrow_scanner_pruning_internal_test.go
@@ -253,16 +253,26 @@ func TestBindTaskFilterValidatesBoundSchema(t *testing.T)
{
require.NoError(t, err)
t.Run("matching schema is accepted", func(t *testing.T) {
- got, err := bindTaskFilter(int64Schema, bound, true)
+ got, changed, err := bindTaskFilter(int64Schema, bound, true)
require.NoError(t, err)
+ require.False(t, changed)
require.Same(t, bound, got)
})
+ t.Run("unbound filter is bound", func(t *testing.T) {
+ unbound := iceberg.EqualTo(iceberg.Reference("id"), int64(42))
+ got, changed, err := bindTaskFilter(int64Schema, unbound, true)
+ require.NoError(t, err)
+ require.True(t, changed)
+ require.NotNil(t, got)
+ require.NotSame(t, unbound, got)
+ })
+
t.Run("missing field is rejected", func(t *testing.T) {
otherSchema := iceberg.NewSchema(0,
iceberg.NestedField{ID: 2, Name: "other", Type:
iceberg.PrimitiveTypes.Int64, Required: true},
)
- _, err := bindTaskFilter(otherSchema, bound, true)
+ _, _, err := bindTaskFilter(otherSchema, bound, true)
require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
require.ErrorContains(t, err, "field ID 1")
})
@@ -271,7 +281,7 @@ func TestBindTaskFilterValidatesBoundSchema(t *testing.T) {
otherSchema := iceberg.NewSchema(0,
iceberg.NestedField{ID: 1, Name: "id", Type:
iceberg.PrimitiveTypes.Int32, Required: true},
)
- _, err := bindTaskFilter(otherSchema, bound, true)
+ _, _, err := bindTaskFilter(otherSchema, bound, true)
require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
require.ErrorContains(t, err, "type")
})
@@ -289,7 +299,7 @@ func TestBindTaskFilterValidatesBoundSchema(t *testing.T) {
iceberg.NestedField{ID: 10, Name: "payload", Type:
&iceberg.StructType{FieldList: []iceberg.NestedField{field}}},
)
- _, err = bindTaskFilter(otherSchema, boundNested, true)
+ _, _, err = bindTaskFilter(otherSchema, boundNested, true)
require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
require.ErrorContains(t, err, "accessor path")
})
diff --git a/table/incremental_append_scan.go b/table/incremental_append_scan.go
index cdb0dc565..ed77dbe1b 100644
--- a/table/incremental_append_scan.go
+++ b/table/incremental_append_scan.go
@@ -123,7 +123,7 @@ func (s *IncrementalAppendScan) PlanFiles(ctx
context.Context) ([]FileScanTask,
if err != nil {
return nil, err
}
- residual, err := bindTaskFilter(schema, planningScan.rowFilter,
planningScan.caseSensitive)
+ residual, _, err := bindTaskFilter(schema, planningScan.rowFilter,
planningScan.caseSensitive)
if err != nil {
return nil, fmt.Errorf("bind incremental scan residual: %w",
err)
}
diff --git a/table/incremental_changelog_scan.go
b/table/incremental_changelog_scan.go
index dcb5f2620..8b8006adc 100644
--- a/table/incremental_changelog_scan.go
+++ b/table/incremental_changelog_scan.go
@@ -136,7 +136,7 @@ func (s *IncrementalChangelogScan) PlanFiles(ctx
context.Context) ([]ChangelogSc
if err != nil {
return nil, err
}
- residual, err := bindTaskFilter(schema, planningScan.rowFilter,
planningScan.caseSensitive)
+ residual, _, err := bindTaskFilter(schema, planningScan.rowFilter,
planningScan.caseSensitive)
if err != nil {
return nil, fmt.Errorf("bind incremental changelog scan
residual: %w", err)
}
diff --git a/table/readtasks_residual_binding_internal_test.go
b/table/readtasks_residual_binding_internal_test.go
new file mode 100644
index 000000000..f395ee0f1
--- /dev/null
+++ b/table/readtasks_residual_binding_internal_test.go
@@ -0,0 +1,426 @@
+// 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"
+ "iter"
+ "os"
+ "path/filepath"
+ "strings"
+ "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/arrow-go/v18/parquet"
+ "github.com/apache/arrow-go/v18/parquet/pqarrow"
+ "github.com/apache/iceberg-go"
+ iceio "github.com/apache/iceberg-go/io"
+ "github.com/stretchr/testify/require"
+)
+
+func residualBindingTestScan(t *testing.T) (*Scan, *iceberg.Schema) {
+ t.Helper()
+
+ schema := iceberg.NewSchema(0,
+ iceberg.NestedField{ID: 1, Name: "id", Type:
iceberg.PrimitiveTypes.Int64},
+ )
+ metadata, err := NewMetadata(
+ schema, iceberg.UnpartitionedSpec, UnsortedSortOrder,
"mem://mixed-residuals", nil,
+ )
+ require.NoError(t, err)
+ memFS := iceio.NewMemFS()
+ tbl := New(
+ Identifier{"db", "tbl"}, metadata, "metadata.json",
+ func(context.Context) (iceio.IO, error) { return memFS, nil },
nil,
+ )
+
+ return tbl.Scan(), schema
+}
+
+func writeResidualBindingParquetFile(t testing.TB, path string, schema
*iceberg.Schema, jsonData string) iceberg.DataFile {
+ t.Helper()
+
+ // Real Iceberg field IDs are needed for the Parquet reader to reach
residual filtering.
+ arrowSchema, err := SchemaToArrowSchema(schema, nil, true, false)
+ require.NoError(t, err)
+ record, _, err := array.RecordFromJSON(memory.DefaultAllocator,
arrowSchema, strings.NewReader(jsonData))
+ require.NoError(t, err)
+ defer record.Release()
+
+ writer, err := (iceio.LocalFS{}).Create(path)
+ require.NoError(t, err)
+ defer writer.Close()
+
+ tbl := array.NewTableFromRecords(arrowSchema,
[]arrow.RecordBatch{record})
+ defer tbl.Release()
+
+ props := parquet.NewWriterProperties(parquet.WithStats(true))
+ // WriteTable flushes and closes the file before returning.
+ require.NoError(t, pqarrow.WriteTable(
+ tbl, writer, record.NumRows(), props,
pqarrow.DefaultWriterProps(),
+ ))
+
+ info, err := os.Stat(path)
+ require.NoError(t, err)
+ builder, err := iceberg.NewDataFileBuilder(
+ *iceberg.UnpartitionedSpec,
+ iceberg.EntryContentData,
+ path,
+ iceberg.ParquetFile,
+ nil, nil, nil,
+ record.NumRows(),
+ info.Size(),
+ )
+ require.NoError(t, err)
+
+ return builder.Build()
+}
+
+func TestBindReadTasksResidualsCopyOnWrite(t *testing.T) {
+ _, schema := residualBindingTestScan(t)
+ unbound := iceberg.GreaterThan(iceberg.Reference("id"), int64(1))
+ bound, err := iceberg.BindExpr(schema, unbound, true)
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ residuals []iceberg.BooleanExpression
+ wantAlias bool
+ wantSame []bool
+ }{
+ {
+ name: "all bound and nil",
+ residuals: []iceberg.BooleanExpression{bound, nil,
bound},
+ wantAlias: true,
+ wantSame: []bool{true, false, true},
+ },
+ {
+ name: "all nil",
+ residuals: []iceberg.BooleanExpression{nil, nil, nil},
+ wantAlias: true,
+ wantSame: []bool{false, false, false},
+ },
+ {
+ name: "mixed",
+ residuals: []iceberg.BooleanExpression{bound, nil,
unbound, bound},
+ wantSame: []bool{true, false, false, true},
+ },
+ {
+ name: "first task unbound",
+ residuals: []iceberg.BooleanExpression{unbound, bound},
+ wantSame: []bool{false, true},
+ },
+ {
+ name: "last task unbound",
+ residuals: []iceberg.BooleanExpression{bound, nil,
unbound},
+ wantSame: []bool{true, false, false},
+ },
+ {
+ name: "all unbound",
+ residuals: []iceberg.BooleanExpression{unbound,
unbound},
+ wantSame: []bool{false, false},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tasks := make([]FileScanTask, len(tt.residuals))
+ for i, residual := range tt.residuals {
+ tasks[i].Residual = residual
+ }
+
+ got, err := bindReadTasksResiduals(schema, tasks, true)
+ require.NoError(t, err)
+ require.Len(t, got, len(tasks))
+ if len(tasks) > 0 {
+ require.Equal(t, tt.wantAlias, &got[0] ==
&tasks[0])
+ }
+
+ for i, original := range tt.residuals {
+ if original == nil {
+ require.Nil(t, got[i].Residual)
+
+ continue
+ }
+
+ // The input plan is never rewritten, even when
the output needs binding.
+ require.Same(t, original, tasks[i].Residual)
+ if tt.wantSame[i] {
+ require.Same(t, original,
got[i].Residual)
+ } else {
+ require.NotSame(t, original,
got[i].Residual)
+ }
+ state, visitErr :=
iceberg.VisitExpr(got[i].Residual, filterBindingVisitor{})
+ require.NoError(t, visitErr)
+ require.True(t, state.hasBound)
+ require.False(t, state.hasUnbound)
+ }
+ })
+ }
+}
+
+func TestReadTasksResidualPlanIsReusable(t *testing.T) {
+ scan, schema := residualBindingTestScan(t)
+ unbound := iceberg.GreaterThan(iceberg.Reference("id"), int64(1))
+ bound, err := iceberg.BindExpr(schema, unbound, true)
+ require.NoError(t, err)
+
+ wrongSchema := iceberg.NewSchema(0,
+ iceberg.NestedField{ID: 2, Name: "other", Type:
iceberg.PrimitiveTypes.Int64},
+ )
+ wrongBound, err := iceberg.BindExpr(
+ wrongSchema, iceberg.EqualTo(iceberg.Reference("other"),
int64(1)), true,
+ )
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ tasks []FileScanTask
+ wantErr bool
+ }{
+ {
+ name: "mixed plan",
+ tasks: []FileScanTask{{Residual: bound}, {}, {Residual:
unbound}, {Residual: bound}},
+ },
+ {
+ name: "invalid bound residual",
+ tasks: []FileScanTask{{Residual: bound}, {Residual:
unbound}, {Residual: wrongBound}},
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ originals := make([]iceberg.BooleanExpression,
len(tt.tasks))
+ for i := range tt.tasks {
+ originals[i] = tt.tasks[i].Residual
+ }
+
+ // Run twice to prove the caller-owned plan remains
reusable.
+ for range 2 {
+ _, _, err := scan.ReadTasks(t.Context(),
tt.tasks)
+ if tt.wantErr {
+ require.ErrorIs(t, err,
iceberg.ErrInvalidArgument)
+ require.ErrorContains(t, err, "bind
residual for task 2")
+ require.ErrorContains(t, err, "field ID
2")
+ } else {
+ require.NoError(t, err)
+ }
+ for i, original := range originals {
+ if original == nil {
+ require.Nil(t,
tt.tasks[i].Residual)
+ } else {
+ require.Same(t, original,
tt.tasks[i].Residual)
+ }
+ }
+ }
+ })
+ }
+}
+
+func TestReadTasksConcurrentScansPreserveInputTasks(t *testing.T) {
+ schema := iceberg.NewSchema(0,
+ iceberg.NestedField{ID: 1, Name: "id", Type:
iceberg.PrimitiveTypes.Int64},
+ )
+ location := t.TempDir()
+ metadata, err := NewMetadata(
+ schema, iceberg.UnpartitionedSpec, UnsortedSortOrder, location,
nil,
+ )
+ require.NoError(t, err)
+ tbl := New(
+ Identifier{"db", "tbl"}, metadata, filepath.Join(location,
"metadata.json"),
+ func(context.Context) (iceio.IO, error) { return
iceio.LocalFS{}, nil }, nil,
+ )
+
+ bound, err := iceberg.BindExpr(schema,
iceberg.GreaterThan(iceberg.Reference("id"), int64(1)), true)
+ require.NoError(t, err)
+ unbound := iceberg.LessThan(iceberg.Reference("id"), int64(3))
+ files := []iceberg.DataFile{
+ writeResidualBindingParquetFile(t, filepath.Join(location,
"data-1.parquet"), schema, `[{"id":2}]`),
+ writeResidualBindingParquetFile(t, filepath.Join(location,
"data-2.parquet"), schema, `[{"id":3}]`),
+ }
+
+ tests := []struct {
+ name string
+ residuals []iceberg.BooleanExpression
+ wantRows int64
+ }{
+ {name: "aliased bound plan", residuals:
[]iceberg.BooleanExpression{bound, bound}, wantRows: 2},
+ {name: "mixed copy-on-write plan", residuals:
[]iceberg.BooleanExpression{bound, unbound}, wantRows: 1},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tasks := []FileScanTask{
+ {File: files[0], Residual: tt.residuals[0]},
+ {File: files[1], Residual: tt.residuals[1]},
+ }
+ snapshot := append([]FileScanTask(nil), tasks...)
+
+ type result struct {
+ rows int64
+ err error
+ }
+ results := make(chan result, 2)
+ for range 2 {
+ go func() {
+ scan := tbl.Scan(WithMaxConcurrency(4))
+ _, records, readErr :=
scan.ReadTasks(t.Context(), tasks)
+ if readErr != nil {
+ results <- result{err: readErr}
+
+ return
+ }
+
+ var rows int64
+ for record, iterErr := range records {
+ if iterErr != nil {
+ results <- result{err:
iterErr}
+
+ return
+ }
+ rows += record.NumRows()
+ record.Release()
+ }
+ results <- result{rows: rows}
+ }()
+ }
+
+ // Join both readers before any assertion can end the
subtest and
+ // remove fixtures still in use by the other reader.
+ completed := []result{<-results, <-results}
+ for _, got := range completed {
+ require.NoError(t, got.err)
+ require.Equal(t, tt.wantRows, got.rows)
+ }
+ require.Equal(t, snapshot, tasks)
+ for i := range tasks {
+ require.Same(t, snapshot[i].Residual,
tasks[i].Residual)
+ }
+ })
+ }
+}
+
+func TestReadTasksPassesBoundResidualsToGetRecords(t *testing.T) {
+ schema := iceberg.NewSchema(0,
+ iceberg.NestedField{ID: 1, Name: "id", Type:
iceberg.PrimitiveTypes.Int64},
+ )
+ location := t.TempDir()
+ metadata, err := NewMetadata(
+ schema, iceberg.UnpartitionedSpec, UnsortedSortOrder, location,
nil,
+ )
+ require.NoError(t, err)
+ tbl := New(
+ Identifier{"db", "tbl"}, metadata, filepath.Join(location,
"metadata.json"),
+ func(context.Context) (iceio.IO, error) { return
iceio.LocalFS{}, nil }, nil,
+ )
+
+ unbound := iceberg.GreaterThan(iceberg.Reference("id"), int64(1))
+ lowerBound, err := iceberg.BindExpr(schema, unbound, true)
+ require.NoError(t, err)
+ upperBound, err := iceberg.BindExpr(schema,
iceberg.LessThan(iceberg.Reference("id"), int64(5)), true)
+ require.NoError(t, err)
+
+ // Distinct data and residuals expose both an unbound handoff and an
+ // incorrect residual-to-task index, rather than just asserting row
totals.
+ files := []iceberg.DataFile{
+ writeResidualBindingParquetFile(t, filepath.Join(location,
"data-1.parquet"), schema, `[{"id":1},{"id":2},{"id":3}]`),
+ writeResidualBindingParquetFile(t, filepath.Join(location,
"data-2.parquet"), schema, `[{"id":4},{"id":5},{"id":6}]`),
+ writeResidualBindingParquetFile(t, filepath.Join(location,
"data-3.parquet"), schema, `[{"id":7},{"id":8},{"id":9}]`),
+ }
+
+ tests := []struct {
+ name string
+ residuals []iceberg.BooleanExpression
+ wantAlias bool
+ wantIDs []int64
+ }{
+ {
+ name: "bound and nil",
+ residuals: []iceberg.BooleanExpression{lowerBound,
upperBound, nil},
+ wantAlias: true,
+ wantIDs: []int64{2, 3, 4, 7, 8, 9},
+ },
+ {
+ name: "all nil",
+ residuals: []iceberg.BooleanExpression{nil, nil, nil},
+ wantAlias: true,
+ wantIDs: []int64{1, 2, 3, 4, 5, 6, 7, 8, 9},
+ },
+ {
+ name: "mixed",
+ residuals: []iceberg.BooleanExpression{unbound,
upperBound, nil},
+ wantIDs: []int64{2, 3, 4, 7, 8, 9},
+ },
+ }
+
+ for _, tt := range tests {
+ for _, concurrency := range []int{1, 4} {
+ t.Run(fmt.Sprintf("%s/concurrency=%d", tt.name,
concurrency), func(t *testing.T) {
+ tasks := make([]FileScanTask, len(files))
+ for i, file := range files {
+ tasks[i] = FileScanTask{File: file,
Residual: tt.residuals[i]}
+ }
+
+ scan :=
tbl.Scan(WithMaxConcurrency(concurrency))
+ readerCalls := 0
+ scan.readRecords = func(reader *arrowScan, ctx
context.Context, received []FileScanTask) (*arrow.Schema,
iter.Seq2[arrow.RecordBatch, error], error) {
+ readerCalls++
+ require.Len(t, received, len(tasks))
+ // Check the actual
ReadTasks-to-GetRecords handoff, so an
+ // unconditional clone outside the
binding helper fails too.
+ require.Equal(t, tt.wantAlias,
&received[0] == &tasks[0])
+ for i, task := range received {
+ if task.Residual != nil {
+ _, bound :=
task.Residual.(iceberg.BoundPredicate)
+ require.True(t, bound,
"residual for task %d must be bound", i)
+ }
+ }
+
+ return reader.GetRecords(ctx, received)
+ }
+ _, records, err := scan.ReadTasks(t.Context(),
tasks)
+ require.NoError(t, err)
+ require.Equal(t, 1, readerCalls)
+
+ var ids []int64
+ for record, readErr := range records {
+ require.NoError(t, readErr)
+ values, ok :=
record.Column(0).(*array.Int64)
+ require.True(t, ok)
+ for i := range values.Len() {
+ ids = append(ids,
values.Value(i))
+ }
+ record.Release()
+ }
+
+ require.Equal(t, tt.wantIDs, ids)
+ for i, residual := range tt.residuals {
+ if residual == nil {
+ require.Nil(t,
tasks[i].Residual)
+ } else {
+ require.Same(t, residual,
tasks[i].Residual)
+ }
+ }
+ })
+ }
+ }
+}
diff --git a/table/scanner.go b/table/scanner.go
index 60507598d..f6bf14c35 100644
--- a/table/scanner.go
+++ b/table/scanner.go
@@ -582,6 +582,10 @@ type Scan struct {
// map cannot silently drop it.
arrowBatchSize int
+ // readRecords is a scan-local reader seam; nil uses
(*arrowScan).GetRecords.
+ // Tests can wrap it to observe the task slice handed to the Arrow
reader.
+ readRecords func(*arrowScan, context.Context, []FileScanTask)
(*arrow.Schema, iter.Seq2[arrow.RecordBatch, error], error)
+
reporter metrics.Reporter
}
@@ -2121,8 +2125,11 @@ type FileScanTask struct {
Start, Length int64
// Residual is the portion of the scan filter that must still be
evaluated
// for this task. Local and remote planners may simplify the original
filter using
- // file metadata; nil means the caller did not provide a task residual.
- // ReadTasks applies the scan's original row filter and each task
residual.
+ // file metadata; nil means the caller did not provide a task residual.
Callers
+ // may supply either a bound or unbound expression; ReadTasks validates
bound
+ // residuals and binds unbound residuals against the scan's effective
schema.
+ // Residuals that mix bound and unbound predicates are rejected.
ReadTasks
+ // applies the scan's original row filter and each task residual.
Residual iceberg.BooleanExpression
// Row lineage (v3): constants used when reading to synthesize _row_id
and _last_updated_sequence_number.
@@ -2155,6 +2162,37 @@ func (scan *Scan) ToArrowRecords(ctx context.Context)
(*arrow.Schema, iter.Seq2[
return scan.ReadTasks(ctx, tasks)
}
+// bindReadTasksResiduals keeps the caller's task slice untouched. If every
+// residual is nil or already bound, the returned top-level slice aliases
tasks.
+// Otherwise it shallow-clones once, on the first residual that needs binding.
+// Nested slices remain shared in either case.
+func bindReadTasksResiduals(
+ schema *iceberg.Schema, tasks []FileScanTask, caseSensitive bool,
+) ([]FileScanTask, error) {
+ readTasks := tasks
+ clonedTasks := false
+ for i := range tasks {
+ if tasks[i].Residual == nil {
+ continue
+ }
+
+ boundResidual, changed, err := bindTaskFilter(schema,
tasks[i].Residual, caseSensitive)
+ if err != nil {
+ return nil, fmt.Errorf("bind residual for task %d: %w",
i, err)
+ }
+ if !changed {
+ continue
+ }
+ if !clonedTasks {
+ readTasks = slices.Clone(tasks)
+ clonedTasks = true
+ }
+ readTasks[i].Residual = boundResidual
+ }
+
+ return readTasks, nil
+}
+
// ReadTasks reads Arrow records from a specific set of FileScanTasks,
applying the
// scan's projection, per-task residual filters, and delete handling. This is
useful
// when the caller has already planned or selected specific tasks to read.
@@ -2167,6 +2205,13 @@ func (scan *Scan) ToArrowRecords(ctx context.Context)
(*arrow.Schema, iter.Seq2[
// reached; if no such task is processed, the file is not read and its error
is not
// returned. The returned iterator is single-use.
//
+// On success, callers must not modify tasks or its elements until
+// the range over the returned iterator has finished, including an early break.
+// If the iterator is never ranged over, callers must ensure it will not be
used
+// before modifying tasks. ReadTasks retains the caller's backing array when no
+// residual needs binding (each is nil or already bound). Any clone is shallow,
+// so nested slices (for example DeleteFiles) remain shared with the caller.
+//
// With [WithMaxConcurrency] above one, tasks are decoded in parallel and the
// batches are returned in task order. Each worker holds the decoded batches of
// at most one task until the iterator has returned them, so while an early
task
@@ -2206,19 +2251,10 @@ func (scan *Scan) ReadTasks(ctx context.Context, tasks
[]FileScanTask) (*arrow.S
}
// Bind task residuals against the schema selected by this scan, which
may
- // be an older snapshot schema rather than the table's current schema.
Keep
- // the caller's task slice untouched because the same plan may be
reused.
- readTasks := slices.Clone(tasks)
- for i := range readTasks {
- if readTasks[i].Residual == nil {
- continue
- }
-
- readTasks[i].Residual, err = bindTaskFilter(effectiveSchema,
- readTasks[i].Residual, scan.caseSensitive)
- if err != nil {
- return nil, nil, fmt.Errorf("bind residual for task %d:
%w", i, err)
- }
+ // be an older snapshot schema rather than the table's current schema.
+ readTasks, err := bindReadTasksResiduals(effectiveSchema, tasks,
scan.caseSensitive)
+ if err != nil {
+ return nil, nil, err
}
// A plan-scoped FileIO (from remote planning) takes precedence over the
@@ -2239,19 +2275,24 @@ func (scan *Scan) ReadTasks(ctx context.Context, tasks
[]FileScanTask) (*arrow.S
return nil, nil, err
}
- outSchema, records, err := (&arrowScan{
- metadata: scan.metadata,
- fs: fs,
- scanSchema: effectiveSchema,
- projectedSchema: schema,
- boundRowFilter: boundFilter,
- filterSchema: effectiveSchema,
- caseSensitive: scan.caseSensitive,
- rowLimit: scan.limit,
- options: scan.options,
- concurrency: scan.concurrency,
- arrowBatchSize: scan.arrowBatchSize,
- }).GetRecords(ctx, readTasks)
+ readRecords := scan.readRecords
+ if readRecords == nil {
+ readRecords = (*arrowScan).GetRecords
+ }
+ outSchema, records, err := readRecords(&arrowScan{
+ metadata: scan.metadata,
+ fs: fs,
+ scanSchema: effectiveSchema,
+ projectedSchema: schema,
+ boundRowFilter: boundFilter,
+ filterSchema: effectiveSchema,
+ caseSensitive: scan.caseSensitive,
+ taskResidualsBound: true,
+ rowLimit: scan.limit,
+ options: scan.options,
+ concurrency: scan.concurrency,
+ arrowBatchSize: scan.arrowBatchSize,
+ }, ctx, readTasks)
if err != nil {
// No iterator to drive cleanup on a setup error, so release
here.
if releasePlanIO != nil {