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


##########
table/arrow_scanner.go:
##########
@@ -1091,14 +1146,23 @@ func (as *arrowScan) recordsFromTask(ctx 
context.Context, task tblutils.Enumerat
        }()
 
        var (
+               rowFilter  iceberg.BooleanExpression
                rdr        tblutils.FileReader
                iceSchema  *iceberg.Schema
                colIndices []int
                filterFunc recProcessFn
                dropFile   bool
        )
 
-       iceSchema, colIndices, rdr, err = as.prepareToRead(ctx, task.Value.File)
+       rowFilter, err = bindTaskFilter(as.metadata.CurrentSchema(), 
task.Value.Residual, as.caseSensitive)

Review Comment:
   I think there's a subtle trap here. `recordsFromTask` binds the residual 
against `as.metadata.CurrentSchema()`, but `ReadTasks` already pre-binds every 
residual against `effectiveSchema` (which can be an older snapshot's schema) 
before it ever reaches here. Today that's fine — `bindTaskFilter` 
short-circuits already-bound expressions, so the current-schema bind is a no-op 
on the production path.
   
   But `arrowScan` is internal and the tests instantiate it directly. The 
moment a caller hands `recordsFromTask` a task with an unbound residual 
referencing a column that lives in a historical schema but not the current one, 
it binds against the wrong schema silently — wrong field ID, or a spurious 
error. I'd thread `effectiveSchema` into `arrowScan` and bind against that 
here, so the invariant is enforced in one place rather than assumed.
   
   While we're here, the assign-then-override-then-check order reads a little 
backwards — the `err` from `bindTaskFilter` is checked after the `Residual == 
nil` branch that throws its result away. I'd flip it to `if task.Value.Residual 
!= nil { rowFilter, err = ...; if err != nil { return err } } else { rowFilter 
= as.boundRowFilter }`. wdyt?



##########
table/incremental_append_scan.go:
##########
@@ -0,0 +1,266 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table
+
+import (
+       "context"
+       "fmt"
+       "slices"
+       "sort"
+
+       "github.com/apache/iceberg-go"
+)
+
+// IncrementalAppendScan plans data files added by append snapshots between a
+// starting snapshot and an ending snapshot. It follows one snapshot ancestry
+// chain and never returns files inherited from an earlier snapshot.
+type IncrementalAppendScan struct {
+       scan           *Scan
+       fromSnapshotID *int64
+       fromInclusive  bool
+       toSnapshotID   *int64
+}
+
+// NewIncrementalAppendScan creates an incremental append scan. Scan options
+// configure the underlying table scan and are retained for callers that pass
+// snapshot, projection, filter, or concurrency options before planning.
+// Auto planning falls back to local planning. Remote planning returns
+// ErrInvalidOperation until incremental remote planning is implemented.
+func (t Table) NewIncrementalAppendScan(opts ...ScanOption) 
*IncrementalAppendScan {
+       return &IncrementalAppendScan{scan: t.Scan(opts...)}
+}
+
+// FromSnapshotInclusive includes files added by the starting snapshot.
+func (s *IncrementalAppendScan) FromSnapshotInclusive(snapshotID int64) 
(*IncrementalAppendScan, error) {
+       if s.scan.metadata.SnapshotByID(snapshotID) == nil {
+               return nil, fmt.Errorf("%w: starting snapshot not found: %d", 
iceberg.ErrInvalidArgument, snapshotID)
+       }
+       out := *s
+       out.fromSnapshotID = &snapshotID
+       out.fromInclusive = true
+
+       return &out, nil
+}
+
+// FromSnapshotExclusive starts after the given snapshot. The starting
+// snapshot must be an ancestor of the ending snapshot when planning.
+func (s *IncrementalAppendScan) FromSnapshotExclusive(snapshotID int64) 
*IncrementalAppendScan {
+       out := *s
+       out.fromSnapshotID = &snapshotID
+       out.fromInclusive = false
+
+       return &out
+}
+
+// ToSnapshot sets the inclusive ending snapshot.
+func (s *IncrementalAppendScan) ToSnapshot(snapshotID int64) 
(*IncrementalAppendScan, error) {
+       if s.scan.metadata.SnapshotByID(snapshotID) == nil {
+               return nil, fmt.Errorf("%w: ending snapshot not found: %d", 
iceberg.ErrInvalidArgument, snapshotID)
+       }
+       out := *s
+       out.toSnapshotID = &snapshotID
+
+       return &out, nil
+}
+
+// PlanFiles returns one task per newly added data file. Delete files are not
+// applied because appended files are not present before the append snapshot.
+func (s *IncrementalAppendScan) PlanFiles(ctx context.Context) 
([]FileScanTask, error) {
+       switch s.scan.planningMode {
+       case ScanPlanningLocal, ScanPlanningAuto:
+       case ScanPlanningRemote:
+               return nil, fmt.Errorf("%w: incremental append scans do not 
support remote planning", ErrInvalidOperation)
+       default:
+               return nil, fmt.Errorf("%w: unknown scan planning mode %q", 
iceberg.ErrInvalidArgument, s.scan.planningMode)
+       }
+
+       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 append scan from %d",
+                               iceberg.ErrInvalidArgument, *s.fromSnapshotID)
+               }
+
+               return nil, nil
+       }
+
+       snapshots, err := s.snapshotsBetween(toSnapshot.SnapshotID)
+       if err != nil {
+               return nil, err
+       }
+       if len(snapshots) == 0 {
+               return nil, nil
+       }
+       appendSnapshots := make(map[int64]struct{}, len(snapshots))
+       for _, snapshot := range snapshots {
+               if snapshot.Summary != nil && snapshot.Summary.Operation == 
OpAppend {
+                       appendSnapshots[snapshot.SnapshotID] = struct{}{}
+               }
+       }
+       if len(appendSnapshots) == 0 {
+               return nil, nil
+       }
+
+       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
+       }
+
+       // An inherited manifest can occur in every later snapshot's manifest 
list.
+       // Read each manifest path once, just as the Java incremental append 
scan
+       // collects the selected manifests into a set before opening them.
+       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.ManifestContentData {
+                               continue
+                       }
+                       if _, ok := appendSnapshots[manifest.SnapshotID()]; !ok 
{
+                               continue
+                       }
+                       manifestsByPath[manifest.FilePath()] = manifest
+               }
+       }
+
+       paths := make([]string, 0, len(manifestsByPath))
+       for path := range manifestsByPath {
+               paths = append(paths, path)
+       }
+       sort.Strings(paths)
+       manifestList := make([]iceberg.ManifestFile, 0, len(paths))
+       for _, path := range paths {
+               manifestList = append(manifestList, manifestsByPath[path])
+       }
+
+       planningScan := *s.scan
+       if s.toSnapshotID != nil {
+               // An explicit end snapshot is a historical scan and must use 
that
+               // snapshot's schema. An implicit current end remains a live 
scan so a
+               // schema-only metadata update is visible during pruning.
+               planningScan.snapshotID = &toSnapshot.SnapshotID
+               planningScan.asOfTimestamp = nil
+       }
+       schema, err := planningScan.effectiveSchema()
+       if err != nil {
+               return nil, err
+       }
+       manifestList, err = 
planningScan.filterManifestsWithSchema(manifestList, schema, 
&scanMetricsAccumulator{})
+       if err != nil {
+               return nil, err
+       }
+       if len(manifestList) == 0 {
+               return nil, nil
+       }
+       entries, err := planningScan.collectManifestEntriesWithSchema(ctx, 
manifestList, schema)
+       if err != nil {
+               return nil, err
+       }
+
+       tasks := make([]FileScanTask, 0, len(entries.dataEntries))
+       for _, entry := range entries.dataEntries {
+               if entry.Status() != iceberg.EntryStatusADDED {
+                       continue
+               }
+               if _, ok := appendSnapshots[entry.SnapshotID()]; !ok {
+                       continue
+               }
+               file := entry.DataFile()
+               task := FileScanTask{File: file, Start: 0, Length: 
file.FileSizeBytes()}
+               task.Residual = s.scan.rowFilter
+               task.FirstRowID = file.FirstRowID()
+               if sequenceNumber := entry.SequenceNum(); sequenceNumber >= 0 {
+                       task.DataSequenceNumber = &sequenceNumber
+               }
+               tasks = append(tasks, task)
+       }
+       sort.Slice(tasks, func(left, right int) bool {
+               return tasks[left].File.FilePath() < 
tasks[right].File.FilePath()
+       })
+
+       return tasks, nil
+}
+
+func (s *IncrementalAppendScan) toSnapshot() (*Snapshot, error) {
+       if s.toSnapshotID != nil {
+               return s.scan.metadata.SnapshotByID(*s.toSnapshotID), nil
+       }
+
+       return s.scan.ResolveSnapshot()
+}
+
+func (s *IncrementalAppendScan) snapshotsBetween(toSnapshotID int64) 
([]Snapshot, error) {
+       ancestors := AncestorsOf(toSnapshotID, s.scan.metadata.SnapshotByID)
+       if len(ancestors) == 0 {
+               return nil, fmt.Errorf("%w: ending snapshot not found: %d", 
iceberg.ErrInvalidArgument, toSnapshotID)
+       }
+
+       if s.fromSnapshotID == nil {
+               slices.Reverse(ancestors)
+
+               return appendOnlySnapshots(ancestors), nil
+       }
+
+       fromID := *s.fromSnapshotID
+       if !s.fromInclusive {
+               between, found := AncestorsBetween(toSnapshotID, fromID, 
s.scan.metadata.SnapshotByID)

Review Comment:
   When `fromSnapshotID == toSnapshotID` on the exclusive path, 
`AncestorsBetween` short-circuits and returns `(nil, true)`, so `found` is 
true, we get an empty snapshot list, and `PlanFiles` returns zero tasks with no 
error. Java's `BaseIncrementalScan` rejects this — `from == to` exclusive can't 
have `from` as a parent-ancestor of `to`, so it throws.
   
   Silently returning empty for what's really an invalid range feels risky: a 
caller polling `from=X exclusive, to=X` sees "no new data" rather than a clear 
error. I'd guard `from == to` explicitly and return `ErrInvalidArgument`.
   
   There's also no test pinning either from==to case. I'd add one for exclusive 
(expect the error) and one for inclusive 
`FromSnapshotInclusive(X).ToSnapshot(X)` (expect just X's files) — the 
inclusive path leans on `IsAncestorOf(X, X)` returning true, and that's 
currently an untested assumption. wdyt?



##########
table/incremental_append_scan.go:
##########
@@ -0,0 +1,266 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table
+
+import (
+       "context"
+       "fmt"
+       "slices"
+       "sort"
+
+       "github.com/apache/iceberg-go"
+)
+
+// IncrementalAppendScan plans data files added by append snapshots between a
+// starting snapshot and an ending snapshot. It follows one snapshot ancestry
+// chain and never returns files inherited from an earlier snapshot.
+type IncrementalAppendScan struct {
+       scan           *Scan
+       fromSnapshotID *int64
+       fromInclusive  bool
+       toSnapshotID   *int64
+}
+
+// NewIncrementalAppendScan creates an incremental append scan. Scan options
+// configure the underlying table scan and are retained for callers that pass
+// snapshot, projection, filter, or concurrency options before planning.
+// Auto planning falls back to local planning. Remote planning returns
+// ErrInvalidOperation until incremental remote planning is implemented.
+func (t Table) NewIncrementalAppendScan(opts ...ScanOption) 
*IncrementalAppendScan {
+       return &IncrementalAppendScan{scan: t.Scan(opts...)}
+}
+
+// FromSnapshotInclusive includes files added by the starting snapshot.
+func (s *IncrementalAppendScan) FromSnapshotInclusive(snapshotID int64) 
(*IncrementalAppendScan, error) {
+       if s.scan.metadata.SnapshotByID(snapshotID) == nil {
+               return nil, fmt.Errorf("%w: starting snapshot not found: %d", 
iceberg.ErrInvalidArgument, snapshotID)
+       }
+       out := *s
+       out.fromSnapshotID = &snapshotID
+       out.fromInclusive = true
+
+       return &out, nil
+}
+
+// FromSnapshotExclusive starts after the given snapshot. The starting
+// snapshot must be an ancestor of the ending snapshot when planning.
+func (s *IncrementalAppendScan) FromSnapshotExclusive(snapshotID int64) 
*IncrementalAppendScan {
+       out := *s
+       out.fromSnapshotID = &snapshotID
+       out.fromInclusive = false
+
+       return &out
+}
+
+// ToSnapshot sets the inclusive ending snapshot.
+func (s *IncrementalAppendScan) ToSnapshot(snapshotID int64) 
(*IncrementalAppendScan, error) {
+       if s.scan.metadata.SnapshotByID(snapshotID) == nil {
+               return nil, fmt.Errorf("%w: ending snapshot not found: %d", 
iceberg.ErrInvalidArgument, snapshotID)
+       }
+       out := *s
+       out.toSnapshotID = &snapshotID
+
+       return &out, nil
+}
+
+// PlanFiles returns one task per newly added data file. Delete files are not
+// applied because appended files are not present before the append snapshot.
+func (s *IncrementalAppendScan) PlanFiles(ctx context.Context) 
([]FileScanTask, error) {
+       switch s.scan.planningMode {
+       case ScanPlanningLocal, ScanPlanningAuto:
+       case ScanPlanningRemote:
+               return nil, fmt.Errorf("%w: incremental append scans do not 
support remote planning", ErrInvalidOperation)
+       default:
+               return nil, fmt.Errorf("%w: unknown scan planning mode %q", 
iceberg.ErrInvalidArgument, s.scan.planningMode)
+       }
+
+       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 append scan from %d",
+                               iceberg.ErrInvalidArgument, *s.fromSnapshotID)
+               }
+
+               return nil, nil
+       }
+
+       snapshots, err := s.snapshotsBetween(toSnapshot.SnapshotID)
+       if err != nil {
+               return nil, err
+       }
+       if len(snapshots) == 0 {
+               return nil, nil
+       }
+       appendSnapshots := make(map[int64]struct{}, len(snapshots))
+       for _, snapshot := range snapshots {
+               if snapshot.Summary != nil && snapshot.Summary.Operation == 
OpAppend {
+                       appendSnapshots[snapshot.SnapshotID] = struct{}{}
+               }
+       }
+       if len(appendSnapshots) == 0 {
+               return nil, nil
+       }
+
+       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
+       }
+
+       // An inherited manifest can occur in every later snapshot's manifest 
list.
+       // Read each manifest path once, just as the Java incremental append 
scan
+       // collects the selected manifests into a set before opening them.
+       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.ManifestContentData {
+                               continue
+                       }
+                       if _, ok := appendSnapshots[manifest.SnapshotID()]; !ok 
{
+                               continue
+                       }
+                       manifestsByPath[manifest.FilePath()] = manifest
+               }
+       }
+
+       paths := make([]string, 0, len(manifestsByPath))
+       for path := range manifestsByPath {
+               paths = append(paths, path)
+       }
+       sort.Strings(paths)
+       manifestList := make([]iceberg.ManifestFile, 0, len(paths))
+       for _, path := range paths {
+               manifestList = append(manifestList, manifestsByPath[path])
+       }
+
+       planningScan := *s.scan

Review Comment:
   Not a blocker, and it's consistent with the existing `Scan` builder 
shallow-copy pattern, but `planningScan := *s.scan` aliases the reference-typed 
fields — `options` (map), `selectedFields` and `identifier` (slices) still 
point at the caller's underlying data. Nothing mutates through `planningScan` 
today so it's safe, but it's the kind of thing that bites later: a future 
change to `filterManifestsWithSchema` that appends to `selectedFields` would 
corrupt the caller's scan.
   
   If it's cheap, I'd `maps.Clone` the options and `slices.Clone` the slices on 
copy (or add a `Scan.clone()` helper) to make the isolation explicit.



##########
table/incremental_append_scan_test.go:
##########
@@ -0,0 +1,437 @@
+// 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
+
+package table
+
+import (
+       "bytes"
+       "context"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/stretchr/testify/require"
+)
+
+func TestIncrementalAppendScanSnapshotBoundaries(t *testing.T) {
+       scan := snapshotsTestTable().NewIncrementalAppendScan()
+       inclusive, err := scan.FromSnapshotInclusive(101)
+       require.NoError(t, err)
+       inclusive, err = inclusive.ToSnapshot(102)
+       require.NoError(t, err)
+
+       snapshots, err := inclusive.snapshotsBetween(102)
+       require.NoError(t, err)
+       require.Len(t, snapshots, 1)
+       require.EqualValues(t, 101, snapshots[0].SnapshotID)
+
+       exclusive := scan.FromSnapshotExclusive(101)
+       exclusive, err = exclusive.ToSnapshot(102)
+       require.NoError(t, err)
+       snapshots, err = exclusive.snapshotsBetween(102)
+       require.NoError(t, err)
+       require.Empty(t, snapshots, "the only snapshot after 101 is not an 
append")
+}
+
+func TestIncrementalAppendScanRejectsUnknownStart(t *testing.T) {
+       _, err := 
snapshotsTestTable().NewIncrementalAppendScan().FromSnapshotInclusive(999)
+       require.Error(t, err)

Review Comment:
   Every other error assertion in this file uses `require.ErrorIs(t, err, 
iceberg.ErrInvalidArgument)` — I'd match that here instead of bare 
`require.Error`, plus a `require.ErrorContains(t, err, "starting snapshot not 
found")` so we're actually pinning the not-found path. Small thing while here: 
`999` could be a named `const nonExistentSnapshotID = int64(999)` to mirror the 
`expiredSnapshotID` const the neighboring test uses.



##########
table/incremental_append_scan_test.go:
##########
@@ -0,0 +1,437 @@
+// 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

Review Comment:
   This header stops at the LICENSE-2.0 URL and is missing the closing 
boilerplate ("Unless required by applicable law..." through "...limitations 
under the License.") that every other file in the tree carries — compare 
`task_residual_test.go` in this same PR. I'd paste the full 16-line header for 
consistency.



##########
table/incremental_append_scan.go:
##########
@@ -0,0 +1,266 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table
+
+import (
+       "context"
+       "fmt"
+       "slices"
+       "sort"
+
+       "github.com/apache/iceberg-go"
+)
+
+// IncrementalAppendScan plans data files added by append snapshots between a
+// starting snapshot and an ending snapshot. It follows one snapshot ancestry
+// chain and never returns files inherited from an earlier snapshot.
+type IncrementalAppendScan struct {
+       scan           *Scan
+       fromSnapshotID *int64
+       fromInclusive  bool
+       toSnapshotID   *int64
+}
+
+// NewIncrementalAppendScan creates an incremental append scan. Scan options
+// configure the underlying table scan and are retained for callers that pass
+// snapshot, projection, filter, or concurrency options before planning.
+// Auto planning falls back to local planning. Remote planning returns
+// ErrInvalidOperation until incremental remote planning is implemented.
+func (t Table) NewIncrementalAppendScan(opts ...ScanOption) 
*IncrementalAppendScan {
+       return &IncrementalAppendScan{scan: t.Scan(opts...)}
+}
+
+// FromSnapshotInclusive includes files added by the starting snapshot.
+func (s *IncrementalAppendScan) FromSnapshotInclusive(snapshotID int64) 
(*IncrementalAppendScan, error) {
+       if s.scan.metadata.SnapshotByID(snapshotID) == nil {
+               return nil, fmt.Errorf("%w: starting snapshot not found: %d", 
iceberg.ErrInvalidArgument, snapshotID)
+       }
+       out := *s
+       out.fromSnapshotID = &snapshotID
+       out.fromInclusive = true
+
+       return &out, nil
+}
+
+// FromSnapshotExclusive starts after the given snapshot. The starting
+// snapshot must be an ancestor of the ending snapshot when planning.
+func (s *IncrementalAppendScan) FromSnapshotExclusive(snapshotID int64) 
*IncrementalAppendScan {
+       out := *s
+       out.fromSnapshotID = &snapshotID
+       out.fromInclusive = false
+
+       return &out
+}
+
+// ToSnapshot sets the inclusive ending snapshot.
+func (s *IncrementalAppendScan) ToSnapshot(snapshotID int64) 
(*IncrementalAppendScan, error) {
+       if s.scan.metadata.SnapshotByID(snapshotID) == nil {
+               return nil, fmt.Errorf("%w: ending snapshot not found: %d", 
iceberg.ErrInvalidArgument, snapshotID)
+       }
+       out := *s
+       out.toSnapshotID = &snapshotID
+
+       return &out, nil
+}
+
+// PlanFiles returns one task per newly added data file. Delete files are not
+// applied because appended files are not present before the append snapshot.
+func (s *IncrementalAppendScan) PlanFiles(ctx context.Context) 
([]FileScanTask, error) {
+       switch s.scan.planningMode {
+       case ScanPlanningLocal, ScanPlanningAuto:
+       case ScanPlanningRemote:
+               return nil, fmt.Errorf("%w: incremental append scans do not 
support remote planning", ErrInvalidOperation)
+       default:
+               return nil, fmt.Errorf("%w: unknown scan planning mode %q", 
iceberg.ErrInvalidArgument, s.scan.planningMode)
+       }
+
+       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 append scan from %d",
+                               iceberg.ErrInvalidArgument, *s.fromSnapshotID)
+               }
+
+               return nil, nil
+       }
+
+       snapshots, err := s.snapshotsBetween(toSnapshot.SnapshotID)
+       if err != nil {
+               return nil, err
+       }
+       if len(snapshots) == 0 {
+               return nil, nil
+       }
+       appendSnapshots := make(map[int64]struct{}, len(snapshots))
+       for _, snapshot := range snapshots {
+               if snapshot.Summary != nil && snapshot.Summary.Operation == 
OpAppend {

Review Comment:
   This append-only filter is dead code — `snapshotsBetween` already runs 
everything through `appendOnlySnapshots`, so every snapshot reaching here is 
guaranteed `OpAppend` and this `if` is always true (and the 
`len(appendSnapshots) == 0` check just below is unreachable, given the 
`len(snapshots) == 0` guard above it).
   
   Harmless today, but it makes it look like there are two independent 
append-only fences when there's really one. The actual invariant is the 
`appendSnapshots` membership checks at the manifest level and entry level below 
— if someone later widens `snapshotsBetween` to return all snapshots, 
`appendSnapshots` silently fills with overwrite/delete IDs and this filter 
won't save us. I'd pick a single authority: keep `snapshotsBetween` append-only 
and drop this guard with a comment, or have it return all snapshots and let 
`PlanFiles` do the filtering.
   
   Related — I don't see an end-to-end test that drives a range containing a 
non-append snapshot (append->overwrite->append) through `PlanFiles` and asserts 
only the two appends' files show up. 
`TestIncrementalAppendScanSnapshotBoundaries` checks `snapshotsBetween` 
directly, but the manifest/entry-level fence, the thing that actually protects 
correctness, isn't exercised in the full planning flow. I'd add that fixture.



##########
table/incremental_append_scan.go:
##########
@@ -0,0 +1,266 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table
+
+import (
+       "context"
+       "fmt"
+       "slices"
+       "sort"
+
+       "github.com/apache/iceberg-go"
+)
+
+// IncrementalAppendScan plans data files added by append snapshots between a
+// starting snapshot and an ending snapshot. It follows one snapshot ancestry
+// chain and never returns files inherited from an earlier snapshot.
+type IncrementalAppendScan struct {
+       scan           *Scan
+       fromSnapshotID *int64
+       fromInclusive  bool
+       toSnapshotID   *int64
+}
+
+// NewIncrementalAppendScan creates an incremental append scan. Scan options
+// configure the underlying table scan and are retained for callers that pass
+// snapshot, projection, filter, or concurrency options before planning.
+// Auto planning falls back to local planning. Remote planning returns
+// ErrInvalidOperation until incremental remote planning is implemented.
+func (t Table) NewIncrementalAppendScan(opts ...ScanOption) 
*IncrementalAppendScan {
+       return &IncrementalAppendScan{scan: t.Scan(opts...)}
+}
+
+// FromSnapshotInclusive includes files added by the starting snapshot.
+func (s *IncrementalAppendScan) FromSnapshotInclusive(snapshotID int64) 
(*IncrementalAppendScan, error) {
+       if s.scan.metadata.SnapshotByID(snapshotID) == nil {
+               return nil, fmt.Errorf("%w: starting snapshot not found: %d", 
iceberg.ErrInvalidArgument, snapshotID)
+       }
+       out := *s
+       out.fromSnapshotID = &snapshotID
+       out.fromInclusive = true
+
+       return &out, nil
+}
+
+// FromSnapshotExclusive starts after the given snapshot. The starting
+// snapshot must be an ancestor of the ending snapshot when planning.
+func (s *IncrementalAppendScan) FromSnapshotExclusive(snapshotID int64) 
*IncrementalAppendScan {

Review Comment:
   The three builder methods return different shapes — `FromSnapshotInclusive` 
and `ToSnapshot` return `(*IncrementalAppendScan, error)`, but 
`FromSnapshotExclusive` returns just `*IncrementalAppendScan`. That breaks 
uniform chaining, and since this is a brand-new public API, I'd settle it 
before it ships.
   
   The asymmetry also means a bogus exclusive from-ID gets no feedback until 
`PlanFiles`, and the error there ("starting snapshot X is not an ancestor of 
ending snapshot Y") is misleading when the snapshot simply doesn't exist. I get 
that exclusive intentionally tolerates an expired/pruned parent (the 
expired-exclusive test), so eager existence-checking isn't quite right either.
   
   Either way is fine, but it should be deliberate: move all validation into 
`PlanFiles` and have every builder return `*IncrementalAppendScan` only 
(matches how `Scan` defers errors to `PlanFiles`, most idiomatic here), or make 
all three return `(*..., error)` and document why exclusive still allows a 
missing/expired from-snapshot. wdyt?



##########
table/incremental_append_scan.go:
##########
@@ -0,0 +1,266 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table
+
+import (
+       "context"
+       "fmt"
+       "slices"
+       "sort"
+
+       "github.com/apache/iceberg-go"
+)
+
+// IncrementalAppendScan plans data files added by append snapshots between a
+// starting snapshot and an ending snapshot. It follows one snapshot ancestry
+// chain and never returns files inherited from an earlier snapshot.
+type IncrementalAppendScan struct {
+       scan           *Scan
+       fromSnapshotID *int64
+       fromInclusive  bool
+       toSnapshotID   *int64
+}
+
+// NewIncrementalAppendScan creates an incremental append scan. Scan options
+// configure the underlying table scan and are retained for callers that pass
+// snapshot, projection, filter, or concurrency options before planning.
+// Auto planning falls back to local planning. Remote planning returns
+// ErrInvalidOperation until incremental remote planning is implemented.
+func (t Table) NewIncrementalAppendScan(opts ...ScanOption) 
*IncrementalAppendScan {
+       return &IncrementalAppendScan{scan: t.Scan(opts...)}
+}
+
+// FromSnapshotInclusive includes files added by the starting snapshot.
+func (s *IncrementalAppendScan) FromSnapshotInclusive(snapshotID int64) 
(*IncrementalAppendScan, error) {
+       if s.scan.metadata.SnapshotByID(snapshotID) == nil {
+               return nil, fmt.Errorf("%w: starting snapshot not found: %d", 
iceberg.ErrInvalidArgument, snapshotID)
+       }
+       out := *s
+       out.fromSnapshotID = &snapshotID
+       out.fromInclusive = true
+
+       return &out, nil
+}
+
+// FromSnapshotExclusive starts after the given snapshot. The starting
+// snapshot must be an ancestor of the ending snapshot when planning.
+func (s *IncrementalAppendScan) FromSnapshotExclusive(snapshotID int64) 
*IncrementalAppendScan {
+       out := *s
+       out.fromSnapshotID = &snapshotID
+       out.fromInclusive = false
+
+       return &out
+}
+
+// ToSnapshot sets the inclusive ending snapshot.
+func (s *IncrementalAppendScan) ToSnapshot(snapshotID int64) 
(*IncrementalAppendScan, error) {
+       if s.scan.metadata.SnapshotByID(snapshotID) == nil {
+               return nil, fmt.Errorf("%w: ending snapshot not found: %d", 
iceberg.ErrInvalidArgument, snapshotID)
+       }
+       out := *s
+       out.toSnapshotID = &snapshotID
+
+       return &out, nil
+}
+
+// PlanFiles returns one task per newly added data file. Delete files are not
+// applied because appended files are not present before the append snapshot.
+func (s *IncrementalAppendScan) PlanFiles(ctx context.Context) 
([]FileScanTask, error) {
+       switch s.scan.planningMode {
+       case ScanPlanningLocal, ScanPlanningAuto:
+       case ScanPlanningRemote:
+               return nil, fmt.Errorf("%w: incremental append scans do not 
support remote planning", ErrInvalidOperation)
+       default:
+               return nil, fmt.Errorf("%w: unknown scan planning mode %q", 
iceberg.ErrInvalidArgument, s.scan.planningMode)
+       }
+
+       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 append scan from %d",
+                               iceberg.ErrInvalidArgument, *s.fromSnapshotID)
+               }
+
+               return nil, nil
+       }
+
+       snapshots, err := s.snapshotsBetween(toSnapshot.SnapshotID)
+       if err != nil {
+               return nil, err
+       }
+       if len(snapshots) == 0 {
+               return nil, nil
+       }
+       appendSnapshots := make(map[int64]struct{}, len(snapshots))
+       for _, snapshot := range snapshots {
+               if snapshot.Summary != nil && snapshot.Summary.Operation == 
OpAppend {
+                       appendSnapshots[snapshot.SnapshotID] = struct{}{}
+               }
+       }
+       if len(appendSnapshots) == 0 {
+               return nil, nil
+       }
+
+       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
+       }
+
+       // An inherited manifest can occur in every later snapshot's manifest 
list.
+       // Read each manifest path once, just as the Java incremental append 
scan
+       // collects the selected manifests into a set before opening them.
+       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.ManifestContentData {
+                               continue
+                       }
+                       if _, ok := appendSnapshots[manifest.SnapshotID()]; !ok 
{
+                               continue
+                       }
+                       manifestsByPath[manifest.FilePath()] = manifest
+               }
+       }
+
+       paths := make([]string, 0, len(manifestsByPath))
+       for path := range manifestsByPath {
+               paths = append(paths, path)
+       }
+       sort.Strings(paths)

Review Comment:
   The surrounding code uses `slices` consistently, so I'd swap 
`sort.Strings(paths)` for `slices.Sort(paths)` here and the `sort.Slice(tasks, 
...)` below for `slices.SortFunc(tasks, func(a, b FileScanTask) int { return 
cmp.Compare(a.File.FilePath(), b.File.FilePath()) })`, then drop the `sort` 
import.



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