laskoviymishka commented on code in PR #2171:
URL: https://github.com/apache/iceberg-go/pull/2171#discussion_r4326433890


##########
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:
   Fine as a follow-up: I'd add a delete-backed case to both subtests here, a 
position delete and a DV are enough. As written it only drains an append-only 
table, so every task is an `AddedRowsScanTask` and none of 
`stampChangelogFilePos`, `keepAddedDeleteRows` or `dropChangelogFilePos` run 
under the checked allocator. Those are the paths in this PR that build and 
filter batches.



##########
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:
   Another one for a follow-up: a sibling that reads with 
`WithSelectedFields("data")` while the equality delete stays on `id`. Right now 
`id` is always projected, so nothing pins that `addFieldIDs` in 
`prepareAddedDeleteLoaders` still pulls the column into the read when the 
caller doesn't select it.



##########
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:
   nit: "Delete-file handling is iceberg-go behavior" could name the two limits 
a caller actually hits. If the start snapshot has been expired, changes in 
expired snapshots before the oldest retained one aren't reported. And each 
snapshot that adds deletes is planned against every live data file (only the 
partition filter narrows it), so cost scales with table size rather than range 
size.



##########
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:
   Worth a follow-up: the row-delete variant next to this one. Append, delete 
row 1, delete row 2, expire the row-1 snapshot, then read from it exclusive and 
expect only row 2 as DELETE. This test reaches row 1 through the overwrite's 
`DeletedDataFileScanTask`, so a pre-range and an in-range delete on the same 
still-live file (the `DeletedRowsScanTask.existing` path) isn't pinned yet. A 
v3 run of the same shape for DVs would be nice if it's cheap.



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