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


##########
table/incremental_changelog_deletes.go:
##########
@@ -0,0 +1,658 @@
+// 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 (
+       "cmp"
+       "context"
+       "fmt"
+       "slices"
+
+       "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/io"
+)
+
+// changelogTaskPlan is the snapshot range and the data-file changes already
+// read for one changelog plan.
+type changelogTaskPlan struct {
+       scan                *Scan
+       fs                  io.IO
+       snapshots           []Snapshot
+       manifestsBySnapshot map[int64][]iceberg.ManifestFile
+       deleteManifests     map[int64][]iceberg.ManifestFile
+       dataEntries         []iceberg.ManifestEntry
+       snapshotOrdinals    map[int64]int
+       schema              *iceberg.Schema
+       partitionFilters    *keyDefaultMapErr[int, iceberg.BooleanExpression]
+       residual            iceberg.BooleanExpression
+       metrics             *scanMetricsAccumulator
+}
+
+// changelogDeleteIndex matches delete files to a data file with the same
+// sequence rules as a normal scan.
+type changelogDeleteIndex struct {
+       pos *positionalDeleteIndex
+       dv  map[string]iceberg.ManifestEntry
+       eq  *equalityDeleteIndex
+}
+
+// changelogDeleteState tracks delete files added and removed inside the
+// changelog range, plus delete files that were already live before it.
+type changelogDeleteState struct {
+       active    bool
+       snapshots []Snapshot
+       preRange  []iceberg.ManifestEntry
+       added     map[int64][]iceberg.ManifestEntry
+       removed   map[int64]map[string]struct{}
+       specs     partitionSpecLookup
+       schema    *iceberg.Schema
+
+       addedCache  map[int64]*changelogDeleteIndex
+       beforeCache map[int64]*changelogDeleteIndex
+}
+
+func (s *IncrementalChangelogScan) planChangelogTasks(ctx context.Context, 
plan changelogTaskPlan) ([]plannedChangelogTask, error) {
+       state, err := s.loadChangelogDeleteState(ctx, plan)
+       if err != nil {
+               return nil, err
+       }
+
+       changedPaths := make(map[int64]map[string]struct{})
+       tasks := make([]plannedChangelogTask, 0, len(plan.dataEntries))
+       for _, entry := range plan.dataEntries {
+               ordinal, ok := plan.snapshotOrdinals[entry.SnapshotID()]
+               if !ok {
+                       continue
+               }
+               paths := changedPaths[entry.SnapshotID()]
+               if paths == nil {
+                       paths = make(map[string]struct{})
+                       changedPaths[entry.SnapshotID()] = paths
+               }
+               paths[entry.DataFile().FilePath()] = struct{}{}
+
+               deletes, err := state.deletesFor(entry)
+               if err != nil {
+                       return nil, fmt.Errorf("incremental changelog scan 
snapshot %d: %w", entry.SnapshotID(), err)
+               }
+               task, err := newChangelogScanTask(entry, ordinal, 
plan.residual, deletes)
+               if err != nil {
+                       return nil, fmt.Errorf("incremental changelog scan 
snapshot %d: %w", entry.SnapshotID(), err)
+               }
+               tasks = append(tasks, plannedChangelogTask{task: task})
+       }
+
+       rowTasks, err := s.planDeletedRowTasks(ctx, plan, state, changedPaths)
+       if err != nil {
+               return nil, err
+       }
+       tasks = append(tasks, rowTasks...)
+       sortChangelogTasks(tasks)
+
+       return tasks, nil
+}
+
+func (s *IncrementalChangelogScan) loadChangelogDeleteState(ctx 
context.Context, plan changelogTaskPlan) (*changelogDeleteState, error) {
+       state := &changelogDeleteState{
+               snapshots: plan.snapshots,
+               added:     make(map[int64][]iceberg.ManifestEntry),
+               removed:   make(map[int64]map[string]struct{}),
+               specs:     plan.scan.metadata,
+               schema:    plan.schema,
+       }
+       for _, snapshot := range plan.snapshots {
+               if err := ctx.Err(); err != nil {
+                       return nil, err
+               }
+               manifests := plan.deleteManifests[snapshot.SnapshotID]
+               if len(manifests) == 0 {
+                       continue
+               }
+               filtered, err := plan.scan.filterManifestsWithSchemaOptions(
+                       manifests, plan.schema, plan.metrics, 
plan.partitionFilters, true)
+               if err != nil {
+                       return nil, err
+               }
+               entries, err := readManifestEntries(ctx, plan.fs, filtered, 
false, false)
+               if err != nil {
+                       return nil, err
+               }
+               for _, entry := range entries {
+                       if entry.SnapshotID() != snapshot.SnapshotID {
+                               continue
+                       }
+                       if entry.Status() == iceberg.EntryStatusDELETED {
+                               if state.removed[snapshot.SnapshotID] == nil {
+                                       state.removed[snapshot.SnapshotID] = 
make(map[string]struct{})
+                               }
+                               
state.removed[snapshot.SnapshotID][entry.DataFile().FilePath()] = struct{}{}
+
+                               continue
+                       }
+                       state.added[snapshot.SnapshotID] = 
append(state.added[snapshot.SnapshotID], entry)
+               }
+       }
+
+       deletedData := false
+       for _, entry := range plan.dataEntries {
+               if entry.Status() == iceberg.EntryStatusDELETED {
+                       deletedData = true
+
+                       break
+               }
+       }
+       for _, entries := range state.added {
+               if len(entries) > 0 {
+                       state.active = true
+
+                       break
+               }
+       }
+       if len(state.removed) > 0 || deletedData {
+               state.active = true
+       }
+       if !state.active {
+               return state, nil
+       }
+
+       preRange, err := s.liveDeletesBeforeRange(ctx, plan.fs, plan.snapshots)
+       if err != nil {
+               return nil, err
+       }
+       state.preRange = preRange
+
+       return state, nil
+}
+
+// liveDeletesBeforeRange returns delete files that were already live on the
+// snapshot immediately before the changelog range.
+func (s *IncrementalChangelogScan) liveDeletesBeforeRange(ctx context.Context, 
fs io.IO, snapshots []Snapshot) ([]iceberg.ManifestEntry, error) {
+       parent := s.snapshotBeforeRange(snapshots)
+       if parent == nil {
+               return nil, nil
+       }
+       manifests, err := parent.Manifests(fs)
+       if err != nil {
+               return nil, err
+       }
+       deleteManifests := make([]iceberg.ManifestFile, 0)
+       for _, manifest := range manifests {
+               if manifest.ManifestContent() == iceberg.ManifestContentDeletes 
{
+                       deleteManifests = append(deleteManifests, manifest)
+               }
+       }
+       entries, err := readManifestEntries(ctx, fs, deleteManifests, false, 
false)
+       if err != nil {
+               return nil, err
+       }
+
+       return liveManifestEntries(entries), nil
+}
+
+func (s *IncrementalChangelogScan) snapshotBeforeRange(snapshots []Snapshot) 
*Snapshot {
+       if s.fromSnapshotID != nil && !s.fromInclusive {
+               return s.scan.metadata.SnapshotByID(*s.fromSnapshotID)

Review Comment:
   When the exclusive from-snapshot has been expired, `SnapshotByID` here 
returns nil, `liveDeletesBeforeRange` returns `nil, nil`, and preRange ends up 
empty with no error. We explicitly support that expired-parent case 
(`TestIncrementalChangelogScanAllowsExpiredExclusiveParent`), so this path is 
reachable in normal use.
   
   The consequence is silent: a removed data file, or a `DeletedRowsScanTask` 
over a file that had older position deletes, DVs, or equality deletes, gets an 
empty `ExistingDeletes`, and a consumer re-emits DELETE events for rows that 
were already deleted before the range. That breaks the "only still-live rows 
are deleted" guarantee with no signal.
   
   I'd fall through to `snapshots[0].ParentSnapshotID` when the from-snapshot 
is missing, and if that's also gone, return an error rather than plan with 
empty pre-range state. Either way, add a test for expired-start-with-deletes.
   



##########
table/incremental_changelog_scan_test.go:
##########
@@ -768,6 +809,477 @@ func incrementalChangelogDeleteManifestTable(t 
*testing.T) *Table {
        }, nil)
 }
 
+func TestIncrementalChangelogScanAttachesSameSnapshotDeletes(t *testing.T) {
+       spec := partitionedSpec()
+       data := newTestDataFile(t, spec, 
"mem://default/changelog-same/data.parquet", map[int]any{1000: int32(1)})
+       posDelete := newTestPosDeleteFileForSpec(t, spec,
+               "mem://default/changelog-same/pos.parquet", map[int]any{1000: 
int32(1)}, data.FilePath())
+       eqDelete := newChangelogEqDelete(t, spec,
+               "mem://default/changelog-same/eq.parquet", map[int]any{1000: 
int32(1)})
+       tbl := writeChangelogScanTable(t, "changelog-same", 
[]changelogScanStep{{
+               operation: OpAppend,
+               data:      
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 1, 1, 
data)},
+               deletes: []iceberg.ManifestEntry{
+                       changelogManifestEntry(iceberg.EntryStatusADDED, 1, 1, 
posDelete),
+                       changelogManifestEntry(iceberg.EntryStatusADDED, 1, 1, 
eqDelete),
+               },
+       }})
+
+       tasks, err := 
tbl.NewIncrementalChangelogScan().PlanFiles(context.Background())
+       require.NoError(t, err)
+       require.Len(t, tasks, 1)
+       added, ok := tasks[0].(AddedRowsScanTask)
+       require.True(t, ok)
+       require.Equal(t, []string{posDelete.FilePath()}, 
changelogFilePaths(added.Deletes()))
+       require.Empty(t, added.ScanTask().EqualityDeleteFiles)
+}
+
+func TestIncrementalChangelogScanSeparatesAddedAndExistingDeletes(t 
*testing.T) {
+       spec := partitionedSpec()
+       data := newTestDataFile(t, spec, 
"mem://default/changelog-prior/data.parquet", map[int]any{1000: int32(1)})
+       first := newTestPosDeleteFileForSpec(t, spec,
+               "mem://default/changelog-prior/first.parquet", 
map[int]any{1000: int32(1)}, data.FilePath())
+       second := newTestPosDeleteFileForSpec(t, spec,
+               "mem://default/changelog-prior/second.parquet", 
map[int]any{1000: int32(1)}, data.FilePath())
+       tbl := writeChangelogScanTable(t, "changelog-prior", 
[]changelogScanStep{
+               {
+                       operation: OpAppend,
+                       data:      
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 1, 1, 
data)},
+               },
+               {
+                       operation: OpDelete,
+                       deletes:   
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 2, 2, 
first)},
+               },
+               {
+                       operation: OpDelete,
+                       deletes:   
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 3, 3, 
second)},
+               },
+       })
+
+       tasks, err := 
tbl.NewIncrementalChangelogScan().PlanFiles(context.Background())
+       require.NoError(t, err)
+       require.Len(t, tasks, 3)
+
+       firstRows, ok := tasks[1].(DeletedRowsScanTask)
+       require.True(t, ok)
+       require.Equal(t, []string{first.FilePath()}, 
changelogFilePaths(firstRows.AddedDeletes()))
+       require.Empty(t, firstRows.ExistingDeletes())
+
+       secondRows, ok := tasks[2].(DeletedRowsScanTask)
+       require.True(t, ok)
+       require.Equal(t, 2, secondRows.ChangeOrdinal())
+       require.Equal(t, []string{second.FilePath()}, 
changelogFilePaths(secondRows.AddedDeletes()))
+       require.Equal(t, []string{first.FilePath()}, 
changelogFilePaths(secondRows.ExistingDeletes()))
+}
+
+func TestIncrementalChangelogScanPlansEqualityDeletes(t *testing.T) {

Review Comment:
   Every delete fixture in the new tests sets delete seq == data seq == 
snapshot ID, so the sequence-number rules that are the core of this change 
never actually get exercised. A regression that ignored sequence numbers 
entirely would still pass all of these. Nothing covers a position delete with a 
seq below its data file (must not apply), an equality delete at the same or 
lower seq than an existing data file, or multiple delete files for one data 
file in one snapshot.
   
   I'd add a table-driven case varying (delete kind, delete seq, data seq) and 
assert what applies vs. what doesn't.
   



##########
table/incremental_changelog_deletes.go:
##########
@@ -0,0 +1,658 @@
+// 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 (
+       "cmp"
+       "context"
+       "fmt"
+       "slices"
+
+       "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/io"
+)
+
+// changelogTaskPlan is the snapshot range and the data-file changes already
+// read for one changelog plan.
+type changelogTaskPlan struct {
+       scan                *Scan
+       fs                  io.IO
+       snapshots           []Snapshot
+       manifestsBySnapshot map[int64][]iceberg.ManifestFile
+       deleteManifests     map[int64][]iceberg.ManifestFile
+       dataEntries         []iceberg.ManifestEntry
+       snapshotOrdinals    map[int64]int
+       schema              *iceberg.Schema
+       partitionFilters    *keyDefaultMapErr[int, iceberg.BooleanExpression]
+       residual            iceberg.BooleanExpression
+       metrics             *scanMetricsAccumulator
+}
+
+// changelogDeleteIndex matches delete files to a data file with the same
+// sequence rules as a normal scan.
+type changelogDeleteIndex struct {
+       pos *positionalDeleteIndex
+       dv  map[string]iceberg.ManifestEntry
+       eq  *equalityDeleteIndex
+}
+
+// changelogDeleteState tracks delete files added and removed inside the
+// changelog range, plus delete files that were already live before it.
+type changelogDeleteState struct {
+       active    bool
+       snapshots []Snapshot
+       preRange  []iceberg.ManifestEntry
+       added     map[int64][]iceberg.ManifestEntry
+       removed   map[int64]map[string]struct{}
+       specs     partitionSpecLookup
+       schema    *iceberg.Schema
+
+       addedCache  map[int64]*changelogDeleteIndex
+       beforeCache map[int64]*changelogDeleteIndex
+}
+
+func (s *IncrementalChangelogScan) planChangelogTasks(ctx context.Context, 
plan changelogTaskPlan) ([]plannedChangelogTask, error) {
+       state, err := s.loadChangelogDeleteState(ctx, plan)
+       if err != nil {
+               return nil, err
+       }
+
+       changedPaths := make(map[int64]map[string]struct{})
+       tasks := make([]plannedChangelogTask, 0, len(plan.dataEntries))
+       for _, entry := range plan.dataEntries {
+               ordinal, ok := plan.snapshotOrdinals[entry.SnapshotID()]
+               if !ok {
+                       continue
+               }
+               paths := changedPaths[entry.SnapshotID()]
+               if paths == nil {
+                       paths = make(map[string]struct{})
+                       changedPaths[entry.SnapshotID()] = paths
+               }
+               paths[entry.DataFile().FilePath()] = struct{}{}
+
+               deletes, err := state.deletesFor(entry)
+               if err != nil {
+                       return nil, fmt.Errorf("incremental changelog scan 
snapshot %d: %w", entry.SnapshotID(), err)
+               }
+               task, err := newChangelogScanTask(entry, ordinal, 
plan.residual, deletes)
+               if err != nil {
+                       return nil, fmt.Errorf("incremental changelog scan 
snapshot %d: %w", entry.SnapshotID(), err)
+               }
+               tasks = append(tasks, plannedChangelogTask{task: task})
+       }
+
+       rowTasks, err := s.planDeletedRowTasks(ctx, plan, state, changedPaths)
+       if err != nil {
+               return nil, err
+       }
+       tasks = append(tasks, rowTasks...)
+       sortChangelogTasks(tasks)
+
+       return tasks, nil
+}
+
+func (s *IncrementalChangelogScan) loadChangelogDeleteState(ctx 
context.Context, plan changelogTaskPlan) (*changelogDeleteState, error) {
+       state := &changelogDeleteState{
+               snapshots: plan.snapshots,
+               added:     make(map[int64][]iceberg.ManifestEntry),
+               removed:   make(map[int64]map[string]struct{}),
+               specs:     plan.scan.metadata,
+               schema:    plan.schema,
+       }
+       for _, snapshot := range plan.snapshots {
+               if err := ctx.Err(); err != nil {
+                       return nil, err
+               }
+               manifests := plan.deleteManifests[snapshot.SnapshotID]
+               if len(manifests) == 0 {
+                       continue
+               }
+               filtered, err := plan.scan.filterManifestsWithSchemaOptions(
+                       manifests, plan.schema, plan.metrics, 
plan.partitionFilters, true)
+               if err != nil {
+                       return nil, err
+               }
+               entries, err := readManifestEntries(ctx, plan.fs, filtered, 
false, false)
+               if err != nil {
+                       return nil, err
+               }
+               for _, entry := range entries {
+                       if entry.SnapshotID() != snapshot.SnapshotID {
+                               continue
+                       }
+                       if entry.Status() == iceberg.EntryStatusDELETED {
+                               if state.removed[snapshot.SnapshotID] == nil {
+                                       state.removed[snapshot.SnapshotID] = 
make(map[string]struct{})
+                               }
+                               
state.removed[snapshot.SnapshotID][entry.DataFile().FilePath()] = struct{}{}
+
+                               continue
+                       }
+                       state.added[snapshot.SnapshotID] = 
append(state.added[snapshot.SnapshotID], entry)
+               }
+       }
+
+       deletedData := false
+       for _, entry := range plan.dataEntries {
+               if entry.Status() == iceberg.EntryStatusDELETED {
+                       deletedData = true
+
+                       break
+               }
+       }
+       for _, entries := range state.added {
+               if len(entries) > 0 {
+                       state.active = true
+
+                       break
+               }
+       }
+       if len(state.removed) > 0 || deletedData {
+               state.active = true
+       }
+       if !state.active {
+               return state, nil
+       }
+
+       preRange, err := s.liveDeletesBeforeRange(ctx, plan.fs, plan.snapshots)
+       if err != nil {
+               return nil, err
+       }
+       state.preRange = preRange
+
+       return state, nil
+}
+
+// liveDeletesBeforeRange returns delete files that were already live on the
+// snapshot immediately before the changelog range.
+func (s *IncrementalChangelogScan) liveDeletesBeforeRange(ctx context.Context, 
fs io.IO, snapshots []Snapshot) ([]iceberg.ManifestEntry, error) {
+       parent := s.snapshotBeforeRange(snapshots)
+       if parent == nil {
+               return nil, nil
+       }
+       manifests, err := parent.Manifests(fs)
+       if err != nil {
+               return nil, err
+       }
+       deleteManifests := make([]iceberg.ManifestFile, 0)
+       for _, manifest := range manifests {
+               if manifest.ManifestContent() == iceberg.ManifestContentDeletes 
{
+                       deleteManifests = append(deleteManifests, manifest)
+               }
+       }
+       entries, err := readManifestEntries(ctx, fs, deleteManifests, false, 
false)
+       if err != nil {
+               return nil, err
+       }
+
+       return liveManifestEntries(entries), nil
+}
+
+func (s *IncrementalChangelogScan) snapshotBeforeRange(snapshots []Snapshot) 
*Snapshot {
+       if s.fromSnapshotID != nil && !s.fromInclusive {
+               return s.scan.metadata.SnapshotByID(*s.fromSnapshotID)
+       }
+       if len(snapshots) == 0 || snapshots[0].ParentSnapshotID == nil {
+               return nil
+       }
+
+       return s.scan.metadata.SnapshotByID(*snapshots[0].ParentSnapshotID)
+}
+
+func (s *IncrementalChangelogScan) planDeletedRowTasks(
+       ctx context.Context,
+       plan changelogTaskPlan,
+       state *changelogDeleteState,
+       changedPaths map[int64]map[string]struct{},
+) ([]plannedChangelogTask, error) {
+       if state == nil || !state.active {
+               return nil, nil
+       }
+       matcher, err := plan.scan.dataFileMatcher(plan.schema, 
plan.partitionFilters)
+       if err != nil {
+               return nil, err
+       }
+
+       var tasks []plannedChangelogTask
+       for _, snapshot := range plan.snapshots {
+               if err := ctx.Err(); err != nil {
+                       return nil, err
+               }
+               if len(state.added[snapshot.SnapshotID]) == 0 {
+                       continue
+               }
+               addedIndex, err := state.addedIndex(snapshot.SnapshotID)
+               if err != nil {
+                       return nil, fmt.Errorf("incremental changelog scan 
snapshot %d: %w", snapshot.SnapshotID, err)
+               }
+               manifests := plan.manifestsBySnapshot[snapshot.SnapshotID]
+               dataManifests := make([]iceberg.ManifestFile, 0, len(manifests))
+               for _, manifest := range manifests {
+                       if manifest.ManifestContent() == 
iceberg.ManifestContentData {
+                               dataManifests = append(dataManifests, manifest)
+                       }
+               }
+               // Prune with the scan partition filters before opening 
manifests.
+               // A local accumulator keeps carried-forward data manifests out 
of the
+               // changelog scan report, which already counted them.
+               var pruned scanMetricsAccumulator
+               dataManifests, err = plan.scan.filterManifestsWithSchemaOptions(
+                       dataManifests, plan.schema, &pruned, 
plan.partitionFilters, true)
+               if err != nil {
+                       return nil, err
+               }
+               live, err := liveDataEntries(ctx, plan.fs, dataManifests)

Review Comment:
   For every in-range snapshot that adds a delete file, this re-reads every 
live data manifest of that snapshot in full (`readManifestEntries` passes 
keep-everything, so partition/metrics pruning only kicks in after all entries 
are already materialized), and nothing is cached across snapshots. Cost is 
roughly (snapshots with deletes) x (table data manifests), which is exactly the 
shape a CDC consumer polling a wide range hits, and an unpartitioned equality 
delete fans out to one `DeletedRowsScanTask` per live file per snapshot.
   
   I'd push the partition and metrics evaluators from `dataFileMatcher` into 
`openManifestWithOptions` instead of keep-everything, and cache decoded entries 
by manifest path across snapshots. Not a correctness blocker, but for the 
workload this feature targets I'd want it addressed, at worst as a fast follow 
once the correctness items land.
   



##########
table/incremental_changelog_deletes.go:
##########
@@ -0,0 +1,658 @@
+// 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 (
+       "cmp"
+       "context"
+       "fmt"
+       "slices"
+
+       "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/io"
+)
+
+// changelogTaskPlan is the snapshot range and the data-file changes already
+// read for one changelog plan.
+type changelogTaskPlan struct {
+       scan                *Scan
+       fs                  io.IO
+       snapshots           []Snapshot
+       manifestsBySnapshot map[int64][]iceberg.ManifestFile
+       deleteManifests     map[int64][]iceberg.ManifestFile
+       dataEntries         []iceberg.ManifestEntry
+       snapshotOrdinals    map[int64]int
+       schema              *iceberg.Schema
+       partitionFilters    *keyDefaultMapErr[int, iceberg.BooleanExpression]
+       residual            iceberg.BooleanExpression
+       metrics             *scanMetricsAccumulator
+}
+
+// changelogDeleteIndex matches delete files to a data file with the same
+// sequence rules as a normal scan.
+type changelogDeleteIndex struct {
+       pos *positionalDeleteIndex
+       dv  map[string]iceberg.ManifestEntry
+       eq  *equalityDeleteIndex
+}
+
+// changelogDeleteState tracks delete files added and removed inside the
+// changelog range, plus delete files that were already live before it.
+type changelogDeleteState struct {
+       active    bool
+       snapshots []Snapshot
+       preRange  []iceberg.ManifestEntry
+       added     map[int64][]iceberg.ManifestEntry
+       removed   map[int64]map[string]struct{}
+       specs     partitionSpecLookup
+       schema    *iceberg.Schema
+
+       addedCache  map[int64]*changelogDeleteIndex
+       beforeCache map[int64]*changelogDeleteIndex
+}
+
+func (s *IncrementalChangelogScan) planChangelogTasks(ctx context.Context, 
plan changelogTaskPlan) ([]plannedChangelogTask, error) {
+       state, err := s.loadChangelogDeleteState(ctx, plan)
+       if err != nil {
+               return nil, err
+       }
+
+       changedPaths := make(map[int64]map[string]struct{})
+       tasks := make([]plannedChangelogTask, 0, len(plan.dataEntries))
+       for _, entry := range plan.dataEntries {
+               ordinal, ok := plan.snapshotOrdinals[entry.SnapshotID()]
+               if !ok {
+                       continue
+               }
+               paths := changedPaths[entry.SnapshotID()]
+               if paths == nil {
+                       paths = make(map[string]struct{})
+                       changedPaths[entry.SnapshotID()] = paths
+               }
+               paths[entry.DataFile().FilePath()] = struct{}{}
+
+               deletes, err := state.deletesFor(entry)
+               if err != nil {
+                       return nil, fmt.Errorf("incremental changelog scan 
snapshot %d: %w", entry.SnapshotID(), err)
+               }
+               task, err := newChangelogScanTask(entry, ordinal, 
plan.residual, deletes)
+               if err != nil {
+                       return nil, fmt.Errorf("incremental changelog scan 
snapshot %d: %w", entry.SnapshotID(), err)
+               }
+               tasks = append(tasks, plannedChangelogTask{task: task})
+       }
+
+       rowTasks, err := s.planDeletedRowTasks(ctx, plan, state, changedPaths)
+       if err != nil {
+               return nil, err
+       }
+       tasks = append(tasks, rowTasks...)
+       sortChangelogTasks(tasks)
+
+       return tasks, nil
+}
+
+func (s *IncrementalChangelogScan) loadChangelogDeleteState(ctx 
context.Context, plan changelogTaskPlan) (*changelogDeleteState, error) {
+       state := &changelogDeleteState{
+               snapshots: plan.snapshots,
+               added:     make(map[int64][]iceberg.ManifestEntry),
+               removed:   make(map[int64]map[string]struct{}),
+               specs:     plan.scan.metadata,
+               schema:    plan.schema,
+       }
+       for _, snapshot := range plan.snapshots {
+               if err := ctx.Err(); err != nil {
+                       return nil, err
+               }
+               manifests := plan.deleteManifests[snapshot.SnapshotID]
+               if len(manifests) == 0 {
+                       continue
+               }
+               filtered, err := plan.scan.filterManifestsWithSchemaOptions(
+                       manifests, plan.schema, plan.metrics, 
plan.partitionFilters, true)
+               if err != nil {
+                       return nil, err
+               }
+               entries, err := readManifestEntries(ctx, plan.fs, filtered, 
false, false)
+               if err != nil {
+                       return nil, err
+               }
+               for _, entry := range entries {
+                       if entry.SnapshotID() != snapshot.SnapshotID {
+                               continue
+                       }
+                       if entry.Status() == iceberg.EntryStatusDELETED {
+                               if state.removed[snapshot.SnapshotID] == nil {
+                                       state.removed[snapshot.SnapshotID] = 
make(map[string]struct{})
+                               }
+                               
state.removed[snapshot.SnapshotID][entry.DataFile().FilePath()] = struct{}{}
+
+                               continue
+                       }
+                       state.added[snapshot.SnapshotID] = 
append(state.added[snapshot.SnapshotID], entry)
+               }
+       }
+
+       deletedData := false
+       for _, entry := range plan.dataEntries {
+               if entry.Status() == iceberg.EntryStatusDELETED {
+                       deletedData = true
+
+                       break
+               }
+       }
+       for _, entries := range state.added {
+               if len(entries) > 0 {
+                       state.active = true
+
+                       break
+               }
+       }
+       if len(state.removed) > 0 || deletedData {
+               state.active = true
+       }
+       if !state.active {
+               return state, nil
+       }
+
+       preRange, err := s.liveDeletesBeforeRange(ctx, plan.fs, plan.snapshots)
+       if err != nil {
+               return nil, err
+       }
+       state.preRange = preRange
+
+       return state, nil
+}
+
+// liveDeletesBeforeRange returns delete files that were already live on the
+// snapshot immediately before the changelog range.
+func (s *IncrementalChangelogScan) liveDeletesBeforeRange(ctx context.Context, 
fs io.IO, snapshots []Snapshot) ([]iceberg.ManifestEntry, error) {
+       parent := s.snapshotBeforeRange(snapshots)
+       if parent == nil {
+               return nil, nil
+       }
+       manifests, err := parent.Manifests(fs)
+       if err != nil {
+               return nil, err
+       }
+       deleteManifests := make([]iceberg.ManifestFile, 0)
+       for _, manifest := range manifests {
+               if manifest.ManifestContent() == iceberg.ManifestContentDeletes 
{
+                       deleteManifests = append(deleteManifests, manifest)
+               }
+       }
+       entries, err := readManifestEntries(ctx, fs, deleteManifests, false, 
false)
+       if err != nil {
+               return nil, err
+       }
+
+       return liveManifestEntries(entries), nil
+}
+
+func (s *IncrementalChangelogScan) snapshotBeforeRange(snapshots []Snapshot) 
*Snapshot {
+       if s.fromSnapshotID != nil && !s.fromInclusive {
+               return s.scan.metadata.SnapshotByID(*s.fromSnapshotID)
+       }
+       if len(snapshots) == 0 || snapshots[0].ParentSnapshotID == nil {
+               return nil
+       }
+
+       return s.scan.metadata.SnapshotByID(*snapshots[0].ParentSnapshotID)
+}
+
+func (s *IncrementalChangelogScan) planDeletedRowTasks(
+       ctx context.Context,
+       plan changelogTaskPlan,
+       state *changelogDeleteState,
+       changedPaths map[int64]map[string]struct{},
+) ([]plannedChangelogTask, error) {
+       if state == nil || !state.active {
+               return nil, nil
+       }
+       matcher, err := plan.scan.dataFileMatcher(plan.schema, 
plan.partitionFilters)
+       if err != nil {
+               return nil, err
+       }
+
+       var tasks []plannedChangelogTask
+       for _, snapshot := range plan.snapshots {
+               if err := ctx.Err(); err != nil {
+                       return nil, err
+               }
+               if len(state.added[snapshot.SnapshotID]) == 0 {
+                       continue
+               }
+               addedIndex, err := state.addedIndex(snapshot.SnapshotID)
+               if err != nil {
+                       return nil, fmt.Errorf("incremental changelog scan 
snapshot %d: %w", snapshot.SnapshotID, err)
+               }
+               manifests := plan.manifestsBySnapshot[snapshot.SnapshotID]
+               dataManifests := make([]iceberg.ManifestFile, 0, len(manifests))
+               for _, manifest := range manifests {
+                       if manifest.ManifestContent() == 
iceberg.ManifestContentData {
+                               dataManifests = append(dataManifests, manifest)
+                       }
+               }
+               // Prune with the scan partition filters before opening 
manifests.
+               // A local accumulator keeps carried-forward data manifests out 
of the
+               // changelog scan report, which already counted them.
+               var pruned scanMetricsAccumulator
+               dataManifests, err = plan.scan.filterManifestsWithSchemaOptions(
+                       dataManifests, plan.schema, &pruned, 
plan.partitionFilters, true)
+               if err != nil {
+                       return nil, err
+               }
+               live, err := liveDataEntries(ctx, plan.fs, dataManifests)
+               if err != nil {
+                       return nil, err
+               }
+               ordinal := plan.snapshotOrdinals[snapshot.SnapshotID]
+               seen := make(map[string]struct{})
+               for _, entry := range live {
+                       path := entry.DataFile().FilePath()
+                       if _, changed := 
changedPaths[snapshot.SnapshotID][path]; changed {
+                               continue
+                       }
+                       if _, ok := seen[path]; ok {
+                               continue
+                       }
+                       seen[path] = struct{}{}
+
+                       keep, err := matcher(entry.DataFile())
+                       if err != nil {
+                               return nil, err
+                       }
+                       if !keep {
+                               continue
+                       }
+                       added, err := addedIndex.forDataFile(entry)
+                       if err != nil {
+                               return nil, fmt.Errorf("incremental changelog 
scan snapshot %d: %w", snapshot.SnapshotID, err)
+                       }
+                       if len(added) == 0 {
+                               continue
+                       }
+                       var existing []iceberg.DataFile
+                       before, err := state.beforeIndex(snapshot.SnapshotID)
+                       if err != nil {
+                               return nil, fmt.Errorf("incremental changelog 
scan snapshot %d: %w", snapshot.SnapshotID, err)
+                       }
+                       if before != nil {
+                               existing, err = before.forDataFile(entry)
+                               if err != nil {
+                                       return nil, fmt.Errorf("incremental 
changelog scan snapshot %d: %w", snapshot.SnapshotID, err)
+                               }
+                       }
+
+                       task, err := NewDeletedRowsScanTask(entry.DataFile(), 
added, existing, ordinal, snapshot.SnapshotID)
+                       if err != nil {
+                               return nil, fmt.Errorf("incremental changelog 
scan snapshot %d: %w", snapshot.SnapshotID, err)
+                       }
+                       configureChangelogFileTask(&task.FileScanTask, entry, 
plan.residual)
+                       tasks = append(tasks, plannedChangelogTask{task: task})
+               }
+       }
+
+       return tasks, nil
+}
+
+func (st *changelogDeleteState) deletesFor(entry iceberg.ManifestEntry) 
([]iceberg.DataFile, error) {
+       if st == nil || !st.active {
+               return nil, nil
+       }
+
+       var (
+               idx *changelogDeleteIndex
+               err error
+       )
+       switch entry.Status() {
+       case iceberg.EntryStatusADDED:
+               idx, err = st.addedIndex(entry.SnapshotID())
+       case iceberg.EntryStatusDELETED:
+               idx, err = st.beforeIndex(entry.SnapshotID())
+       default:
+               return nil, fmt.Errorf("%w: unknown manifest entry status %d", 
ErrInvalidMetadata, entry.Status())
+       }
+       if err != nil || idx == nil {
+               return nil, err
+       }
+
+       return idx.forDataFile(entry)
+}
+
+func (st *changelogDeleteState) addedIndex(snapshotID int64) 
(*changelogDeleteIndex, error) {
+       if st.addedCache == nil {
+               st.addedCache = make(map[int64]*changelogDeleteIndex)
+       }
+       if idx, ok := st.addedCache[snapshotID]; ok {
+               return idx, nil
+       }
+       idx, err := buildChangelogDeleteIndex(st.added[snapshotID], st.specs, 
st.schema)
+       if err != nil {
+               return nil, err
+       }
+       st.addedCache[snapshotID] = idx
+
+       return idx, nil
+}
+
+func (st *changelogDeleteState) beforeIndex(snapshotID int64) 
(*changelogDeleteIndex, error) {
+       if st.beforeCache == nil {
+               st.beforeCache = make(map[int64]*changelogDeleteIndex)
+       }
+       if idx, ok := st.beforeCache[snapshotID]; ok {
+               return idx, nil
+       }
+       idx, err := buildChangelogDeleteIndex(st.entriesBefore(snapshotID), 
st.specs, st.schema)
+       if err != nil {
+               return nil, err
+       }
+       st.beforeCache[snapshotID] = idx
+
+       return idx, nil
+}
+
+// entriesBefore returns delete-file entries that applied strictly before
+// snapshotID. Removals drop a path from the running set. A path removed and
+// added again in one snapshot stays live as the added entry.
+func (st *changelogDeleteState) entriesBefore(snapshotID int64) 
[]iceberg.ManifestEntry {
+       removed := make(map[string]struct{})
+       var accumulated []iceberg.ManifestEntry
+       for _, snapshot := range st.snapshots {

Review Comment:
   `entriesBefore` walks `st.snapshots`, which has replace snapshots filtered 
out upstream (`changelogSnapshots` drops `OpReplace`), and `added`/`removed` 
are built only from the retained snapshots. So a mid-range replace that 
rewrites delete files (position-delete compaction, merging position deletes 
into a DV, `rewrite_position_delete_files`) never updates the delete timeline.
   
   That means `ExistingDeletes` can still reference delete files a replace 
removed, and those files may be physically gone, so a consumer that reads them 
fails. The inclusive and exclusive paths are also inconsistent: inclusive 
derives pre-range from the parent of `snapshots[0]` (which does fold in a 
preceding replace), exclusive anchors on the from-snapshot and skips it.
   
   I'd keep folding delete-manifest adds/removes from replace snapshots into 
the timeline while still emitting no tasks for them, and add a test with a 
replace that rewrites deletes inside the range.
   



##########
table/incremental_changelog_deletes.go:
##########
@@ -0,0 +1,658 @@
+// 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 (
+       "cmp"
+       "context"
+       "fmt"
+       "slices"
+
+       "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/io"
+)
+
+// changelogTaskPlan is the snapshot range and the data-file changes already
+// read for one changelog plan.
+type changelogTaskPlan struct {
+       scan                *Scan
+       fs                  io.IO
+       snapshots           []Snapshot
+       manifestsBySnapshot map[int64][]iceberg.ManifestFile
+       deleteManifests     map[int64][]iceberg.ManifestFile
+       dataEntries         []iceberg.ManifestEntry
+       snapshotOrdinals    map[int64]int
+       schema              *iceberg.Schema
+       partitionFilters    *keyDefaultMapErr[int, iceberg.BooleanExpression]
+       residual            iceberg.BooleanExpression
+       metrics             *scanMetricsAccumulator
+}
+
+// changelogDeleteIndex matches delete files to a data file with the same
+// sequence rules as a normal scan.
+type changelogDeleteIndex struct {
+       pos *positionalDeleteIndex
+       dv  map[string]iceberg.ManifestEntry
+       eq  *equalityDeleteIndex
+}
+
+// changelogDeleteState tracks delete files added and removed inside the
+// changelog range, plus delete files that were already live before it.
+type changelogDeleteState struct {
+       active    bool
+       snapshots []Snapshot
+       preRange  []iceberg.ManifestEntry
+       added     map[int64][]iceberg.ManifestEntry
+       removed   map[int64]map[string]struct{}
+       specs     partitionSpecLookup
+       schema    *iceberg.Schema
+
+       addedCache  map[int64]*changelogDeleteIndex
+       beforeCache map[int64]*changelogDeleteIndex
+}
+
+func (s *IncrementalChangelogScan) planChangelogTasks(ctx context.Context, 
plan changelogTaskPlan) ([]plannedChangelogTask, error) {
+       state, err := s.loadChangelogDeleteState(ctx, plan)
+       if err != nil {
+               return nil, err
+       }
+
+       changedPaths := make(map[int64]map[string]struct{})
+       tasks := make([]plannedChangelogTask, 0, len(plan.dataEntries))
+       for _, entry := range plan.dataEntries {
+               ordinal, ok := plan.snapshotOrdinals[entry.SnapshotID()]
+               if !ok {
+                       continue
+               }
+               paths := changedPaths[entry.SnapshotID()]
+               if paths == nil {
+                       paths = make(map[string]struct{})
+                       changedPaths[entry.SnapshotID()] = paths
+               }
+               paths[entry.DataFile().FilePath()] = struct{}{}
+
+               deletes, err := state.deletesFor(entry)
+               if err != nil {
+                       return nil, fmt.Errorf("incremental changelog scan 
snapshot %d: %w", entry.SnapshotID(), err)
+               }
+               task, err := newChangelogScanTask(entry, ordinal, 
plan.residual, deletes)
+               if err != nil {
+                       return nil, fmt.Errorf("incremental changelog scan 
snapshot %d: %w", entry.SnapshotID(), err)
+               }
+               tasks = append(tasks, plannedChangelogTask{task: task})
+       }
+
+       rowTasks, err := s.planDeletedRowTasks(ctx, plan, state, changedPaths)
+       if err != nil {
+               return nil, err
+       }
+       tasks = append(tasks, rowTasks...)
+       sortChangelogTasks(tasks)
+
+       return tasks, nil
+}
+
+func (s *IncrementalChangelogScan) loadChangelogDeleteState(ctx 
context.Context, plan changelogTaskPlan) (*changelogDeleteState, error) {
+       state := &changelogDeleteState{
+               snapshots: plan.snapshots,
+               added:     make(map[int64][]iceberg.ManifestEntry),
+               removed:   make(map[int64]map[string]struct{}),
+               specs:     plan.scan.metadata,
+               schema:    plan.schema,
+       }
+       for _, snapshot := range plan.snapshots {
+               if err := ctx.Err(); err != nil {
+                       return nil, err
+               }
+               manifests := plan.deleteManifests[snapshot.SnapshotID]
+               if len(manifests) == 0 {
+                       continue
+               }
+               filtered, err := plan.scan.filterManifestsWithSchemaOptions(
+                       manifests, plan.schema, plan.metrics, 
plan.partitionFilters, true)
+               if err != nil {
+                       return nil, err
+               }
+               entries, err := readManifestEntries(ctx, plan.fs, filtered, 
false, false)
+               if err != nil {
+                       return nil, err
+               }
+               for _, entry := range entries {
+                       if entry.SnapshotID() != snapshot.SnapshotID {
+                               continue
+                       }
+                       if entry.Status() == iceberg.EntryStatusDELETED {
+                               if state.removed[snapshot.SnapshotID] == nil {
+                                       state.removed[snapshot.SnapshotID] = 
make(map[string]struct{})
+                               }
+                               
state.removed[snapshot.SnapshotID][entry.DataFile().FilePath()] = struct{}{}
+
+                               continue
+                       }
+                       state.added[snapshot.SnapshotID] = 
append(state.added[snapshot.SnapshotID], entry)
+               }
+       }
+
+       deletedData := false
+       for _, entry := range plan.dataEntries {
+               if entry.Status() == iceberg.EntryStatusDELETED {
+                       deletedData = true
+
+                       break
+               }
+       }
+       for _, entries := range state.added {
+               if len(entries) > 0 {
+                       state.active = true
+
+                       break
+               }
+       }
+       if len(state.removed) > 0 || deletedData {
+               state.active = true
+       }
+       if !state.active {
+               return state, nil
+       }
+
+       preRange, err := s.liveDeletesBeforeRange(ctx, plan.fs, plan.snapshots)
+       if err != nil {
+               return nil, err
+       }
+       state.preRange = preRange
+
+       return state, nil
+}
+
+// liveDeletesBeforeRange returns delete files that were already live on the
+// snapshot immediately before the changelog range.
+func (s *IncrementalChangelogScan) liveDeletesBeforeRange(ctx context.Context, 
fs io.IO, snapshots []Snapshot) ([]iceberg.ManifestEntry, error) {
+       parent := s.snapshotBeforeRange(snapshots)
+       if parent == nil {
+               return nil, nil
+       }
+       manifests, err := parent.Manifests(fs)
+       if err != nil {
+               return nil, err
+       }
+       deleteManifests := make([]iceberg.ManifestFile, 0)
+       for _, manifest := range manifests {
+               if manifest.ManifestContent() == iceberg.ManifestContentDeletes 
{
+                       deleteManifests = append(deleteManifests, manifest)
+               }
+       }
+       entries, err := readManifestEntries(ctx, fs, deleteManifests, false, 
false)

Review Comment:
   The in-range delete manifests go through `filterManifestsWithSchemaOptions`, 
but the pre-range ones are read unfiltered here, so every live delete file in 
the table lands in memory even with a selective partition filter. I'd run the 
parent's delete manifests through the same filter.
   



##########
table/incremental_changelog_scan.go:
##########
@@ -29,11 +28,11 @@ import (
        "github.com/apache/iceberg-go/metrics"
 )
 
-// IncrementalChangelogScan plans data-file changes between snapshots. It
-// emits insert and delete tasks for data-manifest entries and skips replace
-// snapshots. PlanFiles returns an error if any in-range snapshot's manifest
-// list references a delete manifest, including ones carried forward from
-// earlier snapshots.
+// IncrementalChangelogScan plans data-file and delete-file changes between

Review Comment:
   Worth calling out that Java's changelog scan rejects delete manifests 
outright ("Delete files are currently not supported in changelog scans"). This 
PR defines delete-aware planning Java never did (same-snapshot position 
deletes/DVs on added files, a `DeletedRowsScanTask` for existing files). I'm 
fine with that as an iceberg-go extension, but I'd note it here and in the PR 
description as iceberg-go-defined rather than Java-parity, so consumers know 
the semantics are ours to keep stable.
   



##########
table/incremental_changelog_deletes.go:
##########
@@ -0,0 +1,658 @@
+// 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 (
+       "cmp"
+       "context"
+       "fmt"
+       "slices"
+
+       "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/io"
+)
+
+// changelogTaskPlan is the snapshot range and the data-file changes already
+// read for one changelog plan.
+type changelogTaskPlan struct {
+       scan                *Scan
+       fs                  io.IO
+       snapshots           []Snapshot
+       manifestsBySnapshot map[int64][]iceberg.ManifestFile
+       deleteManifests     map[int64][]iceberg.ManifestFile
+       dataEntries         []iceberg.ManifestEntry
+       snapshotOrdinals    map[int64]int
+       schema              *iceberg.Schema
+       partitionFilters    *keyDefaultMapErr[int, iceberg.BooleanExpression]
+       residual            iceberg.BooleanExpression
+       metrics             *scanMetricsAccumulator
+}
+
+// changelogDeleteIndex matches delete files to a data file with the same
+// sequence rules as a normal scan.
+type changelogDeleteIndex struct {
+       pos *positionalDeleteIndex
+       dv  map[string]iceberg.ManifestEntry
+       eq  *equalityDeleteIndex
+}
+
+// changelogDeleteState tracks delete files added and removed inside the
+// changelog range, plus delete files that were already live before it.
+type changelogDeleteState struct {
+       active    bool
+       snapshots []Snapshot
+       preRange  []iceberg.ManifestEntry
+       added     map[int64][]iceberg.ManifestEntry
+       removed   map[int64]map[string]struct{}
+       specs     partitionSpecLookup
+       schema    *iceberg.Schema
+
+       addedCache  map[int64]*changelogDeleteIndex
+       beforeCache map[int64]*changelogDeleteIndex
+}
+
+func (s *IncrementalChangelogScan) planChangelogTasks(ctx context.Context, 
plan changelogTaskPlan) ([]plannedChangelogTask, error) {
+       state, err := s.loadChangelogDeleteState(ctx, plan)
+       if err != nil {
+               return nil, err
+       }
+
+       changedPaths := make(map[int64]map[string]struct{})
+       tasks := make([]plannedChangelogTask, 0, len(plan.dataEntries))
+       for _, entry := range plan.dataEntries {
+               ordinal, ok := plan.snapshotOrdinals[entry.SnapshotID()]
+               if !ok {
+                       continue
+               }
+               paths := changedPaths[entry.SnapshotID()]
+               if paths == nil {
+                       paths = make(map[string]struct{})
+                       changedPaths[entry.SnapshotID()] = paths
+               }
+               paths[entry.DataFile().FilePath()] = struct{}{}
+
+               deletes, err := state.deletesFor(entry)
+               if err != nil {
+                       return nil, fmt.Errorf("incremental changelog scan 
snapshot %d: %w", entry.SnapshotID(), err)
+               }
+               task, err := newChangelogScanTask(entry, ordinal, 
plan.residual, deletes)
+               if err != nil {
+                       return nil, fmt.Errorf("incremental changelog scan 
snapshot %d: %w", entry.SnapshotID(), err)
+               }
+               tasks = append(tasks, plannedChangelogTask{task: task})
+       }
+
+       rowTasks, err := s.planDeletedRowTasks(ctx, plan, state, changedPaths)
+       if err != nil {
+               return nil, err
+       }
+       tasks = append(tasks, rowTasks...)
+       sortChangelogTasks(tasks)
+
+       return tasks, nil
+}
+
+func (s *IncrementalChangelogScan) loadChangelogDeleteState(ctx 
context.Context, plan changelogTaskPlan) (*changelogDeleteState, error) {
+       state := &changelogDeleteState{
+               snapshots: plan.snapshots,
+               added:     make(map[int64][]iceberg.ManifestEntry),
+               removed:   make(map[int64]map[string]struct{}),
+               specs:     plan.scan.metadata,
+               schema:    plan.schema,

Review Comment:
   The delete index uses `plan.schema` (the range's end schema) for every 
snapshot, including older ones, so a historical equality delete whose field was 
dropped later would fail to resolve against this schema. I'd either resolve the 
eq-delete schema per snapshot or document the limitation.
   



##########
table/incremental_changelog_deletes.go:
##########
@@ -0,0 +1,658 @@
+// 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 (
+       "cmp"
+       "context"
+       "fmt"
+       "slices"
+
+       "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/io"
+)
+
+// changelogTaskPlan is the snapshot range and the data-file changes already
+// read for one changelog plan.
+type changelogTaskPlan struct {
+       scan                *Scan
+       fs                  io.IO
+       snapshots           []Snapshot
+       manifestsBySnapshot map[int64][]iceberg.ManifestFile
+       deleteManifests     map[int64][]iceberg.ManifestFile
+       dataEntries         []iceberg.ManifestEntry
+       snapshotOrdinals    map[int64]int
+       schema              *iceberg.Schema
+       partitionFilters    *keyDefaultMapErr[int, iceberg.BooleanExpression]
+       residual            iceberg.BooleanExpression
+       metrics             *scanMetricsAccumulator
+}
+
+// changelogDeleteIndex matches delete files to a data file with the same
+// sequence rules as a normal scan.
+type changelogDeleteIndex struct {
+       pos *positionalDeleteIndex
+       dv  map[string]iceberg.ManifestEntry
+       eq  *equalityDeleteIndex
+}
+
+// changelogDeleteState tracks delete files added and removed inside the
+// changelog range, plus delete files that were already live before it.
+type changelogDeleteState struct {
+       active    bool
+       snapshots []Snapshot
+       preRange  []iceberg.ManifestEntry
+       added     map[int64][]iceberg.ManifestEntry
+       removed   map[int64]map[string]struct{}
+       specs     partitionSpecLookup
+       schema    *iceberg.Schema
+
+       addedCache  map[int64]*changelogDeleteIndex
+       beforeCache map[int64]*changelogDeleteIndex
+}
+
+func (s *IncrementalChangelogScan) planChangelogTasks(ctx context.Context, 
plan changelogTaskPlan) ([]plannedChangelogTask, error) {
+       state, err := s.loadChangelogDeleteState(ctx, plan)
+       if err != nil {
+               return nil, err
+       }
+
+       changedPaths := make(map[int64]map[string]struct{})
+       tasks := make([]plannedChangelogTask, 0, len(plan.dataEntries))
+       for _, entry := range plan.dataEntries {
+               ordinal, ok := plan.snapshotOrdinals[entry.SnapshotID()]
+               if !ok {
+                       continue
+               }
+               paths := changedPaths[entry.SnapshotID()]
+               if paths == nil {
+                       paths = make(map[string]struct{})
+                       changedPaths[entry.SnapshotID()] = paths
+               }
+               paths[entry.DataFile().FilePath()] = struct{}{}
+
+               deletes, err := state.deletesFor(entry)
+               if err != nil {
+                       return nil, fmt.Errorf("incremental changelog scan 
snapshot %d: %w", entry.SnapshotID(), err)
+               }
+               task, err := newChangelogScanTask(entry, ordinal, 
plan.residual, deletes)
+               if err != nil {
+                       return nil, fmt.Errorf("incremental changelog scan 
snapshot %d: %w", entry.SnapshotID(), err)
+               }
+               tasks = append(tasks, plannedChangelogTask{task: task})
+       }
+
+       rowTasks, err := s.planDeletedRowTasks(ctx, plan, state, changedPaths)
+       if err != nil {
+               return nil, err
+       }
+       tasks = append(tasks, rowTasks...)
+       sortChangelogTasks(tasks)
+
+       return tasks, nil
+}
+
+func (s *IncrementalChangelogScan) loadChangelogDeleteState(ctx 
context.Context, plan changelogTaskPlan) (*changelogDeleteState, error) {
+       state := &changelogDeleteState{
+               snapshots: plan.snapshots,
+               added:     make(map[int64][]iceberg.ManifestEntry),
+               removed:   make(map[int64]map[string]struct{}),
+               specs:     plan.scan.metadata,
+               schema:    plan.schema,
+       }
+       for _, snapshot := range plan.snapshots {
+               if err := ctx.Err(); err != nil {
+                       return nil, err
+               }
+               manifests := plan.deleteManifests[snapshot.SnapshotID]
+               if len(manifests) == 0 {
+                       continue
+               }
+               filtered, err := plan.scan.filterManifestsWithSchemaOptions(
+                       manifests, plan.schema, plan.metrics, 
plan.partitionFilters, true)
+               if err != nil {
+                       return nil, err
+               }
+               entries, err := readManifestEntries(ctx, plan.fs, filtered, 
false, false)
+               if err != nil {
+                       return nil, err
+               }
+               for _, entry := range entries {
+                       if entry.SnapshotID() != snapshot.SnapshotID {
+                               continue
+                       }
+                       if entry.Status() == iceberg.EntryStatusDELETED {
+                               if state.removed[snapshot.SnapshotID] == nil {
+                                       state.removed[snapshot.SnapshotID] = 
make(map[string]struct{})
+                               }
+                               
state.removed[snapshot.SnapshotID][entry.DataFile().FilePath()] = struct{}{}
+
+                               continue
+                       }
+                       state.added[snapshot.SnapshotID] = 
append(state.added[snapshot.SnapshotID], entry)
+               }
+       }
+
+       deletedData := false
+       for _, entry := range plan.dataEntries {
+               if entry.Status() == iceberg.EntryStatusDELETED {
+                       deletedData = true
+
+                       break
+               }
+       }
+       for _, entries := range state.added {
+               if len(entries) > 0 {
+                       state.active = true
+
+                       break
+               }
+       }
+       if len(state.removed) > 0 || deletedData {
+               state.active = true
+       }
+       if !state.active {
+               return state, nil
+       }
+
+       preRange, err := s.liveDeletesBeforeRange(ctx, plan.fs, plan.snapshots)
+       if err != nil {
+               return nil, err
+       }
+       state.preRange = preRange
+
+       return state, nil
+}
+
+// liveDeletesBeforeRange returns delete files that were already live on the
+// snapshot immediately before the changelog range.
+func (s *IncrementalChangelogScan) liveDeletesBeforeRange(ctx context.Context, 
fs io.IO, snapshots []Snapshot) ([]iceberg.ManifestEntry, error) {
+       parent := s.snapshotBeforeRange(snapshots)
+       if parent == nil {
+               return nil, nil
+       }
+       manifests, err := parent.Manifests(fs)
+       if err != nil {
+               return nil, err
+       }
+       deleteManifests := make([]iceberg.ManifestFile, 0)
+       for _, manifest := range manifests {
+               if manifest.ManifestContent() == iceberg.ManifestContentDeletes 
{
+                       deleteManifests = append(deleteManifests, manifest)
+               }
+       }
+       entries, err := readManifestEntries(ctx, fs, deleteManifests, false, 
false)
+       if err != nil {
+               return nil, err
+       }
+
+       return liveManifestEntries(entries), nil
+}
+
+func (s *IncrementalChangelogScan) snapshotBeforeRange(snapshots []Snapshot) 
*Snapshot {
+       if s.fromSnapshotID != nil && !s.fromInclusive {
+               return s.scan.metadata.SnapshotByID(*s.fromSnapshotID)
+       }
+       if len(snapshots) == 0 || snapshots[0].ParentSnapshotID == nil {
+               return nil
+       }
+
+       return s.scan.metadata.SnapshotByID(*snapshots[0].ParentSnapshotID)
+}
+
+func (s *IncrementalChangelogScan) planDeletedRowTasks(
+       ctx context.Context,
+       plan changelogTaskPlan,
+       state *changelogDeleteState,
+       changedPaths map[int64]map[string]struct{},
+) ([]plannedChangelogTask, error) {
+       if state == nil || !state.active {
+               return nil, nil
+       }
+       matcher, err := plan.scan.dataFileMatcher(plan.schema, 
plan.partitionFilters)
+       if err != nil {
+               return nil, err
+       }
+
+       var tasks []plannedChangelogTask
+       for _, snapshot := range plan.snapshots {
+               if err := ctx.Err(); err != nil {
+                       return nil, err
+               }
+               if len(state.added[snapshot.SnapshotID]) == 0 {
+                       continue
+               }
+               addedIndex, err := state.addedIndex(snapshot.SnapshotID)
+               if err != nil {
+                       return nil, fmt.Errorf("incremental changelog scan 
snapshot %d: %w", snapshot.SnapshotID, err)
+               }
+               manifests := plan.manifestsBySnapshot[snapshot.SnapshotID]
+               dataManifests := make([]iceberg.ManifestFile, 0, len(manifests))
+               for _, manifest := range manifests {
+                       if manifest.ManifestContent() == 
iceberg.ManifestContentData {
+                               dataManifests = append(dataManifests, manifest)
+                       }
+               }
+               // Prune with the scan partition filters before opening 
manifests.
+               // A local accumulator keeps carried-forward data manifests out 
of the
+               // changelog scan report, which already counted them.
+               var pruned scanMetricsAccumulator
+               dataManifests, err = plan.scan.filterManifestsWithSchemaOptions(
+                       dataManifests, plan.schema, &pruned, 
plan.partitionFilters, true)
+               if err != nil {
+                       return nil, err
+               }
+               live, err := liveDataEntries(ctx, plan.fs, dataManifests)
+               if err != nil {
+                       return nil, err
+               }
+               ordinal := plan.snapshotOrdinals[snapshot.SnapshotID]
+               seen := make(map[string]struct{})
+               for _, entry := range live {
+                       path := entry.DataFile().FilePath()
+                       if _, changed := 
changedPaths[snapshot.SnapshotID][path]; changed {
+                               continue
+                       }
+                       if _, ok := seen[path]; ok {
+                               continue
+                       }
+                       seen[path] = struct{}{}
+
+                       keep, err := matcher(entry.DataFile())
+                       if err != nil {
+                               return nil, err
+                       }
+                       if !keep {
+                               continue
+                       }
+                       added, err := addedIndex.forDataFile(entry)
+                       if err != nil {
+                               return nil, fmt.Errorf("incremental changelog 
scan snapshot %d: %w", snapshot.SnapshotID, err)
+                       }
+                       if len(added) == 0 {
+                               continue
+                       }
+                       var existing []iceberg.DataFile
+                       before, err := state.beforeIndex(snapshot.SnapshotID)
+                       if err != nil {
+                               return nil, fmt.Errorf("incremental changelog 
scan snapshot %d: %w", snapshot.SnapshotID, err)
+                       }
+                       if before != nil {
+                               existing, err = before.forDataFile(entry)
+                               if err != nil {
+                                       return nil, fmt.Errorf("incremental 
changelog scan snapshot %d: %w", snapshot.SnapshotID, err)
+                               }
+                       }
+
+                       task, err := NewDeletedRowsScanTask(entry.DataFile(), 
added, existing, ordinal, snapshot.SnapshotID)
+                       if err != nil {
+                               return nil, fmt.Errorf("incremental changelog 
scan snapshot %d: %w", snapshot.SnapshotID, err)
+                       }
+                       configureChangelogFileTask(&task.FileScanTask, entry, 
plan.residual)
+                       tasks = append(tasks, plannedChangelogTask{task: task})
+               }
+       }
+
+       return tasks, nil
+}
+
+func (st *changelogDeleteState) deletesFor(entry iceberg.ManifestEntry) 
([]iceberg.DataFile, error) {
+       if st == nil || !st.active {
+               return nil, nil
+       }
+
+       var (
+               idx *changelogDeleteIndex
+               err error
+       )
+       switch entry.Status() {
+       case iceberg.EntryStatusADDED:
+               idx, err = st.addedIndex(entry.SnapshotID())
+       case iceberg.EntryStatusDELETED:
+               idx, err = st.beforeIndex(entry.SnapshotID())
+       default:
+               return nil, fmt.Errorf("%w: unknown manifest entry status %d", 
ErrInvalidMetadata, entry.Status())
+       }
+       if err != nil || idx == nil {
+               return nil, err
+       }
+
+       return idx.forDataFile(entry)
+}
+
+func (st *changelogDeleteState) addedIndex(snapshotID int64) 
(*changelogDeleteIndex, error) {
+       if st.addedCache == nil {
+               st.addedCache = make(map[int64]*changelogDeleteIndex)
+       }
+       if idx, ok := st.addedCache[snapshotID]; ok {
+               return idx, nil
+       }
+       idx, err := buildChangelogDeleteIndex(st.added[snapshotID], st.specs, 
st.schema)
+       if err != nil {
+               return nil, err
+       }
+       st.addedCache[snapshotID] = idx
+
+       return idx, nil
+}
+
+func (st *changelogDeleteState) beforeIndex(snapshotID int64) 
(*changelogDeleteIndex, error) {
+       if st.beforeCache == nil {
+               st.beforeCache = make(map[int64]*changelogDeleteIndex)
+       }
+       if idx, ok := st.beforeCache[snapshotID]; ok {
+               return idx, nil
+       }
+       idx, err := buildChangelogDeleteIndex(st.entriesBefore(snapshotID), 
st.specs, st.schema)
+       if err != nil {
+               return nil, err
+       }
+       st.beforeCache[snapshotID] = idx
+
+       return idx, nil
+}
+
+// entriesBefore returns delete-file entries that applied strictly before
+// snapshotID. Removals drop a path from the running set. A path removed and
+// added again in one snapshot stays live as the added entry.
+func (st *changelogDeleteState) entriesBefore(snapshotID int64) 
[]iceberg.ManifestEntry {
+       removed := make(map[string]struct{})
+       var accumulated []iceberg.ManifestEntry
+       for _, snapshot := range st.snapshots {
+               if snapshot.SnapshotID == snapshotID {
+                       break
+               }
+               currentRemoved := st.removed[snapshot.SnapshotID]
+               if len(currentRemoved) > 0 {
+                       for path := range currentRemoved {
+                               removed[path] = struct{}{}
+                       }
+                       accumulated = dropManifestEntries(accumulated, 
currentRemoved)
+               }
+               accumulated = append(accumulated, 
st.added[snapshot.SnapshotID]...)
+       }
+
+       out := make([]iceberg.ManifestEntry, 0, 
len(st.preRange)+len(accumulated))
+       for _, entry := range st.preRange {
+               if _, gone := removed[entry.DataFile().FilePath()]; gone {
+                       continue
+               }
+               out = append(out, entry)
+       }
+       out = append(out, accumulated...)
+
+       return keepNewestDeletionVector(out)
+}
+
+func (idx *changelogDeleteIndex) forDataFile(entry iceberg.ManifestEntry) 
([]iceberg.DataFile, error) {
+       if idx == nil {
+               return nil, nil
+       }
+       task, err := fileScanTaskForDataEntry(entry, idx.pos, idx.dv, idx.eq)
+       if err != nil {
+               return nil, err
+       }
+       files := allDeleteFiles(task)
+       if len(files) == 0 {
+               return nil, nil
+       }
+
+       return files, nil
+}
+
+func buildChangelogDeleteIndex(entries []iceberg.ManifestEntry, specs 
partitionSpecLookup, schema *iceberg.Schema) (*changelogDeleteIndex, error) {
+       if len(entries) == 0 {
+               return nil, nil
+       }
+       entries = keepNewestDeletionVector(entries)
+       classified, err := classifyManifestEntries(entries)
+       if err != nil {
+               return nil, err
+       }
+       if len(classified.dataEntries) > 0 {
+               return nil, fmt.Errorf("%w: expected delete file, got %s",
+                       ErrInvalidMetadata, 
classified.dataEntries[0].DataFile().FilePath())
+       }
+
+       pos, err := 
buildPositionalDeleteIndex(classified.positionalDeleteEntries)
+       if err != nil {
+               return nil, err
+       }
+       dv, err := buildDVIndex(classified.dvEntries)
+       if err != nil {
+               return nil, err
+       }
+       eq, err := buildEqualityDeleteIndex(classified.equalityDeleteEntries, 
specs, schema)
+       if err != nil {
+               return nil, err
+       }
+
+       return &changelogDeleteIndex{pos: pos, dv: dv, eq: eq}, nil
+}
+
+func (scan *Scan) dataFileMatcher(
+       schema *iceberg.Schema,
+       partitionFilters *keyDefaultMapErr[int, iceberg.BooleanExpression],
+) (func(iceberg.DataFile) (bool, error), error) {
+       metricsEval, err := newInclusiveMetricsEvaluator(
+               schema,
+               scan.rowFilter,
+               scan.caseSensitive,
+               scan.options["include_empty_files"] == "true",
+       )
+       if err != nil {
+               return nil, err
+       }
+       partitionEvaluators := newKeyDefaultMapWrapErr(func(specID int) 
(func(iceberg.DataFile) (bool, error), error) {
+               return buildPartitionEvaluator(specID, scan.metadata, schema, 
partitionFilters, scan.caseSensitive)
+       })
+
+       return func(file iceberg.DataFile) (bool, error) {
+               partEval, err := partitionEvaluators.Get(int(file.SpecID()))
+               if err != nil {
+                       return false, fmt.Errorf("failed to build partition 
evaluator for spec %d: %w", file.SpecID(), err)
+               }
+               use, err := partEval(file)
+               if err != nil || !use {
+                       return use, err
+               }
+
+               return metricsEval(file)
+       }, nil
+}
+
+func liveDataEntries(ctx context.Context, fs io.IO, manifests 
[]iceberg.ManifestFile) ([]iceberg.ManifestEntry, error) {
+       dataManifests := make([]iceberg.ManifestFile, 0, len(manifests))
+       for _, manifest := range manifests {
+               if manifest.ManifestContent() == iceberg.ManifestContentData {
+                       dataManifests = append(dataManifests, manifest)
+               }
+       }
+       entries, err := readManifestEntries(ctx, fs, dataManifests, false, 
false)
+       if err != nil {
+               return nil, err
+       }
+       dataEntries := make([]iceberg.ManifestEntry, 0, len(entries))
+       for _, entry := range entries {
+               if entry.DataFile().ContentType() == iceberg.EntryContentData {
+                       dataEntries = append(dataEntries, entry)
+               }
+       }
+
+       return liveManifestEntries(dataEntries), nil
+}
+
+// liveManifestEntries keeps the highest-sequence entry for each path and
+// drops paths whose winning entry is a deletion. Equal sequence numbers
+// keep the later entry.
+func liveManifestEntries(entries []iceberg.ManifestEntry) 
[]iceberg.ManifestEntry {
+       latest := make(map[string]iceberg.ManifestEntry, len(entries))
+       order := make([]string, 0, len(entries))
+       for _, entry := range entries {
+               path := entry.DataFile().FilePath()
+               current, seen := latest[path]
+               if !seen {
+                       order = append(order, path)
+                       latest[path] = entry
+
+                       continue
+               }
+               if entry.SequenceNum() >= current.SequenceNum() {

Review Comment:
   The `>=` tie-break resolves equal sequence numbers by iteration order, and 
equal is the common case here, not an edge: a DELETED entry keeps the data 
sequence number of the ADDED entry it supersedes. So for a path that's ADDED in 
one manifest and DELETED in another at the same seq, the winner depends on 
manifest-list order, which the spec doesn't pin down. If the stale ADDED entry 
is listed last, a removed file reads as still live.
   
   I'd break ties on status (DELETED beats ADDED) or on the entry's snapshot 
id, and add a test that runs both orderings through `liveManifestEntries`. The 
existing test only uses distinct seqs 1/2/3, so it never hits the tie.
   



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