zeroshade commented on code in PR #1883:
URL: https://github.com/apache/iceberg-go/pull/1883#discussion_r3926373509


##########
table/incremental_changelog_scan.go:
##########
@@ -0,0 +1,392 @@
+// 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"
+       "maps"
+       "slices"
+       "time"
+
+       "github.com/apache/iceberg-go"
+       "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.
+type IncrementalChangelogScan struct {
+       scan           *Scan
+       fromSnapshotID *int64
+       fromInclusive  bool
+       toSnapshotID   *int64
+}
+
+// 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 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.
+func (t Table) NewIncrementalChangelogScan(opts ...ScanOption) 
*IncrementalChangelogScan {
+       return &IncrementalChangelogScan{scan: t.Scan(opts...)}
+}
+
+// FromSnapshotInclusive includes changes committed by the starting snapshot.
+// The snapshot is validated when files are planned.
+func (s *IncrementalChangelogScan) FromSnapshotInclusive(snapshotID int64) 
*IncrementalChangelogScan {
+       out := *s
+       out.fromSnapshotID = &snapshotID
+       out.fromInclusive = true
+
+       return &out
+}
+
+// FromSnapshotExclusive starts after the given snapshot. The starting
+// snapshot must be a parent ancestor of the ending snapshot when planning.
+func (s *IncrementalChangelogScan) FromSnapshotExclusive(snapshotID int64) 
*IncrementalChangelogScan {
+       out := *s
+       out.fromSnapshotID = &snapshotID
+       out.fromInclusive = false
+
+       return &out
+}
+
+// ToSnapshot sets the inclusive ending snapshot.
+// The snapshot is validated when files are planned.
+func (s *IncrementalChangelogScan) ToSnapshot(snapshotID int64) 
*IncrementalChangelogScan {
+       out := *s
+       out.toSnapshotID = &snapshotID
+
+       return &out
+}
+
+// PlanFiles returns one task for each added or deleted data-file entry. Tasks
+// are ordered by change ordinal, then by DELETE before INSERT within an
+// ordinal, and finally by data-file path. It emits a ScanReport through the
+// configured reporter on successful planning.
+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)
+       }
+
+       switch s.scan.planningMode {
+       case ScanPlanningLocal, ScanPlanningAuto:
+       case ScanPlanningRemote:
+               return nil, fmt.Errorf("%w: incremental changelog scans do not 
support remote planning", ErrInvalidOperation)
+       default:
+               return nil, fmt.Errorf("%w: unknown scan planning mode %q", 
iceberg.ErrInvalidArgument, s.scan.planningMode)
+       }
+       start := time.Now()
+
+       toSnapshot, err := s.toSnapshot()
+       if err != nil {
+               return nil, err
+       }
+       if toSnapshot == nil {
+               if s.fromSnapshotID != nil {
+                       return nil, fmt.Errorf("%w: no ending snapshot found 
for incremental changelog scan from %d",
+                               iceberg.ErrInvalidArgument, *s.fromSnapshotID)
+               }
+
+               return nil, nil
+       }
+
+       planningScan := *s.scan
+       planningScan.identifier = slices.Clone(s.scan.identifier)
+       planningScan.selectedFields = slices.Clone(s.scan.selectedFields)
+       planningScan.options = maps.Clone(s.scan.options)
+       if s.toSnapshotID != nil {
+               planningScan.snapshotID = &toSnapshot.SnapshotID
+               planningScan.asOfTimestamp = nil
+       }
+       schema, err := planningScan.effectiveSchema()
+       if err != nil {
+               return nil, err
+       }
+       residual, err := bindTaskFilter(schema, planningScan.rowFilter, 
planningScan.caseSensitive)
+       if err != nil {
+               return nil, fmt.Errorf("bind incremental changelog scan 
residual: %w", err)
+       }
+       var acc scanMetricsAccumulator
+       finish := func(tasks []ChangelogScanTask) ([]ChangelogScanTask, error) {
+               acc.resultDataFiles = int64(len(tasks))
+               fileTasks := make([]FileScanTask, 0, len(tasks))
+               for _, task := range tasks {
+                       fileTask, err := changelogTaskFileScanTask(task)
+                       if err != nil {
+                               return nil, err
+                       }
+                       acc.totalFileSize += fileTask.File.FileSizeBytes()
+                       fileTasks = append(fileTasks, fileTask)
+               }
+               acc.applyResultDeleteMetrics(fileTasks)
+               planningDuration := time.Since(start)
+
+               if rep := planningScan.Reporter(); !metrics.IsNop(rep) {
+                       projected, _ := planningScan.Projection()
+                       safeReport(ctx, rep, planningScan.buildScanReport(&acc, 
schema, projected, planningDuration))
+               }
+
+               return tasks, nil
+       }
+
+       snapshotRange, err := incrementalSnapshotsBetween(
+               s.scan.metadata, s.fromSnapshotID, s.fromInclusive, 
toSnapshot.SnapshotID)
+       if err != nil {
+               return nil, err
+       }
+       snapshots, err := changelogSnapshots(snapshotRange)
+       if err != nil {
+               return nil, err
+       }
+       if len(snapshots) == 0 {
+               return finish(nil)
+       }
+
+       changelogSnapshotIDs := make(map[int64]struct{}, len(snapshots))
+       snapshotOrdinals := make(map[int64]int, len(snapshots))
+       for ordinal, snapshot := range snapshots {
+               changelogSnapshotIDs[snapshot.SnapshotID] = struct{}{}
+               snapshotOrdinals[snapshot.SnapshotID] = ordinal
+       }
+
+       if s.scan.ioF == nil {
+               return nil, fmt.Errorf("%w: table file IO is not configured", 
ErrInvalidOperation)
+       }
+       fs, err := s.scan.ioF(ctx)
+       if err != nil {
+               return nil, err
+       }
+
+       manifestsByPath := make(map[string]iceberg.ManifestFile)
+       for _, snapshot := range snapshots {
+               if err := ctx.Err(); err != nil {
+                       return nil, err
+               }
+               manifests, err := snapshot.Manifests(fs)
+               if err != nil {
+                       return nil, err
+               }
+               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 {
+                               continue
+                       }
+                       manifestsByPath[manifest.FilePath()] = manifest
+               }
+       }
+
+       paths := make([]string, 0, len(manifestsByPath))
+       for path := range manifestsByPath {
+               paths = append(paths, path)
+       }
+       slices.Sort(paths)
+       manifestList := make([]iceberg.ManifestFile, 0, len(paths))
+       for _, path := range paths {
+               manifestList = append(manifestList, manifestsByPath[path])
+       }
+
+       // Changelog metrics intentionally count only manifests that contain 
added or
+       // deleted data files; no-change manifests are removed before the scan 
metric
+       // accumulator sees them.
+       manifestList = slices.DeleteFunc(manifestList, func(manifest 
iceberg.ManifestFile) bool {
+               return !manifestHasChangelogEntries(manifest)
+       })
+       partitionFilters := planningScan.partitionFiltersForSchema(schema)
+       manifestList, err = planningScan.filterManifestsWithSchemaOptions(
+               manifestList, schema, &acc, partitionFilters,
+               /* includeDeleted= */ true)
+       if err != nil {
+               return nil, err
+       }
+       if len(manifestList) == 0 {
+               return finish(nil)
+       }
+       entries, err := planningScan.collectManifestEntriesWithSchemaOptions(
+               ctx, manifestList, schema,
+               partitionFilters,
+               /* discardDeleted= */ false,
+               /* discardExisting= */ true,
+       )
+       if err != nil {
+               return nil, err
+       }
+
+       type plannedChangelogTask struct {
+               task ChangelogScanTask
+               file FileScanTask
+       }
+       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)
+               if err != nil {
+                       return nil, fmt.Errorf("incremental changelog scan 
snapshot %d: %w", entry.SnapshotID(), err)
+               }
+               fileTask, err := changelogTaskFileScanTask(task)
+               if err != nil {
+                       return nil, fmt.Errorf("incremental changelog scan 
snapshot %d: %w", entry.SnapshotID(), err)
+               }
+               plannedTasks = append(plannedTasks, plannedChangelogTask{task: 
task, file: fileTask})
+       }
+       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())
+       })

