This is an automated email from the ASF dual-hosted git repository.
laskoviymishka pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push:
new a4d6a0949 feat(table): read file-level incremental changelog rows
(#2170)
a4d6a0949 is described below
commit a4d6a0949a3ddc1f1b6f2cc7bec5c479cedb012e
Author: Digvijay <[email protected]>
AuthorDate: Fri Oct 30 17:29:44 2026 -0500
feat(table): read file-level incremental changelog rows (#2170)
---
table/changelog_reader.go | 323 +++++++++++++++++++++
table/changelog_reader_external_test.go | 498 ++++++++++++++++++++++++++++++++
table/changelog_reader_test.go | 88 ++++++
3 files changed, 909 insertions(+)
diff --git a/table/changelog_reader.go b/table/changelog_reader.go
new file mode 100644
index 000000000..79f399096
--- /dev/null
+++ b/table/changelog_reader.go
@@ -0,0 +1,323 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table
+
+import (
+ "context"
+ "fmt"
+ "iter"
+ "maps"
+ "strconv"
+ "sync/atomic"
+
+ "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/arrow-go/v18/arrow/scalar"
+ "github.com/apache/iceberg-go"
+)
+
+const (
+ // ChangelogChangeTypeColumn is INSERT or DELETE. A copy-on-write
overwrite
+ // emits both for rows that did not change. UPDATE_BEFORE and
UPDATE_AFTER
+ // are not produced.
+ ChangelogChangeTypeColumn = "_change_type"
+ // ChangelogChangeOrdinalColumn is the zero-based ordinal of the
snapshot
+ // that produced the change, shared by every row from that snapshot. The
+ // Arrow type is int32, matching Java MetadataColumns.CHANGE_ORDINAL.
+ ChangelogChangeOrdinalColumn = "_change_ordinal"
+ // ChangelogCommitSnapshotIDColumn is the snapshot ID that committed
the change.
+ ChangelogCommitSnapshotIDColumn = "_commit_snapshot_id"
+)
+
+// ToArrowRecords plans the changelog and reads it. The result is a raw
+// file-level changelog: a copy-on-write overwrite emits a DELETE and an
+// INSERT for every row in the rewritten files, including rows that did not
+// change, and UPDATE_BEFORE and UPDATE_AFTER are not produced. Added and
+// removed data files are read with the deletes already stored on their scan
+// task. A plan that includes a row-level delete returns an error instead of
+// dropping those rows.
+func (s *IncrementalChangelogScan) ToArrowRecords(ctx context.Context)
(*arrow.Schema, iter.Seq2[arrow.RecordBatch, error], error) {
+ tasks, err := s.PlanFiles(ctx)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ return s.Read(ctx, tasks)
+}
+
+// Read materializes changelog rows for tasks. Each batch has the scan
+// projection followed by _change_type, _change_ordinal, and
+// _commit_snapshot_id. This is a raw file-level changelog: unchanged rows in
+// a rewritten file are emitted as DELETE and INSERT, and UPDATE_BEFORE and
+// UPDATE_AFTER are not produced. AddedRowsScanTask and DeletedDataFileScanTask
+// are read with Scan.ReadTasks, so same-snapshot deletes and deletes that
+// already applied to a removed file are applied before the changelog columns
+// are added. DeletedRowsScanTask returns an error; those rows are produced by
+// a later reader. Files are read one at a time, in task order, so the rows
+// stay in that order. WithMaxConcurrency does not read changelog files in
+// parallel. The returned iterator is single-use. The caller releases every
+// batch.
+func (s *IncrementalChangelogScan) Read(ctx context.Context, tasks
[]ChangelogScanTask) (*arrow.Schema, iter.Seq2[arrow.RecordBatch, error],
error) {
+ if s == nil || s.scan == nil {
+ return nil, nil, fmt.Errorf("%w: incremental changelog scan is
not initialized", ErrInvalidOperation)
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, nil, err
+ }
+ for _, task := range tasks {
+ if err := readableChangelogTask(task); err != nil {
+ return nil, nil, err
+ }
+ }
+
+ schema, useLargeTypes, err := changelogArrowSchema(s.scan)
+ if err != nil {
+ return nil, nil, err
+ }
+ if s.scan.limit == 0 || len(tasks) == 0 {
+ return schema, func(yield func(arrow.RecordBatch, error) bool)
{}, nil
+ }
+
+ readScan := s.scan.clone()
+ limit := s.scan.limit
+
+ return schema, func(yield func(arrow.RecordBatch, error) bool) {
+ var emitted int64
+ for _, task := range tasks {
+ if err := ctx.Err(); err != nil {
+ yield(nil, err)
+
+ return
+ }
+ if limit > 0 {
+ // Safe because tasks are read sequentially on
one clone.
+ readScan.limit = limit - emitted
+ }
+
+ _, records, err := readScan.ReadTasks(ctx,
[]FileScanTask{task.ScanTask()})
+ if err != nil {
+ yield(nil, err)
+
+ return
+ }
+
+ for rec, err := range records {
+ if err != nil {
+ yield(nil, err)
+
+ return
+ }
+
+ stamped, rows, err := stampChangelogBatch(ctx,
schema, useLargeTypes, rec, task, limit, emitted)
+ rec.Release()
+ if err != nil {
+ yield(nil, err)
+
+ return
+ }
+ if stamped == nil {
+ continue
+ }
+ if !yield(stamped, nil) {
+ return
+ }
+ emitted += rows
+ if limit > 0 && emitted >= limit {
+ return
+ }
+ }
+ }
+ }, nil
+}
+
+func readableChangelogTask(task ChangelogScanTask) error {
+ switch task.(type) {
+ case AddedRowsScanTask, DeletedDataFileScanTask:
+ return nil
+ case DeletedRowsScanTask:
+ return fmt.Errorf("%w: reading deleted rows from added delete
files is not supported", iceberg.ErrNotImplemented)
+ default:
+ return fmt.Errorf("%w: unsupported changelog scan task %T",
iceberg.ErrInvalidArgument, task)
+ }
+}
+
+func changelogArrowSchema(scan *Scan) (*arrow.Schema, bool, error) {
+ if atomic.LoadUint32(&scan.closed) != 0 {
+ return nil, false, fmt.Errorf("%w: scan is closed",
ErrInvalidOperation)
+ }
+ if scan.selectorErr != nil {
+ return nil, false, scan.selectorErr
+ }
+
+ projected, err := scan.Projection()
+ if err != nil {
+ return nil, false, err
+ }
+
+ useLargeTypes, err :=
strconv.ParseBool(scan.options.Get(ScanOptionArrowUseLargeTypes, "false"))
+ if err != nil {
+ useLargeTypes = false
+ }
+
+ props := scan.metadata.Properties()
+ if scan.arrowBatchSize > 0 {
+ props = maps.Clone(props)
+ if props == nil {
+ props = iceberg.Properties{}
+ }
+ props[ParquetBatchSizeKey] = strconv.Itoa(scan.arrowBatchSize)
+ }
+
+ dataSchema, err := SchemaToArrowSchemaWithOptions(projected,
ArrowSchemaOptions{
+ UseLargeTypes: useLargeTypes,
+ TableProperties: props,
+ })
+ if err != nil {
+ return nil, false, err
+ }
+
+ if err := rejectChangelogColumnCollision(dataSchema); err != nil {
+ return nil, false, err
+ }
+
+ schema := dataSchema
+ for _, field := range changelogArrowFields(useLargeTypes) {
+ schema, err = schema.AddField(schema.NumFields(), field)
+ if err != nil {
+ return nil, false, err
+ }
+ }
+
+ return schema, useLargeTypes, nil
+}
+
+func rejectChangelogColumnCollision(schema *arrow.Schema) error {
+ for _, name := range []string{
+ ChangelogChangeTypeColumn,
+ ChangelogChangeOrdinalColumn,
+ ChangelogCommitSnapshotIDColumn,
+ } {
+ if len(schema.FieldIndices(name)) > 0 {
+ return fmt.Errorf("%w: projected column %q conflicts
with a changelog column",
+ iceberg.ErrInvalidSchema, name)
+ }
+ }
+
+ return nil
+}
+
+func changelogBatchMatches(out, data *arrow.Schema) error {
+ want := out.NumFields() - 3
+ if data.NumFields() != want {
+ return fmt.Errorf("%w: read batch has %d columns, changelog
schema expects %d",
+ iceberg.ErrInvalidSchema, data.NumFields(), want)
+ }
+ for i := range want {
+ if !data.Field(i).Equal(out.Field(i)) {
+ return fmt.Errorf("%w: read column %q does not match
the changelog schema",
+ iceberg.ErrInvalidSchema, data.Field(i).Name)
+ }
+ }
+
+ return nil
+}
+
+func changelogArrowFields(useLargeTypes bool) []arrow.Field {
+ changeType := arrow.BinaryTypes.String
+ if useLargeTypes {
+ changeType = arrow.BinaryTypes.LargeString
+ }
+
+ return []arrow.Field{
+ {Name: ChangelogChangeTypeColumn, Type: changeType, Nullable:
false},
+ {Name: ChangelogChangeOrdinalColumn, Type:
arrow.PrimitiveTypes.Int32, Nullable: false},
+ {Name: ChangelogCommitSnapshotIDColumn, Type:
arrow.PrimitiveTypes.Int64, Nullable: false},
+ }
+}
+
+// stampChangelogBatch appends the changelog columns and applies any remaining
+// row limit. A nil batch means the limit was already exhausted. rec is not
+// released here.
+func stampChangelogBatch(ctx context.Context, schema *arrow.Schema,
useLargeTypes bool, rec arrow.RecordBatch, task ChangelogScanTask, limit,
emitted int64) (arrow.RecordBatch, int64, error) {
+ if limit > 0 && emitted >= limit {
+ return nil, 0, nil
+ }
+
+ rows := rec.NumRows()
+ source := rec
+ if limit > 0 && emitted+rows > limit {
+ source = rec.NewSlice(0, limit-emitted)
+ defer source.Release()
+ rows = source.NumRows()
+ }
+ if rows == 0 {
+ return nil, 0, nil
+ }
+
+ stamped, err := appendChangelogColumns(ctx, schema, useLargeTypes,
source, task)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ return stamped, rows, nil
+}
+
+func appendChangelogColumns(ctx context.Context, schema *arrow.Schema,
useLargeTypes bool, rec arrow.RecordBatch, task ChangelogScanTask)
(arrow.RecordBatch, error) {
+ if err := changelogBatchMatches(schema, rec.Schema()); err != nil {
+ return nil, err
+ }
+
+ mem := allocatorFrom(ctx)
+ n := int(rec.NumRows())
+ var changeScalar scalar.Scalar =
scalar.NewStringScalar(string(task.Operation()))
+ if useLargeTypes {
+ changeScalar =
scalar.NewLargeStringScalar(string(task.Operation()))
+ }
+ changeType, err := scalar.MakeArrayFromScalar(changeScalar, n, mem)
+ if err != nil {
+ return nil, err
+ }
+ defer changeType.Release()
+
+ ordinal, err :=
scalar.MakeArrayFromScalar(scalar.NewInt32Scalar(int32(task.ChangeOrdinal())),
n, mem)
+ if err != nil {
+ return nil, err
+ }
+ defer ordinal.Release()
+ snapshotID, err :=
scalar.MakeArrayFromScalar(scalar.NewInt64Scalar(task.CommitSnapshotID()), n,
mem)
+ if err != nil {
+ return nil, err
+ }
+ defer snapshotID.Release()
+
+ cols := make([]arrow.Array, 0, rec.NumCols()+3)
+ cols = append(cols, rec.Columns()...)
+ cols = append(cols, changeType, ordinal, snapshotID)
+
+ return array.NewRecordBatch(schema, cols, rec.NumRows()), nil
+}
+
+func allocatorFrom(ctx context.Context) memory.Allocator {
+ if mem := compute.GetAllocator(ctx); mem != nil {
+ return mem
+ }
+
+ return memory.DefaultAllocator
+}
diff --git a/table/changelog_reader_external_test.go
b/table/changelog_reader_external_test.go
new file mode 100644
index 000000000..7c46f26e9
--- /dev/null
+++ b/table/changelog_reader_external_test.go
@@ -0,0 +1,498 @@
+// 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"
+ "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)
+ for _, rows := range []string{
+ `[{"id": 1, "data": "a"}]`,
+ `[{"id": 2, "data": "b"}, {"id": 3, "data": "c"}]`,
+ } {
+ data, err := array.TableFromJSON(memory.DefaultAllocator,
arrowSchema, []string{rows})
+ 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)
+ require.Equal(t, []int64{1, 3}, changelogIDs(got,
table.ChangelogOpInsert, 0))
+ require.Equal(t, []int64{1, 3}, changelogIDs(got,
table.ChangelogOpDelete, 1))
+ require.Equal(t, []int64{4}, changelogIDs(got, table.ChangelogOpInsert,
1))
+}
+
+func TestIncrementalChangelogReadListColumn(t *testing.T) {
+ ctx := t.Context()
+ location := filepath.ToSlash(t.TempDir())
+ schema := iceberg.NewSchema(0,
+ iceberg.NestedField{ID: 1, Name: "id", Type:
iceberg.PrimitiveTypes.Int64, Required: true},
+ iceberg.NestedField{ID: 2, Name: "tags", Type:
&iceberg.ListType{
+ ElementID: 3, Element: iceberg.PrimitiveTypes.String,
+ }},
+ )
+ meta, err := table.NewMetadata(schema, iceberg.UnpartitionedSpec,
table.UnsortedSortOrder, location,
+ iceberg.Properties{table.PropertyFormatVersion: "2"})
+ require.NoError(t, err)
+ metaLoc := location + "/metadata/v1.metadata.json"
+ fsF := func(context.Context) (iceio.IO, error) { return
iceio.LocalFS{}, nil }
+ tbl := table.New(table.Identifier{"db", "changelog_list"}, meta,
metaLoc, fsF,
+ &concurrentTestCatalog{metadata: meta, location: metaLoc, fsF:
fsF})
+
+ arrowSchema, err := table.SchemaToArrowSchema(schema, nil, false, false)
+ require.NoError(t, err)
+ data, err := array.TableFromJSON(memory.DefaultAllocator, arrowSchema,
[]string{
+ `[{"id": 1, "tags": ["red", "blue"]}]`,
+ })
+ require.NoError(t, err)
+ t.Cleanup(data.Release)
+ tbl, err = tbl.Append(ctx, array.NewTableReader(data, -1), nil)
+ require.NoError(t, err)
+
+ out, records, err :=
tbl.NewIncrementalChangelogScan().ToArrowRecords(ctx)
+ require.NoError(t, err)
+ tags, ok :=
out.Field(out.FieldIndices("tags")[0]).Type.(*arrow.ListType)
+ require.True(t, ok)
+ require.Equal(t, arrow.STRING, tags.Elem().ID())
+
+ var seen int
+ for rec, err := range records {
+ require.NoError(t, err)
+ list := rec.Column(out.FieldIndices("tags")[0]).(*array.List)
+ values := list.ListValues().(*array.String)
+ require.Equal(t, []string{"red", "blue"},
[]string{values.Value(0), values.Value(1)})
+ seen += int(rec.NumRows())
+ rec.Release()
+ }
+ require.Equal(t, 1, seen)
+}
+
+func TestIncrementalChangelogReadLargeTypes(t *testing.T) {
+ ctx := t.Context()
+ tbl := newV2RowLineageTestTable(t)
+ arrowSchema, err :=
table.SchemaToArrowSchema(tbl.Metadata().CurrentSchema(), nil, false, false)
+ require.NoError(t, err)
+ data, err := array.TableFromJSON(memory.DefaultAllocator, arrowSchema,
[]string{
+ `[{"id": 1, "data": "a"}]`,
+ })
+ require.NoError(t, err)
+ t.Cleanup(data.Release)
+ tbl, err = tbl.Append(ctx, array.NewTableReader(data, -1), nil)
+ require.NoError(t, err)
+
+ out, records, err :=
tbl.NewIncrementalChangelogScan(table.WithOptions(iceberg.Properties{
+ table.ScanOptionArrowUseLargeTypes: "true",
+ })).ToArrowRecords(ctx)
+ require.NoError(t, err)
+ require.Equal(t, arrow.LARGE_STRING,
out.Field(out.FieldIndices("data")[0]).Type.ID())
+ require.Equal(t, arrow.LARGE_STRING,
out.Field(out.FieldIndices(table.ChangelogChangeTypeColumn)[0]).Type.ID())
+
+ for rec, err := range records {
+ require.NoError(t, err)
+ require.Equal(t, "a",
rec.Column(out.FieldIndices("data")[0]).(*array.LargeString).Value(0))
+ require.Equal(t, string(table.ChangelogOpInsert),
rec.Column(out.FieldIndices(table.ChangelogChangeTypeColumn)[0]).(*array.LargeString).Value(0))
+ rec.Release()
+ }
+}
+
+func TestIncrementalChangelogReadPreRangeDeleteStaysOutOfFileDelete(t
*testing.T) {
+ ctx := t.Context()
+ tbl := newV2RowLineageTestTable(t)
+ arrowSchema, err :=
table.SchemaToArrowSchema(tbl.Metadata().CurrentSchema(), nil, false, false)
+ require.NoError(t, err)
+ 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)
+
+ files, err := tbl.Scan().PlanFiles(ctx)
+ require.NoError(t, err)
+ require.Len(t, files, 1)
+ dataPath := files[0].File.FilePath()
+ 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).AddDeletes(changelogPosDeleteFile(t, posPath)).Commit(ctx))
+ tbl, err = tx.Commit(ctx)
+ require.NoError(t, err)
+ deleted := tbl.CurrentSnapshot().SnapshotID
+
+ replacement, err := array.TableFromJSON(memory.DefaultAllocator,
arrowSchema, []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)
+
+ got := collectChangelogRows(t, mustReadChangelog(t, ctx,
tbl.NewIncrementalChangelogScan().FromSnapshotExclusive(deleted)))
+ require.Equal(t, []int64{4}, changelogIDs(got, table.ChangelogOpInsert,
0))
+ require.Equal(t, []int64{1, 3}, changelogIDs(got,
table.ChangelogOpDelete, 0))
+}
+
+func TestIncrementalChangelogReadPreRangeSharedDeletionVectorStaysOut(t
*testing.T) {
+ ctx := t.Context()
+ tbl, location, pathA, _, dvA, _ := newTableWithSharedPuffinDVs(t)
+ // Replace one deletion vector in the shared Puffin. The sibling stays.
+ replacementDV := writeDV(t, location, "dv-a2.puffin", pathA, []int64{1})
+ tx := tbl.NewTransaction()
+ require.NoError(t,
tx.NewRowDelta(nil).AddDeletes(replacementDV).RemoveDeletes(dvA).Commit(ctx))
+ tbl, err := tx.Commit(ctx)
+ require.NoError(t, err)
+ removed := tbl.CurrentSnapshot().SnapshotID
+
+ arrowSchema, err :=
table.SchemaToArrowSchema(tbl.Metadata().CurrentSchema(), nil, false, false)
+ require.NoError(t, err)
+ replacement, err := array.TableFromJSON(memory.DefaultAllocator,
arrowSchema, []string{
+ `[{"id": 5, "data": "e"}]`,
+ })
+ 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)
+
+ got := collectChangelogRows(t, mustReadChangelog(t, ctx,
tbl.NewIncrementalChangelogScan().FromSnapshotExclusive(removed)))
+ require.Equal(t, []int64{5}, changelogIDs(got, table.ChangelogOpInsert,
0))
+ // id 2 is hidden by the replacement and id 3 by the sibling that
stayed in
+ // the shared Puffin. Both were committed before the range.
+ require.Equal(t, []int64{1, 4}, changelogIDs(got,
table.ChangelogOpDelete, 0))
+}
+
+func mustReadChangelog(t *testing.T, ctx context.Context, scan
*table.IncrementalChangelogScan) iter.Seq2[arrow.RecordBatch, error] {
+ t.Helper()
+
+ _, records, err := scan.ToArrowRecords(ctx)
+ require.NoError(t, err)
+
+ return records
+}
+
+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 clearChangelogSnapshot(rows []changelogRow) []changelogRow {
+ out := make([]changelogRow, len(rows))
+ for i, row := range rows {
+ row.snapshot = 0
+ out[i] = row
+ }
+
+ return out
+}
+
+func changelogIDs(rows []changelogRow, op table.ChangelogOperation, ordinal
int64) []int64 {
+ var ids []int64
+ for _, row := range rows {
+ if row.op == string(op) && row.ordinal == ordinal {
+ ids = append(ids, row.id)
+ }
+ }
+
+ return ids
+}
+
+func changelogDataFile(t *testing.T, path string, records int64)
iceberg.DataFile {
+ t.Helper()
+
+ info, err := os.Stat(path)
+ require.NoError(t, err)
+ b, err := iceberg.NewDataFileBuilder(
+ *iceberg.UnpartitionedSpec, iceberg.EntryContentData,
+ path, iceberg.ParquetFile, nil, nil, nil, records, info.Size())
+ require.NoError(t, err)
+
+ return b.Build()
+}
+
+func changelogPosDeleteFile(t *testing.T, path string) iceberg.DataFile {
+ t.Helper()
+
+ info, err := os.Stat(path)
+ require.NoError(t, err)
+ b, err := iceberg.NewDataFileBuilder(
+ *iceberg.UnpartitionedSpec, iceberg.EntryContentPosDeletes,
+ path, iceberg.ParquetFile, nil, nil, nil, 1, info.Size())
+ require.NoError(t, err)
+
+ return b.Build()
+}
diff --git a/table/changelog_reader_test.go b/table/changelog_reader_test.go
new file mode 100644
index 000000000..4f3545434
--- /dev/null
+++ b/table/changelog_reader_test.go
@@ -0,0 +1,88 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table
+
+import (
+ "context"
+ "testing"
+
+ "github.com/apache/arrow-go/v18/arrow"
+ "github.com/apache/arrow-go/v18/arrow/array"
+ "github.com/apache/arrow-go/v18/arrow/memory"
+ "github.com/apache/iceberg-go"
+ "github.com/stretchr/testify/require"
+)
+
+func TestChangelogReadRejectsNilScan(t *testing.T) {
+ var scan *IncrementalChangelogScan
+ _, _, err := scan.Read(context.Background(), nil)
+ require.ErrorIs(t, err, ErrInvalidOperation)
+}
+
+func TestChangelogReadRejectsDeletedRowTasks(t *testing.T) {
+ data := changelogTestDataFile(t, "data/f2.parquet",
iceberg.EntryContentData, iceberg.ParquetFile)
+ added := changelogTestDataFile(t, "deletes/d2.parquet",
iceberg.EntryContentPosDeletes, iceberg.ParquetFile)
+ task, err := NewDeletedRowsScanTask(data, []iceberg.DataFile{added},
nil, 1, 44)
+ require.NoError(t, err)
+
+ scan := &IncrementalChangelogScan{scan: &Scan{limit: ScanNoLimit}}
+ _, _, err = scan.Read(context.Background(), []ChangelogScanTask{task})
+ require.ErrorIs(t, err, iceberg.ErrNotImplemented)
+}
+
+func TestStampChangelogBatchCutsToTheRemainingBudget(t *testing.T) {
+ builder := array.NewInt64Builder(memory.DefaultAllocator)
+ builder.AppendValues([]int64{1, 2, 3}, nil)
+ values := builder.NewArray()
+ defer values.Release()
+ dataSchema := arrow.NewSchema([]arrow.Field{
+ {Name: "id", Type: arrow.PrimitiveTypes.Int64, Nullable: false},
+ }, nil)
+ rec := array.NewRecordBatch(dataSchema, []arrow.Array{values},
int64(values.Len()))
+ defer rec.Release()
+
+ out := dataSchema
+ var err error
+ for _, field := range changelogArrowFields(false) {
+ out, err = out.AddField(out.NumFields(), field)
+ require.NoError(t, err)
+ }
+ file := changelogTestDataFile(t, "data.parquet",
iceberg.EntryContentData, iceberg.ParquetFile)
+ task, err := NewAddedRowsScanTask(file, nil, 0, 7)
+ require.NoError(t, err)
+
+ stamped, rows, err := stampChangelogBatch(context.Background(), out,
false, rec, task, 2, 0)
+ require.NoError(t, err)
+ require.Equal(t, int64(2), rows)
+ require.Equal(t, int64(2), stamped.NumRows())
+ t.Cleanup(stamped.Release)
+ require.Equal(t, []int64{1, 2},
stamped.Column(0).(*array.Int64).Int64Values())
+}
+
+func TestChangelogArrowSchemaRejectsReservedName(t *testing.T) {
+ schema := iceberg.NewSchema(0, iceberg.NestedField{
+ ID: 1, Name: ChangelogChangeTypeColumn, Type:
iceberg.PrimitiveTypes.Int64, Required: true,
+ })
+ meta, err := NewMetadata(schema, iceberg.UnpartitionedSpec,
UnsortedSortOrder, "mem://changelog-reserved", nil)
+ require.NoError(t, err)
+ tbl := New(Identifier{"db", "changelog"}, meta, "metadata.json", nil,
nil)
+
+ _, _, err = changelogArrowSchema(tbl.NewIncrementalChangelogScan().scan)
+ require.ErrorIs(t, err, iceberg.ErrInvalidSchema)
+ require.ErrorContains(t, err, ChangelogChangeTypeColumn)
+}