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

zeroshade 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 84c6fd693 refactor(table): unify cycle-safe snapshot ancestry walks 
(#2285)
84c6fd693 is described below

commit 84c6fd693fe25bc9f6e3c9d99ef733930a6bc30f
Author: Minh Vu <[email protected]>
AuthorDate: Fri Oct 23 20:26:03 2026 +0200

    refactor(table): unify cycle-safe snapshot ancestry walks (#2285)
    
    * refactor(table): unify cycle-safe snapshot ancestry walks
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * test(table): guard self-referencing snapshot ancestry
    
    * style(table): satisfy nlreturn in ancestry test
    
    ---------
    
    Signed-off-by: Minh Vu <[email protected]>
---
 table/snapshot_ancestry.go      | 121 ++++++++++++++++------------------------
 table/snapshot_ancestry_test.go |  66 ++++++++++++++++++++++
 2 files changed, 115 insertions(+), 72 deletions(-)

diff --git a/table/snapshot_ancestry.go b/table/snapshot_ancestry.go
index 7fe95c653..bb233f7f3 100644
--- a/table/snapshot_ancestry.go
+++ b/table/snapshot_ancestry.go
@@ -64,6 +64,51 @@ func (s *snapshotVisitSet) seenOrAdd(id int64) bool {
        return false
 }
 
+// ancestryWalkMode selects how a snapshot ancestry walk ends.
+// An expired base ID can end a conflict-detection walk, but checking whether
+// an ID is an ancestor requires resolving that snapshot.
+type ancestryWalkMode uint8
+
+const (
+       ancestryToRoot ancestryWalkMode = iota
+       ancestryBeforeBase
+       ancestryToResolvedAncestor
+)
+
+// walkSnapshotAncestry shares parent traversal, missing-snapshot handling and
+// cycle detection. Only the first two modes accumulate snapshot values.
+func walkSnapshotAncestry(startID, boundaryID int64, lookup SnapshotLookup, 
mode ancestryWalkMode) ([]Snapshot, bool) {
+       var history []Snapshot
+       var visited snapshotVisitSet
+
+       id := startID
+       for {
+               // A base may have expired, so stop before resolving it.
+               if mode == ancestryBeforeBase && id == boundaryID {
+                       return history, true
+               }
+               if visited.seenOrAdd(id) {
+                       return history, false
+               }
+               snap := lookup(id)
+               if snap == nil {
+                       return history, false
+               }
+
+               // Membership requires the candidate ancestor to resolve.
+               if mode == ancestryToResolvedAncestor && id == boundaryID {
+                       return nil, true
+               }
+               if mode != ancestryToResolvedAncestor {
+                       history = append(history, *snap)
+               }
+               if snap.ParentSnapshotID == nil {
+                       return history, mode == ancestryToRoot
+               }
+               id = *snap.ParentSnapshotID
+       }
+}
+
 // AncestorsOf walks the parent chain starting from snapshotID and yields
 // every snapshot reachable via ParentSnapshotID, including the starting
 // snapshot itself. Iteration stops when a snapshot has no parent, the
@@ -99,27 +144,7 @@ func AncestorsOf(snapshotID int64, lookup SnapshotLookup) 
[]Snapshot {
 // Snapshots are returned by value in reverse-chronological order.
 // Returns an empty slice and false when snapshotID cannot be resolved.
 func AncestorsOfChecked(snapshotID int64, lookup SnapshotLookup) ([]Snapshot, 
bool) {
-       var ancestors []Snapshot
-       var visited snapshotVisitSet
-
-       id := snapshotID
-       for {
-               if visited.seenOrAdd(id) {
-                       return ancestors, false
-               }
-
-               snap := lookup(id)
-               if snap == nil {
-                       return ancestors, false
-               }
-
-               ancestors = append(ancestors, *snap)
-
-               if snap.ParentSnapshotID == nil {
-                       return ancestors, true
-               }
-               id = *snap.ParentSnapshotID
-       }
+       return walkSnapshotAncestry(snapshotID, 0, lookup, ancestryToRoot)
 }
 
 // AncestorsBetween returns the snapshots from latestID (inclusive) down
@@ -147,33 +172,7 @@ func AncestorsBetween(latestID, baseID int64, lookup 
SnapshotLookup) ([]Snapshot
                return nil, true
        }
 
-       var between []Snapshot
-       var visited snapshotVisitSet
-
-       id := latestID
-       for {
-               if id == baseID {
-                       return between, true
-               }
-               if visited.seenOrAdd(id) {
-                       // Cycle detected before reaching baseID.
-                       break
-               }
-
-               snap := lookup(id)
-               if snap == nil {
-                       break
-               }
-
-               between = append(between, *snap)
-
-               if snap.ParentSnapshotID == nil {
-                       break
-               }
-               id = *snap.ParentSnapshotID
-       }
-
-       return between, false
+       return walkSnapshotAncestry(latestID, baseID, lookup, 
ancestryBeforeBase)
 }
 
 // IsAncestorOf returns true if ancestorID is in the parent chain of
@@ -189,29 +188,7 @@ func AncestorsBetween(latestID, baseID int64, lookup 
SnapshotLookup) ([]Snapshot
 // semantics are stricter (conservative) — broken chains return false
 // rather than matching an unreachable ancestor id.
 func IsAncestorOf(snapshotID, ancestorID int64, lookup SnapshotLookup) bool {
-       var visited snapshotVisitSet
-
-       id := snapshotID
-       for {
-               if visited.seenOrAdd(id) {
-                       return false
-               }
-
-               snap := lookup(id)
-               if snap == nil {
-                       return false
-               }
-
-               // Resolve before matching, so an absent ancestor ID does not
-               // count as reachable. An ID inside a cycle succeeds on its 
first
-               // visit, before cycle detection can reject a repeated ID.
-               if id == ancestorID {
-                       return true
-               }
+       _, found := walkSnapshotAncestry(snapshotID, ancestorID, lookup, 
ancestryToResolvedAncestor)
 
-               if snap.ParentSnapshotID == nil {
-                       return false
-               }
-               id = *snap.ParentSnapshotID
-       }
+       return found
 }
diff --git a/table/snapshot_ancestry_test.go b/table/snapshot_ancestry_test.go
index fa1c75842..65aac0d4e 100644
--- a/table/snapshot_ancestry_test.go
+++ b/table/snapshot_ancestry_test.go
@@ -94,6 +94,29 @@ func TestAncestorsOf_BrokenChain(t *testing.T) {
        assert.Equal(t, []int64{3}, snapshotIDs(got))
 }
 
+func TestSnapshotAncestrySelfCycleTerminates(t *testing.T) {
+       id := int64(17)
+       snapshot := Snapshot{SnapshotID: id, ParentSnapshotID: &id}
+       lookup := func(candidate int64) *Snapshot {
+               if candidate == id {
+                       return &snapshot
+               }
+
+               return nil
+       }
+
+       ancestors, complete := AncestorsOfChecked(id, lookup)
+       assert.False(t, complete)
+       assert.Equal(t, []int64{id}, snapshotIDs(ancestors))
+
+       between, found := AncestorsBetween(id, 999, lookup)
+       assert.False(t, found)
+       assert.Equal(t, []int64{id}, snapshotIDs(between))
+
+       assert.True(t, IsAncestorOf(id, id, lookup))
+       assert.False(t, IsAncestorOf(id, 999, lookup))
+}
+
 func TestAncestorsOf_CycleDefense(t *testing.T) {
        got := AncestorsOf(1, buildCycle())
 
@@ -289,3 +312,46 @@ func snapshotIDs(snaps []Snapshot) []int64 {
 
        return ids
 }
+
+func TestSnapshotAncestryWalkBoundarySemantics(t *testing.T) {
+       // The parent has expired, but its ID remains recorded on the child.
+       // Conflict checks may use it as an exclusive base while membership must
+       // resolve the requested ancestor.
+       parentID := int64(7)
+       childID := int64(8)
+       child := Snapshot{SnapshotID: childID, ParentSnapshotID: &parentID}
+
+       var lookedUp []int64
+       lookup := func(id int64) *Snapshot {
+               lookedUp = append(lookedUp, id)
+               if id == childID {
+                       return &child
+               }
+
+               return nil
+       }
+
+       between, found := AncestorsBetween(childID, parentID, lookup)
+       assert.True(t, found)
+       assert.Equal(t, []int64{childID}, snapshotIDs(between))
+       assert.Equal(t, []int64{childID}, lookedUp, "exclusive base should not 
be resolved")
+
+       lookedUp = nil
+       assert.False(t, IsAncestorOf(childID, parentID, lookup))
+       assert.Equal(t, []int64{childID, parentID}, lookedUp, "ancestor must 
resolve")
+
+       lookedUp = nil
+       ancestors, complete := AncestorsOfChecked(childID, lookup)
+       assert.False(t, complete)
+       assert.Equal(t, []int64{childID}, snapshotIDs(ancestors))
+       assert.Equal(t, []int64{childID, parentID}, lookedUp)
+
+       lookedUp = nil
+       between, found = AncestorsBetween(parentID, parentID, lookup)
+       assert.True(t, found)
+       assert.Empty(t, between)
+       assert.Empty(t, lookedUp, "equal base and latest must not be resolved")
+
+       assert.False(t, IsAncestorOf(parentID, parentID, lookup))
+       assert.Equal(t, []int64{parentID}, lookedUp, "equal ancestor and 
snapshot must resolve")
+}

Reply via email to