This is an automated email from the ASF dual-hosted git repository.

laskoviymishka pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git


The following commit(s) were added to refs/heads/main by this push:
     new 2a51abaf7 feat(table): plan delete-file changes in incremental 
changelog scans (#2169)
2a51abaf7 is described below

commit 2a51abaf7490d9f1714785a5be2b144cd0b727b4
Author: Digvijay <[email protected]>
AuthorDate: Fri Oct 30 17:26:57 2026 -0500

    feat(table): plan delete-file changes in incremental changelog scans (#2169)
---
 table/incremental_changelog_deletes.go   |  940 +++++++++++++++++++++
 table/incremental_changelog_scan.go      |  186 +++--
 table/incremental_changelog_scan_test.go | 1330 +++++++++++++++++++++++++++++-
 3 files changed, 2363 insertions(+), 93 deletions(-)

diff --git a/table/incremental_changelog_deletes.go 
b/table/incremental_changelog_deletes.go
new file mode 100644
index 000000000..fb57a0bdc
--- /dev/null
+++ b/table/incremental_changelog_deletes.go
@@ -0,0 +1,940 @@
+// 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"
+       "strconv"
+
+       "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
+       timeline            []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
+       // beforeEntries is each snapshot's delete set strictly before that
+       // snapshot. afterEntries is the set after the last timeline snapshot.
+       beforeEntries map[int64][]iceberg.ManifestEntry
+       afterEntries  []iceberg.ManifestEntry
+}
+
+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) {
+       // timeline includes replace snapshots so their delete-file adds and
+       // removals update later tasks. Task emission uses plan.snapshots, which
+       // omits those replace snapshots.
+       timeline := plan.timeline
+       if len(timeline) == 0 {
+               timeline = plan.snapshots
+       }
+       state := &changelogDeleteState{
+               snapshots: timeline,
+               added:     make(map[int64][]iceberg.ManifestEntry),
+               removed:   make(map[int64]map[string]struct{}),
+               specs:     plan.scan.metadata,
+               schema:    plan.schema,
+       }
+       for _, snapshot := range timeline {
+               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][deleteEntryKey(entry.DataFile())] = 
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)
+       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. When that snapshot has been
+// expired, the first in-range snapshot's own delete manifests are used 
instead.
+func (s *IncrementalChangelogScan) liveDeletesBeforeRange(ctx context.Context, 
plan changelogTaskPlan) ([]iceberg.ManifestEntry, error) {
+       snapshots := timelineSnapshots(plan)
+       parent := s.snapshotBeforeRange(snapshots)
+       if parent == nil {
+               if len(snapshots) == 0 || snapshots[0].ParentSnapshotID == nil {
+                       return nil, nil
+               }
+
+               return s.deletesLiveBeforeExpiredParent(ctx, plan, snapshots[0])
+       }
+       manifests, err := parent.Manifests(plan.fs)
+       if err != nil {
+               return nil, err
+       }
+       entries, err := s.readDeleteManifestEntries(ctx, plan, manifests)
+       if err != nil {
+               return nil, err
+       }
+
+       return liveDeleteEntries(entries, 
snapshotSequences(plan.scan.metadata)), nil
+}
+
+// deletesLiveBeforeExpiredParent rebuilds deletes that applied before the
+// range from the first timeline snapshot. This is exact when that snapshot
+// records a DELETED entry, under its own snapshot id, for every delete file
+// it removed, and carried entries keep the snapshot id that added them. A
+// writer that drops a fully removed delete manifest, instead of recording a
+// DELETED entry, cannot be reconstructed. When the snapshot summary reports
+// more removed-delete-files than the DELETED delete entries in its manifests,
+// planning returns an error instead of dropping those deletes. That count is
+// taken before partition pruning, so a filtered scan does not look like a
+// missing delete file.
+func (s *IncrementalChangelogScan) deletesLiveBeforeExpiredParent(
+       ctx context.Context, plan changelogTaskPlan, snapshot Snapshot,
+) ([]iceberg.ManifestEntry, error) {
+       manifests, err := snapshot.Manifests(plan.fs)
+       if err != nil {
+               return nil, err
+       }
+       raw, err := readManifestEntries(ctx, plan.fs, 
deleteContentManifests(manifests), false, false)
+       if err != nil {
+               return nil, err
+       }
+       if err := checkRemovedDeleteFiles(snapshot, raw); err != nil {
+               return nil, err
+       }
+       entries, err := s.readDeleteManifestEntries(ctx, plan, manifests)
+       if err != nil {
+               return nil, err
+       }
+
+       return entriesLiveBefore(entries, snapshot.SnapshotID), nil
+}
+
+func deleteContentManifests(manifests []iceberg.ManifestFile) 
[]iceberg.ManifestFile {
+       out := make([]iceberg.ManifestFile, 0)
+       for _, manifest := range manifests {
+               if manifest.ManifestContent() == iceberg.ManifestContentDeletes 
{
+                       out = append(out, manifest)
+               }
+       }
+
+       return out
+}
+
+// checkRemovedDeleteFiles reports an error when the snapshot summary claims
+// more removed delete files than the DELETED delete entries recorded under
+// that snapshot id. A snapshot summary without removed-delete-files passes
+// the guard; the guard only fires when the summary claims more removals than
+// the DELETED entries recorded. Entries must be the unfiltered
+// delete-manifest contents.
+func checkRemovedDeleteFiles(snapshot Snapshot, entries 
[]iceberg.ManifestEntry) error {
+       if snapshot.Summary == nil || len(snapshot.Summary.Properties) == 0 {
+               return nil
+       }
+       raw, ok := snapshot.Summary.Properties[removedDeleteFilesKey]
+       if !ok {
+               return nil
+       }
+       reported, err := strconv.Atoi(raw)
+       if err != nil {
+               return fmt.Errorf("%w: snapshot %d removed-delete-files %q", 
iceberg.ErrInvalidArgument, snapshot.SnapshotID, raw)
+       }
+       recorded := 0
+       for _, entry := range entries {
+               if entry.Status() != iceberg.EntryStatusDELETED || 
entry.SnapshotID() != snapshot.SnapshotID {
+                       continue
+               }
+               if entry.DataFile().ContentType() == iceberg.EntryContentData {
+                       continue
+               }
+               recorded++
+       }
+       if reported > recorded {
+               return fmt.Errorf("%w: snapshot %d %s is %d but recorded %d 
deleted delete entries",
+                       iceberg.ErrInvalidArgument, snapshot.SnapshotID, 
removedDeleteFilesKey, reported, recorded)
+       }
+
+       return nil
+}
+
+func (s *IncrementalChangelogScan) readDeleteManifestEntries(
+       ctx context.Context, plan changelogTaskPlan, manifests 
[]iceberg.ManifestFile,
+) ([]iceberg.ManifestEntry, error) {
+       deleteManifests := deleteContentManifests(manifests)
+       // Prune with the scan partition filters. A local accumulator keeps this
+       // pass from counting the same delete manifests in the changelog report.
+       var pruned scanMetricsAccumulator
+       deleteManifests, err := plan.scan.filterManifestsWithSchemaOptions(
+               deleteManifests, plan.schema, &pruned, plan.partitionFilters, 
true)
+       if err != nil {
+               return nil, err
+       }
+
+       return readManifestEntries(ctx, plan.fs, deleteManifests, false, false)
+}
+
+// snapshotBeforeRange returns the snapshot whose live delete files already
+// applied before the range. An exclusive start that is still present is that
+// snapshot. When it has been expired, the parent of the first snapshot in the
+// range is used instead. A nil result with a recorded parent means that parent
+// has expired.
+func (s *IncrementalChangelogScan) snapshotBeforeRange(snapshots []Snapshot) 
*Snapshot {
+       if s.fromSnapshotID != nil && !s.fromInclusive {
+               if snapshot := s.scan.metadata.SnapshotByID(*s.fromSnapshotID); 
snapshot != nil {
+                       return snapshot
+               }
+       }
+       if len(snapshots) == 0 || snapshots[0].ParentSnapshotID == nil {
+               return nil
+       }
+
+       return s.scan.metadata.SnapshotByID(*snapshots[0].ParentSnapshotID)
+}
+
+func timelineSnapshots(plan changelogTaskPlan) []Snapshot {
+       if len(plan.timeline) > 0 {
+               return plan.timeline
+       }
+
+       return plan.snapshots
+}
+
+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
+       }
+       // Partition and metrics evaluators are constant for the scan, so a 
manifest
+       // pruned while it is opened can be reused for every later snapshot.
+       partitionFilter, metricsEval, err := 
plan.scan.changelogDataFilters(plan.schema, plan.partitionFilters)
+       if err != nil {
+               return nil, err
+       }
+
+       var tasks []plannedChangelogTask
+       decodedManifests := make(map[string][]iceberg.ManifestEntry)
+       sequences := snapshotSequences(plan.scan.metadata)
+       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
+               }
+               // Each snapshot that adds deletes reads that snapshot's live 
data
+               // manifests. Equality deletes and position deletes without a 
referenced
+               // data file cannot be narrowed to one path, so those snapshots 
scan the
+               // manifests the partition filter keeps. Entries that fail the 
scan's
+               // partition or metrics evaluators are dropped while the 
manifest is
+               // opened, then cached by path.
+               live, err := liveDataEntries(ctx, plan.fs, dataManifests, 
decodedManifests, sequences, partitionFilter, metricsEval)
+               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{}{}
+
+                       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. Each snapshot's set is cached from the previous snapshot's
+// result: drop the deletes that snapshot removed, then append the deletes it
+// added. A delete removed and added again in one snapshot stays live as the
+// added entry. keepNewestDeletionVector runs on each result.
+func (st *changelogDeleteState) entriesBefore(snapshotID int64) 
[]iceberg.ManifestEntry {
+       if st.beforeEntries == nil {
+               st.beforeEntries = make(map[int64][]iceberg.ManifestEntry, 
len(st.snapshots))
+               running := keepNewestDeletionVector(slices.Clone(st.preRange))
+               for _, snapshot := range st.snapshots {
+                       st.beforeEntries[snapshot.SnapshotID] = running
+                       running = nextDeleteEntries(running, 
st.added[snapshot.SnapshotID], st.removed[snapshot.SnapshotID])
+               }
+               st.afterEntries = running
+       }
+       if entries, ok := st.beforeEntries[snapshotID]; ok {
+               return entries
+       }
+
+       return st.afterEntries
+}
+
+// nextDeleteEntries returns before with removed identities dropped and added
+// entries appended. The result does not alias before, so a cached set stays
+// intact when a later snapshot adds deletes.
+func nextDeleteEntries(before, added []iceberg.ManifestEntry, removed 
map[string]struct{}) []iceberg.ManifestEntry {
+       next := dropManifestEntries(before, removed)
+       if len(added) == 0 {
+               return keepNewestDeletionVector(next)
+       }
+       out := make([]iceberg.ManifestEntry, len(next), len(next)+len(added))
+       copy(out, next)
+       out = append(out, added...)
+
+       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
+}
+
+// buildChangelogDeleteIndex groups delete files with the scan's sequence
+// rules. Equality deletes are resolved against schema, which is the schema at
+// the end of the changelog range. An equality delete whose columns were
+// dropped before that schema cannot be resolved.
+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
+}
+
+// changelogDataFilters returns the partition and metrics evaluators applied
+// while a data manifest is opened. Both are constant for one scan.
+func (scan *Scan) changelogDataFilters(
+       schema *iceberg.Schema,
+       partitionFilters *keyDefaultMapErr[int, iceberg.BooleanExpression],
+) (func(iceberg.DataFile) (bool, error), 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, nil, err
+       }
+       partitionEvaluators := newKeyDefaultMapWrapErr(func(specID int) 
(func(iceberg.DataFile) (bool, error), error) {
+               return buildPartitionEvaluator(specID, scan.metadata, schema, 
partitionFilters, scan.caseSensitive)
+       })
+       partitionFilter := 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)
+               }
+
+               return partEval(file)
+       }
+
+       return partitionFilter, metricsEval, nil
+}
+
+func liveDataEntries(
+       ctx context.Context,
+       fs io.IO,
+       manifests []iceberg.ManifestFile,
+       decoded map[string][]iceberg.ManifestEntry,
+       sequences snapshotSequenceLookup,
+       partitionFilter, metricsEval func(iceberg.DataFile) (bool, error),
+) ([]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 := readCachedManifestEntries(ctx, fs, dataManifests, 
decoded, partitionFilter, metricsEval)
+       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, sequences), nil
+}
+
+// snapshotSequenceLookup returns the sequence number of the snapshot that
+// wrote an entry. Snapshot IDs are random, so they are not an order. The
+// boolean is false when that snapshot is not in table metadata.
+type snapshotSequenceLookup func(snapshotID int64) (sequence int64, ok bool)
+
+func snapshotSequences(metadata Metadata) snapshotSequenceLookup {
+       return func(snapshotID int64) (int64, bool) {
+               snapshot := metadata.SnapshotByID(snapshotID)
+               if snapshot == nil {
+                       return 0, false
+               }
+
+               return snapshot.SequenceNumber, true
+       }
+}
+
+// liveManifestEntries keeps the winning entry for each data-file path and
+// drops paths whose winning entry is a deletion. A higher entry sequence
+// number wins. Equal entry sequence numbers use the sequence number of the
+// snapshot that wrote each entry. When those are equal or unknown, a deletion
+// wins.
+func liveManifestEntries(entries []iceberg.ManifestEntry, sequences 
snapshotSequenceLookup) []iceberg.ManifestEntry {
+       return liveEntriesByKey(entries, func(entry iceberg.ManifestEntry) 
string {
+               return entry.DataFile().FilePath()
+       }, sequences)
+}
+
+// liveDeleteEntries is liveManifestEntries for delete files. Deletion vectors
+// that share a Puffin path stay distinct, keyed by that path plus the
+// referenced data file and content offset.
+func liveDeleteEntries(entries []iceberg.ManifestEntry, sequences 
snapshotSequenceLookup) []iceberg.ManifestEntry {
+       return liveEntriesByKey(entries, func(entry iceberg.ManifestEntry) 
string {
+               return deleteEntryKey(entry.DataFile())
+       }, sequences)
+}
+
+func liveEntriesByKey(entries []iceberg.ManifestEntry, key 
func(iceberg.ManifestEntry) string, sequences snapshotSequenceLookup) 
[]iceberg.ManifestEntry {
+       latest := make(map[string]iceberg.ManifestEntry, len(entries))
+       order := make([]string, 0, len(entries))
+       for _, entry := range entries {
+               id := key(entry)
+               current, seen := latest[id]
+               if !seen {
+                       order = append(order, id)
+                       latest[id] = entry
+
+                       continue
+               }
+               if changelogEntrySupersedes(entry, current, sequences) {
+                       latest[id] = entry
+               }
+       }
+       out := make([]iceberg.ManifestEntry, 0, len(latest))
+       for _, id := range order {
+               entry := latest[id]
+               if entry.Status() == iceberg.EntryStatusDELETED {
+                       continue
+               }
+               out = append(out, entry)
+       }
+
+       return out
+}
+
+// changelogEntrySupersedes reports whether next replaces current. The entry
+// sequence number wins first. Equal entry sequence numbers use the sequence
+// number of the snapshot that wrote each entry. Snapshot IDs are not compared.
+// When the snapshot sequence numbers are equal, or either snapshot is unknown,
+// a deletion wins.
+func changelogEntrySupersedes(next, current iceberg.ManifestEntry, sequences 
snapshotSequenceLookup) bool {
+       if next.DataSequenceNumber() != current.DataSequenceNumber() {
+               return next.DataSequenceNumber() > current.DataSequenceNumber()
+       }
+       if sequences != nil {
+               nextSequence, nextOK := sequences(next.SnapshotID())
+               currentSequence, currentOK := sequences(current.SnapshotID())
+               if nextOK && currentOK && nextSequence != currentSequence {
+                       return nextSequence > currentSequence
+               }
+       }
+
+       return next.Status() == iceberg.EntryStatusDELETED && current.Status() 
!= iceberg.EntryStatusDELETED
+}
+
+// entriesLiveBefore selects delete entries that were live before snapshotID.
+// A non-deleted entry written by another snapshot was carried forward. A
+// deleted entry written by snapshotID was live until this snapshot removed it.
+// One entry is kept per delete identity, preferring a non-deleted entry.
+func entriesLiveBefore(entries []iceberg.ManifestEntry, snapshotID int64) 
[]iceberg.ManifestEntry {
+       latest := make(map[string]iceberg.ManifestEntry)
+       order := make([]string, 0)
+       for _, entry := range entries {
+               carried := entry.Status() != iceberg.EntryStatusDELETED && 
entry.SnapshotID() != snapshotID
+               removedHere := entry.Status() == iceberg.EntryStatusDELETED && 
entry.SnapshotID() == snapshotID
+               if !carried && !removedHere {
+                       continue
+               }
+               key := deleteEntryKey(entry.DataFile())
+               current, seen := latest[key]
+               if !seen {
+                       order = append(order, key)
+                       latest[key] = entry
+
+                       continue
+               }
+               if current.Status() == iceberg.EntryStatusDELETED && 
entry.Status() != iceberg.EntryStatusDELETED {
+                       latest[key] = entry
+               }
+       }
+       out := make([]iceberg.ManifestEntry, 0, len(order))
+       for _, key := range order {
+               out = append(out, latest[key])
+       }
+
+       return out
+}
+
+// deleteEntryKey identifies a delete file. Position and equality deletes use
+// their path. Deletion vectors in one Puffin file share that path, so they
+// also use the referenced data file and content offset.
+func deleteEntryKey(file iceberg.DataFile) string {
+       if !IsDeletionVector(file) {
+               return file.FilePath()
+       }
+       offset := int64(-1)
+       if fileOffset := file.ContentOffset(); fileOffset != nil {
+               offset = *fileOffset
+       }
+
+       return file.FilePath() + "\x00" + referencedDataFilePath(file) + "\x00" 
+ strconv.FormatInt(offset, 10)
+}
+
+// readCachedManifestEntries returns manifest entries, reusing entries already
+// decoded for the same manifest path. Manifest files are immutable, and the
+// partition and metrics evaluators do not change during the scan, so the
+// pruned entries stay valid for every snapshot that carries the manifest.
+func readCachedManifestEntries(
+       ctx context.Context,
+       fs io.IO,
+       manifests []iceberg.ManifestFile,
+       decoded map[string][]iceberg.ManifestEntry,
+       partitionFilter, metricsEval func(iceberg.DataFile) (bool, error),
+) ([]iceberg.ManifestEntry, error) {
+       if len(manifests) == 0 {
+               return nil, nil
+       }
+       out := make([]iceberg.ManifestEntry, 0)
+       for _, manifest := range manifests {
+               if err := ctx.Err(); err != nil {
+                       return nil, err
+               }
+               path := manifest.FilePath()
+               entries, ok := decoded[path]
+               if !ok {
+                       var err error
+                       entries, err = openManifestWithOptions(fs, manifest, 
partitionFilter, metricsEval, false, false)
+                       if err != nil {
+                               return nil, err
+                       }
+                       if entries == nil {
+                               entries = []iceberg.ManifestEntry{}
+                       }
+                       if decoded != nil {
+                               decoded[path] = entries
+                       }
+               }
+               out = append(out, entries...)
+       }
+
+       return out, nil
+}
+
+func readManifestEntries(
+       ctx context.Context,
+       fs io.IO,
+       manifests []iceberg.ManifestFile,
+       discardDeleted, discardExisting bool,
+) ([]iceberg.ManifestEntry, error) {
+       if len(manifests) == 0 {
+               return nil, nil
+       }
+       keep := func(iceberg.DataFile) (bool, error) { return true, nil }
+       out := make([]iceberg.ManifestEntry, 0)
+       for _, manifest := range manifests {
+               if err := ctx.Err(); err != nil {
+                       return nil, err
+               }
+               entries, err := openManifestWithOptions(fs, manifest, keep, 
keep, discardDeleted, discardExisting)
+               if err != nil {
+                       return nil, err
+               }
+               out = append(out, entries...)
+       }
+
+       return out, nil
+}
+
+func dropManifestEntries(entries []iceberg.ManifestEntry, gone 
map[string]struct{}) []iceberg.ManifestEntry {
+       if len(gone) == 0 {
+               return entries
+       }
+       out := make([]iceberg.ManifestEntry, 0, len(entries))
+       for _, entry := range entries {
+               if _, drop := gone[deleteEntryKey(entry.DataFile())]; drop {
+                       continue
+               }
+               out = append(out, entry)
+       }
+
+       return out
+}
+
+// keepNewestDeletionVector leaves one deletion vector per referenced data
+// file. Later entries win. A deletion vector is cumulative, so the newest one
+// already contains positions deleted by the ones it replaced.
+func keepNewestDeletionVector(entries []iceberg.ManifestEntry) 
[]iceberg.ManifestEntry {
+       latest := make(map[string]int)
+       drop := make(map[int]struct{})
+       for i, entry := range entries {
+               file := entry.DataFile()
+               if !IsDeletionVector(file) {
+                       continue
+               }
+               ref := referencedDataFilePath(file)
+               if ref == "" {
+                       continue
+               }
+               if prev, ok := latest[ref]; ok {
+                       drop[prev] = struct{}{}
+               }
+               latest[ref] = i
+       }
+       if len(drop) == 0 {
+               return entries
+       }
+       out := make([]iceberg.ManifestEntry, 0, len(entries)-len(drop))
+       for i, entry := range entries {
+               if _, ok := drop[i]; ok {
+                       continue
+               }
+               out = append(out, entry)
+       }
+
+       return out
+}
+
+func sortChangelogTasks(tasks []plannedChangelogTask) {
+       slices.SortFunc(tasks, func(left, right plannedChangelogTask) int {
+               if ordinal := cmp.Compare(left.task.ChangeOrdinal(), 
right.task.ChangeOrdinal()); ordinal != 0 {
+                       return ordinal
+               }
+               if operation := 
cmp.Compare(changelogOperationOrder(left.task.Operation()), 
changelogOperationOrder(right.task.Operation())); operation != 0 {
+                       return operation
+               }
+               if path := cmp.Compare(left.task.ScanTask().File.FilePath(), 
right.task.ScanTask().File.FilePath()); path != 0 {
+                       return path
+               }
+
+               return cmp.Compare(changelogTaskKindOrder(left.task), 
changelogTaskKindOrder(right.task))
+       })
+}
+
+func changelogTaskKindOrder(task ChangelogScanTask) int {
+       switch task.(type) {
+       case DeletedDataFileScanTask:
+               return 0
+       case DeletedRowsScanTask:
+               return 1
+       case AddedRowsScanTask:
+               return 2
+       default:
+               return 3
+       }
+}
+
+// changelogMetricsTask counts delete files that produce a row-level change as
+// well as deletes already stored on the scan task. Each delete is counted once
+// per task, matching a normal scan report.
+func changelogMetricsTask(task ChangelogScanTask) FileScanTask {
+       file := task.ScanTask()
+       rows, ok := task.(DeletedRowsScanTask)
+       if !ok {
+               return file
+       }
+       file.DeleteFiles = slices.Clone(file.DeleteFiles)
+       file.EqualityDeleteFiles = slices.Clone(file.EqualityDeleteFiles)
+       file.DeletionVectorFiles = slices.Clone(file.DeletionVectorFiles)
+       for _, deleteFile := range rows.AddedDeletes() {
+               switch {
+               case IsDeletionVector(deleteFile):
+                       file.DeletionVectorFiles = 
append(file.DeletionVectorFiles, deleteFile)
+               case deleteFile.ContentType() == iceberg.EntryContentEqDeletes:
+                       file.EqualityDeleteFiles = 
append(file.EqualityDeleteFiles, deleteFile)
+               default:
+                       file.DeleteFiles = append(file.DeleteFiles, deleteFile)
+               }
+       }
+
+       return file
+}
diff --git a/table/incremental_changelog_scan.go 
b/table/incremental_changelog_scan.go
index 7ec37d123..d1918b60f 100644
--- a/table/incremental_changelog_scan.go
+++ b/table/incremental_changelog_scan.go
@@ -18,7 +18,6 @@
 package table
 
 import (
-       "cmp"
        "context"
        "fmt"
        "maps"
@@ -29,11 +28,16 @@ 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
+// snapshots. It emits insert tasks for added data files, delete tasks for
+// removed data files, and row-level delete tasks for existing data files
+// affected by delete files added in the range. Replace snapshots are not
+// emitted. Delete files they add or remove still apply to later tasks.
+// Remote planning is not supported.
+//
+// Java's changelog scan rejects delete manifests. The delete-aware tasks
+// here, including same-snapshot position deletes and deletion vectors on an
+// added file, are iceberg-go behavior.
 type IncrementalChangelogScan struct {
        scan           *Scan
        fromSnapshotID *int64
@@ -43,16 +47,20 @@ type IncrementalChangelogScan struct {
 
 type plannedChangelogTask struct {
        task ChangelogScanTask
-       file FileScanTask
 }
 
 // NewIncrementalChangelogScan creates an incremental changelog planner.
 // Projection and row limits are not applied to returned tasks. Auto planning
 // falls back to local planning, while remote planning, including a catalog
-// `server` scan-planning-mode directive, is not supported. Use
-// ChangelogScanTask.ScanTask with Scan.ReadTasks to read the returned files.
-// Row filters are attached to each task as residuals without 
partition-specific
-// simplification, matching the existing incremental append scan behavior.
+// `server` scan-planning-mode directive, is not supported.
+//
+// ScanTask carries the data file and the deletes applied before the change is
+// interpreted: deletes committed with an added data file, or deletes that
+// already applied to a removed data file or a row-level delete. AddedDeletes
+// on a row-level delete are the deletes that produce the change and are not
+// part of ScanTask. Use ScanTask with Scan.ReadTasks to read the returned
+// files. Row filters are attached to each task as residuals without
+// partition-specific simplification, matching incremental append scans.
 func (t Table) NewIncrementalChangelogScan(opts ...ScanOption) 
*IncrementalChangelogScan {
        return &IncrementalChangelogScan{scan: t.Scan(opts...)}
 }
@@ -87,14 +95,16 @@ func (s *IncrementalChangelogScan) ToSnapshot(snapshotID 
int64) *IncrementalChan
        return &out
 }
 
-// PlanFiles returns one task for each added or deleted data-file entry. A
-// cancelled context returns its cancellation error before planning starts.
-// Tasks are ordered by change ordinal, then by DELETE before INSERT within an
-// ordinal, and finally by data-file path. When an ending snapshot is
-// available, it emits a ScanReport through the configured reporter on
-// successful planning. Changelog reports count every returned task in
-// ResultDataFiles and TotalFileSizeInBytes, so a file inserted and deleted
-// within the range is counted twice.
+// PlanFiles returns one task for each added or deleted data file, plus one
+// row-level delete task for each existing data file affected by delete files
+// added in a snapshot. A cancelled context returns its cancellation error
+// before planning starts. Tasks are ordered by change ordinal, then by DELETE
+// before INSERT within an ordinal, and finally by data-file path. When an
+// ending snapshot is available, it emits a ScanReport through the configured
+// reporter on successful planning. Changelog reports count every returned task
+// in ResultDataFiles and TotalFileSizeInBytes, so a file inserted and deleted
+// within the range is counted twice. Delete files are counted once per task
+// they apply to.
 func (s *IncrementalChangelogScan) PlanFiles(ctx context.Context) 
([]ChangelogScanTask, error) {
        if s == nil || s.scan == nil {
                return nil, fmt.Errorf("%w: incremental changelog scan is not 
initialized", ErrInvalidOperation)
@@ -144,8 +154,8 @@ func (s *IncrementalChangelogScan) PlanFiles(ctx 
context.Context) ([]ChangelogSc
                fileTasks := make([]FileScanTask, len(plannedTasks))
                for i, planned := range plannedTasks {
                        tasks[i] = planned.task
-                       fileTasks[i] = planned.file
-                       acc.totalFileSize += planned.file.File.FileSizeBytes()
+                       fileTasks[i] = changelogMetricsTask(planned.task)
+                       acc.totalFileSize += fileTasks[i].File.FileSizeBytes()
                }
                acc.applyResultDeleteMetrics(fileTasks)
                planningDuration := time.Since(start)
@@ -187,7 +197,9 @@ func (s *IncrementalChangelogScan) PlanFiles(ctx 
context.Context) ([]ChangelogSc
        }
 
        manifestsByPath := make(map[string]iceberg.ManifestFile)
-       for _, snapshot := range snapshots {
+       manifestsBySnapshot := make(map[int64][]iceberg.ManifestFile, 
len(snapshots))
+       deleteManifestsBySnapshot := make(map[int64][]iceberg.ManifestFile)
+       for _, snapshot := range snapshotRange {
                if err := ctx.Err(); err != nil {
                        return nil, err
                }
@@ -195,18 +207,32 @@ func (s *IncrementalChangelogScan) PlanFiles(ctx 
context.Context) ([]ChangelogSc
                if err != nil {
                        return nil, err
                }
+               replace := snapshot.Summary != nil && 
snapshot.Summary.Operation == OpReplace
+               if !replace {
+                       manifestsBySnapshot[snapshot.SnapshotID] = manifests
+               }
                for _, manifest := range manifests {
-                       if manifest.ManifestContent() == 
iceberg.ManifestContentDeletes {
-                               return nil, fmt.Errorf("%w: incremental 
changelog scan range references a delete manifest originating in snapshot %d",
-                                       ErrInvalidOperation, 
manifest.SnapshotID())
-                       }
-                       if manifest.ManifestContent() != 
iceberg.ManifestContentData {
-                               continue
-                       }
-                       if _, ok := 
changelogSnapshotIDs[manifest.SnapshotID()]; !ok {
+                       switch manifest.ManifestContent() {
+                       case iceberg.ManifestContentDeletes:
+                               // Only manifests written by this snapshot are 
new delete-file
+                               // changes. Carried-forward delete manifests 
stay available as
+                               // deletes that already applied. Replace 
snapshots are included
+                               // so a rewrite of delete files updates later 
tasks.
+                               if manifest.SnapshotID() == snapshot.SnapshotID 
{
+                                       
deleteManifestsBySnapshot[snapshot.SnapshotID] = append(
+                                               
deleteManifestsBySnapshot[snapshot.SnapshotID], manifest)
+                               }
+                       case iceberg.ManifestContentData:
+                               if replace {
+                                       continue
+                               }
+                               if _, ok := 
changelogSnapshotIDs[manifest.SnapshotID()]; !ok {
+                                       continue
+                               }
+                               manifestsByPath[manifest.FilePath()] = manifest
+                       default:
                                continue
                        }
-                       manifestsByPath[manifest.FilePath()] = manifest
                }
        }
 
@@ -233,45 +259,37 @@ func (s *IncrementalChangelogScan) PlanFiles(ctx 
context.Context) ([]ChangelogSc
        if err != nil {
                return nil, err
        }
-       if len(manifestList) == 0 {
-               return finish(nil), nil
-       }
-       entries, err := planningScan.collectManifestEntriesWithSchemaOptions(
-               ctx, manifestList, schema,
-               partitionFilters,
-               /* discardDeleted= */ false,
-               /* discardExisting= */ true,
-       )
-       if err != nil {
-               return nil, err
-       }
-
-       plannedTasks := make([]plannedChangelogTask, 0, 
len(entries.dataEntries))
-       for _, entry := range entries.dataEntries {
-               ordinal, ok := snapshotOrdinals[entry.SnapshotID()]
-               if !ok {
-                       continue
-               }
-
-               task, err := newChangelogScanTask(entry, ordinal, residual)
+       var dataEntries []iceberg.ManifestEntry
+       if len(manifestList) > 0 {
+               entries, err := 
planningScan.collectManifestEntriesWithSchemaOptions(
+                       ctx, manifestList, schema,
+                       partitionFilters,
+                       /* discardDeleted= */ false,
+                       /* discardExisting= */ true,
+               )
                if err != nil {
-                       return nil, fmt.Errorf("incremental changelog scan 
snapshot %d: %w", entry.SnapshotID(), err)
+                       return nil, err
                }
-               plannedTasks = append(plannedTasks, plannedChangelogTask{
-                       task: task,
-                       file: task.ScanTask(),
-               })
+               dataEntries = entries.dataEntries
        }
-       slices.SortFunc(plannedTasks, func(left, right plannedChangelogTask) 
int {
-               if ordinal := cmp.Compare(left.task.ChangeOrdinal(), 
right.task.ChangeOrdinal()); ordinal != 0 {
-                       return ordinal
-               }
-               if operation := 
cmp.Compare(changelogOperationOrder(left.task.Operation()), 
changelogOperationOrder(right.task.Operation())); operation != 0 {
-                       return operation
-               }
 
-               return cmp.Compare(left.file.File.FilePath(), 
right.file.File.FilePath())
+       plannedTasks, err := s.planChangelogTasks(ctx, changelogTaskPlan{
+               scan:                &planningScan,
+               fs:                  fs,
+               snapshots:           snapshots,
+               timeline:            snapshotRange,
+               manifestsBySnapshot: manifestsBySnapshot,
+               deleteManifests:     deleteManifestsBySnapshot,
+               dataEntries:         dataEntries,
+               snapshotOrdinals:    snapshotOrdinals,
+               schema:              schema,
+               partitionFilters:    partitionFilters,
+               residual:            residual,
+               metrics:             &acc,
        })
+       if err != nil {
+               return nil, err
+       }
 
        return finish(plannedTasks), nil
 }
@@ -310,38 +328,27 @@ func changelogOperation(status 
iceberg.ManifestEntryStatus) (ChangelogOperation,
        }
 }
 
-func newChangelogScanTask(entry iceberg.ManifestEntry, ordinal int, residual 
iceberg.BooleanExpression) (ChangelogScanTask, error) {
+func newChangelogScanTask(entry iceberg.ManifestEntry, ordinal int, residual 
iceberg.BooleanExpression, deletes []iceberg.DataFile) (ChangelogScanTask, 
error) {
        operation, err := changelogOperation(entry.Status())
        if err != nil {
                return nil, err
        }
 
-       file := entry.DataFile()
-       configureFileScanTask := func(task *FileScanTask) {
-               task.Start = 0
-               task.Length = file.FileSizeBytes()
-               task.Residual = residual
-               task.FirstRowID = file.FirstRowID()
-               if sequenceNumber := entry.DataSequenceNumber(); sequenceNumber 
>= 0 {
-                       task.DataSequenceNumber = &sequenceNumber
-               }
-       }
-
        switch operation {
        case ChangelogOpInsert:
-               task, err := NewAddedRowsScanTask(file, nil, ordinal, 
entry.SnapshotID())
+               task, err := NewAddedRowsScanTask(entry.DataFile(), deletes, 
ordinal, entry.SnapshotID())
                if err != nil {
                        return nil, err
                }
-               configureFileScanTask(&task.FileScanTask)
+               configureChangelogFileTask(&task.FileScanTask, entry, residual)
 
                return task, nil
        case ChangelogOpDelete:
-               task, err := NewDeletedDataFileScanTask(file, nil, ordinal, 
entry.SnapshotID())
+               task, err := NewDeletedDataFileScanTask(entry.DataFile(), 
deletes, ordinal, entry.SnapshotID())
                if err != nil {
                        return nil, err
                }
-               configureFileScanTask(&task.FileScanTask)
+               configureChangelogFileTask(&task.FileScanTask, entry, residual)
 
                return task, nil
        default:
@@ -349,6 +356,17 @@ func newChangelogScanTask(entry iceberg.ManifestEntry, 
ordinal int, residual ice
        }
 }
 
+func configureChangelogFileTask(task *FileScanTask, entry 
iceberg.ManifestEntry, residual iceberg.BooleanExpression) {
+       file := entry.DataFile()
+       task.Start = 0
+       task.Length = file.FileSizeBytes()
+       task.Residual = residual
+       task.FirstRowID = file.FirstRowID()
+       if sequenceNumber := entry.DataSequenceNumber(); sequenceNumber >= 0 {
+               task.DataSequenceNumber = &sequenceNumber
+       }
+}
+
 func (s *IncrementalChangelogScan) toSnapshot() (*Snapshot, error) {
        if s.toSnapshotID != nil {
                snapshot := s.scan.metadata.SnapshotByID(*s.toSnapshotID)
@@ -363,8 +381,10 @@ func (s *IncrementalChangelogScan) toSnapshot() 
(*Snapshot, error) {
 }
 
 // changelogSnapshots retains every snapshot with a non-empty operation except
-// replace snapshots. Keeping unknown future operations matches Java's
-// incremental changelog scan behavior; only a missing operation is rejected.
+// replace snapshots, which are not emitted as changelog tasks. Delete-file
+// changes from those replace snapshots are still tracked. Keeping unknown
+// future operations matches Java's incremental changelog scan behavior; only
+// a missing operation is rejected.
 func changelogSnapshots(snapshots []Snapshot) ([]Snapshot, error) {
        result := make([]Snapshot, 0, len(snapshots))
        for _, snapshot := range snapshots {
diff --git a/table/incremental_changelog_scan_test.go 
b/table/incremental_changelog_scan_test.go
index cdf727a47..cabf3c0fc 100644
--- a/table/incremental_changelog_scan_test.go
+++ b/table/incremental_changelog_scan_test.go
@@ -20,9 +20,19 @@ package table
 import (
        "bytes"
        "context"
+       "fmt"
+       "os"
+       "path/filepath"
+       "strconv"
+       "strings"
        "sync/atomic"
        "testing"
 
+       "github.com/apache/arrow-go/v18/arrow"
+       "github.com/apache/arrow-go/v18/arrow/array"
+       "github.com/apache/arrow-go/v18/arrow/memory"
+       "github.com/apache/arrow-go/v18/parquet"
+       "github.com/apache/arrow-go/v18/parquet/pqarrow"
        "github.com/apache/iceberg-go"
        iceio "github.com/apache/iceberg-go/io"
        "github.com/apache/iceberg-go/metrics"
@@ -256,26 +266,66 @@ func TestIncrementalChangelogScanEmitsScanReport(t 
*testing.T) {
        require.Equal(t, int64(3), report.Metrics.ScannedDataManifests.Value)
 }
 
-func TestIncrementalChangelogScanRejectsDeleteManifests(t *testing.T) {
+func TestIncrementalChangelogScanPlansPositionDeletes(t *testing.T) {
+       reporter := &metrics.InMemoryReporter{}
        tbl := incrementalChangelogDeleteManifestTable(t)
 
-       tasks, err := 
tbl.NewIncrementalChangelogScan().PlanFiles(context.Background())
-       require.ErrorIs(t, err, ErrInvalidOperation)
-       require.ErrorContains(t, err, "scan range references a delete manifest")
-       require.Nil(t, tasks)
+       tasks, err := 
tbl.NewIncrementalChangelogScan(WithReporter(reporter)).PlanFiles(context.Background())
+       require.NoError(t, err)
+       require.Len(t, tasks, 3)
+
+       require.Equal(t, ChangelogOpInsert, tasks[0].Operation())
+       require.Equal(t, "mem://default/changelog-delete/data-1.parquet", 
tasks[0].ScanTask().File.FilePath())
+       require.Equal(t, 0, tasks[0].ChangeOrdinal())
+       require.Empty(t, tasks[0].ScanTask().DeleteFiles)
+
+       rows, ok := tasks[1].(DeletedRowsScanTask)
+       require.True(t, ok)
+       require.Equal(t, ChangelogOpDelete, rows.Operation())
+       require.Equal(t, 1, rows.ChangeOrdinal())
+       require.Equal(t, int64(2), rows.CommitSnapshotID())
+       require.Equal(t, "mem://default/changelog-delete/data-1.parquet", 
rows.ScanTask().File.FilePath())
+       require.Equal(t, 
[]string{"mem://default/changelog-delete/delete.parquet"}, 
changelogFilePaths(rows.AddedDeletes()))
+       require.Empty(t, rows.ExistingDeletes())
+       require.NotNil(t, rows.ScanTask().DataSequenceNumber)
+       require.Equal(t, int64(1), *rows.ScanTask().DataSequenceNumber)
+
+       require.Equal(t, ChangelogOpInsert, tasks[2].Operation())
+       require.Equal(t, "mem://default/changelog-delete/data-3.parquet", 
tasks[2].ScanTask().File.FilePath())
+       require.Equal(t, 2, tasks[2].ChangeOrdinal())
+
+       onlyDelete, err := tbl.NewIncrementalChangelogScan().
+               FromSnapshotInclusive(2).
+               ToSnapshot(2).
+               PlanFiles(context.Background())
+       require.NoError(t, err)
+       require.Len(t, onlyDelete, 1)
+       _, ok = onlyDelete[0].(DeletedRowsScanTask)
+       require.True(t, ok)
+
+       reports := reporter.Reports()
+       require.Len(t, reports, 1)
+       report, ok := reports[0].(metrics.ScanReport)
+       require.True(t, ok)
+       require.Equal(t, int64(3), report.Metrics.ResultDataFiles.Value)
+       require.Equal(t, int64(1), report.Metrics.ResultDeleteFiles.Value)
+       require.Equal(t, int64(1), report.Metrics.PositionalDeleteFiles.Value)
+       require.Equal(t, int64(1), report.Metrics.TotalDeleteManifests.Value)
+       require.Equal(t, int64(1), report.Metrics.ScannedDeleteManifests.Value)
 }
 
-func TestIncrementalChangelogScanRejectsCarriedForwardDeleteManifests(t 
*testing.T) {
+func TestIncrementalChangelogScanIgnoresCarriedForwardDeleteManifests(t 
*testing.T) {
        tbl := incrementalChangelogDeleteManifestTable(t)
 
        tasks, err := tbl.NewIncrementalChangelogScan().
                FromSnapshotExclusive(2).
                ToSnapshot(3).
                PlanFiles(context.Background())
-       require.ErrorIs(t, err, ErrInvalidOperation)
-       require.ErrorContains(t, err, "scan range references a delete manifest")
-       require.ErrorContains(t, err, "snapshot 2")
-       require.Nil(t, tasks)
+       require.NoError(t, err)
+       require.Len(t, tasks, 1)
+       require.Equal(t, ChangelogOpInsert, tasks[0].Operation())
+       require.Equal(t, "mem://default/changelog-delete/data-3.parquet", 
tasks[0].ScanTask().File.FilePath())
+       require.Empty(t, tasks[0].ScanTask().DeleteFiles)
 }
 
 func TestIncrementalChangelogScanRejectsMissingSnapshotOperation(t *testing.T) 
{
@@ -768,6 +818,1124 @@ 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},
+               {
+                       operation: OpAppend,
+                       data:      
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 2, 2, 
data)},
+                       deletes: []iceberg.ManifestEntry{
+                               
changelogManifestEntry(iceberg.EntryStatusADDED, 2, 2, posDelete),
+                               
changelogManifestEntry(iceberg.EntryStatusADDED, 2, 2, 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) {
+       spec := partitionedSpec()
+       data := newTestDataFile(t, spec, 
"mem://default/changelog-eq/data.parquet", map[int]any{1000: int32(1)})
+       eqDelete := newChangelogEqDelete(t, spec,
+               "mem://default/changelog-eq/eq.parquet", map[int]any{1000: 
int32(1)})
+       tbl := writeChangelogScanTable(t, "changelog-eq", []changelogScanStep{
+               {
+                       operation: OpAppend,
+                       data:      
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 1, 1, 
data)},
+               },
+               {
+                       operation: OpDelete,
+                       deletes:   
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 2, 2, 
eqDelete)},
+               },
+       })
+
+       tasks, err := 
tbl.NewIncrementalChangelogScan().PlanFiles(context.Background())
+       require.NoError(t, err)
+       require.Len(t, tasks, 2)
+       rows, ok := tasks[1].(DeletedRowsScanTask)
+       require.True(t, ok)
+       require.Equal(t, []string{eqDelete.FilePath()}, 
changelogFilePaths(rows.AddedDeletes()))
+       require.Empty(t, rows.ScanTask().EqualityDeleteFiles)
+       require.Equal(t, iceberg.EntryContentEqDeletes, 
rows.AddedDeletes()[0].ContentType())
+}
+
+func TestIncrementalChangelogScanPlansDeletionVectors(t *testing.T) {
+       spec := partitionedSpec()
+       data := newTestDataFile(t, spec, 
"mem://default/changelog-dv/data.parquet", map[int]any{1000: int32(1)})
+       posDelete := newTestPosDeleteFileForSpec(t, spec,
+               "mem://default/changelog-dv/pos.parquet", map[int]any{1000: 
int32(1)}, data.FilePath())
+       dv := newChangelogDeletionVector(t, spec, 
"mem://default/changelog-dv/dv.puffin", data.FilePath())
+       tbl := writeChangelogScanTable(t, "changelog-dv", []changelogScanStep{
+               {
+                       operation: OpAppend,
+                       data:      
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 1, 1, 
data)},
+               },
+               {
+                       operation: OpDelete,
+                       deletes:   
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 2, 2, 
posDelete)},
+               },
+               {
+                       operation:       OpDelete,
+                       manifestVersion: 3,
+                       deletes:         
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 3, 3, 
dv)},
+               },
+       })
+
+       tasks, err := 
tbl.NewIncrementalChangelogScan().PlanFiles(context.Background())
+       require.NoError(t, err)
+       require.Len(t, tasks, 3)
+       rows, ok := tasks[2].(DeletedRowsScanTask)
+       require.True(t, ok)
+       require.Equal(t, []string{dv.FilePath()}, 
changelogFilePaths(rows.AddedDeletes()))
+       require.Equal(t, []string{posDelete.FilePath()}, 
changelogFilePaths(rows.ExistingDeletes()))
+       require.Empty(t, rows.ScanTask().DeletionVectorFiles)
+}
+
+func TestIncrementalChangelogScanAppliesExistingDeletesToRemovedFiles(t 
*testing.T) {
+       spec := partitionedSpec()
+       data := newTestDataFile(t, spec, 
"mem://default/changelog-removed/data.parquet", map[int]any{1000: int32(1)})
+       posDelete := newTestPosDeleteFileForSpec(t, spec,
+               "mem://default/changelog-removed/pos.parquet", 
map[int]any{1000: int32(1)}, data.FilePath())
+       tbl := writeChangelogScanTable(t, "changelog-removed", 
[]changelogScanStep{
+               {
+                       operation: OpAppend,
+                       data:      
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 1, 1, 
data)},
+               },
+               {
+                       operation: OpDelete,
+                       deletes:   
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 2, 2, 
posDelete)},
+               },
+               {
+                       operation: OpOverwrite,
+                       data:      
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusDELETED, 3, 
1, data)},
+               },
+       })
+
+       tasks, err := 
tbl.NewIncrementalChangelogScan().PlanFiles(context.Background())
+       require.NoError(t, err)
+       require.Len(t, tasks, 3)
+       removed, ok := tasks[2].(DeletedDataFileScanTask)
+       require.True(t, ok)
+       require.Equal(t, ChangelogOpDelete, removed.Operation())
+       require.Equal(t, 2, removed.ChangeOrdinal())
+       require.Equal(t, []string{posDelete.FilePath()}, 
changelogFilePaths(removed.ExistingDeletes()))
+
+       bounded, err := tbl.NewIncrementalChangelogScan().
+               FromSnapshotExclusive(2).
+               ToSnapshot(3).
+               PlanFiles(context.Background())
+       require.NoError(t, err)
+       require.Len(t, bounded, 1)
+       removed, ok = bounded[0].(DeletedDataFileScanTask)
+       require.True(t, ok)
+       require.Equal(t, []string{posDelete.FilePath()}, 
changelogFilePaths(removed.ExistingDeletes()))
+}
+
+func TestIncrementalChangelogScanFiltersDeleteBackedTasks(t *testing.T) {
+       tbl := incrementalChangelogDeleteManifestTable(t)
+       filter := iceberg.EqualTo(iceberg.Reference("id"), int32(3))
+
+       tasks, err := 
tbl.NewIncrementalChangelogScan(WithRowFilter(filter)).PlanFiles(context.Background())
+       require.NoError(t, err)
+       require.Len(t, tasks, 1)
+       require.Equal(t, "mem://default/changelog-delete/data-3.parquet", 
tasks[0].ScanTask().File.FilePath())
+       require.Equal(t, ChangelogOpInsert, tasks[0].Operation())
+}
+
+func TestIncrementalChangelogScanFiltersDeletedRowsByPartition(t *testing.T) {
+       spec := partitionedSpec()
+       partOne := newTestDataFile(t, spec, 
"mem://default/changelog-part/data-1.parquet", map[int]any{1000: int32(1)})
+       partTwo := newTestDataFile(t, spec, 
"mem://default/changelog-part/data-2.parquet", map[int]any{1000: int32(2)})
+       deleteOne := newTestPosDeleteFileForSpec(t, spec,
+               "mem://default/changelog-part/delete-1.parquet", 
map[int]any{1000: int32(1)}, partOne.FilePath())
+       deleteTwo := newTestPosDeleteFileForSpec(t, spec,
+               "mem://default/changelog-part/delete-2.parquet", 
map[int]any{1000: int32(2)}, partTwo.FilePath())
+       tbl := writeChangelogScanTable(t, "changelog-part", []changelogScanStep{
+               {
+                       operation: OpAppend,
+                       data: []iceberg.ManifestEntry{
+                               
changelogManifestEntry(iceberg.EntryStatusADDED, 1, 1, partOne),
+                               
changelogManifestEntry(iceberg.EntryStatusADDED, 1, 1, partTwo),
+                       },
+               },
+               {
+                       operation: OpDelete,
+                       deletes: []iceberg.ManifestEntry{
+                               
changelogManifestEntry(iceberg.EntryStatusADDED, 2, 2, deleteOne),
+                               
changelogManifestEntry(iceberg.EntryStatusADDED, 2, 2, deleteTwo),
+                       },
+               },
+       })
+
+       tasks, err := tbl.NewIncrementalChangelogScan(
+               WithRowFilter(iceberg.EqualTo(iceberg.Reference("id"), 
int32(1))),
+       ).PlanFiles(context.Background())
+       require.NoError(t, err)
+
+       var deleted []DeletedRowsScanTask
+       for _, task := range tasks {
+               require.NotEqual(t, partTwo.FilePath(), 
task.ScanTask().File.FilePath())
+               rows, ok := task.(DeletedRowsScanTask)
+               if !ok {
+                       continue
+               }
+               deleted = append(deleted, rows)
+       }
+       require.Len(t, deleted, 1)
+       require.Equal(t, partOne.FilePath(), 
deleted[0].ScanTask().File.FilePath())
+       require.Equal(t, []string{deleteOne.FilePath()}, 
changelogFilePaths(deleted[0].AddedDeletes()))
+}
+
+func TestIncrementalChangelogScanAppliesDeletesBySequence(t *testing.T) {
+       spec := partitionedSpec()
+
+       // A writer commits the data file in snapshot 2 at sequence 2, then 
delete
+       // files in snapshots 3 and 4 at those snapshots' sequences. A later
+       // snapshot cannot commit a delete sequence below that data sequence.
+       t.Run("later snapshots", func(t *testing.T) {
+               for _, kind := range []string{"pos", "eq", "dv"} {
+                       t.Run(kind, func(t *testing.T) {
+                               data := newTestDataFile(t, spec, 
"mem://default/changelog-seq/data.parquet", map[int]any{1000: int32(1)})
+                               first, version := changelogSequenceDelete(t, 
spec, kind, "mem://default/changelog-seq/first", data)
+                               second, _ := changelogSequenceDelete(t, spec, 
kind, "mem://default/changelog-seq/second", data)
+                               tbl := writeChangelogScanTable(t, 
"changelog-seq-"+kind, []changelogScanStep{
+                                       {operation: OpAppend},
+                                       {
+                                               operation: OpAppend,
+                                               data:      
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 2, 2, 
data)},
+                                       },
+                                       {
+                                               operation:       OpDelete,
+                                               manifestVersion: version,
+                                               deletes:         
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 3, 3, 
first)},
+                                       },
+                                       {
+                                               operation:       OpDelete,
+                                               manifestVersion: version,
+                                               deletes:         
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 4, 4, 
second)},
+                                       },
+                               })
+
+                               tasks, err := 
tbl.NewIncrementalChangelogScan().PlanFiles(context.Background())
+                               require.NoError(t, err)
+                               bySnapshot := changelogTasksBySnapshot(tasks)
+                               require.Equal(t, ChangelogOpInsert, 
bySnapshot[2].Operation())
+
+                               firstRows, ok := 
bySnapshot[3].(DeletedRowsScanTask)
+                               require.True(t, ok)
+                               require.Equal(t, []string{first.FilePath()}, 
changelogFilePaths(firstRows.AddedDeletes()))
+                               require.Empty(t, firstRows.ExistingDeletes())
+
+                               secondRows, ok := 
bySnapshot[4].(DeletedRowsScanTask)
+                               require.True(t, ok)
+                               require.Equal(t, []string{second.FilePath()}, 
changelogFilePaths(secondRows.AddedDeletes()))
+                               require.Equal(t, []string{first.FilePath()}, 
changelogFilePaths(secondRows.ExistingDeletes()))
+                       })
+               }
+       })
+
+       // Equality deletes do not apply at the data file's own sequence. 
Position
+       // deletes and deletion vectors do.
+       t.Run("same snapshot", func(t *testing.T) {
+               for _, tt := range []struct {
+                       kind string
+                       want bool
+               }{
+                       {kind: "pos", want: true},
+                       {kind: "eq", want: false},
+                       {kind: "dv", want: true},
+               } {
+                       t.Run(tt.kind, func(t *testing.T) {
+                               data := newTestDataFile(t, spec, 
"mem://default/changelog-seq-same/data.parquet", map[int]any{1000: int32(1)})
+                               deleteFile, version := 
changelogSequenceDelete(t, spec, tt.kind, 
"mem://default/changelog-seq-same/delete", data)
+                               tbl := writeChangelogScanTable(t, 
"changelog-seq-same-"+tt.kind, []changelogScanStep{
+                                       {operation: OpAppend},
+                                       {
+                                               operation:       OpAppend,
+                                               manifestVersion: version,
+                                               data:            
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 2, 2, 
data)},
+                                               deletes:         
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 2, 2, 
deleteFile)},
+                                       },
+                               })
+
+                               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)
+                               if !tt.want {
+                                       require.Empty(t, added.Deletes())
+
+                                       return
+                               }
+                               require.Equal(t, 
[]string{deleteFile.FilePath()}, changelogFilePaths(added.Deletes()))
+                       })
+               }
+       })
+
+       // A delete committed before the data file keeps its lower sequence and
+       // must not come back as an existing delete when the file is removed.
+       t.Run("older delete", func(t *testing.T) {
+               data := newTestDataFile(t, spec, 
"mem://default/changelog-seq-old/data.parquet", map[int]any{1000: int32(1)})
+               older := newTestPosDeleteFileForSpec(t, spec,
+                       "mem://default/changelog-seq-old/older.parquet", 
map[int]any{1000: int32(1)}, data.FilePath())
+               newer := newTestPosDeleteFileForSpec(t, spec,
+                       "mem://default/changelog-seq-old/newer.parquet", 
map[int]any{1000: int32(1)}, data.FilePath())
+               tbl := writeChangelogScanTable(t, "changelog-seq-old", 
[]changelogScanStep{
+                       {
+                               operation: OpDelete,
+                               deletes:   
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 1, 1, 
older)},
+                       },
+                       {
+                               operation: OpAppend,
+                               data:      
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 2, 2, 
data)},
+                       },
+                       {
+                               operation: OpDelete,
+                               deletes:   
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 3, 3, 
newer)},
+                       },
+                       {
+                               operation: OpOverwrite,
+                               data:      
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusDELETED, 4, 
2, data)},
+                       },
+               })
+
+               tasks, err := 
tbl.NewIncrementalChangelogScan().PlanFiles(context.Background())
+               require.NoError(t, err)
+               bySnapshot := changelogTasksBySnapshot(tasks)
+               rows, ok := bySnapshot[3].(DeletedRowsScanTask)
+               require.True(t, ok)
+               require.Equal(t, []string{newer.FilePath()}, 
changelogFilePaths(rows.AddedDeletes()))
+               require.Empty(t, rows.ExistingDeletes())
+
+               removed, ok := bySnapshot[4].(DeletedDataFileScanTask)
+               require.True(t, ok)
+               require.Equal(t, []string{newer.FilePath()}, 
changelogFilePaths(removed.ExistingDeletes()))
+       })
+}
+
+func TestIncrementalChangelogScanAppliesEveryDeleteInOneSnapshot(t *testing.T) 
{
+       spec := partitionedSpec()
+       data := newTestDataFile(t, spec, 
"mem://default/changelog-multi/data.parquet", map[int]any{1000: int32(1)})
+       first := newTestPosDeleteFileForSpec(t, spec,
+               "mem://default/changelog-multi/first.parquet", 
map[int]any{1000: int32(1)}, data.FilePath())
+       second := newTestPosDeleteFileForSpec(t, spec,
+               "mem://default/changelog-multi/second.parquet", 
map[int]any{1000: int32(1)}, data.FilePath())
+       tbl := writeChangelogScanTable(t, "changelog-multi", 
[]changelogScanStep{
+               {
+                       operation: OpAppend,
+                       data:      
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 1, 1, 
data)},
+               },
+               {
+                       operation: OpDelete,
+                       deletes: []iceberg.ManifestEntry{
+                               
changelogManifestEntry(iceberg.EntryStatusADDED, 2, 2, first),
+                               
changelogManifestEntry(iceberg.EntryStatusADDED, 2, 2, second),
+                       },
+               },
+       })
+
+       tasks, err := 
tbl.NewIncrementalChangelogScan().PlanFiles(context.Background())
+       require.NoError(t, err)
+       require.Len(t, tasks, 2)
+       rows, ok := tasks[1].(DeletedRowsScanTask)
+       require.True(t, ok)
+       require.ElementsMatch(t, []string{first.FilePath(), second.FilePath()}, 
changelogFilePaths(rows.AddedDeletes()))
+}
+
+func TestIncrementalChangelogScanFoldsReplaceDeleteRewrites(t *testing.T) {
+       spec := partitionedSpec()
+       data := newTestDataFile(t, spec, 
"mem://default/changelog-replace-del/data.parquet", map[int]any{1000: int32(1)})
+       oldDelete := newTestPosDeleteFileForSpec(t, spec,
+               "mem://default/changelog-replace-del/old.parquet", 
map[int]any{1000: int32(1)}, data.FilePath())
+       newDelete := newTestPosDeleteFileForSpec(t, spec,
+               "mem://default/changelog-replace-del/new.parquet", 
map[int]any{1000: int32(1)}, data.FilePath())
+       tbl := writeChangelogScanTable(t, "changelog-replace-del", 
[]changelogScanStep{
+               {
+                       operation: OpAppend,
+                       data:      
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 1, 1, 
data)},
+               },
+               {
+                       operation: OpDelete,
+                       deletes:   
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 2, 2, 
oldDelete)},
+               },
+               {
+                       operation: OpReplace,
+                       deletes: []iceberg.ManifestEntry{
+                               
changelogManifestEntry(iceberg.EntryStatusDELETED, 3, 2, oldDelete),
+                               
changelogManifestEntry(iceberg.EntryStatusADDED, 3, 3, newDelete),
+                       },
+               },
+               {
+                       operation: OpOverwrite,
+                       data:      
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusDELETED, 4, 
1, data)},
+               },
+       })
+
+       tasks, err := 
tbl.NewIncrementalChangelogScan().PlanFiles(context.Background())
+       require.NoError(t, err)
+       require.Len(t, tasks, 3)
+       for _, task := range tasks {
+               require.NotEqual(t, int64(3), task.CommitSnapshotID())
+       }
+       removed, ok := tasks[2].(DeletedDataFileScanTask)
+       require.True(t, ok)
+       require.Equal(t, []string{newDelete.FilePath()}, 
changelogFilePaths(removed.ExistingDeletes()))
+}
+
+func TestIncrementalChangelogScanKeepsSiblingDeletionVector(t *testing.T) {
+       spec := partitionedSpec()
+       dataA := newTestDataFile(t, spec, 
"mem://default/changelog-puffin/a.parquet", map[int]any{1000: int32(1)})
+       dataB := newTestDataFile(t, spec, 
"mem://default/changelog-puffin/b.parquet", map[int]any{1000: int32(2)})
+       puffin := "mem://default/changelog-puffin/both.puffin"
+       dvA := newSharedDeletionVector(t, spec, puffin, dataA.FilePath(), 0)
+       dvB := newSharedDeletionVector(t, spec, puffin, dataB.FilePath(), 64)
+       rewrittenA := newSharedDeletionVector(t, spec, 
"mem://default/changelog-puffin/a.puffin", dataA.FilePath(), 0)
+       later := newTestPosDeleteFileForSpec(t, spec,
+               "mem://default/changelog-puffin/later.parquet", 
map[int]any{1000: int32(2)}, dataB.FilePath())
+       tbl := writeChangelogScanTable(t, "changelog-puffin", 
[]changelogScanStep{
+               {
+                       operation: OpAppend,
+                       data: []iceberg.ManifestEntry{
+                               
changelogManifestEntry(iceberg.EntryStatusADDED, 1, 1, dataA),
+                               
changelogManifestEntry(iceberg.EntryStatusADDED, 1, 1, dataB),
+                       },
+               },
+               {
+                       operation:       OpDelete,
+                       manifestVersion: 3,
+                       deletes: []iceberg.ManifestEntry{
+                               
changelogManifestEntry(iceberg.EntryStatusADDED, 2, 2, dvA),
+                               
changelogManifestEntry(iceberg.EntryStatusADDED, 2, 2, dvB),
+                       },
+               },
+               {
+                       operation:       OpDelete,
+                       manifestVersion: 3,
+                       deletes: []iceberg.ManifestEntry{
+                               
changelogManifestEntry(iceberg.EntryStatusDELETED, 3, 2, dvA),
+                               
changelogManifestEntry(iceberg.EntryStatusADDED, 3, 3, rewrittenA),
+                       },
+               },
+               {
+                       operation:       OpDelete,
+                       manifestVersion: 3,
+                       deletes:         
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 4, 4, 
later)},
+               },
+       })
+
+       tasks, err := 
tbl.NewIncrementalChangelogScan().PlanFiles(context.Background())
+       require.NoError(t, err)
+       var got DeletedRowsScanTask
+       var found bool
+       for _, task := range tasks {
+               rows, ok := task.(DeletedRowsScanTask)
+               if !ok || rows.ScanTask().File.FilePath() != dataB.FilePath() 
|| rows.CommitSnapshotID() != 4 {
+                       continue
+               }
+               got = rows
+               found = true
+       }
+       require.True(t, found)
+       require.Equal(t, []string{later.FilePath()}, 
changelogFilePaths(got.AddedDeletes()))
+       require.Equal(t, []string{dvB.FilePath()}, 
changelogFilePaths(got.ExistingDeletes()))
+       require.Equal(t, dataB.FilePath(), 
referencedDataFilePath(got.ExistingDeletes()[0]))
+}
+
+func TestIncrementalChangelogScanPlansOverwriteWithExpiredParent(t *testing.T) 
{
+       spec := partitionedSpec()
+       txn, fs := createTestTransactionWithMemIO(t, spec)
+       schema := simpleSchema()
+       data := newTestDataFile(t, spec, 
"mem://default/changelog-expired-overwrite/data.parquet", map[int]any{1000: 
int32(1)})
+       snapshotID := int64(2)
+       dataManifest := writeChangelogDataManifest(t, fs, spec, schema,
+               "mem://default/changelog-expired-overwrite/metadata/data.avro", 
snapshotID, 2,
+               
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusDELETED, 
snapshotID, 1, data)})
+       listPath := 
"mem://default/changelog-expired-overwrite/metadata/snap-2.avro"
+       writeChangelogManifestList(t, fs, listPath, snapshotID, 2, 
[]iceberg.ManifestFile{dataManifest})
+
+       expiredParent := int64(1)
+       txn.meta.snapshotList = []Snapshot{{
+               SnapshotID:       snapshotID,
+               ParentSnapshotID: &expiredParent,
+               TimestampMs:      2000,
+               ManifestList:     listPath,
+               SequenceNumber:   snapshotID,
+               SchemaID:         &schema.ID,
+               Summary:          &Summary{Operation: OpOverwrite},
+       }}
+       txn.meta.snapshotLog = []SnapshotLogEntry{{SnapshotID: snapshotID, 
TimestampMs: 2000}}
+       txn.meta.currentSnapshotID = &snapshotID
+       meta, err := txn.meta.Build()
+       require.NoError(t, err)
+       tbl := New(Identifier{"changelog-expired-overwrite"}, meta, 
"metadata.json", func(context.Context) (iceio.IO, error) {
+               return fs, nil
+       }, nil)
+
+       tasks, err := tbl.NewIncrementalChangelogScan().
+               FromSnapshotExclusive(expiredParent).
+               ToSnapshot(snapshotID).
+               PlanFiles(context.Background())
+       require.NoError(t, err)
+       require.Len(t, tasks, 1)
+       _, ok := tasks[0].(DeletedDataFileScanTask)
+       require.True(t, ok)
+}
+
+func TestIncrementalChangelogScanKeepsCarriedDeleteWhenParentExpired(t 
*testing.T) {
+       spec := partitionedSpec()
+       txn, fs := createTestTransactionWithMemIO(t, spec)
+       schema := simpleSchema()
+       data := newTestDataFile(t, spec, 
"mem://default/changelog-expired-delete/data.parquet", map[int]any{1000: 
int32(1)})
+       pos := newTestPosDeleteFileForSpec(t, spec,
+               "mem://default/changelog-expired-delete/pos.parquet", 
map[int]any{1000: int32(1)}, data.FilePath())
+       snapshotID := int64(2)
+       expiredParent := int64(1)
+       dataManifest := writeChangelogDataManifest(t, fs, spec, schema,
+               "mem://default/changelog-expired-delete/metadata/data.avro", 
snapshotID, 2,
+               
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusDELETED, 
snapshotID, 1, data)})
+       deleteManifest := writeChangelogDeleteManifest(t, fs, spec, schema,
+               "mem://default/changelog-expired-delete/metadata/deletes.avro", 
snapshotID, 2,
+               
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusEXISTING, 
expiredParent, 1, pos)})
+       listPath := 
"mem://default/changelog-expired-delete/metadata/snap-2.avro"
+       writeChangelogManifestList(t, fs, listPath, snapshotID, 2, 
[]iceberg.ManifestFile{dataManifest, deleteManifest})
+
+       txn.meta.snapshotList = []Snapshot{{
+               SnapshotID:       snapshotID,
+               ParentSnapshotID: &expiredParent,
+               TimestampMs:      2000,
+               ManifestList:     listPath,
+               SequenceNumber:   snapshotID,
+               SchemaID:         &schema.ID,
+               Summary:          &Summary{Operation: OpOverwrite},
+       }}
+       txn.meta.snapshotLog = []SnapshotLogEntry{{SnapshotID: snapshotID, 
TimestampMs: 2000}}
+       txn.meta.currentSnapshotID = &snapshotID
+       meta, err := txn.meta.Build()
+       require.NoError(t, err)
+       tbl := New(Identifier{"changelog-expired-delete"}, meta, 
"metadata.json", func(context.Context) (iceio.IO, error) {
+               return fs, nil
+       }, nil)
+
+       tasks, err := tbl.NewIncrementalChangelogScan().
+               FromSnapshotExclusive(expiredParent).
+               ToSnapshot(snapshotID).
+               PlanFiles(context.Background())
+       require.NoError(t, err)
+       require.Len(t, tasks, 1)
+       removed, ok := tasks[0].(DeletedDataFileScanTask)
+       require.True(t, ok)
+       require.Equal(t, []string{pos.FilePath()}, 
changelogFilePaths(removed.ExistingDeletes()))
+}
+
+func TestIncrementalChangelogScanKeepsRemovedDeleteWhenParentExpired(t 
*testing.T) {
+       for _, inclusive := range []bool{false, true} {
+               name := "exclusive"
+               if inclusive {
+                       name = "inclusive"
+               }
+               t.Run(name, func(t *testing.T) {
+                       tbl := expiredParentRemovedDeleteTable(t, 
"changelog-expired-removed-"+name)
+                       scan := tbl.NewIncrementalChangelogScan()
+                       if inclusive {
+                               scan = scan.FromSnapshotInclusive(2)
+                       } else {
+                               scan = scan.FromSnapshotExclusive(1)
+                       }
+                       tasks, err := 
scan.ToSnapshot(2).PlanFiles(context.Background())
+                       require.NoError(t, err)
+                       require.Len(t, tasks, 1)
+                       removed, ok := tasks[0].(DeletedDataFileScanTask)
+                       require.True(t, ok)
+                       require.Equal(t, 
[]string{"mem://default/changelog-expired-removed-" + name + "/pos.parquet"}, 
changelogFilePaths(removed.ExistingDeletes()))
+               })
+       }
+}
+
+func TestIncrementalChangelogScanReplaceRewritesDVWhenParentExpired(t 
*testing.T) {
+       spec := partitionedSpec()
+       txn, fs := createTestTransactionWithMemIO(t, spec)
+       schema := simpleSchema()
+       data := newTestDataFile(t, spec, 
"mem://default/changelog-expired-replace/data.parquet", map[int]any{1000: 
int32(1)})
+       oldDV := newChangelogDeletionVector(t, spec, 
"mem://default/changelog-expired-replace/old.puffin", data.FilePath())
+       newDV := newChangelogDeletionVector(t, spec, 
"mem://default/changelog-expired-replace/new.puffin", data.FilePath())
+       expiredParent := int64(1)
+       replaceID := int64(2)
+       overwriteID := int64(3)
+       deleteManifest := writeChangelogDeleteManifest(t, fs, spec, schema,
+               
"mem://default/changelog-expired-replace/metadata/deletes.avro", replaceID, 3,
+               []iceberg.ManifestEntry{
+                       changelogManifestEntry(iceberg.EntryStatusDELETED, 
replaceID, 2, oldDV),
+                       changelogManifestEntry(iceberg.EntryStatusADDED, 
replaceID, 3, newDV),
+               })
+       dataManifest := writeChangelogDataManifest(t, fs, spec, schema,
+               "mem://default/changelog-expired-replace/metadata/data.avro", 
overwriteID, 3,
+               
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusDELETED, 
overwriteID, 2, data)})
+       replaceList := 
"mem://default/changelog-expired-replace/metadata/snap-2.avro"
+       overwriteList := 
"mem://default/changelog-expired-replace/metadata/snap-3.avro"
+       replaceManifests := writeChangelogManifestList(t, fs, replaceList, 
replaceID, 3, []iceberg.ManifestFile{deleteManifest})
+       writeChangelogManifestList(t, fs, overwriteList, overwriteID, 3, 
append(replaceManifests, dataManifest))
+
+       schemaID := schema.ID
+       txn.meta.snapshotList = []Snapshot{
+               {
+                       SnapshotID:       replaceID,
+                       ParentSnapshotID: &expiredParent,
+                       TimestampMs:      2000,
+                       ManifestList:     replaceList,
+                       SequenceNumber:   replaceID,
+                       SchemaID:         &schemaID,
+                       Summary: &Summary{
+                               Operation:  OpReplace,
+                               Properties: 
iceberg.Properties{removedDeleteFilesKey: "1"},
+                       },
+               },
+               {
+                       SnapshotID:       overwriteID,
+                       ParentSnapshotID: &replaceID,
+                       TimestampMs:      3000,
+                       ManifestList:     overwriteList,
+                       SequenceNumber:   overwriteID,
+                       SchemaID:         &schemaID,
+                       Summary:          &Summary{Operation: OpOverwrite},
+               },
+       }
+       txn.meta.snapshotLog = []SnapshotLogEntry{
+               {SnapshotID: replaceID, TimestampMs: 2000},
+               {SnapshotID: overwriteID, TimestampMs: 3000},
+       }
+       txn.meta.currentSnapshotID = &overwriteID
+       meta, err := txn.meta.Build()
+       require.NoError(t, err)
+       tbl := New(Identifier{"changelog-expired-replace"}, meta, 
"metadata.json", func(context.Context) (iceio.IO, error) {
+               return fs, nil
+       }, nil)
+
+       tasks, err := tbl.NewIncrementalChangelogScan().
+               FromSnapshotExclusive(expiredParent).
+               PlanFiles(context.Background())
+       require.NoError(t, err)
+       require.Len(t, tasks, 1)
+       removed, ok := tasks[0].(DeletedDataFileScanTask)
+       require.True(t, ok)
+       require.Equal(t, overwriteID, removed.CommitSnapshotID())
+       require.Equal(t, []string{newDV.FilePath()}, 
changelogFilePaths(removed.ExistingDeletes()))
+}
+
+func TestIncrementalChangelogScanRejectsUnrecordedRemovedDeletes(t *testing.T) 
{
+       spec := partitionedSpec()
+       txn, fs := createTestTransactionWithMemIO(t, spec)
+       schema := simpleSchema()
+       data := newTestDataFile(t, spec, 
"mem://default/changelog-expired-guard/data.parquet", map[int]any{1000: 
int32(1)})
+       snapshotID := int64(2)
+       dataManifest := writeChangelogDataManifest(t, fs, spec, schema,
+               "mem://default/changelog-expired-guard/metadata/data.avro", 
snapshotID, 2,
+               
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusDELETED, 
snapshotID, 1, data)})
+       listPath := "mem://default/changelog-expired-guard/metadata/snap-2.avro"
+       writeChangelogManifestList(t, fs, listPath, snapshotID, 2, 
[]iceberg.ManifestFile{dataManifest})
+
+       expiredParent := int64(1)
+       schemaID := schema.ID
+       txn.meta.snapshotList = []Snapshot{{
+               SnapshotID:       snapshotID,
+               ParentSnapshotID: &expiredParent,
+               TimestampMs:      2000,
+               ManifestList:     listPath,
+               SequenceNumber:   snapshotID,
+               SchemaID:         &schemaID,
+               Summary: &Summary{
+                       Operation:  OpOverwrite,
+                       Properties: iceberg.Properties{removedDeleteFilesKey: 
"1"},
+               },
+       }}
+       txn.meta.snapshotLog = []SnapshotLogEntry{{SnapshotID: snapshotID, 
TimestampMs: 2000}}
+       txn.meta.currentSnapshotID = &snapshotID
+       meta, err := txn.meta.Build()
+       require.NoError(t, err)
+       tbl := New(Identifier{"changelog-expired-guard"}, meta, 
"metadata.json", func(context.Context) (iceio.IO, error) {
+               return fs, nil
+       }, nil)
+
+       _, err = tbl.NewIncrementalChangelogScan().
+               FromSnapshotExclusive(expiredParent).
+               PlanFiles(context.Background())
+       require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+       require.ErrorContains(t, err, removedDeleteFilesKey)
+}
+
+func TestIncrementalChangelogScanCountsRemovedDeletesBeforePartitionPrune(t 
*testing.T) {
+       spec := partitionedSpec()
+       txn, fs := createTestTransactionWithMemIO(t, spec)
+       schema := simpleSchema()
+       location := "mem://default/changelog-expired-prune"
+       data := newTestDataFile(t, spec, location+"/data.parquet", 
map[int]any{1000: int32(1)})
+       kept := newTestPosDeleteFileForSpec(t, spec, location+"/kept.parquet", 
map[int]any{1000: int32(1)}, data.FilePath())
+       pruned := newTestPosDeleteFileForSpec(t, spec, 
location+"/pruned.parquet", map[int]any{1000: int32(2)}, data.FilePath())
+       snapshotID := int64(2)
+       expiredParent := int64(1)
+       dataManifest := writeChangelogDataManifest(t, fs, spec, schema, 
location+"/metadata/data.avro", snapshotID, 2,
+               
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusDELETED, 
snapshotID, 1, data)})
+       keptManifest := writeChangelogDeleteManifest(t, fs, spec, schema, 
location+"/metadata/kept.avro", snapshotID, 2,
+               
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusDELETED, 
snapshotID, 1, kept)})
+       prunedManifest := writeChangelogDeleteManifest(t, fs, spec, schema, 
location+"/metadata/pruned.avro", snapshotID, 2,
+               
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusDELETED, 
snapshotID, 1, pruned)})
+       listPath := location + "/metadata/snap-2.avro"
+       writeChangelogManifestList(t, fs, listPath, snapshotID, 2, 
[]iceberg.ManifestFile{dataManifest, keptManifest, prunedManifest})
+
+       schemaID := schema.ID
+       txn.meta.snapshotList = []Snapshot{{
+               SnapshotID:       snapshotID,
+               ParentSnapshotID: &expiredParent,
+               TimestampMs:      2000,
+               ManifestList:     listPath,
+               SequenceNumber:   snapshotID,
+               SchemaID:         &schemaID,
+               Summary: &Summary{
+                       Operation:  OpOverwrite,
+                       Properties: iceberg.Properties{removedDeleteFilesKey: 
"2"},
+               },
+       }}
+       txn.meta.snapshotLog = []SnapshotLogEntry{{SnapshotID: snapshotID, 
TimestampMs: 2000}}
+       txn.meta.currentSnapshotID = &snapshotID
+       meta, err := txn.meta.Build()
+       require.NoError(t, err)
+       tbl := New(Identifier{"changelog-expired-prune"}, meta, 
"metadata.json", func(context.Context) (iceio.IO, error) {
+               return fs, nil
+       }, nil)
+
+       tasks, err := tbl.NewIncrementalChangelogScan(
+               WithRowFilter(iceberg.EqualTo(iceberg.Reference("id"), 
int32(1))),
+       ).FromSnapshotExclusive(expiredParent).PlanFiles(context.Background())
+       require.NoError(t, err)
+       require.Len(t, tasks, 1)
+       removed, ok := tasks[0].(DeletedDataFileScanTask)
+       require.True(t, ok)
+       require.Equal(t, []string{kept.FilePath()}, 
changelogFilePaths(removed.ExistingDeletes()))
+}
+
+func TestEntriesLiveBefore(t *testing.T) {
+       carried := changelogTestDataFile(t, "carried.parquet", 
iceberg.EntryContentPosDeletes, iceberg.ParquetFile)
+       removed := changelogTestDataFile(t, "removed.parquet", 
iceberg.EntryContentPosDeletes, iceberg.ParquetFile)
+       added := changelogTestDataFile(t, "added.parquet", 
iceberg.EntryContentPosDeletes, iceberg.ParquetFile)
+       dup := changelogTestDataFile(t, "dup.parquet", 
iceberg.EntryContentPosDeletes, iceberg.ParquetFile)
+       snapshotID := int64(5)
+
+       got := entriesLiveBefore([]iceberg.ManifestEntry{
+               changelogManifestEntry(iceberg.EntryStatusEXISTING, 2, 1, 
carried),
+               changelogManifestEntry(iceberg.EntryStatusDELETED, snapshotID, 
1, removed),
+               changelogManifestEntry(iceberg.EntryStatusADDED, snapshotID, 2, 
added),
+       }, snapshotID)
+       require.Equal(t, []string{carried.FilePath(), removed.FilePath()}, 
changelogEntryPaths(got))
+
+       for _, entries := range [][]iceberg.ManifestEntry{
+               {
+                       changelogManifestEntry(iceberg.EntryStatusDELETED, 
snapshotID, 1, dup),
+                       changelogManifestEntry(iceberg.EntryStatusEXISTING, 2, 
1, dup),
+               },
+               {
+                       changelogManifestEntry(iceberg.EntryStatusEXISTING, 2, 
1, dup),
+                       changelogManifestEntry(iceberg.EntryStatusDELETED, 
snapshotID, 1, dup),
+               },
+       } {
+               got = entriesLiveBefore(entries, snapshotID)
+               require.Equal(t, []string{dup.FilePath()}, 
changelogEntryPaths(got))
+               require.Equal(t, iceberg.EntryStatusEXISTING, got[0].Status())
+       }
+}
+
+func TestLiveManifestEntriesUsesTheLatestStatus(t *testing.T) {
+       file := changelogTestDataFile(t, "data.parquet", 
iceberg.EntryContentData, iceberg.ParquetFile)
+       // Snapshot IDs are not chronological. The delete is snapshot 3, whose
+       // sequence number is 2. The add is snapshot 20, whose sequence number 
is 1.
+       added := changelogManifestEntry(iceberg.EntryStatusADDED, 20, 1, file)
+       deleted := changelogManifestEntry(iceberg.EntryStatusDELETED, 3, 1, 
file)
+       readded := changelogManifestEntry(iceberg.EntryStatusADDED, 2, 1, file)
+       sameSnapshotDeleted := 
changelogManifestEntry(iceberg.EntryStatusDELETED, 20, 1, file)
+       sequences := func(snapshotID int64) (int64, bool) {
+               switch snapshotID {
+               case 20:
+                       return 1, true
+               case 3:
+                       return 2, true
+               case 2:
+                       return 3, true
+               default:
+                       return 0, false
+               }
+       }
+
+       for _, entries := range [][]iceberg.ManifestEntry{
+               {added, deleted},
+               {deleted, added},
+               {added, sameSnapshotDeleted},
+               {sameSnapshotDeleted, added},
+       } {
+               require.Empty(t, liveManifestEntries(entries, sequences))
+       }
+
+       for _, entries := range [][]iceberg.ManifestEntry{
+               {added, deleted, readded},
+               {readded, deleted, added},
+       } {
+               got := liveManifestEntries(entries, sequences)
+               require.Len(t, got, 1)
+               require.Equal(t, iceberg.EntryStatusADDED, got[0].Status())
+               require.Equal(t, int64(2), got[0].SnapshotID())
+       }
+}
+
+func TestEntriesBeforeDropsRemovedDeleteFiles(t *testing.T) {
+       keep := changelogTestDataFile(t, "keep.parquet", 
iceberg.EntryContentPosDeletes, iceberg.ParquetFile)
+       drop := changelogTestDataFile(t, "drop.parquet", 
iceberg.EntryContentPosDeletes, iceberg.ParquetFile)
+       state := &changelogDeleteState{
+               snapshots: []Snapshot{{SnapshotID: 1}, {SnapshotID: 2}, 
{SnapshotID: 3}},
+               preRange:  
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusADDED, 1, 1, 
drop)},
+               added: map[int64][]iceberg.ManifestEntry{
+                       1: {changelogManifestEntry(iceberg.EntryStatusADDED, 1, 
1, keep)},
+               },
+               removed: map[int64]map[string]struct{}{
+                       2: {drop.FilePath(): {}},
+               },
+       }
+
+       require.Equal(t, []string{keep.FilePath()}, 
changelogEntryPaths(state.entriesBefore(3)))
+
+       readded := &changelogDeleteState{
+               snapshots: state.snapshots,
+               preRange:  state.preRange,
+               added: map[int64][]iceberg.ManifestEntry{
+                       1: {changelogManifestEntry(iceberg.EntryStatusADDED, 1, 
1, keep)},
+                       2: {changelogManifestEntry(iceberg.EntryStatusADDED, 2, 
2, drop)},
+               },
+               removed: state.removed,
+       }
+       require.Equal(t, []string{keep.FilePath(), drop.FilePath()}, 
changelogEntryPaths(readded.entriesBefore(3)))
+}
+
+func TestKeepNewestDeletionVector(t *testing.T) {
+       spec := partitionedSpec()
+       older := newChangelogDeletionVector(t, spec, "older.puffin", 
"data.parquet")
+       newer := newChangelogDeletionVector(t, spec, "newer.puffin", 
"data.parquet")
+       pos := changelogTestDataFile(t, "pos.parquet", 
iceberg.EntryContentPosDeletes, iceberg.ParquetFile)
+       got := keepNewestDeletionVector([]iceberg.ManifestEntry{
+               changelogManifestEntry(iceberg.EntryStatusADDED, 1, 1, older),
+               changelogManifestEntry(iceberg.EntryStatusADDED, 2, 2, pos),
+               changelogManifestEntry(iceberg.EntryStatusADDED, 3, 3, newer),
+       })
+       require.Equal(t, []string{pos.FilePath(), newer.FilePath()}, 
changelogEntryPaths(got))
+}
+
+func changelogFilePaths(files []iceberg.DataFile) []string {
+       out := make([]string, len(files))
+       for i, file := range files {
+               out[i] = file.FilePath()
+       }
+
+       return out
+}
+
+func changelogEntryPaths(entries []iceberg.ManifestEntry) []string {
+       out := make([]string, len(entries))
+       for i, entry := range entries {
+               out[i] = entry.DataFile().FilePath()
+       }
+
+       return out
+}
+
+func changelogManifestEntry(status iceberg.ManifestEntryStatus, snapshotID, 
sequence int64, file iceberg.DataFile) iceberg.ManifestEntry {
+       return iceberg.NewManifestEntry(status, &snapshotID, &sequence, 
&sequence, file)
+}
+
+func changelogTasksBySnapshot(tasks []ChangelogScanTask) 
map[int64]ChangelogScanTask {
+       out := make(map[int64]ChangelogScanTask, len(tasks))
+       for _, task := range tasks {
+               out[task.CommitSnapshotID()] = task
+       }
+
+       return out
+}
+
+func changelogSequenceDelete(t *testing.T, spec iceberg.PartitionSpec, kind, 
path string, data iceberg.DataFile) (iceberg.DataFile, int) {
+       t.Helper()
+
+       switch kind {
+       case "pos":
+               return newTestPosDeleteFileForSpec(t, spec, path+".parquet", 
map[int]any{1000: int32(1)}, data.FilePath()), 2
+       case "eq":
+               return newChangelogEqDelete(t, spec, path+".parquet", 
map[int]any{1000: int32(1)}), 2
+       default:
+               return newChangelogDeletionVector(t, spec, path+".puffin", 
data.FilePath()), 3
+       }
+}
+
+func expiredParentRemovedDeleteTable(t *testing.T, location string) *Table {
+       t.Helper()
+
+       spec := partitionedSpec()
+       txn, fs := createTestTransactionWithMemIO(t, spec)
+       schema := simpleSchema()
+       data := newTestDataFile(t, spec, 
"mem://default/"+location+"/data.parquet", map[int]any{1000: int32(1)})
+       pos := newTestPosDeleteFileForSpec(t, spec,
+               "mem://default/"+location+"/pos.parquet", map[int]any{1000: 
int32(1)}, data.FilePath())
+       snapshotID := int64(2)
+       expiredParent := int64(1)
+       dataManifest := writeChangelogDataManifest(t, fs, spec, schema,
+               "mem://default/"+location+"/metadata/data.avro", snapshotID, 2,
+               
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusDELETED, 
snapshotID, 1, data)})
+       deleteManifest := writeChangelogDeleteManifest(t, fs, spec, schema,
+               "mem://default/"+location+"/metadata/deletes.avro", snapshotID, 
2,
+               
[]iceberg.ManifestEntry{changelogManifestEntry(iceberg.EntryStatusDELETED, 
snapshotID, 1, pos)})
+       listPath := "mem://default/" + location + "/metadata/snap-2.avro"
+       writeChangelogManifestList(t, fs, listPath, snapshotID, 2, 
[]iceberg.ManifestFile{dataManifest, deleteManifest})
+
+       schemaID := schema.ID
+       txn.meta.snapshotList = []Snapshot{{
+               SnapshotID:       snapshotID,
+               ParentSnapshotID: &expiredParent,
+               TimestampMs:      2000,
+               ManifestList:     listPath,
+               SequenceNumber:   snapshotID,
+               SchemaID:         &schemaID,
+               Summary: &Summary{
+                       Operation:  OpOverwrite,
+                       Properties: iceberg.Properties{removedDeleteFilesKey: 
"1"},
+               },
+       }}
+       txn.meta.snapshotLog = []SnapshotLogEntry{{SnapshotID: snapshotID, 
TimestampMs: 2000}}
+       txn.meta.currentSnapshotID = &snapshotID
+       meta, err := txn.meta.Build()
+       require.NoError(t, err)
+
+       return New(Identifier{location}, meta, "metadata.json", 
func(context.Context) (iceio.IO, error) {
+               return fs, nil
+       }, nil)
+}
+
+func newChangelogEqDelete(t *testing.T, spec iceberg.PartitionSpec, path 
string, partition map[int]any) iceberg.DataFile {
+       t.Helper()
+
+       builder, err := iceberg.NewDataFileBuilder(
+               spec, iceberg.EntryContentEqDeletes, path, iceberg.ParquetFile, 
partition, nil, nil, 1, 1)
+       require.NoError(t, err)
+
+       return builder.EqualityFieldIDs([]int{1}).Build()
+}
+
+func newChangelogDeletionVector(t *testing.T, spec iceberg.PartitionSpec, 
path, dataPath string) iceberg.DataFile {
+       t.Helper()
+
+       return newSharedDeletionVector(t, spec, path, dataPath, 0)
+}
+
+func newSharedDeletionVector(t *testing.T, spec iceberg.PartitionSpec, path, 
dataPath string, offset int64) iceberg.DataFile {
+       t.Helper()
+
+       builder, err := iceberg.NewDataFileBuilder(
+               spec, iceberg.EntryContentPosDeletes, path, iceberg.PuffinFile, 
nil, nil, nil, 1, 1)
+       require.NoError(t, err)
+
+       return 
builder.ReferencedDataFile(dataPath).ContentOffset(offset).ContentSizeInBytes(16).Build()
+}
+
+type changelogScanStep struct {
+       operation       Operation
+       manifestVersion int
+       data            []iceberg.ManifestEntry
+       deletes         []iceberg.ManifestEntry
+}
+
+func writeChangelogScanTable(t *testing.T, location string, steps 
[]changelogScanStep) *Table {
+       t.Helper()
+
+       spec := partitionedSpec()
+       txn, fs := createTestTransactionWithMemIO(t, spec)
+       schema := simpleSchema()
+       var manifestList []iceberg.ManifestFile
+       listVersion := 2
+       snapshots := make([]Snapshot, 0, len(steps))
+       log := make([]SnapshotLogEntry, 0, len(steps))
+       var parent *int64
+       for i, step := range steps {
+               snapshotID := int64(i + 1)
+               version := step.manifestVersion
+               if version == 0 {
+                       version = 2
+               }
+               if version > listVersion {
+                       listVersion = version
+               }
+               var written []iceberg.ManifestFile
+               if len(step.data) > 0 {
+                       path := "mem://default/" + location + "/metadata/data-" 
+ fmtSnapshot(snapshotID) + ".avro"
+                       written = append(written, writeChangelogDataManifest(t, 
fs, spec, schema, path, snapshotID, version, step.data))
+               }
+               if len(step.deletes) > 0 {
+                       path := "mem://default/" + location + 
"/metadata/deletes-" + fmtSnapshot(snapshotID) + ".avro"
+                       written = append(written, 
writeChangelogDeleteManifest(t, fs, spec, schema, path, snapshotID, version, 
step.deletes))
+               }
+               listPath := "mem://default/" + location + "/metadata/snap-" + 
fmtSnapshot(snapshotID) + ".avro"
+               manifestList = writeChangelogManifestList(t, fs, listPath, 
snapshotID, listVersion, append(manifestList, written...))
+               snapshots = append(snapshots, Snapshot{
+                       SnapshotID:       snapshotID,
+                       ParentSnapshotID: parent,
+                       TimestampMs:      snapshotID * 1000,
+                       ManifestList:     listPath,
+                       SequenceNumber:   snapshotID,
+                       SchemaID:         &schema.ID,
+                       Summary:          &Summary{Operation: step.operation},
+               })
+               log = append(log, SnapshotLogEntry{SnapshotID: snapshotID, 
TimestampMs: snapshotID * 1000})
+               id := snapshotID
+               parent = &id
+       }
+       txn.meta.snapshotList = snapshots
+       txn.meta.snapshotLog = log
+       if len(snapshots) > 0 {
+               current := snapshots[len(snapshots)-1].SnapshotID
+               txn.meta.currentSnapshotID = &current
+       }
+       meta, err := txn.meta.Build()
+       require.NoError(t, err)
+
+       return New(Identifier{location}, meta, "metadata.json", 
func(context.Context) (iceio.IO, error) {
+               return fs, nil
+       }, nil)
+}
+
+func fmtSnapshot(snapshotID int64) string {
+       return strconv.FormatInt(snapshotID, 10)
+}
+
+func writeChangelogDataManifest(
+       t *testing.T,
+       fs iceio.WriteFileIO,
+       spec iceberg.PartitionSpec,
+       schema *iceberg.Schema,
+       path string,
+       snapshotID int64,
+       version int,
+       entries []iceberg.ManifestEntry,
+) iceberg.ManifestFile {
+       t.Helper()
+
+       var buf bytes.Buffer
+       manifest, err := iceberg.WriteManifest(path, &buf, version, spec, 
schema, snapshotID, entries)
+       require.NoError(t, err)
+       require.NoError(t, fs.WriteFile(context.Background(), path, 
buf.Bytes()))
+
+       return manifest
+}
+
+func writeChangelogDeleteManifest(
+       t *testing.T,
+       fs iceio.WriteFileIO,
+       spec iceberg.PartitionSpec,
+       schema *iceberg.Schema,
+       path string,
+       snapshotID int64,
+       version int,
+       entries []iceberg.ManifestEntry,
+) iceberg.ManifestFile {
+       t.Helper()
+
+       var buf bytes.Buffer
+       writer, err := iceberg.NewManifestWriter(version, &buf, spec, schema, 
snapshotID,
+               
iceberg.WithManifestWriterContent(iceberg.ManifestContentDeletes))
+       require.NoError(t, err)
+       for _, entry := range entries {
+               switch entry.Status() {
+               case iceberg.EntryStatusDELETED:
+                       require.NoError(t, writer.Delete(entry))
+               case iceberg.EntryStatusEXISTING:
+                       require.NoError(t, writer.Existing(entry))
+               default:
+                       require.NoError(t, writer.Add(entry))
+               }
+       }
+       require.NoError(t, writer.Close())
+       manifest, err := writer.ToManifestFile(path, int64(buf.Len()),
+               iceberg.WithManifestFileContent(iceberg.ManifestContentDeletes))
+       require.NoError(t, err)
+       require.NoError(t, fs.WriteFile(context.Background(), path, 
buf.Bytes()))
+
+       return manifest
+}
+
+func writeChangelogManifestList(
+       t *testing.T,
+       fs iceio.WriteFileIO,
+       path string,
+       snapshotID int64,
+       version int,
+       manifests []iceberg.ManifestFile,
+) []iceberg.ManifestFile {
+       t.Helper()
+
+       var buf bytes.Buffer
+       sequenceNumber := snapshotID
+       require.NoError(t, iceberg.WriteManifestList(version, &buf, snapshotID, 
nil, &sequenceNumber, 0, manifests))
+       require.NoError(t, fs.WriteFile(context.Background(), path, 
buf.Bytes()))
+       listFile, err := fs.Open(context.Background(), path)
+       require.NoError(t, err)
+       list, err := iceberg.ReadManifestList(listFile)
+       require.NoError(t, err)
+       require.NoError(t, listFile.Close())
+
+       return list
+}
+
 type countingOpenIO struct {
        iceio.IO
        opens     *atomic.Int64
@@ -782,3 +1950,145 @@ func (io *countingOpenIO) Open(_ context.Context, name 
string) (iceio.File, erro
 
        return io.IO.Open(context.Background(), name)
 }
+
+// TestIncrementalChangelogScanRebuildsExpiredParentFromRealWriters appends a
+// data file, adds a position delete with RowDelta, then replaces both through
+// the overwrite writer. Expiring the delete snapshot leaves the replace as
+// the first in-range snapshot, and planning rebuilds the position delete from
+// the DELETED entry that writer recorded.
+func TestIncrementalChangelogScanRebuildsExpiredParentFromRealWriters(t 
*testing.T) {
+       ctx := context.Background()
+       dir := t.TempDir()
+       schema := simpleSchema()
+       meta, err := NewMetadata(schema, iceberg.UnpartitionedSpec, 
UnsortedSortOrder, dir, iceberg.Properties{
+               PropertyFormatVersion: "2",
+       })
+       require.NoError(t, err)
+       cat := &countingCatalog{metadata: meta}
+       fsF := func(context.Context) (iceio.IO, error) { return 
iceio.LocalFS{}, nil }
+       tbl := New(Identifier{"db", "changelog-real-writer"}, meta, "", fsF, 
cat)
+
+       arrowSchema, err := SchemaToArrowSchema(schema, nil, false, false)
+       require.NoError(t, err)
+       tbl, err = appendChangelogRows(ctx, tbl, arrowSchema, `[{"id": 1}]`)
+       require.NoError(t, err)
+       require.NotNil(t, tbl.CurrentSnapshot())
+
+       planned, err := tbl.Scan().PlanFiles(ctx)
+       require.NoError(t, err)
+       require.Len(t, planned, 1)
+       dataFile := planned[0].File
+
+       posPath := filepath.Join(dir, "pos-del.parquet")
+       writeChangelogParquet(t, posPath, PositionalDeleteArrowSchema, 
fmt.Sprintf(`[{"file_path": %q, "pos": 0}]`, dataFile.FilePath()))
+       posInfo, err := os.Stat(posPath)
+       require.NoError(t, err)
+       posBuilder, err := iceberg.NewDataFileBuilder(
+               *iceberg.UnpartitionedSpec,
+               iceberg.EntryContentPosDeletes,
+               posPath,
+               iceberg.ParquetFile,
+               nil, nil, nil,
+               1,
+               posInfo.Size(),
+       )
+       require.NoError(t, err)
+       posDelete := posBuilder.ReferencedDataFile(dataFile.FilePath()).Build()
+
+       tx := tbl.NewTransaction()
+       require.NoError(t, 
tx.NewRowDelta(nil).AddDeletes(posDelete).Commit(ctx))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+       snapshotB := tbl.CurrentSnapshot().SnapshotID
+
+       planned, err = tbl.Scan().PlanFiles(ctx)
+       require.NoError(t, err)
+       require.Len(t, planned, 1)
+       require.NotEmpty(t, planned[0].DeleteFiles)
+
+       replacementPath := filepath.Join(dir, "replacement.parquet")
+       writeChangelogParquet(t, replacementPath, arrowSchema, `[{"id": 2}]`)
+       replacementInfo, err := os.Stat(replacementPath)
+       require.NoError(t, err)
+       replacementBuilder, err := iceberg.NewDataFileBuilder(
+               *iceberg.UnpartitionedSpec,
+               iceberg.EntryContentData,
+               replacementPath,
+               iceberg.ParquetFile,
+               nil, nil, nil,
+               1,
+               replacementInfo.Size(),
+       )
+       require.NoError(t, err)
+
+       tx = tbl.NewTransaction()
+       require.NoError(t, tx.ReplaceFiles(ctx,
+               []iceberg.DataFile{planned[0].File},
+               []iceberg.DataFile{replacementBuilder.Build()},
+               append([]iceberg.DataFile{}, planned[0].DeleteFiles...),
+               nil,
+       ))
+       tbl, err = tx.Commit(ctx)
+       require.NoError(t, err)
+       snapshotC := tbl.CurrentSnapshot()
+       require.NotNil(t, snapshotC)
+       require.Contains(t, snapshotC.Summary.Properties, removedDeleteFilesKey)
+
+       builder, err := MetadataBuilderFromBase(tbl.Metadata(), 
tbl.MetadataLocation())
+       require.NoError(t, err)
+       require.NoError(t, builder.RemoveSnapshots([]int64{snapshotB}, false))
+       expired, err := builder.Build()
+       require.NoError(t, err)
+       tbl = New(tbl.Identifier(), expired, tbl.MetadataLocation(), fsF, cat)
+
+       tasks, err := 
tbl.NewIncrementalChangelogScan().FromSnapshotExclusive(snapshotB).PlanFiles(ctx)
+       require.NoError(t, err)
+
+       var deleted DeletedDataFileScanTask
+       found := false
+       for _, task := range tasks {
+               got, ok := task.(DeletedDataFileScanTask)
+               if !ok {
+                       continue
+               }
+               found = true
+               deleted = got
+       }
+       require.True(t, found)
+       require.Equal(t, snapshotC.SnapshotID, deleted.CommitSnapshotID())
+       paths := make([]string, 0, len(deleted.ExistingDeletes()))
+       for _, file := range deleted.ExistingDeletes() {
+               paths = append(paths, file.FilePath())
+       }
+       require.Contains(t, paths, posPath)
+}
+
+func appendChangelogRows(ctx context.Context, tbl *Table, schema 
*arrow.Schema, jsonRows string) (*Table, error) {
+       batch, _, err := array.RecordFromJSON(memory.DefaultAllocator, schema, 
strings.NewReader(jsonRows))
+       if err != nil {
+               return nil, err
+       }
+       defer batch.Release()
+       rows := array.NewTableFromRecords(schema, []arrow.RecordBatch{batch})
+       defer rows.Release()
+       reader := array.NewTableReader(rows, rows.NumRows())
+       defer reader.Release()
+
+       return tbl.Append(ctx, reader, nil)
+}
+
+func writeChangelogParquet(t *testing.T, path string, schema *arrow.Schema, 
jsonData string) {
+       t.Helper()
+
+       batch, _, err := array.RecordFromJSON(memory.DefaultAllocator, schema, 
strings.NewReader(jsonData))
+       require.NoError(t, err)
+       defer batch.Release()
+
+       out, err := iceio.LocalFS{}.Create(context.Background(), path)
+       require.NoError(t, err)
+       defer out.Close()
+
+       rows := array.NewTableFromRecords(schema, []arrow.RecordBatch{batch})
+       defer rows.Release()
+       require.NoError(t, pqarrow.WriteTable(rows, out, batch.NumRows(), 
parquet.NewWriterProperties(), pqarrow.DefaultWriterProps()))
+}

Reply via email to