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

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


The following commit(s) were added to refs/heads/main by this push:
     new 94102c5cb feat(table): replan snapshot expiration on commit retry 
(#2354)
94102c5cb is described below

commit 94102c5cb24f437a7c33e6bf53e2acb4efacdcc5
Author: Harrison Crosse <[email protected]>
AuthorDate: Sun Nov 8 16:17:02 2026 -0500

    feat(table): replan snapshot expiration on commit retry (#2354)
---
 catalog/sql/sql_test.go          | 261 ++++++++-----
 table/commit.go                  |   2 +
 table/commit_retry_test.go       |   7 +-
 table/expire_replay.go           | 191 +++++++++
 table/expire_replay_test.go      | 824 +++++++++++++++++++++++++++++++++++++++
 table/table.go                   | 137 ++++---
 table/table_test.go              | 226 +++++------
 table/transaction.go             | 134 +++++--
 website/src/concurrent-writes.md |  44 ++-
 website/src/releases.md          |   1 +
 10 files changed, 1514 insertions(+), 313 deletions(-)

diff --git a/catalog/sql/sql_test.go b/catalog/sql/sql_test.go
index 2025dc386..f44304536 100644
--- a/catalog/sql/sql_test.go
+++ b/catalog/sql/sql_test.go
@@ -36,6 +36,7 @@ import (
        "sync"
        "sync/atomic"
        "testing"
+       "testing/synctest"
        "time"
 
        "github.com/apache/arrow-go/v18/arrow"
@@ -1526,33 +1527,26 @@ func (s *SqliteCatalogTestSuite) appendStringRow(ctx 
context.Context, tbl *table
        return out
 }
 
-// waitPastSnapshots blocks until the wall clock in milliseconds is past
-// every snapshot timestamp of meta, so an expiry with WithOlderThan(0)
-// treats them all as old enough.
-func (s *SqliteCatalogTestSuite) waitPastSnapshots(meta table.Metadata) {
-       var newest int64
-       for _, snap := range meta.Snapshots() {
-               newest = max(newest, snap.TimestampMs)
-       }
-       s.Require().Eventually(func() bool { return time.Now().UnixMilli() > 
newest },
-               time.Second, time.Millisecond)
-}
-
 // createRetryingStringTable creates a single-column string table that
 // retries conflicting commits numRetries times with millisecond waits,
 // then appends one row per value.
 func (s *SqliteCatalogTestSuite) createRetryingStringTable(ctx 
context.Context, cat *sqlcat.Catalog, numRetries string, values ...string) 
table.Identifier {
+       return s.createStringTable(ctx, cat, 
iceberg.Properties{table.CommitNumRetriesKey: numRetries}, values...)
+}
+
+func (s *SqliteCatalogTestSuite) createStringTable(ctx context.Context, cat 
*sqlcat.Catalog, props iceberg.Properties, values ...string) table.Identifier {
        schema := iceberg.NewSchema(1, iceberg.NestedField{
                ID: 1, Name: "foo", Type: iceberg.PrimitiveTypes.String, 
Required: true,
        })
        tblID := s.randomTableIdentifier()
        s.Require().NoError(cat.CreateNamespace(ctx, 
catalog.NamespaceFromIdent(tblID), nil))
 
-       tbl, err := cat.CreateTable(ctx, tblID, schema, 
catalog.WithProperties(iceberg.Properties{
-               table.CommitNumRetriesKey:     numRetries,
+       all := iceberg.Properties{
                table.CommitMinRetryWaitMsKey: "1",
                table.CommitMaxRetryWaitMsKey: "2",
-       }))
+       }
+       maps.Copy(all, props)
+       tbl, err := cat.CreateTable(ctx, tblID, schema, 
catalog.WithProperties(all))
        s.Require().NoError(err)
        for _, v := range values {
                tbl = s.appendStringRow(ctx, tbl, table.MainBranch, v)
@@ -2033,49 +2027,37 @@ func (s *SqliteCatalogTestSuite) 
TestStaleRollbackFailsAfterPeerAppend() {
        s.Len(current.Metadata().Snapshots(), 3)
 }
 
-// Snapshot expiry staged before a peer rolled the branch back must not
-// be replayed against the new head: its retention window was computed
-// from the old head, so it would expire snapshots the rolled-back
-// branch still retains.
-func (s *SqliteCatalogTestSuite) 
TestStaleExpireSnapshotsFailsAfterPeerRollback() {
-       ctx := context.Background()
-       cat := s.getCatalogSqlite()
-       defer cat.Close()
-       tblID := s.createRetryingStringTable(ctx, cat, "2", "s1", "s2", "s3", 
"s4")
-
-       stale, err := cat.LoadTable(ctx, tblID)
-       s.Require().NoError(err)
-       snaps := slices.Clone(stale.Metadata().Snapshots())
-       s.Require().Len(snaps, 4)
-       slices.SortFunc(snaps, func(a, b table.Snapshot) int { return 
cmp.Compare(a.SequenceNumber, b.SequenceNumber) })
-       ids := make([]int64, len(snaps))
-       for i, snap := range snaps {
-               ids[i] = snap.SnapshotID
-       }
+func (s *SqliteCatalogTestSuite) 
TestStaleExpireSnapshotsReplansAfterPeerRollback() {
+       s.inBubble(func() {
+               ctx := context.Background()
+               cat := s.getCatalogSqlite()
+               defer cat.Close()
+               tblID := s.createStringTable(ctx, cat, bubbleTableProps, "s1", 
"s2", "s3", "s4")
 
-       peer, err := cat.LoadTable(ctx, tblID)
-       s.Require().NoError(err)
-       peerTx := peer.NewTransaction()
-       s.Require().NoError(peerTx.RollbackToSnapshot(ids[1]))
-       _, err = peerTx.Commit(ctx)
-       s.Require().NoError(err)
+               stale, err := cat.LoadTable(ctx, tblID)
+               s.Require().NoError(err)
+               ids := snapshotIDsBySequence(stale.Metadata())
+               s.Require().Len(ids, 4)
 
-       s.waitPastSnapshots(stale.Metadata())
+               peer, err := cat.LoadTable(ctx, tblID)
+               s.Require().NoError(err)
+               peerTx := peer.NewTransaction()
+               s.Require().NoError(peerTx.RollbackToSnapshot(ids[1]))
+               _, err = peerTx.Commit(ctx)
+               s.Require().NoError(err)
+               time.Sleep(time.Millisecond)
 
-       tx := stale.NewTransaction()
-       s.Require().NoError(tx.ExpireSnapshots(table.WithRetainLast(3), 
table.WithOlderThan(0), table.WithPostCommit(false)))
-       _, err = tx.Commit(ctx)
-       s.Require().Error(err)
-       s.ErrorIs(err, table.ErrCommitFailed)
+               tx := stale.NewTransaction()
+               s.Require().NoError(tx.ExpireSnapshots(table.WithRetainLast(3), 
table.WithOlderThan(0), table.WithPostCommit(false)))
+               _, err = tx.Commit(ctx)
+               s.Require().NoError(err)
 
-       current, err := cat.LoadTable(ctx, tblID)
-       s.Require().NoError(err)
-       s.Equal(ids[1], current.Metadata().CurrentSnapshot().SnapshotID)
-       var remaining []int64
-       for _, snap := range current.Metadata().Snapshots() {
-               remaining = append(remaining, snap.SnapshotID)
-       }
-       s.ElementsMatch(ids, remaining)
+               current, err := cat.LoadTable(ctx, tblID)
+               s.Require().NoError(err)
+               s.Equal(ids[1], current.Metadata().CurrentSnapshot().SnapshotID)
+               s.Equal(ids[:2], snapshotIDsBySequence(current.Metadata()))
+               s.ElementsMatch([]string{"s1", "s2"}, s.storedStringValues(ctx, 
current))
+       })
 }
 
 // A rollback of a branch the transaction staged must not be replayed
@@ -2112,44 +2094,149 @@ func (s *SqliteCatalogTestSuite) 
TestStaleRollbackOfStagedBranchFailsAfterPeerCr
        s.Len(current.Metadata().Snapshots(), 3)
 }
 
-// Snapshot expiry in a transaction that staged a new branch must not be
-// replayed against a branch a peer created meanwhile: its retention was
-// computed without the peer's branch.
-func (s *SqliteCatalogTestSuite) 
TestStaleExpireWithStagedBranchFailsAfterPeerCreatesIt() {
-       ctx := context.Background()
-       cat := s.getCatalogSqlite()
-       defer cat.Close()
-       tblID := s.createRetryingStringTable(ctx, cat, "2", "seed", "second")
+func (s *SqliteCatalogTestSuite) 
TestStaleExpireWithStagedBranchReplansAfterPeerCreatesIt() {
+       s.inBubble(func() {
+               ctx := context.Background()
+               cat := s.getCatalogSqlite()
+               defer cat.Close()
+               tblID := s.createStringTable(ctx, cat, bubbleTableProps, 
"seed", "second")
 
-       stale, err := cat.LoadTable(ctx, tblID)
-       s.Require().NoError(err)
+               stale, err := cat.LoadTable(ctx, tblID)
+               s.Require().NoError(err)
+               mainHead := stale.Metadata().CurrentSnapshot().SnapshotID
 
-       peer, err := cat.LoadTable(ctx, tblID)
-       s.Require().NoError(err)
-       peer = s.appendStringRow(ctx, peer, "feature", "peer")
-       peerHead := peer.Metadata().SnapshotByName("feature").SnapshotID
-       s.waitPastSnapshots(peer.Metadata())
+               peer, err := cat.LoadTable(ctx, tblID)
+               s.Require().NoError(err)
+               peer = s.appendStringRow(ctx, peer, "feature", "peer")
+               peerHead := peer.Metadata().SnapshotByName("feature").SnapshotID
+               time.Sleep(time.Millisecond)
 
-       tx, err := stale.NewTransactionOnBranch("feature")
-       s.Require().NoError(err)
-       s.stageStringRow(ctx, tx, "stale")
-       s.Require().NoError(tx.ExpireSnapshots(table.WithRetainLast(1), 
table.WithOlderThan(0), table.WithPostCommit(false)))
-       _, err = tx.Commit(ctx)
-       s.Require().Error(err)
-       s.ErrorIs(err, table.ErrCommitFailed)
+               tx, err := stale.NewTransactionOnBranch("feature")
+               s.Require().NoError(err)
+               s.stageStringRow(ctx, tx, "stale")
+               s.Require().NoError(tx.ExpireSnapshots(table.WithRetainLast(1), 
table.WithOlderThan(0), table.WithPostCommit(false)))
+               _, err = tx.Commit(ctx)
+               s.Require().NoError(err)
 
-       current, err := cat.LoadTable(ctx, tblID)
-       s.Require().NoError(err)
-       s.Equal(peerHead, 
current.Metadata().SnapshotByName("feature").SnapshotID)
-       var remaining []int64
-       for _, snap := range current.Metadata().Snapshots() {
-               remaining = append(remaining, snap.SnapshotID)
-       }
-       var expected []int64
-       for _, snap := range peer.Metadata().Snapshots() {
-               expected = append(expected, snap.SnapshotID)
+               current, err := cat.LoadTable(ctx, tblID)
+               s.Require().NoError(err)
+               feature := current.Metadata().SnapshotByName("feature")
+               s.Require().NotNil(feature)
+               s.Require().NotNil(feature.ParentSnapshotID)
+               s.Equal(peerHead, *feature.ParentSnapshotID)
+               s.Equal([]int64{mainHead, feature.SnapshotID}, 
snapshotIDsBySequence(current.Metadata()))
+               s.ElementsMatch([]string{"seed", "second", "peer", "stale"},
+                       s.storedStringValues(ctx, current, 
table.WithSnapshotID(feature.SnapshotID)))
+               s.ElementsMatch([]string{"seed", "second"}, 
s.storedStringValues(ctx, current))
+       })
+}
+
+// A millisecond sleep makes earlier snapshots older than the expiry cutoff.
+// Use bubbleTableProps for tables created inside the bubble.
+func (s *SqliteCatalogTestSuite) inBubble(f func()) {
+       outer := s.T()
+       synctest.Test(outer, func(t *testing.T) {
+               s.SetT(t)
+               defer s.SetT(outer)
+               f()
+       })
+}
+
+// bubbleTableProps avoid zstd, whose shared decoder in arrow-go belongs to
+// the first bubble that uses it.
+var bubbleTableProps = iceberg.Properties{
+       table.CommitNumRetriesKey:   "2",
+       table.ParquetCompressionKey: "snappy",
+}
+
+func snapshotIDsBySequence(meta table.Metadata) []int64 {
+       snaps := slices.SortedFunc(slices.Values(meta.Snapshots()), func(a, b 
table.Snapshot) int {
+               return cmp.Compare(a.SequenceNumber, b.SequenceNumber)
+       })
+       ids := make([]int64, len(snaps))
+       for i, snap := range snaps {
+               ids[i] = snap.SnapshotID
        }
-       s.ElementsMatch(expected, remaining)
+
+       return ids
+}
+
+func (s *SqliteCatalogTestSuite) 
TestStaleAppendAndExpireReplansAfterPeerRollback() {
+       s.inBubble(func() {
+               ctx := context.Background()
+               cat := s.getCatalogSqlite()
+               defer cat.Close()
+               tblID := s.createStringTable(ctx, cat, bubbleTableProps, "s1", 
"s2", "s3", "s4")
+               stale, err := cat.LoadTable(ctx, tblID)
+               s.Require().NoError(err)
+               ids := snapshotIDsBySequence(stale.Metadata())
+               time.Sleep(time.Millisecond)
+
+               tx := stale.NewTransaction()
+               s.stageStringRow(ctx, tx, "a")
+               s.Require().NoError(tx.ExpireSnapshots(table.WithRetainLast(3), 
table.WithOlderThan(0)))
+
+               peer, err := cat.LoadTable(ctx, tblID)
+               s.Require().NoError(err)
+               peerTx := peer.NewTransaction()
+               s.Require().NoError(peerTx.RollbackToSnapshot(ids[1]))
+               _, err = peerTx.Commit(ctx)
+               s.Require().NoError(err)
+
+               _, err = tx.Commit(ctx)
+               s.Require().NoError(err)
+
+               current, err := cat.LoadTable(ctx, tblID)
+               s.Require().NoError(err)
+               got := snapshotIDsBySequence(current.Metadata())
+               s.Require().Len(got, 3)
+               s.Equal(ids[:2], got[:2])
+               head := current.Metadata().CurrentSnapshot()
+               s.Equal(got[2], head.SnapshotID)
+               s.Require().NotNil(head.ParentSnapshotID)
+               s.Equal(ids[1], *head.ParentSnapshotID)
+               s.ElementsMatch([]string{"s1", "s2", "a"}, 
s.storedStringValues(ctx, current))
+               s.ElementsMatch([]string{"s1", "s2"}, s.storedStringValues(ctx, 
current, table.WithSnapshotID(ids[1])))
+               s.ElementsMatch([]string{"s1"}, s.storedStringValues(ctx, 
current, table.WithSnapshotID(ids[0])))
+       })
+}
+
+func (s *SqliteCatalogTestSuite) 
TestStaleExpireSnapshotsKeepsBranchPeerCreated() {
+       s.inBubble(func() {
+               ctx := context.Background()
+               cat := s.getCatalogSqlite()
+               defer cat.Close()
+               tblID := s.createStringTable(ctx, cat, bubbleTableProps, "s1", 
"s2", "s3")
+               stale, err := cat.LoadTable(ctx, tblID)
+               s.Require().NoError(err)
+               ids := snapshotIDsBySequence(stale.Metadata())
+               time.Sleep(time.Millisecond)
+
+               tx := stale.NewTransaction()
+               s.Require().NoError(tx.ExpireSnapshots(table.WithRetainLast(1), 
table.WithOlderThan(0)))
+
+               // Creating an unread ref causes no conflict, so append to 
force a retry.
+               _, _, err = cat.CommitTable(ctx, tblID, nil, []table.Update{
+                       table.NewSetSnapshotRefUpdate("keep", ids[0], 
table.BranchRef, 0, 0, 0),
+               })
+               s.Require().NoError(err)
+               peer, err := cat.LoadTable(ctx, tblID)
+               s.Require().NoError(err)
+               peer = s.appendStringRow(ctx, peer, table.MainBranch, "p")
+               p := peer.Metadata().CurrentSnapshot().SnapshotID
+
+               _, err = tx.Commit(ctx)
+               s.Require().NoError(err)
+
+               current, err := cat.LoadTable(ctx, tblID)
+               s.Require().NoError(err)
+               s.Equal([]int64{ids[0], p}, 
snapshotIDsBySequence(current.Metadata()))
+               keep := current.Metadata().SnapshotByName("keep")
+               s.Require().NotNil(keep)
+               s.Equal(ids[0], keep.SnapshotID)
+               s.ElementsMatch([]string{"s1", "s2", "s3", "p"}, 
s.storedStringValues(ctx, current))
+               s.ElementsMatch([]string{"s1"}, s.storedStringValues(ctx, 
current, table.WithSnapshotID(ids[0])))
+       })
 }
 
 func (s *SqliteCatalogTestSuite) TestPurgeTable() {
diff --git a/table/commit.go b/table/commit.go
index 516c6e218..cedaa059d 100644
--- a/table/commit.go
+++ b/table/commit.go
@@ -18,6 +18,7 @@
 package table
 
 import (
+       "cmp"
        "errors"
        "slices"
 )
@@ -87,6 +88,7 @@ func (t *Transaction) TableCommit() (TableCommit, error) {
        // their updates stage no ref requirement. The helper returns a fresh
        // slice, so appending here cannot touch t.reqs.
        reqs := append(transactionRequirements(t.reqs, t.branch, 
t.tbl.metadata), AssertTableUUID(meta.uuid))
+       reqs = append(reqs, expirationAssertions(t.tbl.metadata, 
cmp.Or(t.branch, MainBranch), stagedWalkedRefs(t.expirations), reqs)...)
 
        updates := make([]Update, len(meta.updates))
        copy(updates, meta.updates)
diff --git a/table/commit_retry_test.go b/table/commit_retry_test.go
index 3501e0d3f..c195576a0 100644
--- a/table/commit_retry_test.go
+++ b/table/commit_retry_test.go
@@ -704,9 +704,7 @@ func TestDoCommit_OrphanCleanedOnCommitDiverged(t 
*testing.T) {
        assertOnlyManifestList(t, wfs, originalManifestList)
 }
 
-// assertOnlyManifestList checks that want is the only manifest list left in
-// wfs: a failed commit keeps the staged list and removes every rebuilt one.
-func assertOnlyManifestList(t *testing.T, wfs *memIO, want string) {
+func assertOnlyManifestList(t *testing.T, wfs *memIO, want ...string) {
        t.Helper()
        var lists []string
        for path := range wfs.files {
@@ -714,7 +712,8 @@ func assertOnlyManifestList(t *testing.T, wfs *memIO, want 
string) {
                        lists = append(lists, path)
                }
        }
-       assert.Equal(t, []string{want}, lists)
+       slices.Sort(lists)
+       assert.Equal(t, slices.Sorted(slices.Values(want)), lists)
 }
 
 // TestDoCommit_OrphanCleanedOnRetriesExhausted verifies that when every retry
diff --git a/table/expire_replay.go b/table/expire_replay.go
new file mode 100644
index 000000000..b844e3188
--- /dev/null
+++ b/table/expire_replay.go
@@ -0,0 +1,191 @@
+// 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"
+
+       "github.com/apache/iceberg-go/io"
+)
+
+type stagedReplay struct {
+       branch  string
+       records []expirationRecord
+
+       // Record positions stay fixed even when replanning changes the update 
count.
+       // Snapshots use their most recent rebuild.
+       updates []Update
+
+       // Refs walked by expiries that produced updates.
+       walkedRefs []string
+
+       // Updates from the last successful replay.
+       // ran distinguishes an empty replay from no replay.
+       landed []Update
+       ran    bool
+}
+
+func newStagedReplay(branch string, updates []Update, records 
[]expirationRecord) *stagedReplay {
+       return &stagedReplay{
+               branch:     branch,
+               records:    records,
+               updates:    slices.Clone(updates),
+               walkedRefs: stagedWalkedRefs(records),
+       }
+}
+
+// On error, replay returns partial updates and replaced lists for cleanup.
+// The partial updates include a rebuilt snapshot that failed to apply.
+func (r *stagedReplay) replay(ctx context.Context, fresh Metadata, fs 
io.WriteFileIO, attempt int, force bool) ([]Update, []string, error) {
+       b, err := MetadataBuilderFromBase(fresh, "")
+       if err != nil {
+               return nil, nil, err
+       }
+
+       var (
+               out          []Update
+               orphaned     []string
+               walkedRefs   []string
+               records      = r.records
+               chainRebuilt = force
+       )
+       apply := func(updates ...Update) error {
+               for _, u := range updates {
+                       if err := u.Apply(b); err != nil {
+                               return fmt.Errorf("apply %s: %w", u.Action(), 
err)
+                       }
+                       out = append(out, u)
+               }
+
+               return nil
+       }
+
+       for i := 0; i <= len(r.updates); {
+               if len(records) > 0 && records[0].pos == i {
+                       rec := records[0]
+                       records = records[1:]
+                       updates, walked, err := r.replan(b, rec)
+                       if err != nil {
+                               return out, orphaned, err
+                       }
+                       if err := apply(updates...); err != nil {
+                               return out, orphaned, err
+                       }
+                       if len(updates) > 0 {
+                               walkedRefs = append(walkedRefs, walked...)
+                       }
+                       i += rec.count
+
+                       continue
+               }
+               if i == len(r.updates) {
+                       break
+               }
+
+               if su, ok := r.updates[i].(*addSnapshotUpdate); ok && 
su.rebuildManifestList != nil {
+                       // Sequence numbers and row IDs must include earlier 
replayed updates.
+                       state, err := b.clone().Build()
+                       if err != nil {
+                               return out, orphaned, err
+                       }
+                       next, err := rebuildStagedSnapshot(ctx, su, state, 
b.currentSnapshotForRef(r.branch), fs, attempt, chainRebuilt)
+                       if err != nil {
+                               return out, orphaned, err
+                       }
+                       if next != su {
+                               r.updates[i] = next
+                               orphaned = append(orphaned, 
su.Snapshot.ManifestList)
+                               chainRebuilt = true
+                       }
+               }
+               if err := apply(r.updates[i]); err != nil {
+                       return append(out, r.updates[i]), orphaned, 
fmt.Errorf("%w: staged update does not apply after refresh: %w", 
ErrTransactionUnusable, err)
+               }
+               i++
+       }
+       r.walkedRefs = walkedRefs
+       r.landed, r.ran = out, true
+
+       return out, orphaned, nil
+}
+
+func (r *stagedReplay) replan(b *MetadataBuilder, rec expirationRecord) 
([]Update, []string, error) {
+       updates, walked, err := planExpiration(b, rec.cfg, rec.nowMs)
+       if err != nil {
+               return nil, nil, err
+       }
+       // A no-op expiry must not block other updates solely because GC is 
disabled.
+       if len(updates) > 0 {
+               if err := validateGCEnabledForSnapshotExpiration(b.props); err 
!= nil {
+                       return nil, nil, fmt.Errorf("%w: %w", 
ErrTransactionUnusable, err)
+               }
+       }
+
+       // Later updates depend on whether the staged plan removed the branch.
+       if removes := removesRef(updates, r.branch); removes != 
rec.removedBranch {
+               replanned, staged := "removes", "kept"
+               if !removes {
+                       replanned, staged = "keeps", "removed"
+               }
+
+               return nil, nil, fmt.Errorf("%w: %w: replanned snapshot 
expiration %s commit branch %q, which the staged plan %s",
+                       ErrTransactionUnusable, ErrCommitFailed, replanned, 
r.branch, staged)
+       }
+
+       return updates, walked, nil
+}
+
+func stagedWalkedRefs(records []expirationRecord) []string {
+       var refs []string
+       for _, rec := range records {
+               if rec.count > 0 {
+                       refs = append(refs, rec.walked...)
+               }
+       }
+
+       return refs
+}
+
+// Use base ref IDs, not staged IDs. The commit branch already has an assertion
+// that the transaction rebases.
+func expirationAssertions(base Metadata, branch string, walkedRefs []string, 
reqs []Requirement) []Requirement {
+       asserted := map[string]bool{branch: true}
+       for _, r := range reqs {
+               if a, ok := r.(*assertRefSnapshotID); ok {
+                       asserted[a.Ref] = true
+               }
+       }
+
+       var names []string
+       for _, name := range walkedRefs {
+               if !asserted[name] {
+                       asserted[name] = true
+                       names = append(names, name)
+               }
+       }
+       slices.Sort(names)
+
+       out := make([]Requirement, len(names))
+       for i, name := range names {
+               out[i] = AssertRefSnapshotID(name, refSnapshotID(base, name))
+       }
+
+       return out
+}
diff --git a/table/expire_replay_test.go b/table/expire_replay_test.go
new file mode 100644
index 000000000..fd4fd7c03
--- /dev/null
+++ b/table/expire_replay_test.go
@@ -0,0 +1,824 @@
+// 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"
+       "maps"
+       "path"
+       "slices"
+       "strings"
+       "testing"
+       "testing/synctest"
+       "time"
+
+       "github.com/apache/iceberg-go"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+func newExpireReplayTable(t *testing.T, props iceberg.Properties) (*memIO, 
*reqValidatingCatalog, *Table) {
+       t.Helper()
+       spec := iceberg.NewPartitionSpec()
+       all := iceberg.Properties{
+               PropertyFormatVersion:        "2",
+               CommitNumRetriesKey:          "3",
+               CommitMinRetryWaitMsKey:      "1",
+               CommitMaxRetryWaitMsKey:      "2",
+               CommitTotalRetryTimeoutMsKey: "60000",
+       }
+       maps.Copy(all, props)
+       meta, err := NewMetadata(simpleSchema(), &spec, UnsortedSortOrder, 
retryCleanupLocation, all)
+       require.NoError(t, err)
+       fs := newMemIO(1<<20, nil)
+       cat := &reqValidatingCatalog{metadata: meta}
+
+       return fs, cat, newOCCTable(t, meta, fs, cat)
+}
+
+func stageFiles(t *testing.T, tx *Transaction, names ...string) {
+       t.Helper()
+       files := make([]iceberg.DataFile, 0, len(names))
+       for _, name := range names {
+               files = append(files, retryCleanupFile(t, name))
+       }
+       require.NoError(t, tx.AddDataFiles(t.Context(), files, nil))
+}
+
+func appendOnBranch(t *testing.T, tbl *Table, branch string, names ...string) 
(*Table, int64) {
+       t.Helper()
+       tx, err := tbl.NewTransactionOnBranch(branch)
+       require.NoError(t, err)
+       stageFiles(t, tx, names...)
+       out, err := tx.Commit(t.Context())
+       require.NoError(t, err)
+
+       return out, out.Metadata().SnapshotByName(branch).SnapshotID
+}
+
+func peerAppend(t *testing.T, fs *memIO, cat *reqValidatingCatalog, branch 
string, names ...string) int64 {
+       t.Helper()
+       _, id := appendOnBranch(t, newOCCTable(t, cat.metadata, fs, cat), 
branch, names...)
+
+       return id
+}
+
+// peerUpdate mutates cat.metadata without counting a commit attempt.
+func peerUpdate(t *testing.T, cat *reqValidatingCatalog, updates ...Update) {
+       t.Helper()
+       meta, err := UpdateTableMetadata(cat.metadata, updates, "")
+       require.NoError(t, err)
+       cat.metadata = meta
+}
+
+func stagedHead(t *testing.T, tx *Transaction, branch string) int64 {
+       t.Helper()
+       staged, err := tx.StagedTable()
+       require.NoError(t, err)
+
+       return staged.Metadata().SnapshotByName(branch).SnapshotID
+}
+
+func survivors(meta Metadata) []int64 {
+       return slices.Sorted(slices.Values(snapshotIDs(meta.Snapshots())))
+}
+
+func sortedIDs(ids ...int64) []int64 {
+       return slices.Sorted(slices.Values(ids))
+}
+
+func refHead(t *testing.T, meta Metadata, ref string) int64 {
+       t.Helper()
+       snap := meta.SnapshotByName(ref)
+       require.NotNil(t, snap, "ref %s", ref)
+
+       return snap.SnapshotID
+}
+
+func parentOf(t *testing.T, meta Metadata, id int64) int64 {
+       t.Helper()
+       snap := meta.SnapshotByID(id)
+       require.NotNil(t, snap)
+       require.NotNil(t, snap.ParentSnapshotID)
+
+       return *snap.ParentSnapshotID
+}
+
+func branchFiles(t *testing.T, fs *memIO, meta Metadata, branch string) 
[]string {
+       t.Helper()
+       head := meta.SnapshotByName(branch)
+       require.NotNil(t, head)
+       manifests, err := head.Manifests(t.Context(), fs)
+       require.NoError(t, err)
+
+       var names []string
+       for _, m := range manifests {
+               for entry, err := range m.Entries(t.Context(), fs, true) {
+                       require.NoError(t, err)
+                       names = append(names, 
strings.TrimSuffix(path.Base(entry.DataFile().FilePath()), ".parquet"))
+               }
+       }
+       slices.Sort(names)
+
+       return names
+}
+
+func TestExpireReplayPlansWhereExpiryWasStaged(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               fs, cat, tbl := newExpireReplayTable(t, nil)
+               tbl, _ = appendOnBranch(t, tbl, MainBranch, "b1")
+               tbl, _ = appendOnBranch(t, tbl, MainBranch, "b2")
+               tbl, b3 := appendOnBranch(t, tbl, MainBranch, "b3")
+               time.Sleep(time.Millisecond)
+
+               tx := tbl.NewTransaction()
+               stageFiles(t, tx, "a1")
+               a1 := stagedHead(t, tx, MainBranch)
+               require.NoError(t, tx.ExpireSnapshots(WithOlderThan(0), 
WithRetainLast(3)))
+               stageFiles(t, tx, "a2")
+               a2 := stagedHead(t, tx, MainBranch)
+
+               p := peerAppend(t, fs, cat, MainBranch, "p")
+
+               committed, err := tx.Commit(t.Context())
+               require.NoError(t, err)
+
+               meta := committed.Metadata()
+               assert.Equal(t, sortedIDs(b3, p, a1, a2), survivors(meta))
+               assert.Equal(t, a2, refHead(t, meta, MainBranch))
+               assert.Equal(t, a1, parentOf(t, meta, a2))
+               assert.Equal(t, p, parentOf(t, meta, a1))
+               assert.Equal(t, []string{"a1", "a2", "b1", "b2", "b3", "p"}, 
branchFiles(t, fs, meta, MainBranch))
+       })
+}
+
+func TestExpireReplayAfterRollback(t *testing.T) {
+       stage := func(t *testing.T) (*memIO, *reqValidatingCatalog, 
*Transaction, [3]int64, int64) {
+               t.Helper()
+               fs, cat, tbl := newExpireReplayTable(t, nil)
+               var base [3]int64
+               for i, name := range []string{"b1", "b2", "b3"} {
+                       tbl, base[i] = appendOnBranch(t, tbl, MainBranch, name)
+               }
+               time.Sleep(time.Millisecond)
+
+               tx := tbl.NewTransaction()
+               require.NoError(t, tx.RollbackToSnapshot(base[1]))
+               stageFiles(t, tx, "a")
+               a := stagedHead(t, tx, MainBranch)
+               require.NoError(t, tx.ExpireSnapshots(WithOlderThan(0), 
WithRetainLast(2)))
+
+               return fs, cat, tx, base, a
+       }
+
+       t.Run("peer commits to another branch", func(t *testing.T) {
+               synctest.Test(t, func(t *testing.T) {
+                       fs, cat, tx, base, a := stage(t)
+                       // The peer's sequence number forces a rebuild of a.
+                       p := peerAppend(t, fs, cat, "other", "p")
+
+                       committed, err := tx.Commit(t.Context())
+                       require.NoError(t, err)
+
+                       meta := committed.Metadata()
+                       assert.Equal(t, sortedIDs(base[1], base[2], p, a), 
survivors(meta))
+                       assert.Equal(t, a, refHead(t, meta, MainBranch))
+                       assert.Equal(t, base[1], parentOf(t, meta, a))
+                       assert.Equal(t, []string{"a", "b1", "b2"}, 
branchFiles(t, fs, meta, MainBranch))
+                       assert.Equal(t, p, refHead(t, meta, "other"))
+               })
+       })
+
+       t.Run("peer moves the rolled back branch", func(t *testing.T) {
+               synctest.Test(t, func(t *testing.T) {
+                       fs, cat, tx, _, _ := stage(t)
+                       p := peerAppend(t, fs, cat, MainBranch, "p")
+                       before := cat.metadata
+
+                       _, err := tx.Commit(t.Context())
+                       require.ErrorIs(t, err, ErrTransactionUnusable)
+                       require.ErrorIs(t, err, ErrCommitFailed)
+                       assert.Contains(t, err.Error(), "requirement no longer 
holds after refresh")
+                       assert.Same(t, before, cat.metadata)
+                       assert.Equal(t, p, refHead(t, cat.metadata, MainBranch))
+               })
+       })
+}
+
+func TestExpireReplayPlansEachExpiration(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               fs, cat, tbl := newExpireReplayTable(t, nil)
+               for _, name := range []string{"b1", "b2", "b3", "b4"} {
+                       tbl, _ = appendOnBranch(t, tbl, MainBranch, name)
+               }
+               time.Sleep(time.Millisecond)
+
+               tx := tbl.NewTransaction()
+               require.NoError(t, tx.ExpireSnapshots(WithOlderThan(0), 
WithRetainLast(3)))
+               stageFiles(t, tx, "a")
+               a := stagedHead(t, tx, MainBranch)
+               require.NoError(t, tx.ExpireSnapshots(WithOlderThan(0), 
WithRetainLast(2)))
+
+               p := peerAppend(t, fs, cat, MainBranch, "p")
+
+               committed, err := tx.Commit(t.Context())
+               require.NoError(t, err)
+
+               meta := committed.Metadata()
+               assert.Equal(t, sortedIDs(p, a), survivors(meta))
+               assert.Equal(t, p, parentOf(t, meta, a))
+               assert.Equal(t, []string{"a", "b1", "b2", "b3", "b4", "p"}, 
branchFiles(t, fs, meta, MainBranch))
+       })
+}
+
+func TestExpireReplayOnNonMainBranch(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               fs, cat, tbl := newExpireReplayTable(t, nil)
+               tbl, _ = appendOnBranch(t, tbl, MainBranch, "b1")
+               tbl, b2 := appendOnBranch(t, tbl, MainBranch, "b2")
+               tbl, d1 := appendOnBranch(t, tbl, "dev", "d1")
+               time.Sleep(time.Millisecond)
+
+               tx, err := tbl.NewTransactionOnBranch("dev")
+               require.NoError(t, err)
+               stageFiles(t, tx, "x")
+               x := stagedHead(t, tx, "dev")
+               require.NoError(t, tx.ExpireSnapshots(WithOlderThan(0), 
WithRetainLast(1)))
+
+               p := peerAppend(t, fs, cat, "dev", "p")
+               peerUpdate(t, cat, NewSetSnapshotRefUpdate("hold", d1, TagRef, 
0, 0, 0))
+
+               committed, err := tx.Commit(t.Context())
+               require.NoError(t, err)
+
+               meta := committed.Metadata()
+               assert.Equal(t, sortedIDs(b2, d1, p, x), survivors(meta))
+               assert.Equal(t, x, refHead(t, meta, "dev"))
+               assert.Equal(t, p, parentOf(t, meta, x))
+               assert.Equal(t, d1, refHead(t, meta, "hold"))
+               assert.Equal(t, []string{"b1", "b2", "d1", "p", "x"}, 
branchFiles(t, fs, meta, "dev"))
+               assert.Equal(t, b2, refHead(t, meta, MainBranch))
+
+               assert.Contains(t, cat.lastReqs, AssertRefSnapshotID("hold", 
&d1))
+               assert.Contains(t, cat.lastReqs, 
AssertRefSnapshotID(MainBranch, &b2))
+               assert.Contains(t, cat.lastReqs, 
assertBranchRefSnapshotID("dev", &p))
+       })
+}
+
+func TestExpireReplayKeepsOriginalCutoff(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               fs, cat, tbl := newExpireReplayTable(t, nil)
+               tbl, _ = appendOnBranch(t, tbl, MainBranch, "b1")
+               time.Sleep(2 * time.Hour)
+               tbl, b2 := appendOnBranch(t, tbl, MainBranch, "b2")
+               tbl, b3 := appendOnBranch(t, tbl, MainBranch, "b3")
+
+               tx := tbl.NewTransaction()
+               require.NoError(t, tx.ExpireSnapshots(WithOlderThan(time.Hour), 
WithRetainLast(1)))
+
+               time.Sleep(2 * time.Hour)
+               p := peerAppend(t, fs, cat, MainBranch, "p")
+
+               committed, err := tx.Commit(t.Context())
+               require.NoError(t, err)
+
+               // From the retry's clock, b2 and b3 would be two hours old.
+               assert.Equal(t, sortedIDs(b2, b3, p), 
survivors(committed.Metadata()))
+       })
+}
+
+func TestExpireReplayFailsWhenCommitBranchRemovalChanges(t *testing.T) {
+       props := iceberg.Properties{MaxRefAgeMsKey: "3600000"}
+
+       t.Run("staged plan removed the branch", func(t *testing.T) {
+               synctest.Test(t, func(t *testing.T) {
+                       fs, cat, tbl := newExpireReplayTable(t, props)
+                       tbl, _ = appendOnBranch(t, tbl, MainBranch, "b1")
+                       tbl, _ = appendOnBranch(t, tbl, "dev", "d1")
+                       time.Sleep(2 * time.Hour)
+
+                       tx, err := tbl.NewTransactionOnBranch("dev")
+                       require.NoError(t, err)
+                       require.NoError(t, tx.ExpireSnapshots())
+
+                       p := peerAppend(t, fs, cat, "dev", "p")
+                       before := cat.metadata
+
+                       _, err = tx.Commit(t.Context())
+                       require.ErrorIs(t, err, ErrTransactionUnusable)
+                       require.ErrorIs(t, err, ErrCommitFailed)
+                       assert.EqualError(t, err, `transaction cannot be 
committed again: commit failed, refresh and try again: `+
+                               `replanned snapshot expiration keeps commit 
branch "dev", which the staged plan removed`)
+                       assert.Same(t, before, cat.metadata)
+                       assert.Equal(t, p, refHead(t, cat.metadata, "dev"))
+               })
+       })
+
+       t.Run("staged plan kept the branch", func(t *testing.T) {
+               synctest.Test(t, func(t *testing.T) {
+                       _, cat, tbl := newExpireReplayTable(t, props)
+                       tbl, _ = appendOnBranch(t, tbl, MainBranch, "b1")
+                       tbl, d1 := appendOnBranch(t, tbl, "dev", "d1")
+                       time.Sleep(2 * time.Hour)
+                       tbl, _ = appendOnBranch(t, tbl, "dev", "d2")
+
+                       tx, err := tbl.NewTransactionOnBranch("dev")
+                       require.NoError(t, err)
+                       require.NoError(t, tx.ExpireSnapshots())
+                       require.NoError(t, 
tx.SetProperties(iceberg.Properties{"k": "v"}))
+
+                       peerUpdate(t, cat, NewSetSnapshotRefUpdate("dev", d1, 
BranchRef, 0, 0, 0))
+                       before := cat.metadata
+
+                       _, err = tx.Commit(t.Context())
+                       require.ErrorIs(t, err, ErrTransactionUnusable)
+                       require.ErrorIs(t, err, ErrCommitFailed)
+                       assert.EqualError(t, err, `transaction cannot be 
committed again: commit failed, refresh and try again: `+
+                               `replanned snapshot expiration removes commit 
branch "dev", which the staged plan kept`)
+                       assert.Same(t, before, cat.metadata)
+               })
+       })
+}
+
+func TestExpireReplayPlansNoOpExpiryAgain(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               fs, cat, tbl := newExpireReplayTable(t, nil)
+               tbl, s1 := appendOnBranch(t, tbl, MainBranch, "s1")
+               appendOnBranch(t, tbl, MainBranch, "s2")
+               peerUpdate(t, cat, NewSetSnapshotRefUpdate("keep", s1, 
BranchRef, 0, 0, 0))
+               tbl = newOCCTable(t, cat.metadata, fs, cat)
+               time.Sleep(time.Millisecond)
+
+               tx := tbl.NewTransaction()
+               require.NoError(t, tx.ExpireSnapshots(WithOlderThan(0), 
WithRetainLast(1)))
+               staged, err := tx.StagedTable()
+               require.NoError(t, err)
+               require.Len(t, staged.Metadata().Snapshots(), 2, "keep protects 
s1, so the expiry stages nothing")
+               stageFiles(t, tx, "a")
+               a := stagedHead(t, tx, MainBranch)
+
+               peerUpdate(t, cat, NewRemoveSnapshotRefUpdate("keep"))
+               p := peerAppend(t, fs, cat, MainBranch, "p")
+
+               committed, err := tx.Commit(t.Context())
+               require.NoError(t, err)
+
+               meta := committed.Metadata()
+               assert.Equal(t, sortedIDs(p, a), survivors(meta))
+               assert.Equal(t, []string{"a", "p", "s1", "s2"}, branchFiles(t, 
fs, meta, MainBranch))
+       })
+}
+
+type recordingCatalog struct {
+       *reqValidatingCatalog
+       updates [][]Update
+       loads   int
+
+       before func(attempt int)
+}
+
+func (c *recordingCatalog) CommitTable(ctx context.Context, ident Identifier, 
reqs []Requirement, updates []Update) (Metadata, string, error) {
+       if c.before != nil {
+               c.before(len(c.updates))
+       }
+       c.updates = append(c.updates, updates)
+
+       return c.reqValidatingCatalog.CommitTable(ctx, ident, reqs, updates)
+}
+
+func (c *recordingCatalog) LoadTable(ctx context.Context, ident Identifier) 
(*Table, error) {
+       c.loads++
+
+       return c.reqValidatingCatalog.LoadTable(ctx, ident)
+}
+
+func removedSnapshots(updates []Update) []int64 {
+       var ids []int64
+       for _, u := range updates {
+               if r, ok := u.(*removeSnapshotsUpdate); ok {
+                       ids = append(ids, r.SnapshotIDs...)
+               }
+       }
+
+       return ids
+}
+
+func TestExpireReplayKeepsExpiryThatReplannedToNothing(t *testing.T) {
+       stage := func(t *testing.T, numRetries string) (*memIO, 
*recordingCatalog, *Transaction, int64, int64) {
+               t.Helper()
+               fs, inner, tbl := newExpireReplayTable(t, 
iceberg.Properties{CommitNumRetriesKey: numRetries})
+               tbl, s1 := appendOnBranch(t, tbl, MainBranch, "s1")
+               tbl, s2 := appendOnBranch(t, tbl, MainBranch, "s2")
+               time.Sleep(time.Millisecond)
+
+               cat := &recordingCatalog{reqValidatingCatalog: inner}
+               tx := newOCCTable(t, tbl.Metadata(), fs, cat).NewTransaction()
+               require.NoError(t, tx.ExpireSnapshots(WithOlderThan(0), 
WithRetainLast(1)))
+               require.NoError(t, tx.SetProperties(iceberg.Properties{"k": 
"v"}))
+               inner.failFirst = inner.attempts.Load() + 2
+
+               return fs, cat, tx, s1, s2
+       }
+
+       t.Run("retry", func(t *testing.T) {
+               synctest.Test(t, func(t *testing.T) {
+                       _, cat, tx, s1, s2 := stage(t, "2")
+                       cat.before = func(attempt int) {
+                               switch attempt {
+                               case 0:
+                                       peerUpdate(t, cat.reqValidatingCatalog, 
NewSetSnapshotRefUpdate("hold", s1, TagRef, 0, 0, 0))
+                               case 1:
+                                       peerUpdate(t, cat.reqValidatingCatalog, 
NewRemoveSnapshotRefUpdate("hold"))
+                               }
+                       }
+
+                       committed, err := tx.Commit(t.Context())
+                       require.NoError(t, err)
+
+                       require.Len(t, cat.updates, 3)
+                       assert.Equal(t, []int64{s1}, 
removedSnapshots(cat.updates[0]))
+                       assert.Empty(t, removedSnapshots(cat.updates[1]))
+                       assert.Equal(t, []int64{s1}, 
removedSnapshots(cat.updates[2]))
+                       assert.Equal(t, []int64{s2}, 
survivors(committed.Metadata()))
+                       assert.Equal(t, "v", committed.Properties()["k"])
+               })
+       })
+
+       t.Run("resume", func(t *testing.T) {
+               synctest.Test(t, func(t *testing.T) {
+                       fs, cat, tx, s1, s2 := stage(t, "0")
+
+                       _, err := tx.Commit(t.Context())
+                       require.ErrorIs(t, err, ErrCommitFailed)
+
+                       peerUpdate(t, cat.reqValidatingCatalog, 
NewSetSnapshotRefUpdate("hold", s1, TagRef, 0, 0, 0))
+                       _, err = tx.Commit(t.Context())
+                       require.ErrorIs(t, err, ErrCommitFailed)
+                       require.NotErrorIs(t, err, ErrTransactionUnusable)
+
+                       peerUpdate(t, cat.reqValidatingCatalog, 
NewRemoveSnapshotRefUpdate("hold"))
+                       p := peerAppend(t, fs, cat.reqValidatingCatalog, 
MainBranch, "p")
+                       committed, err := tx.Commit(t.Context())
+                       require.NoError(t, err)
+
+                       require.Len(t, cat.updates, 3)
+                       assert.Equal(t, []int64{s1}, 
removedSnapshots(cat.updates[0]))
+                       assert.Empty(t, removedSnapshots(cat.updates[1]))
+                       assert.ElementsMatch(t, []int64{s1, s2}, 
removedSnapshots(cat.updates[2]))
+                       assert.Equal(t, []int64{p}, 
survivors(committed.Metadata()))
+                       assert.Equal(t, "v", committed.Properties()["k"])
+               })
+       })
+}
+
+func TestExpireReplayToNothingCompletesWithoutCommit(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               fs, cat, tbl := newExpireReplayTable(t, nil)
+               tbl, s1 := appendOnBranch(t, tbl, MainBranch, "s1")
+               tbl, _ = appendOnBranch(t, tbl, MainBranch, "s2")
+               time.Sleep(time.Millisecond)
+               list := tbl.Metadata().SnapshotByID(s1).ManifestList
+
+               tx := tbl.NewTransaction()
+               require.NoError(t, tx.ExpireSnapshots(WithOlderThan(0), 
WithRetainLast(1)))
+
+               // Inject a catalog conflict because no ref moved.
+               peerUpdate(t, cat, NewRemoveSnapshotsUpdate([]int64{s1}, false))
+               attempts := cat.attempts.Load()
+               cat.failFirst = attempts + 1
+               peerMeta := cat.metadata
+
+               committed, err := tx.Commit(t.Context())
+               require.NoError(t, err)
+
+               assert.Equal(t, attempts+1, cat.attempts.Load(), "the replay 
must not reach the catalog")
+               assert.Same(t, peerMeta, cat.metadata)
+               assert.Same(t, peerMeta, committed.Metadata())
+               assert.Contains(t, fs.files, list, "the peer's expiry owns the 
cleanup")
+
+               _, err = tx.Commit(t.Context())
+               assert.EqualError(t, err, "transaction has already been 
committed")
+       })
+}
+
+func TestExpirationAssertionsCoverPlansWithUpdates(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               _, cat, tbl := newExpireReplayTable(t, nil)
+               tbl, _ = appendOnBranch(t, tbl, MainBranch, "s0")
+               tbl, s1 := appendOnBranch(t, tbl, MainBranch, "s1")
+               tbl, s2 := appendOnBranch(t, tbl, MainBranch, "s2")
+               peerUpdate(t, cat,
+                       NewSetSnapshotRefUpdate("t", s2, TagRef, 0, 0, 0),
+                       NewSetSnapshotRefUpdate("b", s1, BranchRef, 0, 0, 0))
+               base := cat.metadata
+               time.Sleep(time.Millisecond)
+
+               noOp := New(tbl.Identifier(), base, "", nil, 
cat).NewTransaction()
+               require.NoError(t, 
noOp.ExpireSnapshots(WithOlderThan(time.Hour)))
+               require.NoError(t, noOp.SetProperties(iceberg.Properties{"k": 
"v"}))
+               commit, err := noOp.TableCommit()
+               require.NoError(t, err)
+               assert.Equal(t, []Requirement{
+                       assertBranchRefSnapshotID(MainBranch, &s2),
+                       AssertTableUUID(base.TableUUID()),
+               }, commit.Requirements)
+
+               tx := New(tbl.Identifier(), base, "", nil, cat).NewTransaction()
+               require.NoError(t, tx.ExpireSnapshots(WithOlderThan(time.Hour)))
+               require.NoError(t, tx.AssertRefSnapshotID("b"))
+               require.NoError(t, tx.ExpireSnapshots(WithOlderThan(0), 
WithRetainLast(1)))
+               commit, err = tx.TableCommit()
+               require.NoError(t, err)
+               assert.Equal(t, []Requirement{
+                       AssertRefSnapshotID("b", &s1),
+                       assertBranchRefSnapshotID(MainBranch, &s2),
+                       AssertTableUUID(base.TableUUID()),
+                       AssertRefSnapshotID("t", &s2),
+               }, commit.Requirements)
+       })
+}
+
+func TestExpirationAssertionsSkipCreatedCommitBranch(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               fs, cat, tbl := newExpireReplayTable(t, nil)
+               tbl, _ = appendOnBranch(t, tbl, MainBranch, "s0")
+               _, s1 := appendOnBranch(t, tbl, MainBranch, "s1")
+               base := cat.metadata
+               time.Sleep(time.Millisecond)
+
+               tx, err := newOCCTable(t, base, fs, 
cat).NewTransactionOnBranch("audit")
+               require.NoError(t, err)
+               stageFiles(t, tx, "a")
+               require.NoError(t, tx.ExpireSnapshots(WithOlderThan(0), 
WithRetainLast(1)))
+               require.Contains(t, stagedWalkedRefs(tx.expirations), "audit", 
"the expiry must stage updates and walk the new branch")
+
+               commit, err := tx.TableCommit()
+               require.NoError(t, err)
+               assert.Equal(t, []Requirement{
+                       assertBranchRefSnapshotID("audit", nil),
+                       AssertTableUUID(base.TableUUID()),
+                       AssertRefSnapshotID(MainBranch, &s1),
+               }, commit.Requirements)
+       })
+}
+
+func TestExpireReplayAssertsWalkedRefFromAttemptBase(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               fs, inner, tbl := newExpireReplayTable(t, nil)
+               tbl, s1 := appendOnBranch(t, tbl, MainBranch, "s1")
+               tbl, s2 := appendOnBranch(t, tbl, MainBranch, "s2")
+               tbl, s3 := appendOnBranch(t, tbl, MainBranch, "s3")
+               _, s4 := appendOnBranch(t, tbl, MainBranch, "s4")
+               peerUpdate(t, inner, NewSetSnapshotRefUpdate("t", s3, TagRef, 
0, 0, 0))
+               time.Sleep(time.Millisecond)
+
+               cat := &recordingCatalog{reqValidatingCatalog: inner}
+               tx := newOCCTable(t, inner.metadata, fs, cat).NewTransaction()
+               require.NoError(t, tx.ExpireSnapshots(WithOlderThan(0), 
WithRetainLast(1)))
+               staged, err := tx.StagedTable()
+               require.NoError(t, err)
+               require.Equal(t, sortedIDs(s3, s4), 
survivors(staged.Metadata()))
+
+               // The staged plan keeps s3 for tag t. A peer moves t to s2, 
which that
+               // plan expires, and moves it again after the first refresh.
+               peerUpdate(t, inner, NewSetSnapshotRefUpdate("t", s2, TagRef, 
0, 0, 0))
+               cat.before = func(attempt int) {
+                       if attempt == 1 {
+                               peerUpdate(t, inner, 
NewSetSnapshotRefUpdate("t", s1, TagRef, 0, 0, 0))
+                       }
+               }
+
+               committed, err := tx.Commit(t.Context())
+               require.NoError(t, err)
+
+               meta := committed.Metadata()
+               assert.Equal(t, s1, refHead(t, meta, "t"))
+               assert.Equal(t, sortedIDs(s1, s4), survivors(meta))
+               assert.Len(t, cat.updates, 3)
+               assert.Contains(t, cat.lastReqs, AssertRefSnapshotID("t", &s1))
+       })
+}
+
+func TestExpireReplayKeepsAgedRefAfterPeerAdvancesIt(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               fs, cat, tbl := newExpireReplayTable(t, 
iceberg.Properties{MaxRefAgeMsKey: "3600000"})
+               tbl, b1 := appendOnBranch(t, tbl, MainBranch, "b1")
+               tbl, d1 := appendOnBranch(t, tbl, "dev", "d1")
+               time.Sleep(2 * time.Hour)
+
+               tx := tbl.NewTransaction()
+               require.NoError(t, tx.ExpireSnapshots())
+               staged, err := tx.StagedTable()
+               require.NoError(t, err)
+               require.Nil(t, staged.Metadata().SnapshotByName("dev"), "the 
staged plan removes the aged ref")
+               require.NoError(t, tx.SetProperties(iceberg.Properties{"k": 
"v"}))
+
+               p := peerAppend(t, fs, cat, "dev", "p")
+
+               committed, err := tx.Commit(t.Context())
+               require.NoError(t, err)
+
+               meta := committed.Metadata()
+               assert.Equal(t, p, refHead(t, meta, "dev"))
+               assert.Equal(t, d1, parentOf(t, meta, p))
+               assert.Equal(t, b1, refHead(t, meta, MainBranch))
+               assert.Equal(t, "v", committed.Properties()["k"])
+       })
+}
+
+func TestExpireReplayFailsWhenPeerDisablesGC(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               _, cat, tbl := newExpireReplayTable(t, nil)
+               tbl, _ = appendOnBranch(t, tbl, MainBranch, "s1")
+               tbl, _ = appendOnBranch(t, tbl, MainBranch, "s2")
+               time.Sleep(time.Millisecond)
+
+               tx := tbl.NewTransaction()
+               require.NoError(t, tx.ExpireSnapshots(WithOlderThan(0), 
WithRetainLast(1)))
+               // Inject a conflict because no requirement checks this 
property.
+               peerUpdate(t, cat, 
NewSetPropertiesUpdate(iceberg.Properties{GCEnabledKey: "false"}))
+               cat.failFirst = cat.attempts.Load() + 1
+               before := cat.metadata
+
+               _, err := tx.Commit(t.Context())
+               require.ErrorIs(t, err, ErrTransactionUnusable)
+               require.NotErrorIs(t, err, ErrCommitFailed)
+               assert.ErrorContains(t, err, "GC is disabled")
+               assert.Same(t, before, cat.metadata)
+       })
+}
+
+func TestExpireReplayNoOpExpiryIgnoresDisabledGC(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               fs, cat, tbl := newExpireReplayTable(t, nil)
+               tbl, s1 := appendOnBranch(t, tbl, MainBranch, "s1")
+               time.Sleep(time.Millisecond)
+
+               tx := tbl.NewTransaction()
+               stageFiles(t, tx, "a")
+               require.NoError(t, tx.ExpireSnapshots(WithOlderThan(0), 
WithRetainLast(10)))
+               a := stagedHead(t, tx, MainBranch)
+
+               peerUpdate(t, cat, 
NewSetPropertiesUpdate(iceberg.Properties{GCEnabledKey: "false"}))
+               p := peerAppend(t, fs, cat, MainBranch, "p")
+
+               committed, err := tx.Commit(t.Context())
+               require.NoError(t, err)
+
+               meta := committed.Metadata()
+               assert.Equal(t, sortedIDs(s1, p, a), survivors(meta))
+               assert.Equal(t, []string{"a", "p", "s1"}, branchFiles(t, fs, 
meta, MainBranch))
+       })
+}
+
+// The expiry enables replay of the staged rollback on retry.
+func TestExpireReplayFailsWhenStagedUpdateDoesNotApply(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               fs, inner, tbl := newExpireReplayTable(t, nil)
+               tbl, _ = appendOnBranch(t, tbl, MainBranch, "s1")
+               tbl, s2 := appendOnBranch(t, tbl, MainBranch, "s2")
+               tbl, _ = appendOnBranch(t, tbl, MainBranch, "s3")
+
+               cat := &recordingCatalog{reqValidatingCatalog: inner}
+               tx := newOCCTable(t, tbl.Metadata(), fs, cat).NewTransaction()
+               require.NoError(t, tx.RollbackToSnapshot(s2))
+               require.NoError(t, tx.ExpireSnapshots(WithOlderThan(time.Hour)))
+
+               // Keep main at s3 so the rollback assertion still holds after 
refresh.
+               peerUpdate(t, inner, NewRemoveSnapshotsUpdate([]int64{s2}, 
false))
+               inner.failFirst = inner.attempts.Load() + 1
+               before := inner.metadata
+
+               _, err := tx.Commit(t.Context())
+               require.ErrorIs(t, err, ErrTransactionUnusable)
+               require.NotErrorIs(t, err, ErrCommitFailed)
+               require.ErrorIs(t, err, ErrSnapshotNotFound)
+               assert.ErrorContains(t, err, "staged update does not apply 
after refresh: apply set-snapshot-ref")
+               assert.Len(t, cat.updates, 1)
+               assert.Equal(t, 1, cat.loads)
+               assert.Same(t, before, inner.metadata)
+
+               _, err = tx.Commit(t.Context())
+               require.ErrorIs(t, err, ErrTransactionUnusable)
+               assert.Len(t, cat.updates, 1, "an unusable transaction must not 
commit")
+               assert.Equal(t, 1, cat.loads, "an unusable transaction must not 
refresh")
+       })
+}
+
+func TestExpireReplayKeepsExplicitPin(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               fs, cat, tbl := newExpireReplayTable(t, nil)
+               tbl, _ = appendOnBranch(t, tbl, MainBranch, "s1")
+               tbl, _ = appendOnBranch(t, tbl, MainBranch, "s2")
+               time.Sleep(time.Millisecond)
+
+               tx := tbl.NewTransaction()
+               require.NoError(t, tx.AssertRefSnapshotID(MainBranch))
+               require.NoError(t, tx.ExpireSnapshots(WithOlderThan(0), 
WithRetainLast(1)))
+               peerAppend(t, fs, cat, MainBranch, "p")
+               before := cat.metadata
+
+               _, err := tx.Commit(t.Context())
+               require.ErrorIs(t, err, ErrTransactionUnusable)
+               assert.Contains(t, err.Error(), "requirement no longer holds 
after refresh")
+               assert.Same(t, before, cat.metadata)
+       })
+}
+
+func manifestListsOf(meta Metadata, ids ...int64) []string {
+       lists := make([]string, len(ids))
+       for i, id := range ids {
+               lists[i] = meta.SnapshotByID(id).ManifestList
+       }
+
+       return lists
+}
+
+func TestExpireReplayCleansUpReplannedRemoval(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               fs, cat, tbl := newExpireReplayTable(t, nil)
+               tbl, _ = appendOnBranch(t, tbl, MainBranch, "s1")
+               tbl, _ = appendOnBranch(t, tbl, MainBranch, "s2")
+               tbl, s3 := appendOnBranch(t, tbl, MainBranch, "s3")
+               time.Sleep(time.Millisecond)
+
+               tx := tbl.NewTransaction()
+               require.NoError(t, tx.ExpireSnapshots(WithOlderThan(0), 
WithRetainLast(2)))
+               p := peerAppend(t, fs, cat, MainBranch, "p")
+
+               committed, err := tx.Commit(t.Context())
+               require.NoError(t, err)
+
+               meta := committed.Metadata()
+               assert.Equal(t, sortedIDs(s3, p), survivors(meta))
+               assertOnlyManifestList(t, fs, manifestListsOf(meta, s3, p)...)
+       })
+}
+
+func TestExpireReplayCleansUpRebuiltSnapshotItExpires(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               fs, cat, tbl := newExpireReplayTable(t, nil)
+               tbl, _ = appendOnBranch(t, tbl, MainBranch, "b1")
+               tbl, _ = appendOnBranch(t, tbl, MainBranch, "b2")
+
+               tx := tbl.NewTransaction()
+               stageFiles(t, tx, "a1")
+               a1 := stagedHead(t, tx, MainBranch)
+               stageFiles(t, tx, "a2")
+               a2 := stagedHead(t, tx, MainBranch)
+               time.Sleep(time.Millisecond)
+               require.NoError(t, tx.ExpireSnapshots(WithOlderThan(0), 
WithRetainLast(1)))
+               staged, err := tx.StagedTable()
+               require.NoError(t, err)
+               require.Nil(t, staged.Metadata().SnapshotByID(a1), "the staged 
plan expires a1")
+
+               peerAppend(t, fs, cat, MainBranch, "p")
+
+               committed, err := tx.Commit(t.Context())
+               require.NoError(t, err)
+
+               meta := committed.Metadata()
+               assert.Equal(t, []int64{a2}, survivors(meta))
+               assert.Equal(t, a1, parentOf(t, meta, a2))
+               assert.Equal(t, []string{"a1", "a2", "b1", "b2", "p"}, 
branchFiles(t, fs, meta, MainBranch))
+               assertOnlyManifestList(t, fs, manifestListsOf(meta, a2)...)
+       })
+}
+
+// The retry rebuilds a on the peer's head, but a keeps its staging timestamp,
+// which is more than a minute before the peer's snapshot.
+func TestExpireReplayRemovesRebuiltListWhenApplyFails(t *testing.T) {
+       synctest.Test(t, func(t *testing.T) {
+               fs, cat, tbl := newExpireReplayTable(t, nil)
+               tbl, _ = appendOnBranch(t, tbl, MainBranch, "b1")
+
+               tx := tbl.NewTransaction()
+               stageFiles(t, tx, "a")
+               require.NoError(t, tx.ExpireSnapshots(WithOlderThan(time.Hour)))
+
+               time.Sleep(2 * time.Minute)
+               peerAppend(t, fs, cat, MainBranch, "p")
+               files := slices.Sorted(maps.Keys(fs.files))
+
+               _, err := tx.Commit(t.Context())
+               require.ErrorIs(t, err, ErrTransactionUnusable)
+               require.NotErrorIs(t, err, ErrCommitFailed)
+               assert.ErrorContains(t, err, "apply add-snapshot: invalid 
snapshot timestamp")
+               assert.Equal(t, files, slices.Sorted(maps.Keys(fs.files)), "the 
staged files are kept and rebuilt files are removed")
+       })
+}
diff --git a/table/table.go b/table/table.go
index f0ebc4dd3..2fc31a6c3 100644
--- a/table/table.go
+++ b/table/table.go
@@ -55,11 +55,16 @@ import (
 // again retries the conflict unless the error also matches
 // ErrTransactionUnusable.
 //
-// After a refresh, a stale requirement, a validator conflict and a deleted
-// commit branch match ErrCommitFailed. A commit branch that became a tag, a
-// replaced table, a diverged base snapshot and files to rewrite that a
-// concurrent commit removed do not, so a retry loop keyed on ErrCommitFailed
-// stops.
+// After refresh, these failures match ErrCommitFailed: a stale requirement,
+// a validator conflict, a deleted commit branch, or a replanned expiry that
+// changes whether a non-main commit branch is removed.
+//
+// These failures do not: a commit branch that became a tag, a replaced table,
+// a diverged base snapshot, or files to rewrite that a concurrent commit
+// removed.
+// For transactions with snapshot expiry, disabled GC or a staged update that
+// cannot apply after refresh also do not match. An ErrCommitFailed retry loop
+// stops on these failures.
 var ErrCommitFailed = errors.New("commit failed, refresh and try again")
 
 // ErrCommitRejected means the catalog rejected a commit without writing
@@ -83,8 +88,10 @@ var ErrCommitRejected = errors.New("commit rejected")
 // non-replayable commit whose branch has moved), a validator conflict with a
 // concurrent commit, a diverged base snapshot, files to rewrite that a
 // concurrent commit removed, a commit branch that became a tag or was
-// deleted, and a replaced table. Only the commit branch's head assertion is
-// rebased.
+// deleted, or a replaced table. With snapshot expiry, they also include
+// disabled GC, replanning that changes whether a non-main commit branch is
+// removed, and a staged update that cannot apply after refresh. Only the
+// commit branch's head assertion is rebased.
 var ErrTransactionUnusable = errors.New("transaction cannot be committed 
again")
 
 // ErrWriteIORequired is returned by write paths when the table's file system
@@ -606,6 +613,8 @@ type commitOpts struct {
        // failed without committing. A resumed commit refreshes and replays
        // before its first submission, as the next retry attempt would.
        resumes int
+
+       replay *stagedReplay
 }
 
 type commitOption func(*commitOpts)
@@ -638,6 +647,10 @@ func withCommitResume(resumes int) commitOption {
        return func(o *commitOpts) { o.resumes = resumes }
 }
 
+func withCommitReplay(replay *stagedReplay) commitOption {
+       return func(o *commitOpts) { o.replay = replay }
+}
+
 func (t Table) doCommit(ctx context.Context, updates []Update, reqs 
[]Requirement, opts ...commitOption) (*Table, error) {
        var co commitOpts
        for _, apply := range opts {
@@ -735,7 +748,7 @@ func (t Table) doCommit(ctx context.Context, updates 
[]Update, reqs []Requiremen
        // equals t.metadata (so the conflict context's concurrent-snapshot
        // walk is empty and validators short-circuit). On subsequent
        // attempts it is the freshly-loaded post-conflict state.
-       current := t.metadata
+       current, currentLoc := t.metadata, t.metadataLocation
 
        // numRetries counts retries; total attempts = 1 initial + numRetries.
        totalAttempts := cfg.numRetries + 1
@@ -798,7 +811,7 @@ func (t Table) doCommit(ctx context.Context, updates 
[]Update, reqs []Requiremen
                                        return nil, fmt.Errorf("%w: table was 
replaced: %w", ErrTransactionUnusable, err)
                                }
                        }
-                       current = fresh.metadata
+                       current, currentLoc = fresh.metadata, 
fresh.metadataLocation
                        reqs = rewriteRefSnapshotRequirements(reqs, co.branch, 
current, pinned)
 
                        if err := validateNonRebasedRequirements(reqs, 
co.branch, pinned, current); err != nil {
@@ -831,7 +844,18 @@ func (t Table) doCommit(ctx context.Context, updates 
[]Update, reqs []Requiremen
                        // submit an older snapshot whose manifests it now 
counts as
                        // superseded and deletes on success.
                        forceRebuild := co.resumes > 0 && attempt == 0 && 
!co.noReplay
-                       rebuiltUpdates, orphaned, rebuildErr := 
rebuildSnapshotUpdates(retryCtx, updates, current, co.branch, wfs, 
attemptBase+int(attempt), forceRebuild)
+                       var (
+                               rebuiltUpdates []Update
+                               orphaned       []string
+                               rebuildErr     error
+                               step           = "rebuild manifest list"
+                       )
+                       if co.replay != nil {
+                               step = "replay staged updates"
+                               rebuiltUpdates, orphaned, rebuildErr = 
co.replay.replay(retryCtx, current, wfs, attemptBase+int(attempt), forceRebuild)
+                       } else {
+                               rebuiltUpdates, orphaned, rebuildErr = 
rebuildSnapshotUpdates(retryCtx, updates, current, co.branch, wfs, 
attemptBase+int(attempt), forceRebuild)
+                       }
                        // A failed rebuild still owns the lists it wrote 
before failing.
                        for list := range stagedManifestLists(rebuiltUpdates) {
                                if _, ok := staged[list]; !ok {
@@ -839,16 +863,26 @@ func (t Table) doCommit(ctx context.Context, updates 
[]Update, reqs []Requiremen
                                }
                        }
                        if rebuildErr != nil {
+                               if errors.Is(rebuildErr, 
ErrTransactionUnusable) {
+                                       return nil, rebuildErr
+                               }
                                // Replaced files missing from the branch head 
stay missing, so
                                // every later attempt fails the same way.
                                if errors.Is(rebuildErr, ErrCommitDiverged) {
                                        return nil, fmt.Errorf("%w: files to 
rewrite were removed: %w", ErrTransactionUnusable, rebuildErr)
                                }
 
-                               return nil, fmt.Errorf("rebuild manifest list 
for retry attempt %d: %w", attempt, rebuildErr)
+                               return nil, fmt.Errorf("%s for retry attempt 
%d: %w", step, attempt, rebuildErr)
                        }
                        orphanedManifests = append(orphanedManifests, 
orphaned...)
                        updates = rebuiltUpdates
+
+                       // A peer may have already expired the snapshots in the 
staged plan.
+                       if co.replay != nil && len(updates) == 0 {
+                               committed = true
+
+                               return t.withMetadata(current, currentLoc), nil
+                       }
                }
 
                // Pre-flight client-side conflict validation. Producers can
@@ -898,7 +932,14 @@ func (t Table) doCommit(ctx context.Context, updates 
[]Update, reqs []Requiremen
                        return nil, context.Cause(retryCtx)
                }
 
-               newMeta, newLoc, err = t.cat.CommitTable(retryCtx, 
slices.Clone(t.identifier), reqs, updates)
+               // Keep per-attempt expiry assertions out of reqs so refresh 
does not
+               // treat them as fixed staged requirements.
+               submitReqs := reqs
+               if co.replay != nil {
+                       submitReqs = append(slices.Clone(reqs), 
expirationAssertions(current, co.replay.branch, co.replay.walkedRefs, reqs)...)
+               }
+
+               newMeta, newLoc, err = t.cat.CommitTable(retryCtx, 
slices.Clone(t.identifier), submitReqs, updates)
                if err == nil {
                        committed = true
                        attemptsUsed = int64(attempt) + 1
@@ -962,10 +1003,14 @@ func (t Table) doCommit(ctx context.Context, updates 
[]Update, reqs []Requiremen
                }
        }
 
+       return t.withMetadata(newMeta, newLoc), nil
+}
+
+func (t Table) withMetadata(meta Metadata, loc string) *Table {
        next := New(
                t.identifier,
-               newMeta,
-               newLoc,
+               meta,
+               loc,
                t.fsF,
                t.cat,
                withReporterState(t.reporter, t.reporterSet),
@@ -976,7 +1021,7 @@ func (t Table) doCommit(ctx context.Context, updates 
[]Update, reqs []Requiremen
        )
        next.manifestContentCache = 
reuseManifestContentCache(t.manifestContentCache, next.manifestContentCache)
 
-       return next, nil
+       return next
 }
 
 // rewriteRefSnapshotRequirements returns a copy of reqs with every
@@ -996,9 +1041,9 @@ func (t Table) doCommit(ctx context.Context, updates 
[]Update, reqs []Requiremen
 // branch check that follows rejects the commit.
 //
 // Assertions on refs in pinned guard the commit (an explicit
-// Transaction.AssertRefSnapshotID, a rollback, or snapshot expiry) and
-// are never rewritten: a ref that has changed must fail the commit, not
-// be replayed against the new head.
+// Transaction.AssertRefSnapshotID or a rollback) and are never
+// rewritten: a ref that has changed must fail the commit, not be
+// replayed against the new head.
 func rewriteRefSnapshotRequirements(reqs []Requirement, branch string, fresh 
Metadata, pinned map[string]struct{}) []Requirement {
        if branch == "" || fresh == nil {
                return reqs
@@ -1184,46 +1229,48 @@ func rebuildSnapshotUpdates(ctx context.Context, 
updates []Update, freshMeta Met
                        continue
                }
 
-               // Skip only when nothing upstream changed: the running head 
still
-               // matches this snapshot's recorded parent, no earlier snapshot 
in this
-               // chain was rebuilt, and the table-wide values the rebuild 
recomputes
-               // are still acceptable. Saves an unnecessary S3 write.
-               if !chainRebuilt && freshHead != nil && 
su.Snapshot.ParentSnapshotID != nil &&
-                       *su.Snapshot.ParentSnapshotID == freshHead.SnapshotID &&
-                       stagedSnapshotStillValid(su.Snapshot, freshMeta) {
-                       // Already parented on the running head; it becomes the 
parent of
-                       // the next staged snapshot in the chain.
-                       freshHead = su.Snapshot
-
-                       continue
-               }
-
-               oldManifestList := su.Snapshot.ManifestList
-
-               newSnap, rebuildErr := su.rebuildManifestList(ctx, freshMeta, 
freshHead, fs, attempt)
+               next, rebuildErr := rebuildStagedSnapshot(ctx, su, freshMeta, 
freshHead, fs, attempt, chainRebuilt)
                if rebuildErr != nil {
                        return result, orphanedPaths, rebuildErr
                }
 
-               result[i] = &addSnapshotUpdate{
-                       baseUpdate:          su.baseUpdate,
-                       Snapshot:            newSnap,
-                       ownManifests:        su.ownManifests,
-                       rebuildManifestList: su.rebuildManifestList,
-                       written:             su.written,
+               freshHead = next.Snapshot
+               if next == su {
+                       continue
                }
 
-               // The old manifest list is now an orphaned object in object 
storage.
-               orphanedPaths = append(orphanedPaths, oldManifestList)
+               result[i] = next
 
-               // Chain the next staged snapshot onto the one just rebuilt.
-               freshHead = newSnap
+               orphanedPaths = append(orphanedPaths, su.Snapshot.ManifestList)
                chainRebuilt = true
        }
 
        return result, orphanedPaths, nil
 }
 
+// Reuse requires an unchanged parent, no earlier rebuild in the chain, and
+// valid table-wide values. This avoids an unnecessary S3 write.
+func rebuildStagedSnapshot(ctx context.Context, su *addSnapshotUpdate, meta 
Metadata, head *Snapshot, fs icebergio.WriteFileIO, attempt int, chainRebuilt 
bool) (*addSnapshotUpdate, error) {
+       if !chainRebuilt && head != nil && su.Snapshot.ParentSnapshotID != nil 
&&
+               *su.Snapshot.ParentSnapshotID == head.SnapshotID &&
+               stagedSnapshotStillValid(su.Snapshot, meta) {
+               return su, nil
+       }
+
+       newSnap, err := su.rebuildManifestList(ctx, meta, head, fs, attempt)
+       if err != nil {
+               return nil, err
+       }
+
+       return &addSnapshotUpdate{
+               baseUpdate:          su.baseUpdate,
+               Snapshot:            newSnap,
+               ownManifests:        su.ownManifests,
+               rebuildManifestList: su.rebuildManifestList,
+               written:             su.written,
+       }, nil
+}
+
 const (
        maxRetryDurationMs = uint64(math.MaxInt64 / int64(time.Millisecond))
        maxRetryCount      = uint64(math.MaxUint32)
diff --git a/table/table_test.go b/table/table_test.go
index 25d9a105a..c76183041 100644
--- a/table/table_test.go
+++ b/table/table_test.go
@@ -36,6 +36,7 @@ import (
        "strconv"
        "strings"
        "testing"
+       "testing/synctest"
        "time"
 
        "github.com/apache/arrow-go/v18/arrow"
@@ -2241,7 +2242,8 @@ type validatingCatalog struct {
 }
 
 func (m *validatingCatalog) LoadTable(ctx context.Context, ident 
table.Identifier) (*table.Table, error) {
-       return nil, nil
+       return table.New(ident, m.metadata, "",
+               func(context.Context) (iceio.IO, error) { return 
iceio.LocalFS{}, nil }, m), nil
 }
 
 func (m *validatingCatalog) CommitTable(ctx context.Context, ident 
table.Identifier, reqs []table.Requirement, updates []table.Update) 
(table.Metadata, string, error) {
@@ -2262,166 +2264,120 @@ func (m *validatingCatalog) CommitTable(ctx 
context.Context, ident table.Identif
        return meta, "", nil
 }
 
-// TestExpireSnapshotsRejectsOnRefRollback verifies that ExpireSnapshots fails
-// when a ref is rolled back to an ancestor snapshot concurrently.
-//
-// Scenario:
-//   - main -> snapshot 5 (newest), chain: 5 <- 4 <- 3 <- 2 <- 1
-//   - ExpireSnapshots calculates: keep {5, 4, 3}, delete {2, 1}
-//   - Concurrently, client rolls main -> snapshot 2
-//   - Without assertion: would delete snapshots 1, leaving main with only 1 
accessible snapshot
-//   - With assertion: commit fails because main's snapshot ID changed
-func (t *TableWritingTestSuite) TestExpireSnapshotsRejectsOnRefRollback() {
-       fs := iceio.LocalFS{}
-
-       files := make([]string, 0)
-       for i := range 5 {
-               filePath := 
fmt.Sprintf("%s/expire_reject_rollback_v%d/data-%d.parquet", t.location, 
t.formatVersion, i)
-               t.writeParquet(fs, filePath, t.arrTablePromotedTypes)
-               files = append(files, filePath)
-       }
+// A millisecond sleep makes earlier snapshots older than the expiry cutoff.
+func (t *TableWritingTestSuite) inBubble(f func()) {
+       outer := t.T()
+       synctest.Test(outer, func(st *testing.T) {
+               t.SetT(st)
+               defer t.SetT(outer)
+               f()
+       })
+}
 
-       ident := table.Identifier{"default", "expire_reject_rollback_v" + 
strconv.Itoa(t.formatVersion)}
+func (t *TableWritingTestSuite) newExpireRetryTable(name string, count int, 
sleepAfterFirst time.Duration) (*table.Table, *validatingCatalog) {
+       fs := iceio.LocalFS{}
+       ident := table.Identifier{"default", name + "_v" + 
strconv.Itoa(t.formatVersion)}
        meta, err := table.NewMetadata(t.tableSchemaPromotedTypes, 
iceberg.UnpartitionedSpec,
-               table.UnsortedSortOrder, t.location, 
iceberg.Properties{table.PropertyFormatVersion: strconv.Itoa(t.formatVersion)})
+               table.UnsortedSortOrder, t.location, iceberg.Properties{
+                       table.PropertyFormatVersion:   
strconv.Itoa(t.formatVersion),
+                       table.CommitNumRetriesKey:     "1",
+                       table.CommitMinRetryWaitMsKey: "1",
+                       table.CommitMaxRetryWaitMsKey: "2",
+               })
        t.Require().NoError(err)
 
        ctx := context.Background()
        cat := &validatingCatalog{meta}
-
-       tbl := table.New(
-               ident,
-               meta,
-               t.getMetadataLoc(),
-               func(ctx context.Context) (iceio.IO, error) {
-                       return fs, nil
-               },
-               cat,
-       )
-
-       // Create 5 snapshots
-       for i := range 5 {
+       tbl := table.New(ident, meta, t.getMetadataLoc(),
+               func(ctx context.Context) (iceio.IO, error) { return fs, nil }, 
cat)
+       for i := range count {
+               file := fmt.Sprintf("%s/%s_v%d/data-%d.parquet", t.location, 
name, t.formatVersion, i)
+               t.writeParquet(fs, file, t.arrTablePromotedTypes)
                tx := tbl.NewTransaction()
-               t.Require().NoError(tx.AddDataFilesByPath(ctx, files[i:i+1], 
nil, false))
+               t.Require().NoError(tx.AddDataFilesByPath(ctx, []string{file}, 
nil, false))
                tbl, err = tx.Commit(ctx)
                t.Require().NoError(err)
+               if i == 0 {
+                       time.Sleep(sleepAfterFirst)
+               }
        }
+       t.Require().Len(tbl.Metadata().Snapshots(), count)
 
-       t.Require().Equal(5, len(tbl.Metadata().Snapshots()))
-
-       // Get snapshot IDs for later use
-       snapshots := tbl.Metadata().Snapshots()
-       snapshot2 := snapshots[1] // Second snapshot (index 1)
-
-       // Start ExpireSnapshots transaction (will calculate based on current 
main -> snapshot 5)
-       tx := tbl.NewTransaction()
-       t.Require().NoError(tx.ExpireSnapshots(table.WithOlderThan(0), 
table.WithRetainLast(3)))
+       return tbl, cat
+}
 
-       // Simulate concurrent rollback: update catalog's metadata to point 
main -> snapshot 2
-       // This simulates another client rolling back main before 
ExpireSnapshots commits
-       rollbackUpdates := []table.Update{
-               table.NewSetSnapshotRefUpdate("main", snapshot2.SnapshotID, 
table.BranchRef, -1, -1, -1),
+func snapshotIDsOf(meta table.Metadata) []int64 {
+       var ids []int64
+       for _, snap := range meta.Snapshots() {
+               ids = append(ids, snap.SnapshotID)
        }
-       cat.metadata, _, err = cat.CommitTable(ctx, ident, nil, rollbackUpdates)
-       t.Require().NoError(err)
 
-       // Attempt to commit ExpireSnapshots - should fail due to 
AssertRefSnapshotID
-       _, err = tx.Commit(ctx)
-       t.Require().Error(err)
-       t.Require().Contains(err.Error(), "requirement failed")
-       t.Require().Contains(err.Error(), "main")
+       return ids
 }
 
-// TestExpireSnapshotsRejectsOnRefUpdate verifies that ExpireSnapshots fails
-// when a ref eligible for deletion is concurrently updated to a newer 
snapshot.
-//
 // Scenario:
-//   - tag1 -> old snapshot, eligible for deletion (maxRefAgeMs exceeded)
-//   - ExpireSnapshots decides to remove tag1
-//   - Concurrently, client updates tag1 -> newer snapshot (no longer eligible)
-//   - Without assertion: tag1 would be deleted despite being updated
-//   - With assertion: commit fails because tag1's snapshot ID changed
-func (t *TableWritingTestSuite) TestExpireSnapshotsRejectsOnRefUpdate() {
-       fs := iceio.LocalFS{}
+//   - main -> snapshot 5 (newest), chain: 5 <- 4 <- 3 <- 2 <- 1
+//   - Concurrently, client rolls main -> snapshot 2
+func (t *TableWritingTestSuite) TestExpireSnapshotsReplansAfterRefRollback() {
+       t.inBubble(func() {
+               tbl, cat := t.newExpireRetryTable("expire_replan_rollback", 5, 
0)
+               snapshots := tbl.Metadata().Snapshots()
+               time.Sleep(time.Millisecond)
 
-       files := make([]string, 0)
-       for i := range 3 {
-               filePath := 
fmt.Sprintf("%s/expire_reject_update_v%d/data-%d.parquet", t.location, 
t.formatVersion, i)
-               t.writeParquet(fs, filePath, t.arrTablePromotedTypes)
-               files = append(files, filePath)
-       }
+               ctx := context.Background()
+               tx := tbl.NewTransaction()
+               t.Require().NoError(tx.ExpireSnapshots(table.WithOlderThan(0), 
table.WithRetainLast(3)))
 
-       ident := table.Identifier{"default", "expire_reject_update_v" + 
strconv.Itoa(t.formatVersion)}
-       meta, err := table.NewMetadata(t.tableSchemaPromotedTypes, 
iceberg.UnpartitionedSpec,
-               table.UnsortedSortOrder, t.location, 
iceberg.Properties{table.PropertyFormatVersion: strconv.Itoa(t.formatVersion)})
-       t.Require().NoError(err)
+               var err error
+               cat.metadata, _, err = cat.CommitTable(ctx, tbl.Identifier(), 
nil, []table.Update{
+                       table.NewSetSnapshotRefUpdate("main", 
snapshots[1].SnapshotID, table.BranchRef, -1, -1, -1),
+               })
+               t.Require().NoError(err)
 
-       ctx := context.Background()
-       cat := &validatingCatalog{meta}
+               committed, err := tx.Commit(ctx)
+               t.Require().NoError(err)
+               t.Equal([]int64{snapshots[0].SnapshotID, 
snapshots[1].SnapshotID}, snapshotIDsOf(committed.Metadata()))
+               t.Equal(snapshots[1].SnapshotID, 
committed.Metadata().CurrentSnapshot().SnapshotID)
+               t.Equal(snapshotIDsOf(committed.Metadata()), 
snapshotIDsOf(cat.metadata))
+       })
+}
 
-       tbl := table.New(
-               ident,
-               meta,
-               t.getMetadataLoc(),
-               func(ctx context.Context) (iceio.IO, error) {
-                       return fs, nil
-               },
-               cat,
-       )
+// Scenario:
+//   - snapshot 1 is two hours older than snapshots 2 and 3
+//   - expiring-tag -> snapshot 1, with a max ref age of one hour
+func (t *TableWritingTestSuite) TestExpireSnapshotsReplansAfterRefUpdate() {
+       t.inBubble(func() {
+               tbl, cat := t.newExpireRetryTable("expire_replan_update", 3, 
2*time.Hour)
+               snapshots := tbl.Metadata().Snapshots()
+
+               ctx := context.Background()
+               maxRefAgeMs := time.Hour.Milliseconds()
+               var err error
+               cat.metadata, _, err = cat.CommitTable(ctx, tbl.Identifier(), 
nil, []table.Update{
+                       table.NewSetSnapshotRefUpdate("expiring-tag", 
snapshots[0].SnapshotID, table.TagRef, maxRefAgeMs, -1, -1),
+               })
+               t.Require().NoError(err)
+               tbl = table.New(tbl.Identifier(), cat.metadata, 
t.getMetadataLoc(),
+                       func(ctx context.Context) (iceio.IO, error) { return 
iceio.LocalFS{}, nil }, cat)
 
-       // Create 3 snapshots
-       for i := range 3 {
                tx := tbl.NewTransaction()
-               t.Require().NoError(tx.AddDataFilesByPath(ctx, files[i:i+1], 
nil, false))
-               tbl, err = tx.Commit(ctx)
+               
t.Require().NoError(tx.ExpireSnapshots(table.WithOlderThan(time.Hour), 
table.WithRetainLast(1)))
+               staged, err := tx.StagedTable()
                t.Require().NoError(err)
-       }
-       t.Require().Equal(3, len(tbl.Metadata().Snapshots()))
-
-       snapshots := tbl.Metadata().Snapshots()
-       oldSnapshot := snapshots[0]   // Oldest snapshot
-       newerSnapshot := snapshots[2] // Newest snapshot
-
-       // Create a tag pointing to the old snapshot with a short maxRefAgeMs
-       // This tag will be eligible for deletion
-       maxRefAgeMs := int64(1) // 1ms - will definitely be exceeded
-       tagUpdates := []table.Update{
-               table.NewSetSnapshotRefUpdate("expiring-tag", 
oldSnapshot.SnapshotID, table.TagRef, maxRefAgeMs, -1, -1),
-       }
-       cat.metadata, _, err = cat.CommitTable(ctx, ident, nil, tagUpdates)
-       t.Require().NoError(err)
+               
t.Require().Nil(staged.Metadata().SnapshotByName("expiring-tag"), "the staged 
plan removes the tag")
 
-       // Reload table with updated metadata
-       tbl = table.New(
-               ident,
-               cat.metadata,
-               t.getMetadataLoc(),
-               func(ctx context.Context) (iceio.IO, error) {
-                       return fs, nil
-               },
-               cat,
-       )
-
-       // Wait a bit to ensure the tag's ref age exceeds maxRefAgeMs
-       time.Sleep(10 * time.Millisecond)
-
-       // Start ExpireSnapshots transaction (will identify expiring-tag as 
eligible for deletion)
-       tx := tbl.NewTransaction()
-       t.Require().NoError(tx.ExpireSnapshots(table.WithOlderThan(time.Hour), 
table.WithRetainLast(1)))
-
-       // Simulate concurrent update: another client updates the tag to point 
to a newer snapshot
-       // This makes the tag no longer eligible for deletion
-       updateTagUpdates := []table.Update{
-               table.NewSetSnapshotRefUpdate("expiring-tag", 
newerSnapshot.SnapshotID, table.TagRef, maxRefAgeMs, -1, -1),
-       }
-       cat.metadata, _, err = cat.CommitTable(ctx, ident, nil, 
updateTagUpdates)
-       t.Require().NoError(err)
+               cat.metadata, _, err = cat.CommitTable(ctx, tbl.Identifier(), 
nil, []table.Update{
+                       table.NewSetSnapshotRefUpdate("expiring-tag", 
snapshots[2].SnapshotID, table.TagRef, maxRefAgeMs, -1, -1),
+               })
+               t.Require().NoError(err)
 
-       // Attempt to commit ExpireSnapshots - should fail due to 
AssertRefSnapshotID
-       _, err = tx.Commit(ctx)
-       t.Require().Error(err)
-       t.Require().Contains(err.Error(), "requirement failed")
-       t.Require().Contains(err.Error(), "expiring-tag")
+               committed, err := tx.Commit(ctx)
+               t.Require().NoError(err)
+               t.Equal([]int64{snapshots[1].SnapshotID, 
snapshots[2].SnapshotID}, snapshotIDsOf(committed.Metadata()))
+               tag := committed.Metadata().SnapshotByName("expiring-tag")
+               t.Require().NotNil(tag)
+               t.Equal(snapshots[2].SnapshotID, tag.SnapshotID)
+       })
 }
 
 func (t *TableWritingTestSuite) TestWriteSpecialCharacterColumn() {
diff --git a/table/transaction.go b/table/transaction.go
index ee88ac319..37a74fc3b 100644
--- a/table/transaction.go
+++ b/table/transaction.go
@@ -95,12 +95,14 @@ type Transaction struct {
        noReplay bool
 
        // pinnedRefs names refs whose AssertRefSnapshotID requirement guards
-       // the commit: those from AssertRefSnapshotID, RollbackToSnapshot, and
-       // ExpireSnapshots. doCommit's refresh-and-replay must not rewrite
-       // their assertions to the fresh head between retries, so a ref that
-       // has changed fails the commit.
+       // the commit: those from AssertRefSnapshotID and RollbackToSnapshot.
+       // doCommit's refresh-and-replay must not rewrite their assertions to
+       // the fresh head between retries, so a ref that has changed fails the
+       // commit.
        pinnedRefs map[string]struct{}
 
+       expirations []expirationRecord
+
        mx        sync.Mutex
        committed bool
 
@@ -683,6 +685,19 @@ type expireSnapshotsCfg struct {
        validationErr      error
 }
 
+// Keep calls that staged no updates so retries can replan them.
+type expirationRecord struct {
+       cfg   expireSnapshotsCfg
+       nowMs int64
+
+       // pos and count refer to the original staged updates, not a retry's 
plan.
+       pos, count int
+
+       walked []string
+
+       removedBranch bool
+}
+
 type ExpireSnapshotsOpt func(*expireSnapshotsCfg)
 
 // WithRetainLast sets the minimum number of snapshots to keep per branch,
@@ -765,20 +780,27 @@ func getRetentionInt64(props iceberg.Properties, 
standardKey, legacyKey string,
 //     }
 //     newTbl, err := txn.Commit(ctx)
 //
-// An expiry that removes something pins every ref it read, including the
-// commit branch. A transaction that also appends then fails rather than
-// rebasing onto a peer's append, since the expiry was computed from the old
-// head.
+// After refresh, a retry or resumed commit replans the expiry at its original
+// position, with this call's options and cutoff time. Replanning retains
+// snapshots made reachable by concurrent commits, including rollback targets
+// and new branch heads. It can expire snapshots that a concurrent append moved
+// outside retention. On each attempt, an expiry that removes snapshots or
+// refs requires the refs it read to be unchanged since loading or refreshing
+// the table.
+//
+// Retries replan even if staging or an earlier attempt produced no expiry
+// updates. If no updates remain, Commit returns the refreshed table without
+// committing. A transaction containing only expiries with no updates makes no
+// commit attempt and never refreshes or replans.
+//
+// The replay failures that make the transaction unusable are listed on
+// ErrTransactionUnusable.
 //
 // The "iceberg expire-snapshots" CLI command wraps the same operation.
 func (t *Transaction) ExpireSnapshots(opts ...ExpireSnapshotsOpt) error {
        var (
-               cfg         = expireSnapshotsCfg{postCommit: true}
-               updates     []Update
-               reqs        []Requirement
-               pinned      []string
-               snapsToKeep = make(map[int64]struct{})
-               nowMs       = time.Now().UnixMilli()
+               cfg   = expireSnapshotsCfg{postCommit: true}
+               nowMs = time.Now().UnixMilli()
        )
 
        for _, opt := range opts {
@@ -795,6 +817,56 @@ func (t *Transaction) ExpireSnapshots(opts 
...ExpireSnapshotsOpt) error {
                return err
        }
 
+       updates, walked, err := planExpiration(meta, cfg, nowMs)
+       if err != nil {
+               return err
+       }
+
+       return t.stageExpiration(expirationRecord{
+               cfg:           cfg,
+               nowMs:         nowMs,
+               walked:        walked,
+               removedBranch: removesRef(updates, cmp.Or(t.branch, 
MainBranch)),
+       }, updates)
+}
+
+// Record positions under the same lock as the updates they locate.
+func (t *Transaction) stageExpiration(rec expirationRecord, updates []Update) 
error {
+       if err := t.checkNotNil(); err != nil {
+               return err
+       }
+       t.mx.Lock()
+       defer t.mx.Unlock()
+
+       meta, err := t.txnMeta()
+       if err != nil {
+               return err
+       }
+       rec.pos = len(meta.updates)
+       if err := t.applyLocked(updates, nil); err != nil {
+               return err
+       }
+       rec.count = len(t.meta.updates) - rec.pos
+       t.expirations = append(t.expirations, rec)
+
+       return nil
+}
+
+func removesRef(updates []Update, name string) bool {
+       return slices.ContainsFunc(updates, func(u Update) bool {
+               r, ok := u.(*removeSnapshotRefUpdate)
+
+               return ok && r.RefName == name
+       })
+}
+
+func planExpiration(meta *MetadataBuilder, cfg expireSnapshotsCfg, nowMs 
int64) ([]Update, []string, error) {
+       var (
+               updates     []Update
+               walked      = make([]string, 0, len(meta.refs))
+               snapsToKeep = make(map[int64]struct{})
+       )
+
        // Read table-level retention properties as the last-resort defaults,
        // mirroring the Java implementation. The unprefixed property names are
        // retained as compatibility fallbacks for tables created by older Go
@@ -814,7 +886,7 @@ func (t *Transaction) ExpireSnapshots(opts 
...ExpireSnapshotsOpt) error {
        }
        snapshots, err := meta.snapshotsForFullScan()
        if err != nil {
-               return err
+               return nil, nil, err
        }
 
        lookup := func(id int64) *Snapshot {
@@ -825,19 +897,11 @@ func (t *Transaction) ExpireSnapshots(opts 
...ExpireSnapshotsOpt) error {
 
        retainedRefs := make(map[string]SnapshotRef, len(meta.refs))
        for refName, ref := range meta.refs {
-               // Assert and pin the ref's base snapshot id so we don't expire
-               // snapshots that concurrently updated refs now reference. Refs
-               // the transaction itself staged (absent on the base) already
-               // carry an absence assertion, which the pin keeps from being
-               // rebased.
-               if id := t.baseRefSnapshotID(refName); id != nil {
-                       reqs = append(reqs, AssertRefSnapshotID(refName, id))
-               }
-               pinned = append(pinned, refName)
+               walked = append(walked, refName)
 
                snap, err := meta.SnapshotByID(ref.SnapshotID)
                if err != nil {
-                       return err
+                       return nil, nil, err
                }
 
                maxRefAgeMs := cmp.Or(ref.MaxRefAgeMs, &propMaxRefAgeMs)
@@ -914,14 +978,7 @@ func (t *Transaction) ExpireSnapshots(opts 
...ExpireSnapshotsOpt) error {
                updates = append(updates, removal)
        }
 
-       // A no-op expiry must not pin the commit branch, which would stop the
-       // transaction's other updates from rebasing on retry. apply still
-       // rejects a committed or unusable transaction.
-       if len(updates) == 0 {
-               return t.apply(nil, nil)
-       }
-
-       return t.applyPinned(updates, reqs, pinned)
+       return updates, walked, nil
 }
 
 func (t *Transaction) AppendTable(ctx context.Context, tbl arrow.Table, 
batchSize int64, snapshotProps iceberg.Properties) error {
@@ -2638,12 +2695,17 @@ func (t *Transaction) Commit(ctx context.Context) 
(*Table, error) {
 
        if len(meta.updates) > 0 {
                reqs := append(transactionRequirements(t.reqs, t.branch, 
t.tbl.metadata), AssertTableUUID(meta.uuid))
+               var replay *stagedReplay
+               if len(t.expirations) > 0 {
+                       replay = newStagedReplay(cmp.Or(t.branch, MainBranch), 
meta.updates, t.expirations)
+               }
                tbl, err := t.tbl.doCommit(ctx, meta.updates, reqs,
                        withCommitBranch(t.branch),
                        withCommitValidators(t.validators...),
                        withCommitNoReplay(t.noReplay),
                        withCommitPinnedRefs(t.pinnedRefs),
                        withCommitResume(t.failedCommits),
+                       withCommitReplay(replay),
                )
                if err != nil {
                        // A failure that committed nothing stays committable, 
and the next
@@ -2668,8 +2730,12 @@ func (t *Transaction) Commit(ctx context.Context) 
(*Table, error) {
                // otherwise a retry would re-run the catalog commit.
                t.committed = true
 
+               landed := meta.updates
+               if replay != nil && replay.ran {
+                       landed = replay.landed
+               }
                var removals []*removeSnapshotsUpdate
-               for _, u := range meta.updates {
+               for _, u := range landed {
                        if removal, ok := u.(*removeSnapshotsUpdate); ok {
                                removals = append(removals, removal)
 
diff --git a/website/src/concurrent-writes.md b/website/src/concurrent-writes.md
index 2dbca534a..fadd8fad4 100644
--- a/website/src/concurrent-writes.md
+++ b/website/src/concurrent-writes.md
@@ -84,11 +84,11 @@ rejects when concurrent deletes touch the same files.
 iceberg-go can refresh the table and replay the operation against the latest
 snapshot between attempts. On each retry the table metadata is reloaded, the
 producer's validators re-run against the fresh snapshot, and the commit is
-re-submitted only if it is still valid. `RollbackToSnapshot`, `ExpireSnapshots`
-and `AssertRefSnapshotID` pin the refs they guard, so a retry does not rebase
-those assertions. If a guarded ref has moved, the commit fails with
-`ErrCommitFailed` instead of retrying. An `ExpireSnapshots` that removes 
nothing
-adds no guards.
+re-submitted only if it is still valid. `RollbackToSnapshot` and
+`AssertRefSnapshotID` pin their refs, so retries do not rebase those 
assertions.
+If a pinned ref moved, the commit fails with `ErrCommitFailed` instead of
+retrying. `ExpireSnapshots` does not pin refs. Retries replan it as described 
in
+[Snapshot expiration](#snapshot-expiration).
 
 Retry is **off by default** (`commit.retry.num-retries` is `0`). Opt in by
 setting the retry properties — see [Manifest and 
commit](./configuration.md#manifest-and-commit)
@@ -129,6 +129,34 @@ manifests that later attempts merged. `TableCommit` 
refuses a transaction whose
 commit failed, since the multi-table path does not refresh. Call `Commit` to
 resume it, or build a new transaction.
 
-Because an `ExpireSnapshots` that removes something pins the commit branch, a
-transaction that also appends fails instead of rebasing onto a peer's append.
-This is deliberate, since the expiry was computed from the old head.
+### Snapshot expiration
+
+A retry replays staged changes in order against the refreshed table. It replans
+each `ExpireSnapshots` call at its original position, with the original options
+and cutoff time. The plan retains snapshots that a concurrent commit made
+reachable, such as rollback targets and new branch heads. It can expire
+snapshots that a concurrent append moved outside the retention window. A
+transaction that appends and expires snapshots rebases onto a concurrent append
+like any other append.
+
+Each attempt requires the refs read by expiries that remove snapshots or refs
+to be unchanged since loading or refreshing the table. An expiry that removes
+nothing adds no such requirement. If a concurrent commit moves one of those
+refs before the attempt succeeds, it causes another conflict. The next attempt
+replans the expiry. If a concurrent commit creates a ref that the plan did not
+read, that ref does not cause a conflict. Expiring its snapshot also removes
+the ref.
+
+- Retries replan an expiry even if staging or an earlier attempt produced no
+  expiry updates.
+- If no updates remain, for example because a concurrent commit already expired
+  the same snapshots, `Commit` returns the refreshed table without committing.
+- A transaction containing only expiries with no updates makes no commit 
attempt
+  and never refreshes or replans.
+- Post-commit cleanup deletes files for snapshots expired by the successful
+  attempt, if cleanup is enabled.
+
+A retry fails with `ErrTransactionUnusable` if GC is disabled after refresh, a
+staged change no longer applies, or replanning changes whether a non-main
+commit branch is removed. `ErrTransactionUnusable` and `ErrCommitFailed` list
+these cases and which of them a new transaction can retry.
diff --git a/website/src/releases.md b/website/src/releases.md
index bfc29a9fd..f4885948d 100644
--- a/website/src/releases.md
+++ b/website/src/releases.md
@@ -62,6 +62,7 @@ Per-release notes (highlights, breaking changes, 
contributors) are published on
 - **Hadoop catalog construction takes a context.** `hadoop.NewCatalog(name, 
warehouse, props)` becomes `hadoop.NewCatalog(ctx, name, warehouse, props)`. 
The context bounds filesystem setup; the catalog's filesystem keeps its values 
but not its cancellation.
 - **Config loading reports errors.** `config.LoadConfig` now returns `([]byte, 
error)`, and `config.LoadConfigFile` is removed; call `LoadConfig` instead. A 
missing default `~/.iceberg-go.yaml` still returns no data and no error.
 - **Branch transactions return initialization errors.** 
`Table.NewTransactionOnBranch(branch)` now returns `(*Transaction, error)`, and 
`Table.NewTransactionOnBranchWithError` is removed. Use 
`NewTransactionOnBranch` and handle its error.
+- **Snapshot expiration now tolerates concurrent ref changes.** If a 
concurrent commit moves a ref that the expiry read, a retry or later `Commit` 
replans instead of failing with `table.ErrCommitFailed`. The committed expiry 
can differ from `Transaction.StagedTable`. See [Snapshot 
expiration](./concurrent-writes.md#snapshot-expiration) for cases that now fail 
with `table.ErrTransactionUnusable`.
 - **Schema JSON constructor uses the Go initialism.** Replace 
`iceberg.NewSchemaFromJsonFields` with `iceberg.NewSchemaFromJSONFields`.
 - **AWS identifiers use the Go initialism.** `glue.AwsProperties`, 
`glue.WithAwsConfig` and `glue.WithAwsProperties` become `glue.AWSProperties`, 
`glue.WithAWSConfig` and `glue.WithAWSProperties`; `sigv4.WithAwsConfig` 
becomes `sigv4.WithAWSConfig`; `utils.WithAwsConfig` and `utils.GetAwsConfig` 
become `utils.WithAWSConfig` and `utils.GetAWSConfig`; and 
`config.CatalogConfig.AwsProfile` becomes `AWSProfile`. The `aws-profile` YAML 
key is unchanged.
 - **KMS factory registration now takes a context.** Change 
`encryption.KMSFactory` implementations to accept `(context.Context, 
map[string]string)` instead of only properties, and call 
`encryption.LoadKeyManagementClient(ctx, props)` instead of 
`LoadKeyManagementClient(props)`. The existing global `RegisterKMS`, 
`UnregisterKMS`, and `GetRegisteredKMSNames` functions still use the default 
registry; `RegisterKMS` still panics for invalid registrations. Use 
`encryption.MustRegisterKMS` for  [...]

Reply via email to