Review Comment:
   **major** — Documented file-path tiebreak is still unpinned; mutation to 
`return 0` leaves the suite green
   
   PlanFiles' doc (:83-86) now promises tasks are ordered '...and finally by 
data-file path' — a new public behavioural contract with no Java counterpart. 
No fixture presents two tasks sharing both change ordinal and operation in 
non-sorted order, so the comparator's third clause never changes an outcome. 
Add a fixture where one in-range snapshot adds several files in reverse-sorted 
order within a single manifest (e.g. data-z, data-m, data-a) and assert the 
planned order is a, m, z. That single test turns the mutation red.
   
   <details><summary>Evidence</summary>
   
   ```text
   Coverage -covermode=count: line 270 execcount=1 (263=29, 266=10) — executed 
but result unobservable, since the new b/z pair is already sorted in manifest 
order and n=4 uses stable insertion sort. Mutation `return 
cmp.Compare(left.file.File.FilePath(), ...)` -> `return 0`: `go test ./table/ 
-run Changelog -count=1` => ok 0.955s (GREEN). Throwaway probe adding z, m, a 
in one manifest: at head logs 'PROBE order: [data-a data-m data-z]' and PASSES; 
under the same mutation it FAILS with '- data-a.parquet / + data-z.parquet ... 
- data-z.parquet / + data-a.parquet'.
   ```
   
   </details>



