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


##########
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:
   A missing parent is no longer treated as an empty pre-range, and it does not 
fail the scan. liveDeletesBeforeRange rebuilds those deletes from the first 
timeline snapshot's delete manifests: a non-deleted entry written by another 
snapshot, or a deleted entry written by this snapshot. 
TestIncrementalChangelogScanPlansOverwriteWithExpiredParent plans a 
copy-on-write overwrite, and 
TestIncrementalChangelogScanKeepsCarriedDeleteWhenParentExpired checks the 
carried delete is on ExistingDeletes.



##########
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:
   Replace snapshots are still omitted from the tasks. plan.timeline keeps the 
full snapshot range, and loadChangelogDeleteState replays delete-file adds and 
removes from those snapshots. 
TestIncrementalChangelogScanFoldsReplaceDeleteRewrites checks that no task is 
committed by the replace and that the removed data file's ExistingDeletes is 
the rewritten delete file.



##########
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:
   Equal entry sequence numbers no longer compare snapshot IDs. 
changelogEntrySupersedes uses the sequence number of the snapshot that wrote 
each entry, looked up from table metadata. If those sequence numbers are equal, 
or either snapshot is missing, DELETED wins. 
TestLiveManifestEntriesUsesTheLatestStatus deletes from snapshot 3 and adds 
from snapshot 20, and checks both orders.



##########
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:
   planDeletedRowTasks now caches decoded manifest entries by path, so a 
manifest carried into a later snapshot is not opened again. Equality deletes 
and position deletes without a referenced data file are still not narrowed to 
one path before the manifest is opened.



##########
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:
   TestIncrementalChangelogScanAppliesDeletesBySequence sets the data sequence 
to 5 and varies position, equality, and deletion-vector sequences, including 
the ones that must not apply. 
TestIncrementalChangelogScanAppliesEveryDeleteInOneSnapshot commits two 
position deletes in one snapshot.



##########
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:
   Pre-range delete manifests now go through filterManifestsWithSchemaOptions. 
That pass uses its own metrics accumulator so the manifests are not counted 
twice in the changelog report.



##########
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:
   buildChangelogDeleteIndex documents this. Equality deletes are resolved 
against the schema at the end of the range, so a historical equality delete 
whose columns were dropped before that schema cannot be resolved.



##########
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:
   The IncrementalChangelogScan doc says Java's changelog scan rejects delete 
manifests, and that the delete-aware tasks are iceberg-go behavior. The PR 
description says the same.



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