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 21563b22f perf(table): reuse materialized row-lineage batches (#2225)
21563b22f is described below

commit 21563b22f3c1024882c3948d823ceec71fa71b9b
Author: Minh Vu <[email protected]>
AuthorDate: Tue Oct 20 19:16:09 2026 +0200

    perf(table): reuse materialized row-lineage batches (#2225)
    
    * perf(table): reuse fully populated row-lineage batches
    
    Skip rebuilding existing non-null lineage arrays and bulk-advance row 
position
    cursors. Add regression coverage and baseline-compatible benchmarks.
    
    * fix(table): satisfy nlreturn lint in lineage benchmark
---
 table/arrow_scanner.go                       |  71 ++++++++++++++++--
 table/row_lineage_materialized_bench_test.go | 104 +++++++++++++++++++++++++++
 table/row_lineage_materialized_test.go       | 104 +++++++++++++++++++++++++++
 3 files changed, 272 insertions(+), 7 deletions(-)

diff --git a/table/arrow_scanner.go b/table/arrow_scanner.go
index b417dad6e..89c7dfdb2 100644
--- a/table/arrow_scanner.go
+++ b/table/arrow_scanner.go
@@ -2071,6 +2071,42 @@ func (c *rowPositionCursor) next() int64 {
        return pos
 }
 
+// advance consumes rows without materializing their positions. This is used
+// when a row-id column is already complete and only cursor alignment matters.
+// Keep the cursor at an exact span boundary until the next read, matching 
next.
+func (c *rowPositionCursor) advance(rows int64) {
+       if rows <= 0 {
+               return
+       }
+
+       spans := c.src.spans
+       if len(spans) == 0 {
+               c.consumed += rows
+
+               return
+       }
+
+       for c.spanIdx < len(spans)-1 {
+               remaining := spans[c.spanIdx].NumRows - c.consumed
+               if remaining <= 0 {
+                       c.spanIdx++
+                       c.consumed = 0
+
+                       continue
+               }
+               if rows <= remaining {
+                       c.consumed += rows
+
+                       return
+               }
+
+               rows -= remaining
+               c.spanIdx++
+               c.consumed = 0
+       }
+       c.consumed += rows
+}
+
 // synthesizeRowLineageColumns fills the requested row-lineage columns from 
task
 // constants: per the v3 spec a null value inherits first_row_id + position /
 // data_sequence_number, a non-null value is kept. A column absent from the 
batch
@@ -2092,8 +2128,10 @@ func synthesizeRowLineageColumns(
        schema := batch.Schema()
        nrows := batch.NumRows()
 
-       fields := append([]arrow.Field(nil), schema.Fields()...)
-       newCols := append([]arrow.Array(nil), batch.Columns()...)
+       // Copy the schema and column lists only when a value must be 
synthesized.
+       // Completely materialized lineage columns can reuse the entire input 
batch.
+       var fields []arrow.Field
+       var newCols []arrow.Array
        var built []arrow.Array
        defer func() {
                for _, a := range built {
@@ -2101,19 +2139,32 @@ func synthesizeRowLineageColumns(
                }
        }()
 
-       // perRow, when set, runs once for every physical row in order — 
including
-       // rows whose explicit value wins — so a cursor stays in lockstep with 
the
-       // batch even when value is never called.
+       // The row-position cursor must consume every physical row, including
+       // rows whose explicit value wins. A complete row-id column can advance
+       // the cursor in bulk; other cases call perRow for each row.
        synth := func(name string, fieldID int, perRow func(k int64), value 
func(k int64) int64) error {
                idx := fieldIndexByID(schema, fieldID)
                var existing *array.Int64
                if idx >= 0 {
                        var ok bool
-                       if existing, ok = newCols[idx].(*array.Int64); !ok {
-                               return fmt.Errorf("row-lineage column %s is %s, 
want int64", name, newCols[idx].DataType())
+                       if existing, ok = batch.Column(idx).(*array.Int64); !ok 
{
+                               return fmt.Errorf("row-lineage column %s is %s, 
want int64", name, batch.Column(idx).DataType())
                        }
                }
+               if existing != nil && existing.NullN() == 0 {
+                       // The input already contains every value. In the 
row-id case,
+                       // advance past the batch without reconstructing each 
position.
+                       if perRow != nil {
+                               cursor.advance(nrows)
+                       }
 
+                       return nil
+               }
+
+               if newCols == nil {
+                       fields = append([]arrow.Field(nil), schema.Fields()...)
+                       newCols = append([]arrow.Array(nil), batch.Columns()...)
+               }
                bldr := array.NewInt64Builder(alloc)
                defer bldr.Release()
                bldr.Reserve(int(nrows))
@@ -2166,6 +2217,12 @@ func synthesizeRowLineageColumns(
                }
        }
 
+       if newCols == nil {
+               batch.Retain()
+
+               return batch, nil
+       }
+
        meta := schema.Metadata()
 
        return array.NewRecordBatch(arrow.NewSchema(fields, &meta), newCols, 
nrows), nil
diff --git a/table/row_lineage_materialized_bench_test.go 
b/table/row_lineage_materialized_bench_test.go
new file mode 100644
index 000000000..49e47047b
--- /dev/null
+++ b/table/row_lineage_materialized_bench_test.go
@@ -0,0 +1,104 @@
+// 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 (
+       "fmt"
+       "testing"
+
+       "github.com/apache/arrow-go/v18/arrow"
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/arrow-go/v18/arrow/compute"
+       "github.com/apache/arrow-go/v18/arrow/memory"
+       "github.com/apache/iceberg-go"
+       tblutils "github.com/apache/iceberg-go/table/internal"
+)
+
+// makeMaterializedLineageBatch builds two reserved columns with optional 
nulls.
+// A null index of -1 means the column is already fully populated.
+func makeMaterializedLineageBatch(alloc memory.Allocator, rows, rowIDNullAt, 
seqNullAt int) arrow.RecordBatch {
+       values := make([]int64, rows)
+       sequences := make([]int64, rows)
+       for i := range values {
+               values[i] = 1_000 + int64(i)
+               sequences[i] = 10 + int64(i)
+       }
+
+       build := func(values []int64, nullAt int) arrow.Array {
+               var valid []bool
+               if nullAt >= 0 {
+                       valid = make([]bool, len(values))
+                       for i := range valid {
+                               valid[i] = true
+                       }
+                       valid[nullAt] = false
+               }
+               builder := array.NewInt64Builder(alloc)
+               defer builder.Release()
+               builder.AppendValues(values, valid)
+
+               return builder.NewArray()
+       }
+
+       rowID := build(values, rowIDNullAt)
+       sequence := build(sequences, seqNullAt)
+       schema := arrow.NewSchema([]arrow.Field{
+               lineageArrowField(iceberg.RowIDColumnName, 
iceberg.RowIDFieldID),
+               lineageArrowField(iceberg.LastUpdatedSequenceNumberColumnName, 
iceberg.LastUpdatedSequenceNumberFieldID),
+       }, nil)
+       batch := array.NewRecordBatch(schema, []arrow.Array{rowID, sequence}, 
int64(rows))
+       rowID.Release()
+       sequence.Release()
+
+       return batch
+}
+
+func BenchmarkSynthesizeRowLineageColumns(b *testing.B) {
+       for _, rows := range []int{1_024, 65_536} {
+               for _, scenario := range []struct {
+                       name                 string
+                       rowNullAt, seqNullAt int
+               }{
+                       {name: "complete", rowNullAt: -1, seqNullAt: -1},
+                       {name: "row_id_sparse", rowNullAt: rows / 2, seqNullAt: 
-1},
+                       {name: "both_sparse", rowNullAt: rows / 2, seqNullAt: 
rows / 4},
+               } {
+                       b.Run(fmt.Sprintf("rows=%d/%s", rows, scenario.name), 
func(b *testing.B) {
+                               batch := 
makeMaterializedLineageBatch(memory.DefaultAllocator, rows, scenario.rowNullAt, 
scenario.seqNullAt)
+                               defer batch.Release()
+                               firstRowID, sequence := int64(1_000), int64(10)
+                               task := FileScanTask{FirstRowID: &firstRowID, 
DataSequenceNumber: &sequence}
+                               source := &rowPositionSource{spans: 
[]tblutils.RowGroupSpan{
+                                       {FirstRowPos: 0, NumRows: int64(rows / 
2)},
+                                       {FirstRowPos: int64(rows * 2), NumRows: 
int64(rows / 2)},
+                               }}
+                               ctx := compute.WithAllocator(b.Context(), 
memory.DefaultAllocator)
+                               b.ReportAllocs()
+                               b.ResetTimer()
+                               for b.Loop() {
+                                       cursor := source.cursor()
+                                       output, err := 
synthesizeRowLineageColumns(ctx, cursor, task, batch, true, true)
+                                       if err != nil {
+                                               b.Fatal(err)
+                                       }
+                                       output.Release()
+                               }
+                       })
+               }
+       }
+}
diff --git a/table/row_lineage_materialized_test.go 
b/table/row_lineage_materialized_test.go
new file mode 100644
index 000000000..e2d578692
--- /dev/null
+++ b/table/row_lineage_materialized_test.go
@@ -0,0 +1,104 @@
+// 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 (
+       "testing"
+
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/arrow-go/v18/arrow/compute"
+       "github.com/apache/arrow-go/v18/arrow/memory"
+       tblutils "github.com/apache/iceberg-go/table/internal"
+       "github.com/stretchr/testify/require"
+)
+
+func TestSynthesizeRowLineageFullyMaterialized(t *testing.T) {
+       alloc := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer alloc.AssertSize(t, 0)
+       batch := makeMaterializedLineageBatch(alloc, 4, -1, -1)
+       defer batch.Release()
+
+       firstRowID, sequence := int64(1_000), int64(10)
+       task := FileScanTask{FirstRowID: &firstRowID, DataSequenceNumber: 
&sequence}
+       source := &rowPositionSource{spans: []tblutils.RowGroupSpan{
+               {FirstRowPos: 5, NumRows: 2},
+               {FirstRowPos: 20, NumRows: 2},
+       }}
+       cursor := source.cursor()
+       ctx := compute.WithAllocator(t.Context(), alloc)
+
+       output, err := synthesizeRowLineageColumns(ctx, cursor, task, batch, 
true, true)
+       require.NoError(t, err)
+       defer output.Release()
+
+       // The caller owns an extra reference to the input batch, not a rebuild.
+       require.Same(t, batch, output)
+       require.Equal(t, int64(1_002), output.Column(0).(*array.Int64).Value(2))
+       require.Equal(t, int64(12), output.Column(1).(*array.Int64).Value(2))
+       require.Equal(t, int64(22), cursor.next()) // preserved position after 
a pruned group
+}
+
+func TestSynthesizeRowLineageOnlyRebuildsNullColumn(t *testing.T) {
+       alloc := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer alloc.AssertSize(t, 0)
+       batch := makeMaterializedLineageBatch(alloc, 4, 2, -1)
+       defer batch.Release()
+
+       firstRowID, sequence := int64(1_000), int64(10)
+       task := FileScanTask{FirstRowID: &firstRowID, DataSequenceNumber: 
&sequence}
+       source := &rowPositionSource{spans: []tblutils.RowGroupSpan{
+               {FirstRowPos: 5, NumRows: 2},
+               {FirstRowPos: 20, NumRows: 2},
+       }}
+       cursor := source.cursor()
+       output, err := 
synthesizeRowLineageColumns(compute.WithAllocator(t.Context(), alloc), cursor, 
task, batch, true, true)
+       require.NoError(t, err)
+       defer output.Release()
+
+       require.NotSame(t, batch, output)
+       require.NotSame(t, batch.Column(0), output.Column(0))
+       require.Same(t, batch.Column(1), output.Column(1))
+       require.Equal(t, int64(1_020), output.Column(0).(*array.Int64).Value(2))
+       require.Equal(t, int64(12), output.Column(1).(*array.Int64).Value(2))
+       require.Equal(t, int64(22), cursor.next())
+}
+
+func TestRowPositionCursorAdvanceEquivalent(t *testing.T) {
+       spans := [][]tblutils.RowGroupSpan{
+               nil,
+               {{FirstRowPos: 5, NumRows: 2}},
+               {{FirstRowPos: 5, NumRows: 2}, {FirstRowPos: 20, NumRows: 3}},
+               {{FirstRowPos: 0, NumRows: 0}, {FirstRowPos: 10, NumRows: 3}},
+               {{FirstRowPos: 0, NumRows: 1}, {FirstRowPos: 10, NumRows: 0}, 
{FirstRowPos: 20, NumRows: 2}},
+       }
+       for _, groups := range spans {
+               for _, counts := range [][]int64{{0, 1, 0, 2, 3}, {2, 2, 1, 
12}, {100}} {
+                       src := &rowPositionSource{spans: groups}
+                       expected, actual := src.cursor(), src.cursor()
+                       for _, n := range counts {
+                               for range n {
+                                       expected.next()
+                               }
+                               actual.advance(n)
+                               require.Equal(t, expected.spanIdx, 
actual.spanIdx, "spans=%v n=%d", groups, n)
+                               require.Equal(t, expected.consumed, 
actual.consumed, "spans=%v n=%d", groups, n)
+                       }
+                       require.Equal(t, expected.next(), actual.next(), 
"spans=%v counts=%v", groups, counts)
+               }
+       }
+}

Reply via email to