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]
