dgvj-work commented on code in PR #2171:
URL: https://github.com/apache/iceberg-go/pull/2171#discussion_r4420176572


##########
table/changelog_reader_external_test.go:
##########
@@ -0,0 +1,769 @@
+// 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"
+       "fmt"
+       "iter"
+       "os"
+       "path/filepath"
+       "strconv"
+       "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"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/apache/iceberg-go/table"
+       "github.com/stretchr/testify/require"
+)
+
+func TestIncrementalChangelogReadEmptyTable(t *testing.T) {
+       schema, records, err := 
newV2RowLineageTestTable(t).NewIncrementalChangelogScan().ToArrowRecords(t.Context())
+       require.NoError(t, err)
+       require.Equal(t, []string{
+               "id", "data",
+               table.ChangelogChangeTypeColumn,
+               table.ChangelogChangeOrdinalColumn,
+               table.ChangelogCommitSnapshotIDColumn,
+       }, schemaFieldNames(schema))
+
+       require.Empty(t, collectChangelogRows(t, records))
+}
+
+func TestIncrementalChangelogReadFileChanges(t *testing.T) {
+       ctx := t.Context()
+       tbl := changelogOverwriteTable(t, ctx)
+
+       tasks, err := tbl.NewIncrementalChangelogScan().PlanFiles(ctx)
+       require.NoError(t, err)
+       require.Len(t, tasks, 3)
+
+       schema, records, err := tbl.NewIncrementalChangelogScan().Read(ctx, 
tasks)
+       require.NoError(t, err)
+       require.Equal(t, []string{
+               "id", "data",
+               table.ChangelogChangeTypeColumn,
+               table.ChangelogChangeOrdinalColumn,
+               table.ChangelogCommitSnapshotIDColumn,
+       }, schemaFieldNames(schema))
+
+       got := collectChangelogRows(t, records)
+       require.Equal(t, []changelogRow{
+               {id: 1, data: "a", op: string(table.ChangelogOpInsert), 
ordinal: int64(tasks[0].ChangeOrdinal()), snapshot: 
tasks[0].CommitSnapshotID()},
+               {id: 1, data: "a", op: string(table.ChangelogOpDelete), 
ordinal: int64(tasks[1].ChangeOrdinal()), snapshot: 
tasks[1].CommitSnapshotID()},
+               {id: 2, data: "b", op: string(table.ChangelogOpInsert), 
ordinal: int64(tasks[2].ChangeOrdinal()), snapshot: 
tasks[2].CommitSnapshotID()},
+       }, got)
+       require.NotEqual(t, tasks[0].CommitSnapshotID(), 
tasks[1].CommitSnapshotID())
+       require.Equal(t, tasks[1].CommitSnapshotID(), 
tasks[2].CommitSnapshotID())
+       require.Equal(t, 0, tasks[0].ChangeOrdinal())
+       require.Equal(t, 1, tasks[1].ChangeOrdinal())
+       require.Equal(t, 1, tasks[2].ChangeOrdinal())
+}
+
+func TestIncrementalChangelogReadHonorsProjectionFilterAndLimit(t *testing.T) {
+       ctx := t.Context()
+       tbl := changelogOverwriteTable(t, ctx)
+
+       schema, records, err := tbl.NewIncrementalChangelogScan(
+               table.WithSelectedFields("id"),
+               table.WithRowFilter(iceberg.EqualTo(iceberg.Reference("id"), 
int64(2))),
+               table.WithLimit(1),
+       ).ToArrowRecords(ctx)
+       require.NoError(t, err)
+       require.Equal(t, []string{
+               "id",
+               table.ChangelogChangeTypeColumn,
+               table.ChangelogChangeOrdinalColumn,
+               table.ChangelogCommitSnapshotIDColumn,
+       }, schemaFieldNames(schema))
+
+       got := collectChangelogRows(t, records)
+       require.Equal(t, []changelogRow{
+               {id: 2, op: string(table.ChangelogOpInsert), ordinal: 1},
+       }, clearChangelogSnapshot(got))
+}
+
+func TestIncrementalChangelogReadLimitCutsABatch(t *testing.T) {
+       ctx := t.Context()
+       tbl := newV3RowLineageTestTable(t)
+       arrowSchema := arrow.NewSchema([]arrow.Field{
+               {Name: "id", Type: arrow.PrimitiveTypes.Int64, Nullable: false},
+               {Name: "data", Type: arrow.BinaryTypes.String, Nullable: true},
+       }, nil)
+       data, err := array.TableFromJSON(memory.DefaultAllocator, arrowSchema, 
[]string{
+               `[{"id": 1, "data": "a"}, {"id": 2, "data": "b"}, {"id": 3, 
"data": "c"}]`,
+       })
+       require.NoError(t, err)
+       t.Cleanup(data.Release)
+       tbl, err = tbl.Append(ctx, array.NewTableReader(data, -1), nil)
+       require.NoError(t, err)
+
+       _, records, err := tbl.NewIncrementalChangelogScan(
+               table.WithSelectedFields("id"),
+               table.WithLimit(2),
+       ).ToArrowRecords(ctx)
+       require.NoError(t, err)
+       got := collectChangelogRows(t, records)
+       require.Equal(t, []changelogRow{
+               {id: 1, op: string(table.ChangelogOpInsert), ordinal: 0},
+               {id: 2, op: string(table.ChangelogOpInsert), ordinal: 0},
+       }, clearChangelogSnapshot(got))
+}
+
+func TestIncrementalChangelogReadReleasesCheckedAllocator(t *testing.T) {

Review Comment:
   Added a checked-allocator case for a position delete and a deletion vector, 
both full drain and early break. The early break stops on the delete batch and 
leaves the later append unread.



##########
table/changelog_reader_external_test.go:
##########
@@ -0,0 +1,769 @@
+// 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"
+       "fmt"
+       "iter"
+       "os"
+       "path/filepath"
+       "strconv"
+       "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"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/apache/iceberg-go/table"
+       "github.com/stretchr/testify/require"
+)
+
+func TestIncrementalChangelogReadEmptyTable(t *testing.T) {
+       schema, records, err := 
newV2RowLineageTestTable(t).NewIncrementalChangelogScan().ToArrowRecords(t.Context())
+       require.NoError(t, err)
+       require.Equal(t, []string{
+               "id", "data",
+               table.ChangelogChangeTypeColumn,
+               table.ChangelogChangeOrdinalColumn,
+               table.ChangelogCommitSnapshotIDColumn,
+       }, schemaFieldNames(schema))
+
+       require.Empty(t, collectChangelogRows(t, records))
+}
+
+func TestIncrementalChangelogReadFileChanges(t *testing.T) {
+       ctx := t.Context()
+       tbl := changelogOverwriteTable(t, ctx)
+
+       tasks, err := tbl.NewIncrementalChangelogScan().PlanFiles(ctx)
+       require.NoError(t, err)
+       require.Len(t, tasks, 3)
+
+       schema, records, err := tbl.NewIncrementalChangelogScan().Read(ctx, 
tasks)
+       require.NoError(t, err)
+       require.Equal(t, []string{
+               "id", "data",
+               table.ChangelogChangeTypeColumn,
+               table.ChangelogChangeOrdinalColumn,
+               table.ChangelogCommitSnapshotIDColumn,
+       }, schemaFieldNames(schema))
+
+       got := collectChangelogRows(t, records)
+       require.Equal(t, []changelogRow{
+               {id: 1, data: "a", op: string(table.ChangelogOpInsert), 
ordinal: int64(tasks[0].ChangeOrdinal()), snapshot: 
tasks[0].CommitSnapshotID()},
+               {id: 1, data: "a", op: string(table.ChangelogOpDelete), 
ordinal: int64(tasks[1].ChangeOrdinal()), snapshot: 
tasks[1].CommitSnapshotID()},
+               {id: 2, data: "b", op: string(table.ChangelogOpInsert), 
ordinal: int64(tasks[2].ChangeOrdinal()), snapshot: 
tasks[2].CommitSnapshotID()},
+       }, got)
+       require.NotEqual(t, tasks[0].CommitSnapshotID(), 
tasks[1].CommitSnapshotID())
+       require.Equal(t, tasks[1].CommitSnapshotID(), 
tasks[2].CommitSnapshotID())
+       require.Equal(t, 0, tasks[0].ChangeOrdinal())
+       require.Equal(t, 1, tasks[1].ChangeOrdinal())
+       require.Equal(t, 1, tasks[2].ChangeOrdinal())
+}
+
+func TestIncrementalChangelogReadHonorsProjectionFilterAndLimit(t *testing.T) {
+       ctx := t.Context()
+       tbl := changelogOverwriteTable(t, ctx)
+
+       schema, records, err := tbl.NewIncrementalChangelogScan(
+               table.WithSelectedFields("id"),
+               table.WithRowFilter(iceberg.EqualTo(iceberg.Reference("id"), 
int64(2))),
+               table.WithLimit(1),
+       ).ToArrowRecords(ctx)
+       require.NoError(t, err)
+       require.Equal(t, []string{
+               "id",
+               table.ChangelogChangeTypeColumn,
+               table.ChangelogChangeOrdinalColumn,
+               table.ChangelogCommitSnapshotIDColumn,
+       }, schemaFieldNames(schema))
+
+       got := collectChangelogRows(t, records)
+       require.Equal(t, []changelogRow{
+               {id: 2, op: string(table.ChangelogOpInsert), ordinal: 1},
+       }, clearChangelogSnapshot(got))
+}
+
+func TestIncrementalChangelogReadLimitCutsABatch(t *testing.T) {
+       ctx := t.Context()
+       tbl := newV3RowLineageTestTable(t)
+       arrowSchema := arrow.NewSchema([]arrow.Field{
+               {Name: "id", Type: arrow.PrimitiveTypes.Int64, Nullable: false},
+               {Name: "data", Type: arrow.BinaryTypes.String, Nullable: true},
+       }, nil)
+       data, err := array.TableFromJSON(memory.DefaultAllocator, arrowSchema, 
[]string{
+               `[{"id": 1, "data": "a"}, {"id": 2, "data": "b"}, {"id": 3, 
"data": "c"}]`,
+       })
+       require.NoError(t, err)
+       t.Cleanup(data.Release)
+       tbl, err = tbl.Append(ctx, array.NewTableReader(data, -1), nil)
+       require.NoError(t, err)
+
+       _, records, err := tbl.NewIncrementalChangelogScan(
+               table.WithSelectedFields("id"),
+               table.WithLimit(2),
+       ).ToArrowRecords(ctx)
+       require.NoError(t, err)
+       got := collectChangelogRows(t, records)
+       require.Equal(t, []changelogRow{
+               {id: 1, op: string(table.ChangelogOpInsert), ordinal: 0},
+               {id: 2, op: string(table.ChangelogOpInsert), ordinal: 0},
+       }, clearChangelogSnapshot(got))
+}
+
+func TestIncrementalChangelogReadReleasesCheckedAllocator(t *testing.T) {
+       tbl := newV3RowLineageTestTable(t)
+       arrowSchema := arrow.NewSchema([]arrow.Field{
+               {Name: "id", Type: arrow.PrimitiveTypes.Int64, Nullable: false},
+               {Name: "data", Type: arrow.BinaryTypes.String, Nullable: true},
+       }, nil)
+       data, err := array.TableFromJSON(memory.DefaultAllocator, arrowSchema, 
[]string{
+               `[{"id": 1, "data": "a"}, {"id": 2, "data": "b"}, {"id": 3, 
"data": "c"}]`,
+       })
+       require.NoError(t, err)
+       t.Cleanup(data.Release)
+       tbl, err = tbl.Append(t.Context(), array.NewTableReader(data, -1), nil)
+       require.NoError(t, err)
+
+       read := func(t *testing.T, stopEarly bool) {
+               t.Helper()
+
+               mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+               ctx := compute.WithAllocator(t.Context(), mem)
+               defer mem.AssertSize(t, 0)
+
+               _, records, err := tbl.NewIncrementalChangelogScan(
+                       table.WithSelectedFields("id"),
+               ).ToArrowRecords(ctx)
+               require.NoError(t, err)
+               for rec, err := range records {
+                       require.NoError(t, err)
+                       rec.Release()
+                       if stopEarly {
+                               break
+                       }
+               }
+       }
+
+       t.Run("full drain", func(t *testing.T) { read(t, false) })
+       t.Run("early break", func(t *testing.T) { read(t, true) })
+}
+
+func TestIncrementalChangelogReadAppliesFileDeletes(t *testing.T) {
+       ctx := t.Context()
+       tbl := newV2RowLineageTestTable(t)
+       arrowSchema, err := 
table.SchemaToArrowSchema(tbl.Metadata().CurrentSchema(), nil, true, false)
+       require.NoError(t, err)
+
+       dataPath := tbl.Location() + "/data/data-001.parquet"
+       writeParquetFile(t, dataPath, arrowSchema, `[
+               {"id": 1, "data": "a"},
+               {"id": 2, "data": "b"},
+               {"id": 3, "data": "c"}
+       ]`)
+       posPath := tbl.Location() + "/data/pos-del.parquet"
+       writeParquetFile(t, posPath, table.PositionalDeleteArrowSchema, 
fmt.Sprintf(`[{"file_path": %q, "pos": 1}]`, dataPath))
+
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.NewRowDelta(nil).
+               AddRows(changelogDataFile(t, dataPath, 3)).
+               AddDeletes(changelogPosDeleteFile(t, posPath)).
+               Commit(ctx))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+
+       replacementSchema := arrow.NewSchema([]arrow.Field{
+               {Name: "id", Type: arrow.PrimitiveTypes.Int64, Nullable: false},
+               {Name: "data", Type: arrow.BinaryTypes.String, Nullable: true},
+       }, nil)
+       replacement, err := array.TableFromJSON(memory.DefaultAllocator, 
replacementSchema, []string{
+               `[{"id": 4, "data": "d"}]`,
+       })
+       require.NoError(t, err)
+       t.Cleanup(replacement.Release)
+       tbl, err = tbl.Overwrite(ctx, array.NewTableReader(replacement, -1), 
nil, table.WithOverwriteConcurrency(1))
+       require.NoError(t, err)
+
+       _, records, err := tbl.NewIncrementalChangelogScan().ToArrowRecords(ctx)
+       require.NoError(t, err)
+       got := collectChangelogRows(t, records)
+       var inserted, removed, replaced []int64
+       for _, row := range got {
+               switch {
+               case row.op == string(table.ChangelogOpInsert) && row.ordinal 
== 0:
+                       inserted = append(inserted, row.id)
+               case row.op == string(table.ChangelogOpDelete) && row.ordinal 
== 1:
+                       removed = append(removed, row.id)
+               case row.op == string(table.ChangelogOpInsert) && row.ordinal 
== 1:
+                       replaced = append(replaced, row.id)
+               }
+       }
+       require.Equal(t, []int64{1, 3}, inserted)
+       require.Equal(t, []int64{1, 3}, removed)
+       require.Equal(t, []int64{4}, replaced)
+}
+
+func changelogOverwriteTable(t *testing.T, ctx context.Context) *table.Table {
+       t.Helper()
+
+       mem := memory.DefaultAllocator
+       tbl := newV3RowLineageTestTable(t)
+       arrowSchema := arrow.NewSchema([]arrow.Field{
+               {Name: "id", Type: arrow.PrimitiveTypes.Int64, Nullable: false},
+               {Name: "data", Type: arrow.BinaryTypes.String, Nullable: true},
+       }, nil)
+
+       initial, err := array.TableFromJSON(mem, arrowSchema, []string{
+               `[{"id": 1, "data": "a"}]`,
+       })
+       require.NoError(t, err)
+       t.Cleanup(initial.Release)
+       tbl, err = tbl.Append(ctx, array.NewTableReader(initial, -1), nil)
+       require.NoError(t, err)
+
+       replacement, err := array.TableFromJSON(mem, arrowSchema, []string{
+               `[{"id": 2, "data": "b"}]`,
+       })
+       require.NoError(t, err)
+       t.Cleanup(replacement.Release)
+       tbl, err = tbl.Overwrite(ctx, array.NewTableReader(replacement, -1), 
nil, table.WithOverwriteConcurrency(1))
+       require.NoError(t, err)
+
+       return tbl
+}
+
+type changelogRow struct {
+       id       int64
+       data     string
+       op       string
+       ordinal  int64
+       snapshot int64
+}
+
+func collectChangelogRows(t *testing.T, records iter.Seq2[arrow.RecordBatch, 
error]) []changelogRow {
+       t.Helper()
+
+       var got []changelogRow
+       for rec, err := range records {
+               require.NoError(t, err)
+
+               got = append(got, changelogRowsFromBatch(rec)...)
+               rec.Release()
+       }
+
+       return got
+}
+
+func changelogRowsFromBatch(rec arrow.RecordBatch) []changelogRow {
+       ids := rec.Column(0).(*array.Int64)
+       opIdx := 1
+       var data *array.String
+       if rec.NumCols() == 5 {
+               data = rec.Column(1).(*array.String)
+               opIdx = 2
+       }
+       ops := rec.Column(opIdx).(*array.String)
+       ordinals := rec.Column(opIdx + 1).(*array.Int32)
+       snapshots := rec.Column(opIdx + 2).(*array.Int64)
+
+       rows := make([]changelogRow, 0, rec.NumRows())
+       for i := range int(rec.NumRows()) {
+               row := changelogRow{
+                       id:       ids.Value(i),
+                       op:       ops.Value(i),
+                       ordinal:  int64(ordinals.Value(i)),
+                       snapshot: snapshots.Value(i),
+               }
+               if data != nil && !data.IsNull(i) {
+                       row.data = data.Value(i)
+               }
+               rows = append(rows, row)
+       }
+
+       return rows
+}
+
+func schemaFieldNames(schema *arrow.Schema) []string {
+       names := make([]string, schema.NumFields())
+       for i := range names {
+               names[i] = schema.Field(i).Name
+       }
+
+       return names
+}
+
+func TestIncrementalChangelogReadPositionDeletes(t *testing.T) {
+       ctx := t.Context()
+       tbl := appendChangelogRows(t, ctx, newChangelogMORTable(t, 2))
+       tbl = deleteChangelogID(t, ctx, tbl, 2)
+       firstDelete := tbl.CurrentSnapshot().SnapshotID
+       tbl = deleteChangelogID(t, ctx, tbl, 3)
+
+       got := readChangelog(t, ctx, tbl.NewIncrementalChangelogScan())
+       require.Equal(t, []int64{1, 2, 3}, changelogIDs(got, 
table.ChangelogOpInsert))
+       require.Equal(t, []int64{2, 3}, changelogIDs(got, 
table.ChangelogOpDelete))
+
+       later := readChangelog(t, ctx, 
tbl.NewIncrementalChangelogScan().FromSnapshotExclusive(firstDelete))
+       require.Equal(t, []int64{3}, changelogIDs(later, 
table.ChangelogOpDelete))
+       require.Empty(t, changelogIDs(later, table.ChangelogOpInsert))
+
+       filtered := readChangelog(t, ctx, tbl.NewIncrementalChangelogScan(
+               table.WithRowFilter(iceberg.EqualTo(iceberg.Reference("id"), 
int64(2))),
+       ))
+       require.Equal(t, []int64{2}, changelogIDs(filtered, 
table.ChangelogOpInsert))
+       require.Equal(t, []int64{2}, changelogIDs(filtered, 
table.ChangelogOpDelete))
+}
+
+func TestIncrementalChangelogReadDeletionVectors(t *testing.T) {
+       ctx := t.Context()
+       tbl := appendChangelogRows(t, ctx, newChangelogMORTable(t, 3))
+       tbl = deleteChangelogID(t, ctx, tbl, 2)
+
+       got := readChangelog(t, ctx, tbl.NewIncrementalChangelogScan())
+       require.Equal(t, []int64{1, 2, 3}, changelogIDs(got, 
table.ChangelogOpInsert))
+       require.Equal(t, []int64{2}, changelogIDs(got, table.ChangelogOpDelete))
+       require.Equal(t, "b", changelogData(got, table.ChangelogOpDelete)[0])
+}
+
+func TestIncrementalChangelogReadEqualityDeletes(t *testing.T) {

Review Comment:
   The equality-delete read now also projects only `data`. The delete is still 
on `id`, and the deleted row comes back as `b`.



##########
table/changelog_reader_external_test.go:
##########
@@ -0,0 +1,769 @@
+// 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"
+       "fmt"
+       "iter"
+       "os"
+       "path/filepath"
+       "strconv"
+       "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"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/apache/iceberg-go/table"
+       "github.com/stretchr/testify/require"
+)
+
+func TestIncrementalChangelogReadEmptyTable(t *testing.T) {
+       schema, records, err := 
newV2RowLineageTestTable(t).NewIncrementalChangelogScan().ToArrowRecords(t.Context())
+       require.NoError(t, err)
+       require.Equal(t, []string{
+               "id", "data",
+               table.ChangelogChangeTypeColumn,
+               table.ChangelogChangeOrdinalColumn,
+               table.ChangelogCommitSnapshotIDColumn,
+       }, schemaFieldNames(schema))
+
+       require.Empty(t, collectChangelogRows(t, records))
+}
+
+func TestIncrementalChangelogReadFileChanges(t *testing.T) {
+       ctx := t.Context()
+       tbl := changelogOverwriteTable(t, ctx)
+
+       tasks, err := tbl.NewIncrementalChangelogScan().PlanFiles(ctx)
+       require.NoError(t, err)
+       require.Len(t, tasks, 3)
+
+       schema, records, err := tbl.NewIncrementalChangelogScan().Read(ctx, 
tasks)
+       require.NoError(t, err)
+       require.Equal(t, []string{
+               "id", "data",
+               table.ChangelogChangeTypeColumn,
+               table.ChangelogChangeOrdinalColumn,
+               table.ChangelogCommitSnapshotIDColumn,
+       }, schemaFieldNames(schema))
+
+       got := collectChangelogRows(t, records)
+       require.Equal(t, []changelogRow{
+               {id: 1, data: "a", op: string(table.ChangelogOpInsert), 
ordinal: int64(tasks[0].ChangeOrdinal()), snapshot: 
tasks[0].CommitSnapshotID()},
+               {id: 1, data: "a", op: string(table.ChangelogOpDelete), 
ordinal: int64(tasks[1].ChangeOrdinal()), snapshot: 
tasks[1].CommitSnapshotID()},
+               {id: 2, data: "b", op: string(table.ChangelogOpInsert), 
ordinal: int64(tasks[2].ChangeOrdinal()), snapshot: 
tasks[2].CommitSnapshotID()},
+       }, got)
+       require.NotEqual(t, tasks[0].CommitSnapshotID(), 
tasks[1].CommitSnapshotID())
+       require.Equal(t, tasks[1].CommitSnapshotID(), 
tasks[2].CommitSnapshotID())
+       require.Equal(t, 0, tasks[0].ChangeOrdinal())
+       require.Equal(t, 1, tasks[1].ChangeOrdinal())
+       require.Equal(t, 1, tasks[2].ChangeOrdinal())
+}
+
+func TestIncrementalChangelogReadHonorsProjectionFilterAndLimit(t *testing.T) {
+       ctx := t.Context()
+       tbl := changelogOverwriteTable(t, ctx)
+
+       schema, records, err := tbl.NewIncrementalChangelogScan(
+               table.WithSelectedFields("id"),
+               table.WithRowFilter(iceberg.EqualTo(iceberg.Reference("id"), 
int64(2))),
+               table.WithLimit(1),
+       ).ToArrowRecords(ctx)
+       require.NoError(t, err)
+       require.Equal(t, []string{
+               "id",
+               table.ChangelogChangeTypeColumn,
+               table.ChangelogChangeOrdinalColumn,
+               table.ChangelogCommitSnapshotIDColumn,
+       }, schemaFieldNames(schema))
+
+       got := collectChangelogRows(t, records)
+       require.Equal(t, []changelogRow{
+               {id: 2, op: string(table.ChangelogOpInsert), ordinal: 1},
+       }, clearChangelogSnapshot(got))
+}
+
+func TestIncrementalChangelogReadLimitCutsABatch(t *testing.T) {
+       ctx := t.Context()
+       tbl := newV3RowLineageTestTable(t)
+       arrowSchema := arrow.NewSchema([]arrow.Field{
+               {Name: "id", Type: arrow.PrimitiveTypes.Int64, Nullable: false},
+               {Name: "data", Type: arrow.BinaryTypes.String, Nullable: true},
+       }, nil)
+       data, err := array.TableFromJSON(memory.DefaultAllocator, arrowSchema, 
[]string{
+               `[{"id": 1, "data": "a"}, {"id": 2, "data": "b"}, {"id": 3, 
"data": "c"}]`,
+       })
+       require.NoError(t, err)
+       t.Cleanup(data.Release)
+       tbl, err = tbl.Append(ctx, array.NewTableReader(data, -1), nil)
+       require.NoError(t, err)
+
+       _, records, err := tbl.NewIncrementalChangelogScan(
+               table.WithSelectedFields("id"),
+               table.WithLimit(2),
+       ).ToArrowRecords(ctx)
+       require.NoError(t, err)
+       got := collectChangelogRows(t, records)
+       require.Equal(t, []changelogRow{
+               {id: 1, op: string(table.ChangelogOpInsert), ordinal: 0},
+               {id: 2, op: string(table.ChangelogOpInsert), ordinal: 0},
+       }, clearChangelogSnapshot(got))
+}
+
+func TestIncrementalChangelogReadReleasesCheckedAllocator(t *testing.T) {
+       tbl := newV3RowLineageTestTable(t)
+       arrowSchema := arrow.NewSchema([]arrow.Field{
+               {Name: "id", Type: arrow.PrimitiveTypes.Int64, Nullable: false},
+               {Name: "data", Type: arrow.BinaryTypes.String, Nullable: true},
+       }, nil)
+       data, err := array.TableFromJSON(memory.DefaultAllocator, arrowSchema, 
[]string{
+               `[{"id": 1, "data": "a"}, {"id": 2, "data": "b"}, {"id": 3, 
"data": "c"}]`,
+       })
+       require.NoError(t, err)
+       t.Cleanup(data.Release)
+       tbl, err = tbl.Append(t.Context(), array.NewTableReader(data, -1), nil)
+       require.NoError(t, err)
+
+       read := func(t *testing.T, stopEarly bool) {
+               t.Helper()
+
+               mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+               ctx := compute.WithAllocator(t.Context(), mem)
+               defer mem.AssertSize(t, 0)
+
+               _, records, err := tbl.NewIncrementalChangelogScan(
+                       table.WithSelectedFields("id"),
+               ).ToArrowRecords(ctx)
+               require.NoError(t, err)
+               for rec, err := range records {
+                       require.NoError(t, err)
+                       rec.Release()
+                       if stopEarly {
+                               break
+                       }
+               }
+       }
+
+       t.Run("full drain", func(t *testing.T) { read(t, false) })
+       t.Run("early break", func(t *testing.T) { read(t, true) })
+}
+
+func TestIncrementalChangelogReadAppliesFileDeletes(t *testing.T) {
+       ctx := t.Context()
+       tbl := newV2RowLineageTestTable(t)
+       arrowSchema, err := 
table.SchemaToArrowSchema(tbl.Metadata().CurrentSchema(), nil, true, false)
+       require.NoError(t, err)
+
+       dataPath := tbl.Location() + "/data/data-001.parquet"
+       writeParquetFile(t, dataPath, arrowSchema, `[
+               {"id": 1, "data": "a"},
+               {"id": 2, "data": "b"},
+               {"id": 3, "data": "c"}
+       ]`)
+       posPath := tbl.Location() + "/data/pos-del.parquet"
+       writeParquetFile(t, posPath, table.PositionalDeleteArrowSchema, 
fmt.Sprintf(`[{"file_path": %q, "pos": 1}]`, dataPath))
+
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.NewRowDelta(nil).
+               AddRows(changelogDataFile(t, dataPath, 3)).
+               AddDeletes(changelogPosDeleteFile(t, posPath)).
+               Commit(ctx))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+
+       replacementSchema := arrow.NewSchema([]arrow.Field{
+               {Name: "id", Type: arrow.PrimitiveTypes.Int64, Nullable: false},
+               {Name: "data", Type: arrow.BinaryTypes.String, Nullable: true},
+       }, nil)
+       replacement, err := array.TableFromJSON(memory.DefaultAllocator, 
replacementSchema, []string{
+               `[{"id": 4, "data": "d"}]`,
+       })
+       require.NoError(t, err)
+       t.Cleanup(replacement.Release)
+       tbl, err = tbl.Overwrite(ctx, array.NewTableReader(replacement, -1), 
nil, table.WithOverwriteConcurrency(1))
+       require.NoError(t, err)
+
+       _, records, err := tbl.NewIncrementalChangelogScan().ToArrowRecords(ctx)
+       require.NoError(t, err)
+       got := collectChangelogRows(t, records)
+       var inserted, removed, replaced []int64
+       for _, row := range got {
+               switch {
+               case row.op == string(table.ChangelogOpInsert) && row.ordinal 
== 0:
+                       inserted = append(inserted, row.id)
+               case row.op == string(table.ChangelogOpDelete) && row.ordinal 
== 1:
+                       removed = append(removed, row.id)
+               case row.op == string(table.ChangelogOpInsert) && row.ordinal 
== 1:
+                       replaced = append(replaced, row.id)
+               }
+       }
+       require.Equal(t, []int64{1, 3}, inserted)
+       require.Equal(t, []int64{1, 3}, removed)
+       require.Equal(t, []int64{4}, replaced)
+}
+
+func changelogOverwriteTable(t *testing.T, ctx context.Context) *table.Table {
+       t.Helper()
+
+       mem := memory.DefaultAllocator
+       tbl := newV3RowLineageTestTable(t)
+       arrowSchema := arrow.NewSchema([]arrow.Field{
+               {Name: "id", Type: arrow.PrimitiveTypes.Int64, Nullable: false},
+               {Name: "data", Type: arrow.BinaryTypes.String, Nullable: true},
+       }, nil)
+
+       initial, err := array.TableFromJSON(mem, arrowSchema, []string{
+               `[{"id": 1, "data": "a"}]`,
+       })
+       require.NoError(t, err)
+       t.Cleanup(initial.Release)
+       tbl, err = tbl.Append(ctx, array.NewTableReader(initial, -1), nil)
+       require.NoError(t, err)
+
+       replacement, err := array.TableFromJSON(mem, arrowSchema, []string{
+               `[{"id": 2, "data": "b"}]`,
+       })
+       require.NoError(t, err)
+       t.Cleanup(replacement.Release)
+       tbl, err = tbl.Overwrite(ctx, array.NewTableReader(replacement, -1), 
nil, table.WithOverwriteConcurrency(1))
+       require.NoError(t, err)
+
+       return tbl
+}
+
+type changelogRow struct {
+       id       int64
+       data     string
+       op       string
+       ordinal  int64
+       snapshot int64
+}
+
+func collectChangelogRows(t *testing.T, records iter.Seq2[arrow.RecordBatch, 
error]) []changelogRow {
+       t.Helper()
+
+       var got []changelogRow
+       for rec, err := range records {
+               require.NoError(t, err)
+
+               got = append(got, changelogRowsFromBatch(rec)...)
+               rec.Release()
+       }
+
+       return got
+}
+
+func changelogRowsFromBatch(rec arrow.RecordBatch) []changelogRow {
+       ids := rec.Column(0).(*array.Int64)
+       opIdx := 1
+       var data *array.String
+       if rec.NumCols() == 5 {
+               data = rec.Column(1).(*array.String)
+               opIdx = 2
+       }
+       ops := rec.Column(opIdx).(*array.String)
+       ordinals := rec.Column(opIdx + 1).(*array.Int32)
+       snapshots := rec.Column(opIdx + 2).(*array.Int64)
+
+       rows := make([]changelogRow, 0, rec.NumRows())
+       for i := range int(rec.NumRows()) {
+               row := changelogRow{
+                       id:       ids.Value(i),
+                       op:       ops.Value(i),
+                       ordinal:  int64(ordinals.Value(i)),
+                       snapshot: snapshots.Value(i),
+               }
+               if data != nil && !data.IsNull(i) {
+                       row.data = data.Value(i)
+               }
+               rows = append(rows, row)
+       }
+
+       return rows
+}
+
+func schemaFieldNames(schema *arrow.Schema) []string {
+       names := make([]string, schema.NumFields())
+       for i := range names {
+               names[i] = schema.Field(i).Name
+       }
+
+       return names
+}
+
+func TestIncrementalChangelogReadPositionDeletes(t *testing.T) {
+       ctx := t.Context()
+       tbl := appendChangelogRows(t, ctx, newChangelogMORTable(t, 2))
+       tbl = deleteChangelogID(t, ctx, tbl, 2)
+       firstDelete := tbl.CurrentSnapshot().SnapshotID
+       tbl = deleteChangelogID(t, ctx, tbl, 3)
+
+       got := readChangelog(t, ctx, tbl.NewIncrementalChangelogScan())
+       require.Equal(t, []int64{1, 2, 3}, changelogIDs(got, 
table.ChangelogOpInsert))
+       require.Equal(t, []int64{2, 3}, changelogIDs(got, 
table.ChangelogOpDelete))
+
+       later := readChangelog(t, ctx, 
tbl.NewIncrementalChangelogScan().FromSnapshotExclusive(firstDelete))
+       require.Equal(t, []int64{3}, changelogIDs(later, 
table.ChangelogOpDelete))
+       require.Empty(t, changelogIDs(later, table.ChangelogOpInsert))
+
+       filtered := readChangelog(t, ctx, tbl.NewIncrementalChangelogScan(
+               table.WithRowFilter(iceberg.EqualTo(iceberg.Reference("id"), 
int64(2))),
+       ))
+       require.Equal(t, []int64{2}, changelogIDs(filtered, 
table.ChangelogOpInsert))
+       require.Equal(t, []int64{2}, changelogIDs(filtered, 
table.ChangelogOpDelete))
+}
+
+func TestIncrementalChangelogReadDeletionVectors(t *testing.T) {
+       ctx := t.Context()
+       tbl := appendChangelogRows(t, ctx, newChangelogMORTable(t, 3))
+       tbl = deleteChangelogID(t, ctx, tbl, 2)
+
+       got := readChangelog(t, ctx, tbl.NewIncrementalChangelogScan())
+       require.Equal(t, []int64{1, 2, 3}, changelogIDs(got, 
table.ChangelogOpInsert))
+       require.Equal(t, []int64{2}, changelogIDs(got, table.ChangelogOpDelete))
+       require.Equal(t, "b", changelogData(got, table.ChangelogOpDelete)[0])
+}
+
+func TestIncrementalChangelogReadEqualityDeletes(t *testing.T) {
+       ctx := t.Context()
+       tbl := newChangelogMORTable(t, 2)
+       arrowSchema, err := 
table.SchemaToArrowSchema(tbl.Metadata().CurrentSchema(), nil, false, false)
+       require.NoError(t, err)
+
+       dataPath := tbl.Location() + "/data/data-001.parquet"
+       writeParquetFile(t, dataPath, arrowSchema, `[
+               {"id": 1, "data": "a"},
+               {"id": 2, "data": "b"},
+               {"id": 3, "data": "c"}
+       ]`)
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.AddFiles(ctx, []string{dataPath}, nil, false))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+
+       eqPath := tbl.Location() + "/data/eq-del.parquet"
+       eqSchema, err := table.SchemaToArrowSchema(
+               iceberg.NewSchema(0, iceberg.NestedField{ID: 1, Name: "id", 
Type: iceberg.PrimitiveTypes.Int64, Required: true}),
+               nil, true, false)
+       require.NoError(t, err)
+       writeParquetFile(t, eqPath, eqSchema, `[{"id": 2}]`)
+
+       eqBuilder, err := iceberg.NewDataFileBuilder(
+               *iceberg.UnpartitionedSpec, iceberg.EntryContentEqDeletes,
+               eqPath, iceberg.ParquetFile, nil, nil, nil, 1, 128)
+       require.NoError(t, err)
+       eqBuilder.EqualityFieldIDs([]int{1})
+
+       tx = tbl.NewTransaction()
+       rd := tx.NewRowDelta(nil)
+       rd.AddDeletes(eqBuilder.Build())
+       require.NoError(t, rd.Commit(ctx))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+
+       got := readChangelog(t, ctx, tbl.NewIncrementalChangelogScan())
+       require.Equal(t, []int64{1, 2, 3}, changelogIDs(got, 
table.ChangelogOpInsert))
+       require.Equal(t, []int64{2}, changelogIDs(got, table.ChangelogOpDelete))
+}
+
+func TestIncrementalChangelogReadSameSnapshotPositionDelete(t *testing.T) {
+       ctx := t.Context()
+       tbl := newChangelogMORTable(t, 2)
+       arrowSchema, err := 
table.SchemaToArrowSchema(tbl.Metadata().CurrentSchema(), nil, true, false)
+       require.NoError(t, err)
+
+       dataPath := tbl.Location() + "/data/data-001.parquet"
+       writeParquetFile(t, dataPath, arrowSchema, `[
+               {"id": 1, "data": "a"},
+               {"id": 2, "data": "b"},
+               {"id": 3, "data": "c"}
+       ]`)
+       posPath := tbl.Location() + "/data/pos-del.parquet"
+       writeParquetFile(t, posPath, table.PositionalDeleteArrowSchema, 
fmt.Sprintf(`[{"file_path": %q, "pos": 1}]`, dataPath))
+
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.NewRowDelta(nil).AddRows(changelogDataFile(t, 
dataPath, 3)).AddDeletes(changelogPosDeleteFile(t, posPath)).Commit(ctx))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+
+       got := readChangelog(t, ctx, tbl.NewIncrementalChangelogScan())
+       require.Equal(t, []int64{1, 3}, changelogIDs(got, 
table.ChangelogOpInsert))
+       require.Empty(t, changelogIDs(got, table.ChangelogOpDelete))
+}
+
+func TestIncrementalChangelogReadReplacedDeletionVector(t *testing.T) {
+       ctx := t.Context()
+       tbl := appendChangelogRows(t, ctx, newChangelogMORTable(t, 3))
+       tbl = deleteChangelogID(t, ctx, tbl, 2)
+       firstDelete := tbl.CurrentSnapshot().SnapshotID
+       tbl = deleteChangelogID(t, ctx, tbl, 3)
+
+       got := readChangelog(t, ctx, tbl.NewIncrementalChangelogScan())
+       require.Equal(t, []int64{1, 2, 3}, changelogIDs(got, 
table.ChangelogOpInsert))
+       require.Equal(t, []int64{2, 3}, changelogIDs(got, 
table.ChangelogOpDelete))
+
+       later := readChangelog(t, ctx, 
tbl.NewIncrementalChangelogScan().FromSnapshotExclusive(firstDelete))
+       require.Empty(t, changelogIDs(later, table.ChangelogOpInsert))
+       require.Equal(t, []int64{3}, changelogIDs(later, 
table.ChangelogOpDelete))
+}
+
+func TestIncrementalChangelogReadReplacedSharedPuffinDeletionVector(t 
*testing.T) {
+       ctx := t.Context()
+       tbl, location, pathA, pathB, dvA, dvB := newTableWithSharedPuffinDVs(t)
+       shared := tbl.CurrentSnapshot().SnapshotID
+
+       dvA2 := writeDV(t, location, "dv-a2.puffin", pathA, []int64{0, 1})
+       tx := tbl.NewTransaction()
+       require.NoError(t, 
tx.NewRowDelta(nil).AddDeletes(dvA2).RemoveDeletes(dvA).Commit(ctx))
+       tbl, err := tx.Commit(ctx)
+       require.NoError(t, err)
+
+       dvB2 := writeDV(t, location, "dv-b2.puffin", pathB, []int64{0, 1})
+       tx = tbl.NewTransaction()
+       require.NoError(t, 
tx.NewRowDelta(nil).AddDeletes(dvB2).RemoveDeletes(dvB).Commit(ctx))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+
+       later := readChangelog(t, ctx, 
tbl.NewIncrementalChangelogScan().FromSnapshotExclusive(shared))
+       require.ElementsMatch(t, []int64{2, 4}, changelogIDs(later, 
table.ChangelogOpDelete))
+}
+
+func TestIncrementalChangelogReadExpiredParentKeepsPreRangeDelete(t 
*testing.T) {

Review Comment:
   Added that row-delete case for v2 and v3. After the row-1 snapshot is 
expired, the exclusive read emits only row 2 as DELETE. On v3 the second delete 
replaces the deletion vector.



##########
website/src/api.md:
##########
@@ -297,6 +297,26 @@ if err != nil { /* ... */ }
 arrowSchema, batches, err := scan.ReadTasks(ctx, tasks)
 ```
 
+### Incremental changelog scans
+
+`NewIncrementalChangelogScan` returns rows added or deleted between snapshots. 
`PlanFiles` returns the tasks. `Read` reads a planned task list, and 
`ToArrowRecords` plans and reads. Remote planning is not supported.
+
+```go
+schema, batches, err := tbl.NewIncrementalChangelogScan(
+    table.WithSelectedFields("id", "name"),
+).FromSnapshotExclusive(startSnapshotID).ToArrowRecords(ctx)
+```
+
+`FromSnapshotInclusive`, `FromSnapshotExclusive`, and `ToSnapshot` bound the 
snapshot range. Projection, row filters, and limits apply while the rows are 
read.
+
+Each batch is the scan projection followed by three columns:
+
+- `_change_type` — `INSERT` or `DELETE`
+- `_change_ordinal` — int32 ordinal of the snapshot that produced the change
+- `_commit_snapshot_id` — snapshot ID that committed the change
+
+Added data files are inserts. Removed data files are deletes of the rows in 
that file. Delete files added in the range emit the rows they newly remove. A 
rewritten file emits a DELETE and an INSERT for every row in that file, 
including rows that did not change. `UPDATE` rows are not produced. Delete-file 
handling is iceberg-go behavior.

Review Comment:
   The incremental changelog section now names both limits. If the start 
snapshot has been expired, changes in expired snapshots before the oldest 
retained one are not reported. Each snapshot that adds deletes is planned 
against every live data file the partition filter keeps, so the cost follows 
the table size.



-- 
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