##########
table/incremental_changelog_scan.go:
##########
@@ -0,0 +1,392 @@
+// 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"
+       "maps"
+       "slices"
+       "time"
+
+       "github.com/apache/iceberg-go"
+       "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.
+type IncrementalChangelogScan struct {
+       scan           *Scan
+       fromSnapshotID *int64
+       fromInclusive  bool
+       toSnapshotID   *int64
+}
+
+// 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 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.
+func (t Table) NewIncrementalChangelogScan(opts ...ScanOption) 
*IncrementalChangelogScan {
+       return &IncrementalChangelogScan{scan: t.Scan(opts...)}
+}
+
+// FromSnapshotInclusive includes changes committed by the starting snapshot.
+// The snapshot is validated when files are planned.
+func (s *IncrementalChangelogScan) FromSnapshotInclusive(snapshotID int64) 
*IncrementalChangelogScan {
+       out := *s
+       out.fromSnapshotID = &snapshotID
+       out.fromInclusive = true
+
+       return &out
+}
+
+// FromSnapshotExclusive starts after the given snapshot. The starting
+// snapshot must be a parent ancestor of the ending snapshot when planning.
+func (s *IncrementalChangelogScan) FromSnapshotExclusive(snapshotID int64) 
*IncrementalChangelogScan {
+       out := *s
+       out.fromSnapshotID = &snapshotID
+       out.fromInclusive = false
+
+       return &out
+}
+
+// ToSnapshot sets the inclusive ending snapshot.
+// The snapshot is validated when files are planned.
+func (s *IncrementalChangelogScan) ToSnapshot(snapshotID int64) 
*IncrementalChangelogScan {
+       out := *s
+       out.toSnapshotID = &snapshotID
+
+       return &out
+}
+
+// PlanFiles returns one task for each added or deleted data-file entry. Tasks
+// are ordered by change ordinal, then by DELETE before INSERT within an
+// ordinal, and finally by data-file path. It emits a ScanReport through the
+// configured reporter on successful planning.
+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)
+       }
+
+       switch s.scan.planningMode {
+       case ScanPlanningLocal, ScanPlanningAuto:
+       case ScanPlanningRemote:
+               return nil, fmt.Errorf("%w: incremental changelog scans do not 
support remote planning", ErrInvalidOperation)
+       default:
+               return nil, fmt.Errorf("%w: unknown scan planning mode %q", 
iceberg.ErrInvalidArgument, s.scan.planningMode)
+       }
+       start := time.Now()
+
+       toSnapshot, err := s.toSnapshot()
+       if err != nil {
+               return nil, err
+       }
+       if toSnapshot == nil {
+               if s.fromSnapshotID != nil {
+                       return nil, fmt.Errorf("%w: no ending snapshot found 
for incremental changelog scan from %d",
+                               iceberg.ErrInvalidArgument, *s.fromSnapshotID)
+               }
+
+               return nil, nil
+       }
+
+       planningScan := *s.scan
+       planningScan.identifier = slices.Clone(s.scan.identifier)
+       planningScan.selectedFields = slices.Clone(s.scan.selectedFields)
+       planningScan.options = maps.Clone(s.scan.options)
+       if s.toSnapshotID != nil {
+               planningScan.snapshotID = &toSnapshot.SnapshotID
+               planningScan.asOfTimestamp = nil
+       }
+       schema, err := planningScan.effectiveSchema()
+       if err != nil {
+               return nil, err
+       }
+       residual, err := bindTaskFilter(schema, planningScan.rowFilter, 
planningScan.caseSensitive)
+       if err != nil {
+               return nil, fmt.Errorf("bind incremental changelog scan 
residual: %w", err)
+       }
+       var acc scanMetricsAccumulator
+       finish := func(tasks []ChangelogScanTask) ([]ChangelogScanTask, error) {
+               acc.resultDataFiles = int64(len(tasks))

Review Comment:
   **minor** — `finish` rebuilds FileScanTasks it already has, behind an 
unreachable error path
   
   plannedTasks (:241-260) already stores the FileScanTask alongside each 
ChangelogScanTask, but `finish` re-derives them via changelogTaskFileScanTask 
and threads an error return for it. That error is unreachable: the interface is 
sealed (isChangelogScanTask) and newChangelogScanTask returns concrete value 
types, so the task is never a nil interface — the same applies to :256-259. 
Pass plannedTasks into finish and drop the dead error path.



##########
table/incremental_changelog_scan.go:
##########
@@ -0,0 +1,392 @@
+// 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"
+       "maps"
+       "slices"
+       "time"
+
+       "github.com/apache/iceberg-go"
+       "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.
+type IncrementalChangelogScan struct {
+       scan           *Scan
+       fromSnapshotID *int64
+       fromInclusive  bool
+       toSnapshotID   *int64
+}
+
+// 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 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.
+func (t Table) NewIncrementalChangelogScan(opts ...ScanOption) 
*IncrementalChangelogScan {
+       return &IncrementalChangelogScan{scan: t.Scan(opts...)}
+}
+
+// FromSnapshotInclusive includes changes committed by the starting snapshot.
+// The snapshot is validated when files are planned.
+func (s *IncrementalChangelogScan) FromSnapshotInclusive(snapshotID int64) 
*IncrementalChangelogScan {
+       out := *s
+       out.fromSnapshotID = &snapshotID
+       out.fromInclusive = true
+
+       return &out
+}
+
+// FromSnapshotExclusive starts after the given snapshot. The starting
+// snapshot must be a parent ancestor of the ending snapshot when planning.
+func (s *IncrementalChangelogScan) FromSnapshotExclusive(snapshotID int64) 
*IncrementalChangelogScan {
+       out := *s
+       out.fromSnapshotID = &snapshotID
+       out.fromInclusive = false
+
+       return &out
+}
+
+// ToSnapshot sets the inclusive ending snapshot.
+// The snapshot is validated when files are planned.
+func (s *IncrementalChangelogScan) ToSnapshot(snapshotID int64) 
*IncrementalChangelogScan {
+       out := *s
+       out.toSnapshotID = &snapshotID
+
+       return &out
+}
+
+// PlanFiles returns one task for each added or deleted data-file entry. Tasks
+// are ordered by change ordinal, then by DELETE before INSERT within an
+// ordinal, and finally by data-file path. It emits a ScanReport through the
+// configured reporter on successful planning.
+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)
+       }
+
+       switch s.scan.planningMode {
+       case ScanPlanningLocal, ScanPlanningAuto:
+       case ScanPlanningRemote:
+               return nil, fmt.Errorf("%w: incremental changelog scans do not 
support remote planning", ErrInvalidOperation)
+       default:
+               return nil, fmt.Errorf("%w: unknown scan planning mode %q", 
iceberg.ErrInvalidArgument, s.scan.planningMode)
+       }
+       start := time.Now()
+
+       toSnapshot, err := s.toSnapshot()
+       if err != nil {
+               return nil, err
+       }
+       if toSnapshot == nil {
+               if s.fromSnapshotID != nil {
+                       return nil, fmt.Errorf("%w: no ending snapshot found 
for incremental changelog scan from %d",
+                               iceberg.ErrInvalidArgument, *s.fromSnapshotID)
+               }
+
+               return nil, nil
+       }
+
+       planningScan := *s.scan
+       planningScan.identifier = slices.Clone(s.scan.identifier)
+       planningScan.selectedFields = slices.Clone(s.scan.selectedFields)
+       planningScan.options = maps.Clone(s.scan.options)
+       if s.toSnapshotID != nil {
+               planningScan.snapshotID = &toSnapshot.SnapshotID
+               planningScan.asOfTimestamp = nil
+       }
+       schema, err := planningScan.effectiveSchema()
+       if err != nil {
+               return nil, err
+       }
+       residual, err := bindTaskFilter(schema, planningScan.rowFilter, 
planningScan.caseSensitive)
+       if err != nil {
+               return nil, fmt.Errorf("bind incremental changelog scan 
residual: %w", err)
+       }
+       var acc scanMetricsAccumulator
+       finish := func(tasks []ChangelogScanTask) ([]ChangelogScanTask, error) {
+               acc.resultDataFiles = int64(len(tasks))
+               fileTasks := make([]FileScanTask, 0, len(tasks))

Review Comment:
   **minor** — Scan-report metrics count DELETE tasks as result data files, 
double-counting a file inserted then deleted in range
   
   acc.resultDataFiles = len(tasks) and acc.totalFileSize sum over every task 
including DELETEs, so a data file added in S1 and removed in S2 contributes 
twice to ResultDataFiles and TotalFileSize. Java emits no ScanReport for 
changelog scans, so there is no parity reference; the semantics are a 
Go-specific choice. Document what these fields mean for a changelog scan, or 
count only INSERT tasks.



##########
table/incremental_changelog_scan.go:
##########
@@ -0,0 +1,392 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file

Review Comment:
   **nit** — 5 of 11 commits on the branch do not compile
   
   Previously 1 non-compiling commit was flagged; it is now 5. Four are a 
ChangelogOperation redeclaration against #1897's changelog_scan_task.go (a 
rebase artifact) and one is a stale filterManifestsWithSchema arity. Cosmetic 
under squash-merge but it breaks bisect on the branch; a rebase would clean it 
up.



##########
table/incremental_changelog_scan.go:
##########
@@ -0,0 +1,392 @@
+// 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"
+       "maps"
+       "slices"
+       "time"
+
+       "github.com/apache/iceberg-go"
+       "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.
+type IncrementalChangelogScan struct {
+       scan           *Scan
+       fromSnapshotID *int64
+       fromInclusive  bool
+       toSnapshotID   *int64
+}
+
+// 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 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.
+func (t Table) NewIncrementalChangelogScan(opts ...ScanOption) 
*IncrementalChangelogScan {
+       return &IncrementalChangelogScan{scan: t.Scan(opts...)}
+}
+
+// FromSnapshotInclusive includes changes committed by the starting snapshot.
+// The snapshot is validated when files are planned.
+func (s *IncrementalChangelogScan) FromSnapshotInclusive(snapshotID int64) 
*IncrementalChangelogScan {
+       out := *s
+       out.fromSnapshotID = &snapshotID
+       out.fromInclusive = true
+
+       return &out
+}
+
+// FromSnapshotExclusive starts after the given snapshot. The starting
+// snapshot must be a parent ancestor of the ending snapshot when planning.
+func (s *IncrementalChangelogScan) FromSnapshotExclusive(snapshotID int64) 
*IncrementalChangelogScan {
+       out := *s
+       out.fromSnapshotID = &snapshotID
+       out.fromInclusive = false
+
+       return &out
+}
+
+// ToSnapshot sets the inclusive ending snapshot.
+// The snapshot is validated when files are planned.
+func (s *IncrementalChangelogScan) ToSnapshot(snapshotID int64) 
*IncrementalChangelogScan {
+       out := *s
+       out.toSnapshotID = &snapshotID
+
+       return &out
+}
+
+// PlanFiles returns one task for each added or deleted data-file entry. Tasks
+// are ordered by change ordinal, then by DELETE before INSERT within an
+// ordinal, and finally by data-file path. It emits a ScanReport through the
+// configured reporter on successful planning.
+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)
+       }
+
+       switch s.scan.planningMode {
+       case ScanPlanningLocal, ScanPlanningAuto:
+       case ScanPlanningRemote:
+               return nil, fmt.Errorf("%w: incremental changelog scans do not 
support remote planning", ErrInvalidOperation)
+       default:
+               return nil, fmt.Errorf("%w: unknown scan planning mode %q", 
iceberg.ErrInvalidArgument, s.scan.planningMode)
+       }
+       start := time.Now()
+
+       toSnapshot, err := s.toSnapshot()
+       if err != nil {
+               return nil, err
+       }
+       if toSnapshot == nil {
+               if s.fromSnapshotID != nil {
+                       return nil, fmt.Errorf("%w: no ending snapshot found 
for incremental changelog scan from %d",
+                               iceberg.ErrInvalidArgument, *s.fromSnapshotID)
+               }
+
+               return nil, nil
+       }

Review Comment:
   **minor** — No ScanReport is emitted on the no-snapshot early return, though 
PlanFiles' doc promises one
   
   PlanFiles documents 'It emits a ScanReport through the configured reporter 
on successful planning' (:85-86), but the toSnapshot == nil path returns nil, 
nil without calling finish, so a successful empty plan produces no report. This 
exactly mirrors IncrementalAppendScan:108, so consistency argues for leaving 
the behavior and instead qualifying the doc.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to