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 95d8c7d99 feat(table): roll back snapshots by timestamp (#2440)
95d8c7d99 is described below

commit 95d8c7d99d37ac3d0f4795c1d942619a203fd41f
Author: Minh Vu <[email protected]>
AuthorDate: Thu Nov 5 21:05:03 2026 +0100

    feat(table): roll back snapshots by timestamp (#2440)
    
    * feat(table): add timestamp-based transaction rollback
    
    * test(table): cover timestamp rollback ancestry and conflicts
    
    * style(table): satisfy nlreturn in timestamp rollback
    
    * style(table): separate early test return
---
 table/transaction_rollback_time.go      |  78 ++++++++++++++++
 table/transaction_rollback_time_test.go | 159 ++++++++++++++++++++++++++++++++
 2 files changed, 237 insertions(+)

diff --git a/table/transaction_rollback_time.go 
b/table/transaction_rollback_time.go
new file mode 100644
index 000000000..85b8cb40c
--- /dev/null
+++ b/table/transaction_rollback_time.go
@@ -0,0 +1,78 @@
+// 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 (
+       "fmt"
+
+       "github.com/apache/iceberg-go"
+)
+
+// RollbackToTime stages a rollback of the transaction's branch to the
+// reachable snapshot with the latest timestamp strictly before timestampMs.
+// Like Java ManageSnapshots.rollbackToTime, this walks snapshot ancestry
+// rather than the table's snapshot log. A snapshot on another branch, or an
+// orphan snapshot, is never a rollback target.
+//
+// The rollback is pinned to the branch head observed by this transaction,
+// using the same conflict checks as RollbackToSnapshot. The timestamp is
+// resolved when staging, not re-selected after concurrent commits.
+func (t *Transaction) RollbackToTime(timestampMs int64) error {
+       meta, err := t.txnMeta()
+       if err != nil {
+               return err
+       }
+
+       branch := t.branch
+       if branch == "" {
+               branch = MainBranch
+       }
+
+       ref, ok := meta.refs[branch]
+       if !ok {
+               return fmt.Errorf("%w: cannot rollback: branch %q does not 
exist", iceberg.ErrInvalidArgument, branch)
+       }
+       if ref.Type != BranchRef {
+               return fmt.Errorf("%w: cannot rollback: ref %q is a %s, not a 
branch", iceberg.ErrInvalidArgument, branch, ref.Type)
+       }
+
+       // Metadata may have been modified in this transaction, so resolve the
+       // staged branch head and ancestors, not the table's immutable base.
+       lookup := func(id int64) *Snapshot {
+               snapshot, _ := meta.SnapshotByID(id)
+
+               return snapshot
+       }
+       if lookup(ref.SnapshotID) == nil {
+               return fmt.Errorf("cannot rollback: branch %q references 
unknown snapshot %d", branch, ref.SnapshotID)
+       }
+
+       ancestors := AncestorsOf(ref.SnapshotID, lookup)
+       var target *Snapshot
+       for i := range ancestors {
+               snapshot := &ancestors[i]
+               if snapshot.TimestampMs < timestampMs && (target == nil || 
snapshot.TimestampMs > target.TimestampMs) {
+                       target = snapshot
+               }
+       }
+       if target == nil {
+               return fmt.Errorf("%w: no ancestor of branch %q is older than 
timestamp %d", iceberg.ErrInvalidArgument, branch, timestampMs)
+       }
+
+       return t.RollbackToSnapshot(target.SnapshotID)
+}
diff --git a/table/transaction_rollback_time_test.go 
b/table/transaction_rollback_time_test.go
new file mode 100644
index 000000000..201714636
--- /dev/null
+++ b/table/transaction_rollback_time_test.go
@@ -0,0 +1,159 @@
+// 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 (
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       "github.com/stretchr/testify/require"
+)
+
+func TestRollbackToTimeStrictlyBeforeTimestamp(t *testing.T) {
+       base := newConflictTestMetadataWithChain(t, []int64{10, 20, 30})
+       first := base.SnapshotByID(10)
+       middle := base.SnapshotByID(20)
+       latest := base.SnapshotByID(30)
+       require.NotNil(t, first)
+       require.NotNil(t, middle)
+       require.NotNil(t, latest)
+
+       tests := []struct {
+               name         string
+               timestamp    int64
+               wantSnapshot int64
+               wantError    bool
+       }{
+               {name: "equal to latest chooses parent", timestamp: 
latest.TimestampMs, wantSnapshot: 20},
+               {name: "equal to middle chooses grandparent", timestamp: 
middle.TimestampMs, wantSnapshot: 10},
+               {name: "after latest is a no-op", timestamp: latest.TimestampMs 
+ 1, wantSnapshot: 30},
+               {name: "equal to first has no older ancestor", timestamp: 
first.TimestampMs, wantError: true},
+               {name: "before first has no older ancestor", timestamp: 
first.TimestampMs - 1, wantError: true},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       tbl, _ := newAssertRefTestTable(t, base, base)
+                       tx := tbl.NewTransaction()
+                       err := tx.RollbackToTime(tt.timestamp)
+                       if tt.wantError {
+                               require.ErrorIs(t, err, 
iceberg.ErrInvalidArgument)
+                               require.Equal(t, int64(30), 
tx.meta.refs[MainBranch].SnapshotID)
+                               require.Empty(t, tx.meta.updates)
+
+                               return
+                       }
+
+                       require.NoError(t, err)
+                       require.Equal(t, tt.wantSnapshot, 
tx.meta.refs[MainBranch].SnapshotID)
+                       if tt.wantSnapshot == 30 {
+                               require.Empty(t, tx.meta.updates)
+                       } else {
+                               requireContainsRefSnapshotRequirement(t, 
tx.reqs, MainBranch, new(int64(30)))
+                       }
+               })
+       }
+}
+
+func TestRollbackToTimeOnBranchPreservesOtherRefsAndRetention(t *testing.T) {
+       base := newConflictTestMetadataWithChain(t, []int64{10, 20, 30})
+       builder, err := MetadataBuilderFromBase(base, "")
+       require.NoError(t, err)
+       require.NoError(t, builder.SetSnapshotRef("feature", 20, BranchRef,
+               WithMinSnapshotsToKeep(5), WithMaxSnapshotAgeMs(1000), 
WithMaxRefAgeMs(2000)))
+       require.NoError(t, builder.SetSnapshotRef("audit", 30, TagRef, 
WithMaxRefAgeMs(3000)))
+       withRefs, err := builder.Build()
+       require.NoError(t, err)
+
+       tbl, cat := newAssertRefTestTable(t, withRefs, withRefs)
+       tx, err := tbl.NewTransactionOnBranch("feature")
+       require.NoError(t, err)
+       require.NoError(t, 
tx.RollbackToTime(withRefs.SnapshotByID(20).TimestampMs))
+
+       feature := tx.meta.refs["feature"]
+       require.Equal(t, int64(10), feature.SnapshotID)
+       require.Equal(t, 5, *feature.MinSnapshotsToKeep)
+       require.Equal(t, int64(1000), *feature.MaxSnapshotAgeMs)
+       require.Equal(t, int64(2000), *feature.MaxRefAgeMs)
+       require.Equal(t, int64(30), tx.meta.refs[MainBranch].SnapshotID)
+       require.Equal(t, int64(30), tx.meta.refs["audit"].SnapshotID)
+       requireContainsRefSnapshotRequirement(t, tx.reqs, "feature", 
new(int64(20)))
+
+       _, err = tx.Commit(t.Context())
+       require.NoError(t, err)
+       require.Equal(t, int64(10), 
cat.metadata.SnapshotByName("feature").SnapshotID)
+       require.Equal(t, int64(30), cat.metadata.CurrentSnapshot().SnapshotID)
+}
+
+func TestRollbackToTimeIgnoresOrphanSnapshots(t *testing.T) {
+       base := newConflictTestMetadataWithChain(t, []int64{10, 20, 30})
+       builder, err := MetadataBuilderFromBase(base, "")
+       require.NoError(t, err)
+       orphan := Snapshot{
+               SnapshotID:     40,
+               SequenceNumber: 4,
+               TimestampMs:    base.LastUpdatedMillis() + 1,
+               Summary:        &Summary{Operation: OpAppend},
+       }
+       require.NoError(t, builder.AddSnapshot(&orphan))
+       withOrphan, err := builder.Build()
+       require.NoError(t, err)
+
+       tbl, _ := newAssertRefTestTable(t, withOrphan, withOrphan)
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.RollbackToTime(orphan.TimestampMs+1))
+       require.Equal(t, int64(30), tx.meta.refs[MainBranch].SnapshotID)
+       require.Empty(t, tx.meta.updates)
+}
+
+func TestRollbackToTimePinsBranchHeadAgainstConcurrentMove(t *testing.T) {
+       base := newConflictTestMetadataWithChain(t, []int64{10, 20, 30})
+       concurrent := graftSnapshotOnto(t, base, MainBranch, 40)
+       tbl, cat := newAssertRefTestTable(t, base, concurrent)
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.RollbackToTime(base.SnapshotByID(30).TimestampMs))
+
+       _, err := tx.Commit(t.Context())
+       require.ErrorIs(t, err, ErrCommitFailed)
+       require.Equal(t, int64(40), cat.metadata.CurrentSnapshot().SnapshotID)
+       require.Equal(t, int32(1), cat.attempts.Load())
+}
+
+func TestRollbackToTimeRejectsMissingAndTagBranches(t *testing.T) {
+       base := newConflictTestMetadataWithChain(t, []int64{10, 20})
+       tbl, _ := newAssertRefTestTable(t, base, base)
+
+       tx := tbl.NewTransaction()
+       tx.branch = "missing"
+       require.ErrorIs(t, 
tx.RollbackToTime(base.SnapshotByID(20).TimestampMs), 
iceberg.ErrInvalidArgument)
+
+       builder, err := MetadataBuilderFromBase(base, "")
+       require.NoError(t, err)
+       require.NoError(t, builder.SetSnapshotRef("audit", 20, TagRef))
+       withTag, err := builder.Build()
+       require.NoError(t, err)
+       tbl, _ = newAssertRefTestTable(t, withTag, withTag)
+       tx = tbl.NewTransaction()
+       tx.branch = "audit"
+       require.ErrorIs(t, 
tx.RollbackToTime(base.SnapshotByID(20).TimestampMs), 
iceberg.ErrInvalidArgument)
+}
+
+func TestRollbackToTimeRejectsNilTransaction(t *testing.T) {
+       var tx *Transaction
+       require.ErrorIs(t, tx.RollbackToTime(0), ErrInvalidMetadata)
+}

Reply via email to