This is an automated email from the ASF dual-hosted git repository.
zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push:
new 8fe62fb42 fix(table): treat stale snapshot values as commit conflicts
(#2342)
8fe62fb42 is described below
commit 8fe62fb42b778e72472f3571cc42d94c18f176bb
Author: Harrison Crosse <[email protected]>
AuthorDate: Wed Oct 28 15:42:45 2026 -0400
fix(table): treat stale snapshot values as commit conflicts (#2342)
When a peer commits on another branch, the commit branch requirement
still holds, but the metadata builder rejects the staged snapshot
because its sequence number is no longer above last-sequence-number, or
on v3 its first-row-id is behind next-row-id. The SQL, Glue, Hive and
Hadoop catalogs returned that rejection as a plain error, so doCommit
treated it as an unknown commit state: it did not retry, skipped
cleanup, and Transaction.Commit refused a re-commit, although nothing
was written and a refresh and rebuild would land it.
Wrap ErrCommitFailed for these two checks only, as Java does with
RetryableValidationException, which its REST handlers report as a 409.
The row-id rejection still matches ErrInvalidRowLineage. The timestamp
checks and every other builder error stay unchanged, since a rebuild
keeps the snapshot timestamp and would fail the same way.
Signed-off-by: Harrison Crosse <[email protected]>
---
catalog/sql/sql_test.go | 63 +++++++++++++++++++++++++++++++++
table/metadata.go | 9 ++---
table/metadata_builder_internal_test.go | 51 ++++++++++++++++++++++++++
table/no_replay_removals_test.go | 3 +-
table/rewrite_files_test.go | 7 ----
table/table.go | 4 +--
website/src/concurrent-writes.md | 4 ++-
7 files changed, 125 insertions(+), 16 deletions(-)
diff --git a/catalog/sql/sql_test.go b/catalog/sql/sql_test.go
index b320359d7..b8a39360e 100644
--- a/catalog/sql/sql_test.go
+++ b/catalog/sql/sql_test.go
@@ -1626,6 +1626,69 @@ func (s *SqliteCatalogTestSuite)
TestStaleAppendRetriesRequirementFailure() {
}
}
+// A peer append on another branch takes the staged snapshot's sequence number
+// or first-row-id.
+func (s *SqliteCatalogTestSuite)
TestStaleAppendRetriesAfterPeerOnAnotherBranch() {
+ ctx := context.Background()
+ tests := []struct {
+ name string
+ formatVersion int
+ numRetries string
+ // The v3 cases seed nothing, so the staged snapshot has no
parent and
+ // only the first-row-id check rejects it.
+ seed []string
+ conflict string
+ }{
+ {"sequence number, retries enabled", 2, "2", []string{"seed"},
""},
+ {"sequence number, retries disabled", 2, "0", []string{"seed"},
"must be > than last sequence number"},
+ {"first-row-id, retries enabled", 3, "2", nil, ""},
+ {"first-row-id, retries disabled", 3, "0", nil, "first-row-id 0
is behind table next-row-id 1"},
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ cat := s.getCatalogSqlite()
+ defer cat.Close()
+ tblID := s.createRetryingStringTable(ctx, cat,
tt.numRetries)
+ tbl, err := cat.LoadTable(ctx, tblID)
+ s.Require().NoError(err)
+ upgrade := tbl.NewTransaction()
+
s.Require().NoError(upgrade.UpgradeFormatVersion(tt.formatVersion))
+ tbl, err = upgrade.Commit(ctx)
+ s.Require().NoError(err)
+ for _, v := range tt.seed {
+ tbl = s.appendStringRow(ctx, tbl,
table.MainBranch, v)
+ }
+ mainHead := tbl.Metadata().CurrentSnapshot()
+
+ peer := s.appendStringRow(ctx, tbl, "other", "peer")
+ peerHead :=
peer.Metadata().SnapshotByName("other").SnapshotID
+
+ tx := tbl.NewTransaction()
+ s.stageStringRow(ctx, tx, "stale")
+ _, err = tx.Commit(ctx)
+ if tt.numRetries == "0" {
+ s.Require().ErrorIs(err, table.ErrCommitFailed)
+ s.Require().NotErrorIs(err,
table.ErrTransactionUnusable)
+ s.Contains(err.Error(), tt.conflict)
+
+ _, err = tx.Commit(ctx)
+ }
+ s.Require().NoError(err)
+
+ current, err := cat.LoadTable(ctx, tblID)
+ s.Require().NoError(err)
+ s.ElementsMatch(append(slices.Clone(tt.seed), "stale"),
s.storedStringValues(ctx, current))
+ if mainHead == nil {
+
s.Nil(current.Metadata().CurrentSnapshot().ParentSnapshotID)
+ } else {
+ s.Equal(mainHead.SnapshotID,
*current.Metadata().CurrentSnapshot().ParentSnapshotID)
+ }
+ s.Equal(peerHead,
current.Metadata().SnapshotByName("other").SnapshotID)
+ })
+ }
+}
+
func (s *SqliteCatalogTestSuite) addStringColumn(ctx context.Context, tbl
*table.Table, name string) *table.Transaction {
tx := tbl.NewTransaction()
s.Require().NoError(tx.UpdateSchema(true, false).
diff --git a/table/metadata.go b/table/metadata.go
index 7ef3c66f7..6c15b229c 100644
--- a/table/metadata.go
+++ b/table/metadata.go
@@ -953,10 +953,11 @@ func (b *MetadataBuilder) addSnapshotInternal(snapshot
*Snapshot, preserveUpdate
} else if b.formatVersion >= 2 &&
snapshot.ParentSnapshotID != nil &&
snapshot.SequenceNumber <= lastSeqNum {
- return fmt.Errorf("can't add snapshot with sequence number %d,
must be > than last sequence number %d",
- snapshot.SequenceNumber, lastSeqNum)
+ return fmt.Errorf("%w: can't add snapshot with sequence number
%d, must be > than last sequence number %d",
+ ErrCommitFailed, snapshot.SequenceNumber, lastSeqNum)
}
+ // A rebuild keeps the timestamp, so these checks are not retryable.
if len(b.snapshotLog) > 0 {
last := b.snapshotLog[len(b.snapshotLog)-1]
if (snapshot.TimestampMs - last.TimestampMs) < -oneMinuteInMs {
@@ -1012,8 +1013,8 @@ func (b *MetadataBuilder)
validateAndUpdateRowLineage(snapshot *Snapshot) error
nextRowID := b.currentNextRowID()
if *snapshot.FirstRowID < nextRowID {
- return fmt.Errorf("%w: first-row-id %d is behind table
next-row-id %d",
- ErrInvalidRowLineage, *snapshot.FirstRowID, nextRowID)
+ return fmt.Errorf("%w: %w: first-row-id %d is behind table
next-row-id %d",
+ ErrCommitFailed, ErrInvalidRowLineage,
*snapshot.FirstRowID, nextRowID)
}
// next-row-id is non-decreasing across snapshots, not strictly
increasing:
diff --git a/table/metadata_builder_internal_test.go
b/table/metadata_builder_internal_test.go
index a740c6f44..e8dbfa308 100644
--- a/table/metadata_builder_internal_test.go
+++ b/table/metadata_builder_internal_test.go
@@ -1928,6 +1928,57 @@ func TestAddSnapshotOnBuilderWithoutBaseV3(t *testing.T)
{
require.Equal(t, addedRows, meta.NextRowID())
}
+func TestAddSnapshotStaleValuesAreCommitConflicts(t *testing.T) {
+ now := time.Now().UnixMilli()
+ parentID := int64(1)
+
+ t.Run("sequence number taken by a peer", func(t *testing.T) {
+ builder := freshMetadataBuilder(t, 2)
+ peer := freshBuilderSnapshot(1, nil, 1, now)
+ require.NoError(t, builder.AddSnapshot(&peer))
+ stale := freshBuilderSnapshot(2, &parentID, 1, now)
+
+ err := builder.AddSnapshot(&stale)
+ require.ErrorIs(t, err, ErrCommitFailed)
+ require.ErrorContains(t, err, "can't add snapshot with sequence
number 1, must be > than last sequence number 1")
+ })
+
+ t.Run("first-row-id behind next-row-id", func(t *testing.T) {
+ builder := freshMetadataBuilder(t, 3)
+ firstRowID, addedRows := int64(0), int64(10)
+ peer := freshBuilderSnapshot(1, nil, 1, now)
+ peer.FirstRowID, peer.AddedRows = &firstRowID, &addedRows
+ require.NoError(t, builder.AddSnapshot(&peer))
+ stale := freshBuilderSnapshot(2, &parentID, 2, now)
+ stale.FirstRowID, stale.AddedRows = &firstRowID, &addedRows
+
+ err := builder.AddSnapshot(&stale)
+ require.ErrorIs(t, err, ErrCommitFailed)
+ require.ErrorIs(t, err, ErrInvalidRowLineage)
+ require.ErrorContains(t, err, "first-row-id 0 is behind table
next-row-id 10")
+ })
+
+ t.Run("timestamp before last update", func(t *testing.T) {
+ builder := freshMetadataBuilder(t, 2)
+ first := freshBuilderSnapshot(1, nil, 1, now)
+ require.NoError(t, builder.AddSnapshot(&first))
+ stale := freshBuilderSnapshot(2, &parentID, 2,
now-2*oneMinuteInMs)
+
+ err := builder.AddSnapshot(&stale)
+ require.ErrorContains(t, err, "before last updated timestamp")
+ require.NotErrorIs(t, err, ErrCommitFailed)
+ })
+
+ t.Run("missing first-row-id", func(t *testing.T) {
+ builder := freshMetadataBuilder(t, 3)
+ snap := freshBuilderSnapshot(1, nil, 1, now)
+
+ err := builder.AddSnapshot(&snap)
+ require.ErrorIs(t, err, ErrInvalidRowLineage)
+ require.NotErrorIs(t, err, ErrCommitFailed)
+ })
+}
+
func TestAddSnapshotUpdateOnBuilderWithoutBase(t *testing.T) {
builder := freshMetadataBuilder(t, 2)
parentSnapshotID := int64(1)
diff --git a/table/no_replay_removals_test.go b/table/no_replay_removals_test.go
index 0cf55062d..2d1dfd6fb 100644
--- a/table/no_replay_removals_test.go
+++ b/table/no_replay_removals_test.go
@@ -241,8 +241,7 @@ func TestReplaceFilesWithRemovalsFailsInsteadOfReplaying(t
*testing.T) {
// unchanged but takes the staged snapshot's sequence number. The resume of a
// non-replayable compaction rebuilds onto the same pinned parent and lands.
func TestReplaceFilesWithRemovalsResumesAfterPeerOnAnotherBranch(t *testing.T)
{
- tbl, cat := newConcurrentRewriteTestTable(t)
- cat.restConflicts = true
+ tbl, _ := newConcurrentRewriteTestTable(t)
arrowSc, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false,
false)
require.NoError(t, err)
diff --git a/table/rewrite_files_test.go b/table/rewrite_files_test.go
index 9ddb30683..df2c512e8 100644
--- a/table/rewrite_files_test.go
+++ b/table/rewrite_files_test.go
@@ -53,9 +53,6 @@ type concurrentTestCatalog struct {
location string
fsF table.FSysF
attempts atomic.Int32
- // restConflicts reports a metadata builder rejection as a conflict, as
- // a REST catalog's 409 does.
- restConflicts bool
}
func (c *concurrentTestCatalog) LoadTable(_ context.Context, ident
table.Identifier) (*table.Table, error) {
@@ -71,10 +68,6 @@ func (c *concurrentTestCatalog) CommitTable(_
context.Context, _ table.Identifie
}
meta, err := table.UpdateTableMetadata(c.metadata, updates, "")
if err != nil {
- if c.restConflicts {
- return nil, "", fmt.Errorf("%w: %w",
table.ErrCommitFailed, err)
- }
-
return nil, "", err
}
c.metadata = meta
diff --git a/table/table.go b/table/table.go
index d0690372b..db12ced2f 100644
--- a/table/table.go
+++ b/table/table.go
@@ -1065,8 +1065,8 @@ func latestSnapshotForBranch(meta Metadata, branch
string) *Snapshot {
// stagedSnapshotStillValid reports whether a snapshot built on an earlier
// attempt would still be accepted by AddSnapshot against fresh. Both values it
// checks are table-wide, so a peer committing on ANOTHER branch invalidates
them
-// without moving this branch's head — the case the caller's parent check
misses,
-// and one AddSnapshot rejects terminally rather than retryably.
+// without moving this branch's head, which the caller's parent check misses.
+// Resubmitting such a snapshot unchanged would fail every retry.
func stagedSnapshotStillValid(snap *Snapshot, fresh Metadata) bool {
if snap == nil || fresh == nil {
return true
diff --git a/website/src/concurrent-writes.md b/website/src/concurrent-writes.md
index 6e830ba62..f3b0756cc 100644
--- a/website/src/concurrent-writes.md
+++ b/website/src/concurrent-writes.md
@@ -98,7 +98,9 @@ _, err := cat.CreateTable(ctx, ident, schema,
> **Catalog support:** the REST, SQL, Glue, Hive, and Hadoop catalogs report
> commit conflicts, including requirements that fail because of a concurrent
-> change, as `ErrCommitFailed`, so the retry loop engages on all of them.
+> change, as `ErrCommitFailed`, so the retry loop engages on all of them. A
+> commit on another branch that takes the staged snapshot's sequence number or
+> first-row-id is a conflict too, and the retry renumbers the snapshot.
Only the implicit assertion on the commit branch's head is rebased after a
refresh. Other requirements, including the schema, spec, sort order, and pinned