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 e904fb445 fix(table): treat requirement failures as commit conflicts
(#2100)
e904fb445 is described below
commit e904fb445780226acf43812afe78c1bd6ea6c80c
Author: Harrison Crosse <[email protected]>
AuthorDate: Tue Oct 27 16:50:21 2026 -0400
fix(table): treat requirement failures as commit conflicts (#2100)
* fix(table): treat requirement failures as commit conflicts
Catalogs that validate commit requirements client-side (Glue, SQL,
Hadoop) returned a plain error when a requirement failed, such as a
branch that moved since the table was loaded. The REST catalog returns
the same failure as a 409, which already maps to ErrCommitFailed, so the
same race was retryable through REST and terminal elsewhere. Java,
PyIceberg, and iceberg-rust all report these failures as retryable
commit conflicts.
Requirement failures now wrap ErrCommitFailed, keeping their messages.
Two failures stay terminal because a refresh cannot fix them: targeting
a tag as a transaction branch, and metadata with no last assigned
partition id.
Signed-off-by: Harrison Crosse <[email protected]>
* fix(table): shorten requirementFailed comment
Signed-off-by: Harrison Crosse <[email protected]>
* chore: restore go.sum
Signed-off-by: Harrison Crosse <[email protected]>
* test(catalog/sql): drop redundant test comment
Signed-off-by: Harrison Crosse <[email protected]>
* fix(table): pin guarded refs in RollbackToSnapshot and ExpireSnapshots
RollbackToSnapshot asserts its branch head and ExpireSnapshots asserts
every ref so that a concurrent ref move fails the commit. On retry,
doCommit rewrites unpinned assertions on the commit branch to the
refreshed head, so a stale rollback could drop a peer's append and a
stale expiry could remove snapshots that are back inside the retention
window after a peer rollback.
Pin the guarded refs the same way Transaction.AssertRefSnapshotID
does, so the retry fails fast with ErrCommitFailed instead of replaying
against the new head. This includes a branch the transaction itself
staged: its absence assertion would otherwise be rebased onto a branch
a peer created meanwhile. The REST catalog already maps HTTP 409 to
ErrCommitFailed, so REST tables with retries enabled were exposed
before requirement failures started wrapping it; pinning fixes this
for every catalog.
Pins are now published under the same lock hold as the apply that
registers their requirements, so a failed registration publishes no pin
and cannot withdraw one that an overlapping registration relies on.
Snapshot producers still assert unpinned so they can rebase, and the
CLI branch and tag commands call CommitTable directly without retry.
Signed-off-by: Harrison Crosse <[email protected]>
* fix(table): fail fast on non-rebased requirement failures
On retry, doCommit rebases only unpinned AssertRefSnapshotID
requirements on the commit branch. Every other requirement (schema,
partition spec, sort order, table UUID, last assigned ids, other refs,
pinned refs) is resubmitted unchanged, so one the refreshed metadata
violates fails identically on every attempt, spending the backoff and a
LoadTable per retry.
After the refresh, validate those requirements against the fresh
metadata and return immediately on failure, as Java does with
ValidationFailureException. This generalizes the existing pinned-ref
check. The error still wraps ErrCommitFailed, so callers can reload
and rebuild the commit.
Collect the manifests a rewrite producer wrote in the deferred cleanup,
so this and every other safe early exit removes them; previously only
an exhausted retry budget or a successful commit collected them.
Cleanup is still skipped when CommitTable fails in an unknown state.
A failed commit that removed files the staged updates reference (a
rewrite's merged manifests, or the attempt-0 manifest list a rebuild
replaced) now also wraps the new ErrTransactionUnusable sentinel, and
the transaction latches unusable. Later Commit, TableCommit (and so
catalog.MultiTableTransaction) and staging calls return an error
wrapping ErrTransactionUnusable instead of resubmitting references to
deleted files. A failure that removed nothing stays retriable.
Signed-off-by: Harrison Crosse <[email protected]>
* docs: update catalog retry support
The concurrent-writes guide said only the REST catalog engages commit
retry. REST maps HTTP 409 to ErrCommitFailed, and Glue, SQL, Hive and
Hadoop wrap their conflicts and failed requirements. Retry stays off
unless commit.retry.num-retries is set, and requirements other than the
commit branch head assertion fail without retrying.
Also correct the API guide, which said Commit retries automatically,
and note on ErrCommitFailed how the retry loop treats requirements and
that an error also matching ErrTransactionUnusable needs a new
transaction.
Signed-off-by: Harrison Crosse <[email protected]>
* chore: trim comments and docs
Signed-off-by: Harrison Crosse <[email protected]>
* fix(table): skip guards for a no-op ExpireSnapshots
ExpireSnapshots pins every ref it asserts, including the commit branch.
When it stages nothing, the requirements and pins still applied, so a
transaction that wrote and ran a no-op expiry could no longer rebase
onto a peer's commit and failed after one attempt. Return before
applying when there is nothing to remove.
Also correct the comment on the ErrTransactionUnusable latch in
Transaction.Commit: a clean conflict is not retriable once cleanup has
removed staged files, which includes an ordinary append whose manifest
list was rebuilt. Add a transaction-level test for that append path.
Signed-off-by: Harrison Crosse <[email protected]>
* test(catalog/sql): cover an append with a no-op ExpireSnapshots
A transaction that appends and runs an ExpireSnapshots that removes
nothing must still rebase onto a peer's append on retry. Cover that
path on the SQL catalog: the commit succeeds on retry and both appends
are present.
Signed-off-by: Harrison Crosse <[email protected]>
* fix(table): make a non-rebased requirement failure terminal
When a requirement the retry loop does not rebase fails after a
refresh, committing the same transaction again can only reproduce the
failure. The error matched only ErrCommitFailed, so a caller retrying
on errors.Is(err, ErrCommitFailed) would loop on it.
Wrap ErrTransactionUnusable as well, so Transaction.Commit latches the
transaction and callers can tell this case from a retry-safe conflict.
Requirement failures at staging time are unchanged. Update the sentinel
docs and the concurrent-writes guide.
Signed-off-by: Harrison Crosse <[email protected]>
* test(table): pin cleanup after a transient failure following a rebuild
A refresh error or a cancelled context after a rebuilt attempt still
runs the deferred cleanup, which removes the replaced manifest list the
staged updates reference. Cover both paths: the error wraps
ErrTransactionUnusable, the file is gone, and a second Commit on the
same transaction is rejected with ErrTransactionUnusable.
Signed-off-by: Harrison Crosse <[email protected]>
* fix(table): make a conflict with retries disabled terminal
Transaction.Commit rebuilds its requirements from the table it was
created from, so a re-commit resends the same requirements. With
num-retries=0 nothing rebases them, so every ErrCommitFailed, including
a stale branch head, repeats on each re-commit and a caller retrying on
errors.Is(err, ErrCommitFailed) spins. A no-replay commit, which carries
snapshot-relative delete-file removals, aborts on its first conflict
for the same reason at any retry setting.
In both cases end the commit with ErrTransactionUnusable so
Transaction.Commit latches the transaction. With retries enabled, the
next Commit of a replayable transaction refreshes once and a
requirement that no longer holds already makes it unusable.
The terminal errors now lead with "transaction cannot be committed
again" instead of "refresh and try again". They still match the cause,
and ErrCommitFailed when the cause is a conflict. The cleanup wrap is
keyed on whether a removal was attempted and skips errors that already
match ErrTransactionUnusable.
Signed-off-by: Harrison Crosse <[email protected]>
* docs(table): describe when a transaction becomes unusable
Say that ErrCommitFailed is retryable unless the error also matches
ErrTransactionUnusable, and spell out when a transaction becomes
unusable: its commit conflicts with retries disabled or while carrying
delete-file removals that cannot be replayed, a requirement the retry
loop does not rebase fails against the refreshed table, or files the
staged updates reference were cleaned up, which follows any failure
once an attempt was rebuilt or a rewrite staged files, including a
refresh error or a cancelled context.
Signed-off-by: Harrison Crosse <[email protected]>
* fix(table): resume a re-commit after a conflict from a refresh
Transaction.Commit rebuilt its requirements from the transaction's
original base, so a re-commit after a failed commit resent the same
stale requirements and updates. To keep a caller retrying on
ErrCommitFailed from spinning, the previous change made every conflict
with retries disabled, and every conflict of a non-replayable commit,
leave the transaction unusable, which also burned transactions a plain
re-commit would land.
Treat a re-commit after a failed commit as the next retry attempt, as
Java does: it refreshes the table, rebases the commit-branch
assertion, checks the requirements that are not rebased, rebuilds the
staged snapshots and runs the validators before its first submission.
The retry budget per Commit is unchanged. A resume always rebuilds, so
the submitted snapshot and the producers' state come from the same
pass, and the rewrite producer now publishes a pass's state only after
its file-count guard passes. A non-replayable commit pins its branch on
resume, so it resubmits only if the branch head is unchanged. Rebuilt
manifest lists are numbered after earlier calls' attempts so their
names stay unique. Drop the blanket latch.
doCommit now deletes only files it wrote that were not committed. On a
failure it deletes the manifest lists its rebuilds wrote, including a
partial rebuild's. The staged updates' own files, which a later Commit
resends, are deleted only once a commit lands, so a failed commit no
longer makes the transaction unusable. Only a failure of unknown commit
state is terminal.
After a refresh, a rejection that fails every later attempt the same
way makes the transaction unusable: a non-rebased requirement, a
validator conflict, a diverged base, a commit branch that became a tag
or was deleted, or a replaced table. A validator that fails to read
stays resumable. TableCommit refuses a transaction whose commit failed,
since only Commit refreshes it. A no-op ExpireSnapshots now checks that
the transaction is usable.
Signed-off-by: Harrison Crosse <[email protected]>
* docs(table): describe resuming a transaction after a conflict
Explain that calling Commit again after a failed commit refreshes and
replays like the next retry attempt, that staged files are kept until a
commit lands so an abandoned transaction leaves them, and for a rewrite
the manifests later attempts merged, for orphan file removal, that
TableCommit refuses a transaction whose commit failed, which rejections
after a refresh make the transaction unusable, and that a real
ExpireSnapshots deliberately pins the commit branch.
Signed-off-by: Harrison Crosse <[email protected]>
* fix(catalog): pass requirement errors through unchanged
ValidateRequirements wrapped every failed requirement in
ErrCommitFailed. Requirement errors already match ErrCommitFailed when
they report a concurrent change, so the wrap only added the sentinel to
failures that are not conflicts, such as a tag used as a transaction
target, and prefixed every message with the sentinel's text. Return the
requirement's error unchanged apart from the missing-table case.
Signed-off-by: Harrison Crosse <[email protected]>
* docs: limit the catalog support note to conflicting requirement failures
Signed-off-by: Harrison Crosse <[email protected]>
* fix(table): wrap validator conflicts and pin the dual-match contract
Wrap a validator conflict after a refresh with fmt.Errorf as the other
terminal rejections are, instead of a dedicated error type that
rewrote the validator's message. The message now keeps the validator
error as is, including ErrCommitFailed's text, which holds for a
conflict: a new transaction built from a reloaded table may land.
Every row of the terminal-rejection matrix now asserts whether its
error matches ErrCommitFailed, and the matrix gains a stale sort order
and a moved pinned ref. A SQLite test runs the loop a caller writes
around ErrCommitFailed: it lands on the next pass after a validator
conflict or a stale requirement, and stops after one pass when the
base diverged.
Signed-off-by: Harrison Crosse <[email protected]>
* docs(table): describe what ErrCommitFailed tells a caller
Open the ErrCommitFailed godoc with what it means for a caller: a
concurrent change caused the failure, so a new transaction built from a
reloaded table may succeed. Spell out which terminal rejections match it
and which do not, and say on ErrTransactionUnusable that this
transaction can't be committed again and that one error can match both.
Show the dual match in the concurrent-writes guide.
Signed-off-by: Harrison Crosse <[email protected]>
---------
Signed-off-by: Harrison Crosse <[email protected]>
Co-authored-by: Matt Topol <[email protected]>
---
catalog/internal/update_stage_test.go | 21 ++
catalog/internal/utils.go | 10 +-
catalog/sql/sql_test.go | 281 +++++++++++++++++++++
table/commit.go | 9 +-
table/commit_refresh_replay_test.go | 327 ++++++++++++++++++++++++
table/commit_retry_test.go | 410 +++++++++++++++++++++++++++++--
table/no_replay_removals_test.go | 146 ++++++++++-
table/rebuild_manifest_test.go | 6 +-
table/requirement_test.go | 33 +++
table/requirements.go | 44 ++--
table/requirements_internal_test.go | 12 +
table/rewrite_data_files.go | 11 +-
table/rewrite_files_test.go | 7 +
table/rewrite_manifests.go | 48 ++--
table/rewrite_manifests_internal_test.go | 53 ++++
table/rewrite_manifests_test.go | 174 ++++++++++++-
table/snapshot_producers.go | 7 +-
table/table.go | 235 +++++++++++++-----
table/transaction.go | 58 ++++-
table/transaction_assert_ref_test.go | 13 +-
website/src/api.md | 2 +-
website/src/concurrent-writes.md | 31 ++-
22 files changed, 1746 insertions(+), 192 deletions(-)
diff --git a/catalog/internal/update_stage_test.go
b/catalog/internal/update_stage_test.go
index 3095d943f..0c81b2205 100644
--- a/catalog/internal/update_stage_test.go
+++ b/catalog/internal/update_stage_test.go
@@ -19,6 +19,7 @@ package internal
import (
"context"
+ "errors"
"testing"
"github.com/apache/iceberg-go"
@@ -110,6 +111,15 @@ func TestUpdateAndStageTableUsesVersionZeroForCreate(t
*testing.T) {
require.Equal(t, 0, ParseMetadataVersion(staged.MetadataLocation()))
}
+// failingRequirement fails with err, standing in for a requirement whose
+// failure is not a concurrent change, such as a tag used as a transaction
+// target.
+type failingRequirement struct{ err error }
+
+func (r failingRequirement) Validate(table.Metadata) error { return r.err }
+
+func (r failingRequirement) GetType() string { return "failing" }
+
func TestValidateRequirementsClassifiesFailures(t *testing.T) {
metadata, err := table.NewMetadata(iceberg.NewSchema(0), nil,
table.UnsortedSortOrder, "file:///tmp/table", nil)
require.NoError(t, err)
@@ -139,3 +149,14 @@ func TestValidateRequirementsClassifiesFailures(t
*testing.T) {
})
}
}
+
+// A requirement failure that is not a concurrent change must not gain
+// ErrCommitFailed, or a retry loop would treat it as one.
+func TestValidateRequirementsReturnsRequirementErrorUnchanged(t *testing.T) {
+ metadata, err := table.NewMetadata(iceberg.NewSchema(0), nil,
table.UnsortedSortOrder, "file:///tmp/table", nil)
+ require.NoError(t, err)
+ errTag := errors.New("requirement failed: ref \"t\" is a tag")
+
+ err = ValidateRequirements(table.Identifier{"db", "tbl"}, metadata,
[]table.Requirement{failingRequirement{errTag}})
+ require.Same(t, errTag, err)
+}
diff --git a/catalog/internal/utils.go b/catalog/internal/utils.go
index 23713d6af..3ca437076 100644
--- a/catalog/internal/utils.go
+++ b/catalog/internal/utils.go
@@ -263,10 +263,10 @@ func ParseMetadataVersion(location string) int {
// ValidateRequirements checks commit requirements against the table's current
// metadata, which is nil when the table does not exist. A failed requirement
-// on a missing table is reported as catalog.ErrNoSuchTable; any other failure
-// means a concurrent writer changed the table, and is reported as
-// table.ErrCommitFailed so the commit retry loop can refresh and replay, as it
-// does for a REST 409.
+// on a missing table is reported as catalog.ErrNoSuchTable. Any other failure
+// is returned as the requirement reported it, which matches
+// table.ErrCommitFailed when a concurrent writer changed the table, so the
+// commit retry loop can refresh and replay, as it does for a REST 409.
func ValidateRequirements(ident table.Identifier, current table.Metadata, reqs
[]table.Requirement) error {
for _, r := range reqs {
if err := r.Validate(current); err != nil {
@@ -274,7 +274,7 @@ func ValidateRequirements(ident table.Identifier, current
table.Metadata, reqs [
return fmt.Errorf("%w: %s: %w",
catalog.ErrNoSuchTable, strings.Join(ident, "."), err)
}
- return fmt.Errorf("%w: %w", table.ErrCommitFailed, err)
+ return err
}
}
diff --git a/catalog/sql/sql_test.go b/catalog/sql/sql_test.go
index e5005314f..b320359d7 100644
--- a/catalog/sql/sql_test.go
+++ b/catalog/sql/sql_test.go
@@ -1561,6 +1561,287 @@ func (s *SqliteCatalogTestSuite)
createRetryingStringTable(ctx context.Context,
return tblID
}
+// storedStringValues returns the "foo" values in the scan opts select.
+func (s *SqliteCatalogTestSuite) storedStringValues(ctx context.Context, tbl
*table.Table, opts ...table.ScanOption) []string {
+ rows, err := tbl.Scan(opts...).ToArrowTable(ctx)
+ s.Require().NoError(err)
+ defer rows.Release()
+
+ var values []string
+ for i := range int(rows.NumCols()) {
+ if rows.Schema().Field(i).Name != "foo" {
+ continue
+ }
+ for _, chunk := range rows.Column(i).Data().Chunks() {
+ for j := range chunk.Len() {
+ values = append(values, chunk.ValueStr(j))
+ }
+ }
+ }
+
+ return values
+}
+
+func (s *SqliteCatalogTestSuite) TestStaleAppendRetriesRequirementFailure() {
+ ctx := context.Background()
+ tests := []struct {
+ name string
+ numRetries string
+ }{
+ {"retries enabled", "2"},
+ {"retries disabled", "0"},
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ cat := s.getCatalogSqlite()
+ defer cat.Close()
+ tblID := s.createRetryingStringTable(ctx, cat,
tt.numRetries, "seed")
+
+ peer, err := cat.LoadTable(ctx, tblID)
+ s.Require().NoError(err)
+ stale, err := cat.LoadTable(ctx, tblID)
+ s.Require().NoError(err)
+
+ s.appendStringRow(ctx, peer, table.MainBranch, "peer")
+
+ tx := stale.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(), "has changed")
+
+ // The re-commit refreshes and rebases like the
next retry.
+ _, err = tx.Commit(ctx)
+ }
+ s.Require().NoError(err)
+
+ current, err := cat.LoadTable(ctx, tblID)
+ s.Require().NoError(err)
+ s.Len(current.Metadata().Snapshots(), 3)
+ s.ElementsMatch([]string{"seed", "peer", "stale"},
s.storedStringValues(ctx, current))
+ })
+ }
+}
+
+func (s *SqliteCatalogTestSuite) addStringColumn(ctx context.Context, tbl
*table.Table, name string) *table.Transaction {
+ tx := tbl.NewTransaction()
+ s.Require().NoError(tx.UpdateSchema(true, false).
+ AddColumn([]string{name}, iceberg.PrimitiveTypes.String, "",
false, nil).
+ Commit())
+
+ return tx
+}
+
+func (s *SqliteCatalogTestSuite) TestStaleSchemaUpdateIsRefusedOnRecommit() {
+ ctx := context.Background()
+ cat := s.getCatalogSqlite()
+ defer cat.Close()
+ tblID := s.createRetryingStringTable(ctx, cat, "0", "seed")
+
+ stale, err := cat.LoadTable(ctx, tblID)
+ s.Require().NoError(err)
+ tx := s.addStringColumn(ctx, stale, "stale")
+
+ peer, err := cat.LoadTable(ctx, tblID)
+ s.Require().NoError(err)
+ _, err = s.addStringColumn(ctx, peer, "peer").Commit(ctx)
+ s.Require().NoError(err)
+ peerMeta, err := cat.LoadTable(ctx, tblID)
+ s.Require().NoError(err)
+
+ _, err = tx.Commit(ctx)
+ s.Require().ErrorIs(err, table.ErrCommitFailed)
+ s.Require().NotErrorIs(err, table.ErrTransactionUnusable)
+
+ // The re-commit's refresh shows the schema requirement can never hold,
+ // so it stops before the catalog.
+ _, err = tx.Commit(ctx)
+ s.Require().ErrorIs(err, table.ErrTransactionUnusable)
+ s.Require().ErrorIs(err, table.ErrCommitFailed)
+ s.EqualError(err, "transaction cannot be committed again: requirement
no longer holds after refresh: "+
+ "Requirement failed: current schema id has changed: expected 0,
found 1")
+
+ current, err := cat.LoadTable(ctx, tblID)
+ s.Require().NoError(err)
+ s.Equal(peerMeta.MetadataLocation(), current.MetadataLocation(),
"nothing may be committed")
+}
+
+func (s *SqliteCatalogTestSuite) TestConflictClearedBeforeRecommitLands() {
+ ctx := context.Background()
+ cat := s.getCatalogSqlite()
+ defer cat.Close()
+ tblID := s.createRetryingStringTable(ctx, cat, "0", "seed")
+
+ stale, err := cat.LoadTable(ctx, tblID)
+ s.Require().NoError(err)
+ seedHead := stale.Metadata().CurrentSnapshot().SnapshotID
+ tx := stale.NewTransaction()
+ s.stageStringRow(ctx, tx, "writer")
+
+ peer, err := cat.LoadTable(ctx, tblID)
+ s.Require().NoError(err)
+ peer = s.appendStringRow(ctx, peer, table.MainBranch, "peer")
+
+ _, err = tx.Commit(ctx)
+ s.Require().ErrorIs(err, table.ErrCommitFailed)
+ s.Require().NotErrorIs(err, table.ErrTransactionUnusable)
+
+ // The peer rolls main back, so the branch the writer read is current
again.
+ rollback := peer.NewTransaction()
+ s.Require().NoError(rollback.RollbackToSnapshot(seedHead))
+ _, err = rollback.Commit(ctx)
+ s.Require().NoError(err)
+
+ committed, err := tx.Commit(ctx)
+ s.Require().NoError(err)
+ s.Equal(seedHead, *committed.CurrentSnapshot().ParentSnapshotID)
+ s.ElementsMatch([]string{"seed", "writer"}, s.storedStringValues(ctx,
committed))
+}
+
+// A delete staged before a peer appended a matching row is rejected by
+// the serializable validator on the re-commit, and every later attempt
+// would be rejected the same way.
+func (s *SqliteCatalogTestSuite)
TestStaleDeleteRejectedByValidatorIsUnusable() {
+ ctx := context.Background()
+ cat := s.getCatalogSqlite()
+ defer cat.Close()
+ tblID := s.createRetryingStringTable(ctx, cat, "0", "seed", "peer")
+
+ stale, err := cat.LoadTable(ctx, tblID)
+ s.Require().NoError(err)
+ tx := stale.NewTransaction()
+ s.Require().NoError(tx.Delete(ctx,
iceberg.EqualTo(iceberg.Reference("foo"), "peer"), nil))
+
+ peer, err := cat.LoadTable(ctx, tblID)
+ s.Require().NoError(err)
+ peer = s.appendStringRow(ctx, peer, table.MainBranch, "peer")
+
+ _, err = tx.Commit(ctx)
+ s.Require().ErrorIs(err, table.ErrCommitFailed)
+ s.Require().NotErrorIs(err, table.ErrTransactionUnusable)
+
+ _, err = tx.Commit(ctx)
+ s.Require().ErrorIs(err, table.ErrTransactionUnusable)
+ s.Require().ErrorIs(err, table.ErrConflictingDataFiles)
+ s.Require().ErrorIs(err, table.ErrCommitFailed)
+ s.True(strings.HasPrefix(err.Error(), fmt.Sprintf("transaction cannot
be committed again: validation conflict: "+
+ "commit failed, refresh and try again: concurrent data files
added: snapshot %d added data file ",
+ peer.CurrentSnapshot().SnapshotID)), err.Error())
+ s.True(strings.HasSuffix(err.Error(), " matching filter
Equal(term=Reference(name='foo'), literal=peer)"), err.Error())
+
+ current, err := cat.LoadTable(ctx, tblID)
+ s.Require().NoError(err)
+ s.Equal(peer.MetadataLocation(), current.MetadataLocation(), "nothing
may be committed")
+}
+
+func (s *SqliteCatalogTestSuite) retryWithNewTransactions(ctx context.Context,
cat *sqlcat.Catalog, tblID table.Identifier,
+ maxPasses int, stage func(pass int, tbl *table.Table)
*table.Transaction,
+) []error {
+ var errs []error
+ for pass := 1; pass <= maxPasses; pass++ {
+ tbl, err := cat.LoadTable(ctx, tblID)
+ s.Require().NoError(err)
+ _, err = stage(pass, tbl).Commit(ctx)
+ errs = append(errs, err)
+ if !errors.Is(err, table.ErrCommitFailed) {
+ break
+ }
+ }
+
+ return errs
+}
+
+// A failure that matches ErrCommitFailed is one a new transaction built from
+// a reloaded table may get past, and one that does not match ends the loop.
+// Tables retry once, so the first pass refreshes and fails its terminal check.
+func (s *SqliteCatalogTestSuite)
TestRetryLoopOnCommitFailedWithNewTransactions() {
+ ctx := context.Background()
+ const maxPasses = 3
+
+ s.Run("validator conflict lands on the next pass", func() {
+ cat := s.getCatalogSqlite()
+ defer cat.Close()
+ tblID := s.createRetryingStringTable(ctx, cat, "1", "seed",
"peer")
+
+ errs := s.retryWithNewTransactions(ctx, cat, tblID, maxPasses,
func(pass int, tbl *table.Table) *table.Transaction {
+ tx := tbl.NewTransaction()
+ s.Require().NoError(tx.Delete(ctx,
iceberg.EqualTo(iceberg.Reference("foo"), "peer"), nil))
+ if pass == 1 {
+ s.appendStringRow(ctx, tbl, table.MainBranch,
"peer")
+ }
+
+ return tx
+ })
+ s.Require().Len(errs, 2)
+ s.ErrorIs(errs[0], table.ErrTransactionUnusable)
+ s.ErrorIs(errs[0], table.ErrConflictingDataFiles)
+ s.NoError(errs[1])
+
+ current, err := cat.LoadTable(ctx, tblID)
+ s.Require().NoError(err)
+ s.ElementsMatch([]string{"seed"}, s.storedStringValues(ctx,
current))
+ })
+
+ s.Run("stale requirement lands on the next pass", func() {
+ cat := s.getCatalogSqlite()
+ defer cat.Close()
+ tblID := s.createRetryingStringTable(ctx, cat, "1", "seed")
+
+ errs := s.retryWithNewTransactions(ctx, cat, tblID, maxPasses,
func(pass int, tbl *table.Table) *table.Transaction {
+ tx := s.addStringColumn(ctx, tbl, "writer")
+ if pass == 1 {
+ _, err := s.addStringColumn(ctx, tbl,
"peer").Commit(ctx)
+ s.Require().NoError(err)
+ }
+
+ return tx
+ })
+ s.Require().Len(errs, 2)
+ s.ErrorIs(errs[0], table.ErrTransactionUnusable)
+ s.Contains(errs[0].Error(), "current schema id has changed")
+ s.NoError(errs[1])
+
+ current, err := cat.LoadTable(ctx, tblID)
+ s.Require().NoError(err)
+ for _, name := range []string{"peer", "writer"} {
+ _, ok := current.Schema().FindFieldByName(name)
+ s.Truef(ok, "column %s", name)
+ }
+ })
+
+ s.Run("diverged base stops after one pass", func() {
+ cat := s.getCatalogSqlite()
+ defer cat.Close()
+ tblID := s.createRetryingStringTable(ctx, cat, "1", "seed",
"second")
+
+ errs := s.retryWithNewTransactions(ctx, cat, tblID, maxPasses,
func(pass int, tbl *table.Table) *table.Transaction {
+ tx := tbl.NewTransaction()
+ s.Require().NoError(tx.Delete(ctx,
iceberg.EqualTo(iceberg.Reference("foo"), "seed"), nil))
+ if pass == 1 {
+ parent :=
tbl.Metadata().CurrentSnapshot().ParentSnapshotID
+ s.Require().NotNil(parent)
+ rollback := tbl.NewTransaction()
+
s.Require().NoError(rollback.RollbackToSnapshot(*parent))
+ _, err := rollback.Commit(ctx)
+ s.Require().NoError(err)
+ }
+
+ return tx
+ })
+ s.Require().Len(errs, 1, "a failure that does not match
ErrCommitFailed must end the loop")
+ s.ErrorIs(errs[0], table.ErrTransactionUnusable)
+ s.ErrorIs(errs[0], table.ErrCommitDiverged)
+ s.NotErrorIs(errs[0], table.ErrCommitFailed)
+
+ current, err := cat.LoadTable(ctx, tblID)
+ s.Require().NoError(err)
+ s.ElementsMatch([]string{"seed"}, s.storedStringValues(ctx,
current))
+ })
+}
+
// An ExpireSnapshots call with nothing to expire must not stop the
// transaction's append from rebasing onto a peer's append.
func (s *SqliteCatalogTestSuite)
TestStaleAppendWithNoOpExpireRetriesAfterPeerAppend() {
diff --git a/table/commit.go b/table/commit.go
index 22aa98606..516c6e218 100644
--- a/table/commit.go
+++ b/table/commit.go
@@ -66,8 +66,13 @@ func (t *Transaction) TableCommit() (TableCommit, error) {
return TableCommit{}, err
}
- if t.committed {
- return TableCommit{}, errors.New("transaction has already been
committed")
+ if err := t.checkUsable(); err != nil {
+ return TableCommit{}, err
+ }
+ // TableCommit sends the transaction's original requirements and
updates,
+ // which may be stale after a failed commit. Only Commit refreshes them.
+ if t.failedCommits > 0 {
+ return TableCommit{}, errors.New("a previous commit of this
transaction failed, call Commit to resume it")
}
if len(meta.updates) == 0 {
diff --git a/table/commit_refresh_replay_test.go
b/table/commit_refresh_replay_test.go
index 304ad8d05..25116cd36 100644
--- a/table/commit_refresh_replay_test.go
+++ b/table/commit_refresh_replay_test.go
@@ -30,7 +30,9 @@ package table
import (
"context"
+ "errors"
"fmt"
+ "strings"
"sync/atomic"
"testing"
@@ -51,6 +53,7 @@ import (
type headTrackingCatalog struct {
metadata Metadata
attempts atomic.Int32
+ loads atomic.Int32
}
type identifierCapturingCatalog struct {
@@ -61,6 +64,8 @@ type identifierCapturingCatalog struct {
}
func (c *headTrackingCatalog) LoadTable(_ context.Context, ident Identifier)
(*Table, error) {
+ c.loads.Add(1)
+
return New(ident, c.metadata, "",
func(context.Context) (iceio.IO, error) { return
iceio.LocalFS{}, nil }, c), nil
}
@@ -215,6 +220,328 @@ func TestDoCommit_ValidatorRejectsOnRefresh(t *testing.T)
{
"validator rejection on retry must abort before re-issuing
CommitTable")
}
+// Why: after a refresh, a rejection that does not depend on timing fails
+// every later attempt and every re-commit the same way, so the transaction
+// must become unusable rather than leave an errors.Is(ErrCommitFailed) loop
+// spinning, and its message must not tell the caller to try again. A
+// validator that fails to read must stay resumable.
+func TestResumeDeterministicRejectionsMakeTransactionUnusable(t *testing.T) {
+ head := int64(100)
+ noRetries := iceberg.Properties{}
+ // Rejections formatted like the validators in conflict_validation.go.
+ rejectConcurrent := func(sentinel error) conflictValidatorFunc {
+ return func(cc *conflictContext) error {
+ if len(cc.concurrent) > 0 {
+ return fmt.Errorf("%w: snapshot %d added
f.parquet", sentinel, cc.concurrent[0].SnapshotID)
+ }
+
+ return nil
+ }
+ }
+ withFeature := func(t *testing.T, base Metadata) Metadata {
+ b, err := MetadataBuilderFromBase(base, "")
+ require.NoError(t, err)
+ require.NoError(t, b.SetSnapshotRef("feature", head, BranchRef))
+ out, err := b.Build()
+ require.NoError(t, err)
+
+ return out
+ }
+ rebuild := func(t *testing.T, base Metadata, edit
func(*MetadataBuilder)) Metadata {
+ b, err := MetadataBuilderFromBase(base, "")
+ require.NoError(t, err)
+ edit(b)
+ out, err := b.Build()
+ require.NoError(t, err)
+
+ return out
+ }
+
+ tests := []struct {
+ name string
+ branch string
+ stage func(t *testing.T, tx *Transaction)
+ validator conflictValidatorFunc
+ peer func(t *testing.T, base Metadata) Metadata
+ want error
+ message string
+ matchesCommitFailed bool
+ // mentionsRetry marks validator causes, whose text includes
+ // ErrCommitFailed's.
+ mentionsRetry bool
+ }{
+ {
+ name: "diverged base", validator:
func(*conflictContext) error { return nil },
+ peer: func(t *testing.T, base Metadata) Metadata {
+ return rebuild(t, base, func(b
*MetadataBuilder) {
+ require.NoError(t,
b.AddSnapshot(&Snapshot{
+ SnapshotID: 300,
SequenceNumber: 2, TimestampMs: base.LastUpdatedMillis() + 1,
+ Summary: &Summary{Operation:
OpAppend},
+ }))
+ require.NoError(t,
b.SetSnapshotRef(MainBranch, 300, BranchRef))
+ })
+ },
+ want: ErrCommitDiverged,
+ message: "transaction cannot be committed again: base
diverged: commit diverged: base snapshot is no longer on the branch: ",
+ },
+ {
+ name: "conflicting data files", validator:
rejectConcurrent(ErrConflictingDataFiles),
+ peer: func(t *testing.T, base Metadata)
Metadata { return graftSnapshotOnto(t, base, MainBranch, 200) },
+ want: ErrConflictingDataFiles,
+ message: "transaction cannot be committed
again: validation conflict: commit failed, refresh and try again: concurrent
data files added: snapshot 200 added f.parquet",
+ matchesCommitFailed: true, mentionsRetry: true,
+ },
+ {
+ name: "conflicting delete files", validator:
rejectConcurrent(ErrConflictingDeleteFiles),
+ peer: func(t *testing.T, base Metadata)
Metadata { return graftSnapshotOnto(t, base, MainBranch, 200) },
+ want: ErrConflictingDeleteFiles,
+ message: "transaction cannot be committed
again: validation conflict: commit failed, refresh and try again: concurrent
delete files added: snapshot 200 added f.parquet",
+ matchesCommitFailed: true, mentionsRetry: true,
+ },
+ {
+ name: "data files missing", validator:
rejectConcurrent(ErrDataFilesMissing),
+ peer: func(t *testing.T, base Metadata)
Metadata { return graftSnapshotOnto(t, base, MainBranch, 200) },
+ want: ErrDataFilesMissing,
+ message: "transaction cannot be committed
again: validation conflict: commit failed, refresh and try again: referenced
data files missing: snapshot 200 added f.parquet",
+ matchesCommitFailed: true, mentionsRetry: true,
+ },
+ {
+ name: "wrapped conflict from any validator",
+ validator: func(cc *conflictContext) error {
+ if len(cc.concurrent) > 0 {
+ return fmt.Errorf("custom validator:
%w", ErrCommitFailed)
+ }
+
+ return nil
+ },
+ peer: func(t *testing.T, base Metadata)
Metadata { return graftSnapshotOnto(t, base, MainBranch, 200) },
+ message: "transaction cannot be committed
again: validation conflict: custom validator: commit failed, refresh and try
again",
+ matchesCommitFailed: true, mentionsRetry: true,
+ },
+ {
+ name: "commit branch became a tag", branch: "feature",
+ peer: func(t *testing.T, base Metadata) Metadata {
+ return rebuild(t, base, func(b
*MetadataBuilder) {
+ require.NoError(t,
b.RemoveSnapshotRef("feature"))
+ require.NoError(t,
b.SetSnapshotRef("feature", head, TagRef))
+ })
+ },
+ message: `transaction cannot be committed again: commit
branch is no longer a branch: requirement failed: ref "feature" is a tag; tags
cannot be transaction targets`,
+ },
+ {
+ name: "commit branch was deleted", branch: "feature",
+ peer: func(t *testing.T, base Metadata) Metadata {
+ return rebuild(t, base, func(b
*MetadataBuilder) { require.NoError(t, b.RemoveSnapshotRef("feature")) })
+ },
+ message: `transaction cannot be committed
again: commit branch is no longer a branch: requirement failed: branch or tag
"feature" is missing, expected 100`,
+ matchesCommitFailed: true,
+ },
+ {
+ name: "stale schema",
+ stage: func(t *testing.T, tx *Transaction) {
+ require.NoError(t, tx.UpdateSchema(true, false).
+ AddColumn([]string{"writer"},
iceberg.PrimitiveTypes.String, "", false, nil).Commit())
+ },
+ peer: func(t *testing.T, base Metadata) Metadata {
+ return rebuild(t, base, func(b
*MetadataBuilder) {
+ require.NoError(t,
b.AddSchema(iceberg.NewSchema(1,
+ iceberg.NestedField{ID: 1,
Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
+ iceberg.NestedField{ID: 2,
Name: "peer", Type: iceberg.PrimitiveTypes.String},
+ )))
+ require.NoError(t,
b.SetCurrentSchemaID(-1))
+ })
+ },
+ message: "transaction cannot be committed again:
requirement no longer holds after refresh: " +
+ "Requirement failed: current schema id has
changed: expected 0, found 1",
+ matchesCommitFailed: true,
+ },
+ {
+ name: "stale default sort order",
+ stage: func(t *testing.T, tx *Transaction) {
require.NoError(t, tx.AssertDefaultShape()) },
+ peer: func(t *testing.T, base Metadata) Metadata {
+ return rebuild(t, base, func(b
*MetadataBuilder) {
+ order, err := NewSortOrder(1,
[]SortField{{
+ SourceIDs: []int{1}, Transform:
iceberg.IdentityTransform{}, Direction: SortASC, NullOrder: NullsFirst,
+ }})
+ require.NoError(t, err)
+ require.NoError(t,
b.AddSortOrder(&order))
+ require.NoError(t,
b.SetDefaultSortOrderID(-1))
+ })
+ },
+ message: "transaction cannot be committed again:
requirement no longer holds after refresh: " +
+ "requirement failed: default sort order id has
changed: expected 0, found 1",
+ matchesCommitFailed: true,
+ },
+ {
+ name: "pinned ref moved",
+ stage: func(t *testing.T, tx *Transaction) {
require.NoError(t, tx.AssertRefSnapshotID(MainBranch)) },
+ peer: func(t *testing.T, base Metadata) Metadata {
return graftSnapshotOnto(t, base, MainBranch, 200) },
+ message: "transaction cannot be committed again:
requirement no longer holds after refresh: " +
+ `requirement failed: branch "main" has changed:
expected id 100, found 200`,
+ matchesCommitFailed: true,
+ },
+ {
+ name: "non-replayable commit whose branch moved",
+ stage: func(_ *testing.T, tx *Transaction) {
tx.noReplay = true },
+ peer: func(t *testing.T, base Metadata) Metadata {
return graftSnapshotOnto(t, base, MainBranch, 200) },
+ message: "transaction cannot be committed again:
requirement no longer holds after refresh (" + noReplayHint + "): " +
+ `requirement failed: branch "main" has changed:
expected id 100, found 200`,
+ matchesCommitFailed: true,
+ },
+ {
+ name: "table was replaced",
+ peer: func(t *testing.T, base Metadata) Metadata {
+ return rebuild(t, base, func(b
*MetadataBuilder) { require.NoError(t, b.SetUUID(uuid.New())) })
+ },
+ want: ErrInvalidMetadata,
+ message: "transaction cannot be committed again: table
was replaced: ",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ base := withFeature(t,
newConflictTestMetadataWithProps(t, &head, noRetries))
+ tbl, cat := newAssertRefTestTable(t, base, tt.peer(t,
base))
+ tx := tbl.NewTransaction()
+ if tt.branch != "" {
+ branchTx, err :=
tbl.NewTransactionOnBranch(tt.branch)
+ require.NoError(t, err)
+ tx = branchTx
+ }
+ require.NoError(t,
tx.SetProperties(iceberg.Properties{"k": "v"}))
+ if tt.stage != nil {
+ tt.stage(t, tx)
+ }
+ if tt.validator != nil {
+ tx.addValidator(tt.validator)
+ }
+
+ _, err := tx.Commit(t.Context())
+ require.ErrorIs(t, err, ErrCommitFailed)
+ require.NotErrorIs(t, err, ErrTransactionUnusable)
+ require.Equal(t, int32(1), cat.attempts.Load())
+ require.Equal(t, int32(0), cat.loads.Load(), "retries
are disabled")
+
+ _, err = tx.Commit(t.Context())
+ require.ErrorIs(t, err, ErrTransactionUnusable)
+ assert.Equal(t, int32(1), cat.loads.Load(), "the
re-commit refreshes once")
+ if tt.want != nil {
+ require.ErrorIs(t, err, tt.want)
+ }
+ if tt.matchesCommitFailed {
+ require.ErrorIs(t, err, ErrCommitFailed)
+ } else {
+ require.NotErrorIs(t, err, ErrCommitFailed, "a
caller retrying on ErrCommitFailed must stop")
+ }
+ assert.True(t, strings.HasPrefix(err.Error(),
tt.message), err.Error())
+ if !tt.mentionsRetry {
+ assert.NotContains(t, err.Error(), "refresh and
try again")
+ }
+ assert.Equal(t, int32(1), cat.attempts.Load(), "the
resume must stop before the catalog")
+
+ _, err = tx.Commit(t.Context())
+ assert.EqualError(t, err, "transaction cannot be
committed again: a previous commit failed, build a new transaction")
+ assert.Equal(t, int32(1), cat.attempts.Load())
+ assert.Equal(t, int32(1), cat.loads.Load(), "an
unusable transaction must not refresh again")
+ })
+ }
+
+ t.Run("validator read failure stays resumable", func(t *testing.T) {
+ base := newConflictTestMetadataWithProps(t, &head, noRetries)
+ tbl, cat := newAssertRefTestTable(t, base, graftSnapshotOnto(t,
base, MainBranch, 200))
+ tx := tbl.NewTransaction()
+ require.NoError(t, tx.SetProperties(iceberg.Properties{"k":
"v"}))
+ readFailures := 1
+ tx.addValidator(func(cc *conflictContext) error {
+ if len(cc.concurrent) > 0 && readFailures > 0 {
+ readFailures--
+
+ return errors.New("simulated manifest read
failure")
+ }
+
+ return nil
+ })
+
+ _, err := tx.Commit(t.Context())
+ require.ErrorIs(t, err, ErrCommitFailed)
+
+ _, err = tx.Commit(t.Context())
+ require.EqualError(t, err, "simulated manifest read failure")
+
+ _, err = tx.Commit(t.Context())
+ require.NoError(t, err)
+ assert.Equal(t, int32(2), cat.attempts.Load())
+ })
+}
+
+// A stale schema requirement cannot be rebased, but a valid one must not
+// prevent retrying against a changed branch head.
+func TestDoCommit_NonRebasedRequirementFailsAfterOneRefresh(t *testing.T) {
+ writerHead := int64(100)
+ props := iceberg.Properties{
+ CommitNumRetriesKey: "3",
+ CommitMinRetryWaitMsKey: "1",
+ CommitMaxRetryWaitMsKey: "2",
+ }
+
+ stalePeerSchema := func(t *testing.T, props iceberg.Properties)
(*headTrackingCatalog, *Transaction) {
+ writerBase := newConflictTestMetadataWithProps(t, &writerHead,
props)
+
+ builder, err := MetadataBuilderFromBase(writerBase, "")
+ require.NoError(t, err)
+ peerSchema := iceberg.NewSchema(1,
+ iceberg.NestedField{ID: 1, Name: "id", Type:
iceberg.PrimitiveTypes.Int64, Required: true},
+ iceberg.NestedField{ID: 2, Name: "peer", Type:
iceberg.PrimitiveTypes.String},
+ )
+ require.NoError(t, builder.AddSchema(peerSchema))
+ require.NoError(t, builder.SetCurrentSchemaID(-1))
+ peerChanged, err := builder.Build()
+ require.NoError(t, err)
+
+ tbl, cat := newAssertRefTestTable(t, writerBase, peerChanged)
+ tx := tbl.NewTransaction()
+ require.NoError(t, tx.UpdateSchema(true, false).
+ AddColumn([]string{"writer"},
iceberg.PrimitiveTypes.String, "", false, nil).
+ Commit())
+
+ return cat, tx
+ }
+
+ const staleSchema = "transaction cannot be committed again: requirement
no longer holds after refresh: " +
+ "Requirement failed: current schema id has changed: expected 0,
found 1"
+
+ t.Run("stale schema update fails after one refresh", func(t *testing.T)
{
+ cat, tx := stalePeerSchema(t, props)
+
+ _, err := tx.Commit(t.Context())
+ require.ErrorIs(t, err, ErrCommitFailed)
+ require.ErrorIs(t, err, ErrTransactionUnusable)
+ assert.EqualError(t, err, staleSchema)
+ assert.Equal(t, int32(1), cat.attempts.Load(), "the stale
requirement must not be resubmitted")
+ assert.Equal(t, int32(1), cat.loads.Load(), "the commit must
stop after the first refresh")
+ assert.Equal(t, 1, cat.metadata.CurrentSchema().ID, "the peer's
schema must survive")
+ })
+
+ t.Run("requirements that still hold do not block the rebase", func(t
*testing.T) {
+ writerBase := newConflictTestMetadataWithProps(t, &writerHead,
props)
+ advanced := graftSnapshotOnto(t, writerBase, MainBranch, 200)
+ tbl, cat := newAssertRefTestTable(t, writerBase, advanced)
+
+ reqs := []Requirement{
+ AssertRefSnapshotID(MainBranch, &writerHead),
+ AssertCurrentSchemaID(writerBase.CurrentSchema().ID),
+ AssertTableUUID(writerBase.TableUUID()),
+ }
+ _, err := tbl.doCommit(t.Context(),
+ []Update{NewSetPropertiesUpdate(iceberg.Properties{"k":
"v"})}, reqs,
+ withCommitBranch(MainBranch))
+ require.NoError(t, err)
+ assert.Equal(t, int32(2), cat.attempts.Load())
+ assert.Equal(t, int32(1), cat.loads.Load())
+ assert.Equal(t, "v", cat.metadata.Properties()["k"])
+ })
+}
+
func TestRefreshPassesIdentifierCopy(t *testing.T) {
branchHead := int64(1)
meta := newConflictTestMetadataWithProps(t, &branchHead, nil)
diff --git a/table/commit_retry_test.go b/table/commit_retry_test.go
index a404cd852..b0c478be2 100644
--- a/table/commit_retry_test.go
+++ b/table/commit_retry_test.go
@@ -21,8 +21,10 @@ import (
"context"
"errors"
"fmt"
+ "maps"
"math"
"path/filepath"
+ "slices"
"strconv"
"strings"
"sync/atomic"
@@ -630,11 +632,9 @@ func TestDoCommit_OrphanNotCleanedOnUnknownError(t
*testing.T) {
"on unknown 5xx every rebuild's manifest list must be preserved
(one may be the live commit)")
}
-// TestDoCommit_OrphanCleanedOnCommitDiverged verifies that manifest-list files
-// orphaned by rebuild attempts are removed when ErrCommitDiverged is returned
-// by a conflict validator. Diverged commits are terminal (no retry), and since
-// neither of the orphaned files was ever accepted by the catalog, they are
safe
-// to delete. The defer cleanup runs with cleanupOrphans=true on this path.
+// TestDoCommit_OrphanCleanedOnCommitDiverged verifies that when a conflict
+// validator returns ErrCommitDiverged after a rebuild, the manifest list the
+// rebuild wrote is removed while the staged list the caller owns is kept.
func TestDoCommit_OrphanCleanedOnCommitDiverged(t *testing.T) {
spec := iceberg.NewPartitionSpec()
wfs, meta := newMemIOWithRetryMeta(t, spec)
@@ -684,18 +684,26 @@ func TestDoCommit_OrphanCleanedOnCommitDiverged(t
*testing.T) {
)
require.ErrorIs(t, err, ErrCommitDiverged)
- // The defer must have fired with cleanupOrphans=true and removed the
orphan.
- _, stillExists := wfs.files[originalManifestList]
- assert.False(t, stillExists,
- "orphaned manifest list must be removed even on
ErrCommitDiverged: "+
- "the file was never accepted by the catalog so it is
safe to delete")
+ 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) {
+ t.Helper()
+ var lists []string
+ for path := range wfs.files {
+ if strings.HasPrefix(filepath.Base(path), "snap-") {
+ lists = append(lists, path)
+ }
+ }
+ assert.Equal(t, []string{want}, lists)
}
// TestDoCommit_OrphanCleanedOnRetriesExhausted verifies that when every retry
-// attempt fails with ErrCommitFailed and the loop exits with the budget
-// exhausted, the defer still fires with cleanupOrphans=true. None of the
-// orphaned manifest-list files were ever accepted by the catalog, so they are
-// safe to delete on this terminal exit.
+// attempt fails with ErrCommitFailed, the manifest lists the rebuilds wrote,
+// including the final attempt's, are removed while the staged list the caller
+// owns is kept for a later Commit.
func TestDoCommit_OrphanCleanedOnRetriesExhausted(t *testing.T) {
spec := iceberg.NewPartitionSpec()
wfs, meta := newMemIOWithRetryMeta(t, spec)
@@ -724,10 +732,7 @@ func TestDoCommit_OrphanCleanedOnRetriesExhausted(t
*testing.T) {
require.ErrorIs(t, err, ErrCommitFailed,
"retries exhausted: terminal error must be ErrCommitFailed")
- _, stillExists := wfs.files[originalManifestList]
- assert.False(t, stillExists,
- "orphaned manifest list must be removed when retries are
exhausted with ErrCommitFailed: "+
- "none of the retry attempts were accepted, so all
orphans are safe to delete")
+ assertOnlyManifestList(t, wfs, originalManifestList)
}
// cancelAwareRemoveIO wraps memIO and, like blobfs, rejects Remove on a
@@ -763,7 +768,9 @@ func (c *cancelAfterCommitCatalog) CommitTable(ctx
context.Context, ident Identi
// TestDoCommit_OrphanCleanedOnCancellation verifies that orphan cleanup does
// not inherit the caller's cancellation: when doCommit exits because ctx was
-// canceled, an IO that honors ctx on Remove must still remove the orphans.
+// canceled, an IO that honors ctx on Remove must still remove the manifest
+// lists its rebuilds wrote, while the caller's staged list is kept for a
+// re-commit.
func TestDoCommit_OrphanCleanedOnCancellation(t *testing.T) {
spec := iceberg.NewPartitionSpec()
mem, meta := newMemIOWithRetryMeta(t, spec)
@@ -778,10 +785,11 @@ func TestDoCommit_OrphanCleanedOnCancellation(t
*testing.T) {
require.NoError(t, err)
originalManifestList :=
updates[0].(*addSnapshotUpdate).Snapshot.ManifestList
require.Contains(t, mem.files, originalManifestList)
+ filesBefore := slices.Sorted(maps.Keys(mem.files))
- // The first failure triggers a rebuild that orphans the original
manifest
- // list; the context is canceled during the second attempt, so doCommit
- // exits on the cancellation with orphan cleanup still enabled.
+ // The first failure triggers a rebuild that writes a new manifest list;
+ // the context is canceled during the second attempt, so doCommit exits
+ // on the cancellation with orphan cleanup still enabled.
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
cat := &cancelAfterCommitCatalog{
@@ -797,8 +805,8 @@ func TestDoCommit_OrphanCleanedOnCancellation(t *testing.T)
{
_, err = tbl.doCommit(ctx, updates, reqs, withCommitBranch(MainBranch))
require.ErrorIs(t, err, context.Canceled)
- assert.NotContains(t, mem.files, originalManifestList,
- "orphaned manifest list must be removed even when the commit
ends on cancellation")
+ assert.Equal(t, filesBefore, slices.Sorted(maps.Keys(mem.files)),
+ "rebuilt manifest lists must be removed even when the commit
ends on cancellation, and the staged list kept")
}
// ---------------------------------------------------------------------------
@@ -836,9 +844,20 @@ type progressingRebuildCatalog struct {
committedSnapshot *Snapshot // populated when CommitTable accepts
peerCount int // running peer-commit counter (=
total-data-files contributed by peers)
+
+ // failLoad makes that LoadTable call (1-based) fail. cancel runs once
+ // CommitTable has been called cancelAt times.
+ loads int
+ failLoad int
+ cancelAt int32
+ cancel context.CancelFunc
}
func (c *progressingRebuildCatalog) LoadTable(_ context.Context, ident
Identifier) (*Table, error) {
+ c.loads++
+ if c.loads == c.failLoad {
+ return nil, errors.New("simulated refresh failure")
+ }
c.loadedLastSeqNums = append(c.loadedLastSeqNums,
c.metadata.LastSequenceNumber())
return New(ident, c.metadata, "",
@@ -847,6 +866,9 @@ func (c *progressingRebuildCatalog) LoadTable(_
context.Context, ident Identifie
func (c *progressingRebuildCatalog) CommitTable(_ context.Context, _
Identifier, _ []Requirement, updates []Update) (Metadata, string, error) {
n := c.commitTableCalls.Add(1)
+ if c.cancel != nil && n == c.cancelAt {
+ defer c.cancel()
+ }
// Capture the manifest-list path and sequence number being submitted
// this attempt — the test asserts each retry produces a unique path
@@ -1101,17 +1123,19 @@ func
TestTransactionCommit_RetriableAfterCleanConflict(t *testing.T) {
_, err := tx.Commit(t.Context())
require.ErrorIs(t, err, ErrCommitFailed, "first commit must surface the
clean conflict")
+ assert.NotErrorIs(t, err, ErrTransactionUnusable)
assert.False(t, tx.committed, "clean-conflict failure must leave
committed == false")
assert.Equal(t, int32(1), cat.attempts.Load(), "first commit must reach
the catalog once")
- // The transaction stays usable: applying further changes and retrying
the
- // commit must both be allowed.
+ // Changes staged after the conflict are part of the re-commit.
require.NoError(t, tx.SetProperties(map[string]string{"key2":
"value2"}),
"apply must be allowed after a failed commit")
committed, err := tx.Commit(t.Context())
require.NoError(t, err, "commit retry must be allowed after a clean
conflict")
require.NotNil(t, committed)
+ assert.Equal(t, "value", committed.Properties()["key"])
+ assert.Equal(t, "value2", committed.Properties()["key2"])
assert.True(t, tx.committed, "committed must be set only after a
successful commit")
assert.Equal(t, int32(2), cat.attempts.Load(), "the retry must reach
the catalog")
@@ -1122,6 +1146,130 @@ func
TestTransactionCommit_RetriableAfterCleanConflict(t *testing.T) {
assert.ErrorContains(t, err, "already been committed")
}
+func TestTransactionCommit_ResumedConflictStaysResumable(t *testing.T) {
+ cat := &sequentialCatalog{errs: []error{ErrCommitFailed,
ErrCommitFailed}}
+ tbl := newRetryTestTable(t, cat, nil)
+ cat.metadata = tbl.Metadata()
+
+ tx := tbl.NewTransaction()
+ require.NoError(t, tx.SetProperties(map[string]string{"key": "value"}))
+
+ for range 2 {
+ _, err := tx.Commit(t.Context())
+ require.ErrorIs(t, err, ErrCommitFailed)
+ require.NotErrorIs(t, err, ErrTransactionUnusable)
+ }
+
+ committed, err := tx.Commit(t.Context())
+ require.NoError(t, err)
+ assert.Equal(t, "value", committed.Properties()["key"])
+ assert.Equal(t, int32(3), cat.attempts.Load(), "each Commit submits
once with retries disabled")
+}
+
+func TestTransactionCommit_ResumeRebuildsAfterPeerSnapshotOnAnotherBranch(t
*testing.T) {
+ spec := iceberg.NewPartitionSpec()
+ wfs := newMemIO(1<<20, nil)
+ meta, err := NewMetadata(simpleSchema(), &spec, UnsortedSortOrder,
"mem://default/table-location",
+ iceberg.Properties{PropertyFormatVersion: "2"})
+ require.NoError(t, err)
+ cat := &sequentialCatalog{metadata: meta}
+ tbl := newOCCTable(t, meta, wfs, cat)
+
+ seed := tbl.NewTransaction()
+ require.NoError(t, seed.AddDataFiles(t.Context(), []iceberg.DataFile{
+ newTestDataFile(t, spec,
"mem://default/table-location/data/f1.parquet", nil),
+ }, nil))
+ tbl, err = seed.Commit(t.Context())
+ require.NoError(t, err)
+ mainHead := tbl.CurrentSnapshot().SnapshotID
+
+ tx := tbl.NewTransaction()
+ require.NoError(t, tx.AddDataFiles(t.Context(), []iceberg.DataFile{
+ newTestDataFile(t, spec,
"mem://default/table-location/data/f2.parquet", nil),
+ }, nil))
+
+ // A peer adds a snapshot on another branch. main is unchanged, but the
+ // staged snapshot's sequence number is now taken, so the catalog
+ // rejects the commit as a conflict.
+ builder, err := MetadataBuilderFromBase(cat.metadata, "")
+ require.NoError(t, err)
+ peerSeq := cat.metadata.LastSequenceNumber() + 1
+ require.NoError(t, builder.AddSnapshot(&Snapshot{
+ SnapshotID: mainHead + 1,
+ ParentSnapshotID: &mainHead,
+ SequenceNumber: peerSeq,
+ TimestampMs: cat.metadata.LastUpdatedMillis() + 1,
+ Summary: &Summary{Operation: OpAppend},
+ }))
+ require.NoError(t, builder.SetSnapshotRef("other", mainHead+1,
BranchRef))
+ cat.metadata, err = builder.Build()
+ require.NoError(t, err)
+ cat.errs = []error{nil, ErrCommitFailed}
+ var stagedList string
+ for _, u := range tx.meta.updates {
+ if su, ok := u.(*addSnapshotUpdate); ok {
+ stagedList = su.Snapshot.ManifestList
+ }
+ }
+
+ _, err = tx.Commit(t.Context())
+ require.ErrorIs(t, err, ErrCommitFailed)
+ require.NotErrorIs(t, err, ErrTransactionUnusable)
+
+ committed, err := tx.Commit(t.Context())
+ require.NoError(t, err)
+ head := committed.CurrentSnapshot()
+ require.NotNil(t, head.ParentSnapshotID)
+ assert.Equal(t, mainHead, *head.ParentSnapshotID)
+ assert.Equal(t, peerSeq+1, head.SequenceNumber, "the resume must
renumber the staged snapshot")
+ assert.NotEqual(t, stagedList, head.ManifestList, "the rebuilt manifest
list must not reuse the first call's name")
+}
+
+func TestTransactionCommit_ResumeOfNonReplayableCommit(t *testing.T) {
+ const hint = "commit carries snapshot-relative delete-file removals and
cannot be replayed; reload the table and rebuild the removals"
+ head := int64(100)
+
+ t.Run("branch unchanged resubmits", func(t *testing.T) {
+ base := newConflictTestMetadataWithProps(t, &head, nil)
+ cat := &sequentialCatalog{metadata: base, errs:
[]error{ErrCommitFailed}}
+ tbl := New(Identifier{"db", "no-replay"}, base, "metadata.json",
+ func(context.Context) (iceio.IO, error) { return
iceio.LocalFS{}, nil }, cat)
+ tx := tbl.NewTransaction()
+ require.NoError(t, tx.SetProperties(iceberg.Properties{"k":
"v"}))
+ tx.noReplay = true
+
+ _, err := tx.Commit(t.Context())
+ require.ErrorIs(t, err, ErrCommitFailed)
+ require.NotErrorIs(t, err, ErrTransactionUnusable)
+ assert.EqualError(t, err, "commit failed, refresh and try again
("+hint+")")
+
+ _, err = tx.Commit(t.Context())
+ require.NoError(t, err)
+ assert.Equal(t, int32(2), cat.attempts.Load())
+ })
+
+ t.Run("branch moved is unusable", func(t *testing.T) {
+ base := newConflictTestMetadataWithProps(t, &head, nil)
+ cat := &sequentialCatalog{metadata: base, loadMeta:
graftSnapshotOnto(t, base, MainBranch, 200), errs: []error{ErrCommitFailed}}
+ tbl := New(Identifier{"db", "no-replay"}, base, "metadata.json",
+ func(context.Context) (iceio.IO, error) { return
iceio.LocalFS{}, nil }, cat)
+ tx := tbl.NewTransaction()
+ require.NoError(t, tx.SetProperties(iceberg.Properties{"k":
"v"}))
+ tx.noReplay = true
+
+ _, err := tx.Commit(t.Context())
+ require.ErrorIs(t, err, ErrCommitFailed)
+ require.NotErrorIs(t, err, ErrTransactionUnusable)
+
+ _, err = tx.Commit(t.Context())
+ require.ErrorIs(t, err, ErrTransactionUnusable)
+ require.ErrorIs(t, err, ErrCommitFailed)
+ assert.EqualError(t, err, "transaction cannot be committed
again: requirement no longer holds after refresh ("+hint+"): "+
+ `requirement failed: branch "main" has changed:
expected id 100, found 200`)
+ assert.Equal(t, int32(1), cat.attempts.Load(), "the resume must
stop before the catalog")
+ })
+}
+
// doCommit exhausts its own retry loop on ErrCommitFailed, the transaction
must still be left retriable.
func TestTransactionCommit_RetriableAfterExhaustedInternalRetries(t
*testing.T) {
// numRetries == 2 → doCommit makes 3 attempts, all clean conflicts, so
it
@@ -1141,8 +1289,218 @@ func
TestTransactionCommit_RetriableAfterExhaustedInternalRetries(t *testing.T)
_, err := tx.Commit(t.Context())
require.ErrorIs(t, err, ErrCommitFailed)
+ assert.NotErrorIs(t, err, ErrTransactionUnusable)
assert.Equal(t, int32(3), cat.attempts.Load(), "doCommit must exhaust
all internal attempts")
assert.False(t, tx.committed, "exhausted clean-conflict retries must
leave committed == false")
+
+ _, err = tx.Commit(t.Context())
+ require.NoError(t, err)
+ assert.Equal(t, int32(4), cat.attempts.Load())
+ assert.Equal(t, 1, countAssertTableUUID(cat.lastReqs),
+ "retry must not append a duplicate AssertTableUUID")
+}
+
+func TestTransactionCommit_CommittableAfterTransientFailureFollowingRebuild(t
*testing.T) {
+ tests := []struct {
+ name string
+ setup func(*progressingRebuildCatalog, context.CancelFunc)
+ wantErr error
+ message string
+ }{
+ {
+ name: "refresh error",
+ setup: func(c *progressingRebuildCatalog, _
context.CancelFunc) { c.failLoad = 2 },
+ message: "refresh table for retry: simulated refresh
failure",
+ },
+ {
+ name: "context cancelled during retry wait",
+ setup: func(c *progressingRebuildCatalog, cancel
context.CancelFunc) { c.cancelAt, c.cancel = 2, cancel },
+ wantErr: context.Canceled,
+ message: "context canceled",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ spec := iceberg.NewPartitionSpec()
+ wfs, meta := newMemIOWithRetryMeta(t, spec)
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+ // The first two submissions conflict and the third
lands.
+ cat := &progressingRebuildCatalog{
+ metadata: meta, wfs: wfs, location:
"mem://default/table-location", branch: MainBranch, failTimes: 2,
+ }
+ tt.setup(cat, cancel)
+ tbl := newOCCTable(t, meta, wfs, cat)
+
+ tx := tbl.NewTransaction()
+ require.NoError(t, tx.AddDataFiles(t.Context(),
[]iceberg.DataFile{
+ newTestDataFile(t, spec,
"mem://default/table-location/data/f.parquet", nil),
+ }, nil))
+ var staged string
+ for _, u := range tx.meta.updates {
+ if su, ok := u.(*addSnapshotUpdate); ok {
+ staged = su.Snapshot.ManifestList
+ }
+ }
+ require.NotEmpty(t, staged)
+
+ _, err := tx.Commit(ctx)
+ if tt.wantErr != nil {
+ require.ErrorIs(t, err, tt.wantErr)
+ }
+ require.EqualError(t, err, tt.message)
+ require.NotErrorIs(t, err, ErrTransactionUnusable)
+ require.Equal(t, int32(2), cat.commitTableCalls.Load(),
"the failure must follow a rebuilt attempt")
+ assertOnlyManifestList(t, wfs, staged)
+
+ committed, err := tx.Commit(t.Context())
+ require.NoError(t, err)
+ assert.Equal(t, int32(3), cat.commitTableCalls.Load())
+ require.NotNil(t,
committed.CurrentSnapshot().ParentSnapshotID)
+ assert.Equal(t, int64(9_002),
*committed.CurrentSnapshot().ParentSnapshotID, "the resume must rebase onto the
peers")
+ _, stillExists := wfs.files[staged]
+ assert.False(t, stillExists, "the staged list is
deleted once the commit lands")
+ _, live :=
wfs.files[committed.CurrentSnapshot().ManifestList]
+ assert.True(t, live)
+ })
+ }
+}
+
+func TestTransactionCommit_BusyTableWithRetriesDisabledResumesUntilItLands(t
*testing.T) {
+ spec := iceberg.NewPartitionSpec()
+ wfs := newMemIO(1<<20, nil)
+ meta, err := NewMetadata(simpleSchema(), &spec, UnsortedSortOrder,
"mem://default/table-location", nil)
+ require.NoError(t, err)
+ // Each failed submission grafts a peer commit, so every resume must
+ // rebuild onto a head that moves again before the next submission.
+ cat := &progressingRebuildCatalog{
+ metadata: meta, wfs: wfs, location:
"mem://default/table-location", branch: MainBranch, failTimes: 2,
+ }
+ tbl := newOCCTable(t, meta, wfs, cat)
+
+ tx := tbl.NewTransaction()
+ require.NoError(t, tx.AddDataFiles(t.Context(), []iceberg.DataFile{
+ newTestDataFile(t, spec,
"mem://default/table-location/data/f.parquet", nil),
+ }, nil))
+ var staged string
+ for _, u := range tx.meta.updates {
+ if su, ok := u.(*addSnapshotUpdate); ok {
+ staged = su.Snapshot.ManifestList
+ }
+ }
+
+ for range 2 {
+ _, err = tx.Commit(t.Context())
+ require.ErrorIs(t, err, ErrCommitFailed)
+ require.NotErrorIs(t, err, ErrTransactionUnusable)
+ assertOnlyManifestList(t, wfs, staged)
+ }
+
+ committed, err := tx.Commit(t.Context())
+ require.NoError(t, err)
+ assert.Equal(t, int32(3), cat.commitTableCalls.Load(), "each Commit
submits once with retries disabled")
+ head := committed.CurrentSnapshot()
+ require.NotNil(t, head.ParentSnapshotID)
+ assert.Equal(t, int64(9_002), *head.ParentSnapshotID, "the last resume
must rebase onto both peers")
+ assert.Equal(t, "3", head.Summary.Properties["total-data-files"], "both
peers' files and ours")
+ assertOnlyManifestList(t, wfs, head.ManifestList)
+}
+
+func TestTransactionCommit_AbandonedAfterFailureLeavesOnlyStagedFiles(t
*testing.T) {
+ spec := iceberg.NewPartitionSpec()
+ wfs, meta := newMemIOWithRetryMeta(t, spec)
+ cat := &progressingRebuildCatalog{
+ metadata: meta, wfs: wfs, location:
"mem://default/table-location", branch: MainBranch, failTimes: 99,
+ }
+ tbl := newOCCTable(t, meta, wfs, cat)
+
+ tx := tbl.NewTransaction()
+ require.NoError(t, tx.AddDataFiles(t.Context(), []iceberg.DataFile{
+ newTestDataFile(t, spec,
"mem://default/table-location/data/f.parquet", nil),
+ }, nil))
+ before := slices.Sorted(maps.Keys(wfs.files))
+
+ _, err := tx.Commit(t.Context())
+ require.ErrorIs(t, err, ErrCommitFailed)
+ require.Equal(t, int32(4), cat.commitTableCalls.Load())
+
+ var after []string
+ for path := range wfs.files {
+ if !strings.HasPrefix(filepath.Base(path), "peer-") {
+ after = append(after, path)
+ }
+ }
+ slices.Sort(after)
+ assert.Equal(t, before, after, "only the staged files may remain, and
all of them")
+}
+
+func TestTransactionTableCommitRefusedAfterFailedCommit(t *testing.T) {
+ cat := &sequentialCatalog{errs: []error{ErrCommitFailed}}
+ tbl := newRetryTestTable(t, cat, nil)
+ cat.metadata = tbl.Metadata()
+
+ tx := tbl.NewTransaction()
+ require.NoError(t, tx.SetProperties(map[string]string{"key": "value"}))
+ _, err := tx.TableCommit()
+ require.NoError(t, err)
+
+ _, err = tx.Commit(t.Context())
+ require.ErrorIs(t, err, ErrCommitFailed)
+
+ _, err = tx.TableCommit()
+ require.EqualError(t, err, "a previous commit of this transaction
failed, call Commit to resume it")
+ assert.NotErrorIs(t, err, ErrTransactionUnusable)
+
+ _, err = tx.Commit(t.Context())
+ require.NoError(t, err)
+}
+
+// A rebuild that writes the first staged snapshot's list and then fails on
+// the second still owns the list it wrote. The staged lists stay.
+func TestDoCommit_PartialRebuildFailureRemovesWrittenList(t *testing.T) {
+ head := int64(100)
+ base := newConflictTestMetadataWithProps(t, &head, iceberg.Properties{
+ CommitNumRetriesKey: "1",
+ CommitMinRetryWaitMsKey: "1",
+ CommitMaxRetryWaitMsKey: "2",
+ })
+ wfs := newMemIO(1<<20, nil)
+ const loc = "mem://default/table-location/metadata/"
+ for _, name := range []string{"staged-1.avro", "staged-2.avro"} {
+ require.NoError(t, wfs.WriteFile(loc+name, []byte("list")))
+ }
+
+ rebuildErr := errors.New("simulated rebuild failure")
+ first := &addSnapshotUpdate{
+ baseUpdate: baseUpdate{ActionName: UpdateAddSnapshot},
+ Snapshot: &Snapshot{SnapshotID: 101, ParentSnapshotID: &head,
SequenceNumber: 2, ManifestList: loc + "staged-1.avro"},
+ rebuildManifestList: func(_ context.Context, _ Metadata, parent
*Snapshot, fio iceio.WriteFileIO, _ int) (*Snapshot, error) {
+ if err := fio.WriteFile(loc+"rebuilt-1.avro",
[]byte("list")); err != nil {
+ return nil, err
+ }
+ parentID := parent.SnapshotID
+
+ return &Snapshot{SnapshotID: 101, ParentSnapshotID:
&parentID, SequenceNumber: 3, ManifestList: loc + "rebuilt-1.avro"}, nil
+ },
+ }
+ firstID := int64(101)
+ second := &addSnapshotUpdate{
+ baseUpdate: baseUpdate{ActionName: UpdateAddSnapshot},
+ Snapshot: &Snapshot{SnapshotID: 102, ParentSnapshotID:
&firstID, SequenceNumber: 3, ManifestList: loc + "staged-2.avro"},
+ rebuildManifestList: func(context.Context, Metadata, *Snapshot,
iceio.WriteFileIO, int) (*Snapshot, error) {
+ return nil, rebuildErr
+ },
+ }
+
+ cat := &sequentialCatalog{metadata: base, loadMeta:
graftSnapshotOnto(t, base, MainBranch, 200), errs: []error{ErrCommitFailed}}
+ tbl := newOCCTable(t, base, wfs, cat)
+ _, err := tbl.doCommit(t.Context(), []Update{first, second}, nil,
withCommitBranch(MainBranch))
+ require.ErrorIs(t, err, rebuildErr)
+
+ assert.Contains(t, wfs.files, loc+"staged-1.avro")
+ assert.Contains(t, wfs.files, loc+"staged-2.avro")
+ assert.NotContains(t, wfs.files, loc+"rebuilt-1.avro", "the list the
failed rebuild wrote must be removed")
}
func TestTransactionCommit_TerminalOnUnknownState(t *testing.T) {
diff --git a/table/no_replay_removals_test.go b/table/no_replay_removals_test.go
index 36dbf2fd6..0cf55062d 100644
--- a/table/no_replay_removals_test.go
+++ b/table/no_replay_removals_test.go
@@ -26,6 +26,7 @@ package table_test
import (
"context"
+ "fmt"
"path/filepath"
"testing"
@@ -76,7 +77,8 @@ func newNoReplayV3Table(t *testing.T) (*table.Table,
*concurrentTestCatalog) {
// own merged DV; a stale writer (still seeing DV1 live) then commits
// its own supersession with retries enabled.
// Assertion: the stale commit fails wrapping ErrCommitFailed after
-// exactly one CommitTable attempt (no replay), and the table carries
+// exactly one CommitTable attempt (no replay), the re-commit finds the
+// branch moved and is refused before the catalog, and the table carries
// exactly one live DV — the peer's — so only the peer's deletes apply.
func TestMoRDeleteSupersedingDVFailsInsteadOfReplaying(t *testing.T) {
ctx := context.Background()
@@ -101,7 +103,8 @@ func TestMoRDeleteSupersedingDVFailsInsteadOfReplaying(t
*testing.T) {
require.NoError(t, staleTxn.Delete(ctx,
iceberg.EqualTo(iceberg.Reference("id"), int64(3)), nil))
// Peer supersedes DV1 from the same view and wins the race.
- _, err = tbl.Delete(ctx, iceberg.EqualTo(iceberg.Reference("id"),
int64(2)), nil)
+ staleHead := tbl.CurrentSnapshot().SnapshotID
+ peer, err := tbl.Delete(ctx, iceberg.EqualTo(iceberg.Reference("id"),
int64(2)), nil)
require.NoError(t, err)
attemptsBefore := cat.attempts.Load()
@@ -111,13 +114,16 @@ func TestMoRDeleteSupersedingDVFailsInsteadOfReplaying(t
*testing.T) {
assert.Equal(t, attemptsBefore+1, cat.attempts.Load(),
"a commit carrying DV removals must fail on the first CAS
conflict, not refresh-and-replay")
- // The clean conflict leaves the transaction retriable, not latched
- // as committed: a naive same-transaction retry surfaces a fresh
- // conflict (its requirement still targets the stale base) rather
- // than "transaction has already been committed".
+ assert.NotErrorIs(t, err, table.ErrTransactionUnusable)
+ assert.Contains(t, err.Error(), "reload the table and rebuild the
removals")
+
+ attemptsBefore = cat.attempts.Load()
_, err = staleTxn.Commit(ctx)
- require.Error(t, err)
- assert.ErrorIs(t, err, table.ErrCommitFailed)
+ require.ErrorIs(t, err, table.ErrTransactionUnusable)
+ assert.EqualError(t, err, fmt.Sprintf("transaction cannot be committed
again: requirement no longer holds after refresh "+
+ "(commit carries snapshot-relative delete-file removals and
cannot be replayed; reload the table and rebuild the removals): "+
+ "requirement failed: branch \"main\" has changed: expected id
%d, found %d", staleHead, peer.CurrentSnapshot().SnapshotID))
+ assert.Equal(t, attemptsBefore, cat.attempts.Load(), "the re-commit
must stop before the catalog")
// The table is uncorrupted: exactly one live DV (the peer's merged
// one) references the data file, and only the peer's deletes apply.
@@ -231,6 +237,130 @@ func
TestReplaceFilesWithRemovalsFailsInsteadOfReplaying(t *testing.T) {
"the stale compaction must not have landed; ids 1 and 2 stay
deleted by the live position deletes")
}
+// A peer snapshot on another branch leaves main and every requirement
+// 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
+
+ arrowSc, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false,
false)
+ require.NoError(t, err)
+ dataPath := tbl.Location() + "/data/data-001.parquet"
+ writeParquetFile(t, dataPath, arrowSc,
+ `[{"id": 1, "data": "alpha"}, {"id": 2, "data": "beta"}, {"id":
3, "data": "gamma"}]`)
+ tx := tbl.NewTransaction()
+ require.NoError(t, tx.AddDataFilesByPath(t.Context(),
[]string{dataPath}, nil, false))
+ tbl, err = tx.Commit(t.Context())
+ require.NoError(t, err)
+
+ posDelPath := tbl.Location() + "/data/pos-del-001.parquet"
+ writeParquetFile(t, posDelPath, table.PositionalDeleteArrowSchema,
+ `[{"file_path": "`+dataPath+`", "pos": 1}]`)
+ tx2 := tbl.NewTransaction()
+ rd := tx2.NewRowDelta(nil)
+ rd.AddDeletes(newPosDeleteFile(t, posDelPath))
+ require.NoError(t, rd.Commit(t.Context()))
+ tbl, err = tx2.Commit(t.Context())
+ require.NoError(t, err)
+ mainHead := tbl.CurrentSnapshot().SnapshotID
+
+ tasks, err := tbl.Scan().PlanFiles(t.Context())
+ require.NoError(t, err)
+ require.Len(t, tasks, 1)
+ compactedPath := tbl.Location() + "/data/data-compacted.parquet"
+ writeParquetFile(t, compactedPath, arrowSc, `[{"id": 1, "data":
"alpha"}, {"id": 3, "data": "gamma"}]`)
+ compacted, err :=
iceberg.NewDataFileBuilder(*iceberg.UnpartitionedSpec, iceberg.EntryContentData,
+ compactedPath, iceberg.ParquetFile, nil, nil, nil, 2, 512)
+ require.NoError(t, err)
+ staleTxn := tbl.NewTransaction()
+ require.NoError(t, staleTxn.ReplaceFiles(t.Context(),
+ []iceberg.DataFile{tasks[0].File},
[]iceberg.DataFile{compacted.Build()}, tasks[0].DeleteFiles, nil))
+
+ peerPath := tbl.Location() + "/data/data-peer.parquet"
+ writeParquetFile(t, peerPath, arrowSc, `[{"id": 9, "data": "peer"}]`)
+ peerTxn, err := tbl.NewTransactionOnBranch("other")
+ require.NoError(t, err)
+ require.NoError(t, peerTxn.AddDataFilesByPath(t.Context(),
[]string{peerPath}, nil, false))
+ _, err = peerTxn.Commit(t.Context())
+ require.NoError(t, err)
+
+ _, err = staleTxn.Commit(t.Context())
+ require.ErrorIs(t, err, table.ErrCommitFailed)
+ require.NotErrorIs(t, err, table.ErrTransactionUnusable)
+ assert.Contains(t, err.Error(), "reload the table and rebuild the
removals")
+
+ committed, err := staleTxn.Commit(t.Context())
+ require.NoError(t, err)
+ head := committed.CurrentSnapshot()
+ require.NotNil(t, head.ParentSnapshotID)
+ assert.Equal(t, mainHead, *head.ParentSnapshotID, "the resume must keep
the pinned parent")
+ assert.Equal(t, []int64{1, 3}, idsInTable(t, committed))
+ assert.Empty(t, deleteEntriesReferencing(t, committed,
map[string]struct{}{dataPath: {}, compactedPath: {}}),
+ "the compaction removed the only live delete")
+ other := committed.Metadata().SnapshotByName("other")
+ require.NotNil(t, other.ParentSnapshotID)
+ assert.Equal(t, mainHead, *other.ParentSnapshotID, "the peer branch is
untouched")
+}
+
+// A peer that removed a file this compaction replaces makes the resume's
+// rebuild fail the same way on every later attempt, so the transaction
+// must become unusable.
+func TestReplaceFilesResumeAfterPeerRemovedInputIsUnusable(t *testing.T) {
+ tbl, cat := newConcurrentRewriteTestTable(t)
+
+ arrowSc, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false,
false)
+ require.NoError(t, err)
+ dataPath := tbl.Location() + "/data/data-001.parquet"
+ writeParquetFile(t, dataPath, arrowSc, `[{"id": 1, "data": "alpha"},
{"id": 2, "data": "beta"}]`)
+ tx := tbl.NewTransaction()
+ require.NoError(t, tx.AddDataFilesByPath(t.Context(),
[]string{dataPath}, nil, false))
+ require.NoError(t,
tx.SetProperties(iceberg.Properties{table.CommitNumRetriesKey: "0"}))
+ tbl, err = tx.Commit(t.Context())
+ require.NoError(t, err)
+
+ tasks, err := tbl.Scan().PlanFiles(t.Context())
+ require.NoError(t, err)
+ require.Len(t, tasks, 1)
+ replacement := func(name string) iceberg.DataFile {
+ path := tbl.Location() + "/data/" + name + ".parquet"
+ writeParquetFile(t, path, arrowSc, `[{"id": 1, "data":
"alpha"}, {"id": 2, "data": "beta"}]`)
+ b, err :=
iceberg.NewDataFileBuilder(*iceberg.UnpartitionedSpec, iceberg.EntryContentData,
+ path, iceberg.ParquetFile, nil, nil, nil, 2, 512)
+ require.NoError(t, err)
+
+ return b.Build()
+ }
+
+ staleTxn := tbl.NewTransaction()
+ require.NoError(t, staleTxn.ReplaceFiles(t.Context(),
+ []iceberg.DataFile{tasks[0].File},
[]iceberg.DataFile{replacement("stale")}, nil, nil))
+
+ peerTxn := tbl.NewTransaction()
+ require.NoError(t, peerTxn.ReplaceFiles(t.Context(),
+ []iceberg.DataFile{tasks[0].File},
[]iceberg.DataFile{replacement("peer")}, nil, nil))
+ _, err = peerTxn.Commit(t.Context())
+ require.NoError(t, err)
+
+ _, err = staleTxn.Commit(t.Context())
+ require.ErrorIs(t, err, table.ErrCommitFailed)
+ require.NotErrorIs(t, err, table.ErrTransactionUnusable)
+
+ attempts := cat.attempts.Load()
+ _, err = staleTxn.Commit(t.Context())
+ require.ErrorIs(t, err, table.ErrTransactionUnusable)
+ require.ErrorIs(t, err, table.ErrCommitDiverged)
+ require.NotErrorIs(t, err, table.ErrCommitFailed, "a caller retrying on
ErrCommitFailed must stop")
+ assert.EqualError(t, err, fmt.Sprintf("transaction cannot be committed
again: files to rewrite were removed: "+
+ "commit diverged: base snapshot is no longer on the branch: 1
data file(s) to rewrite are no longer on the branch head, e.g. [%q]", dataPath))
+ assert.NotContains(t, err.Error(), "refresh and try again")
+ assert.Equal(t, attempts, cat.attempts.Load(), "the resume must stop
before the catalog")
+
+ _, err = staleTxn.Commit(t.Context())
+ require.ErrorIs(t, err, table.ErrTransactionUnusable)
+ assert.Equal(t, attempts, cat.attempts.Load())
+}
+
// Regression guard: the noReplay flag must not leak to commits that
// remove only data files. Those removals are path-keyed and the retry
// rebuild fails terminally when the path is gone from the fresh base,
diff --git a/table/rebuild_manifest_test.go b/table/rebuild_manifest_test.go
index d6b619503..411324f6a 100644
--- a/table/rebuild_manifest_test.go
+++ b/table/rebuild_manifest_test.go
@@ -137,6 +137,7 @@ func
TestRebuildSnapshotUpdates_CallsClosureWithFreshParent(t *testing.T) {
MainBranch,
iceio.LocalFS{},
1,
+ false,
)
require.NoError(t, err)
require.Len(t, rebuilt, 1)
@@ -225,6 +226,7 @@ func TestRebuildSnapshotUpdates_SkipsWhenParentUnchanged(t
*testing.T) {
MainBranch,
iceio.LocalFS{},
1,
+ false,
)
require.NoError(t, err)
assert.False(t, called, "rebuild closure must not be called when parent
is already up-to-date")
@@ -259,6 +261,7 @@ func
TestRebuildSnapshotUpdates_RebuildsWhenTableStateMoved(t *testing.T) {
MainBranch,
iceio.LocalFS{},
1,
+ false,
)
require.NoError(t, err)
require.NotNil(t, receivedParent, "the rebuild must run even though the
parent is unchanged")
@@ -287,6 +290,7 @@ func
TestRebuildSnapshotUpdates_PassesThroughNonRebuildUpdates(t *testing.T) {
MainBranch,
iceio.LocalFS{},
0,
+ false,
)
require.NoError(t, err)
assert.Empty(t, orphaned)
@@ -315,7 +319,7 @@ func TestRebuildSnapshotUpdates_PropagatesClosureError(t
*testing.T) {
},
}
- _, _, err := rebuildSnapshotUpdates(t.Context(), []Update{upd},
freshMeta, MainBranch, iceio.LocalFS{}, 1)
+ _, _, err := rebuildSnapshotUpdates(t.Context(), []Update{upd},
freshMeta, MainBranch, iceio.LocalFS{}, 1, false)
assert.ErrorIs(t, err, wantErr)
}
diff --git a/table/requirement_test.go b/table/requirement_test.go
index 19b41f7ac..550c44d26 100644
--- a/table/requirement_test.go
+++ b/table/requirement_test.go
@@ -20,6 +20,7 @@ package table_test
import (
"encoding/json"
"fmt"
+ "strings"
"testing"
"github.com/apache/iceberg-go/table"
@@ -310,3 +311,35 @@ func TestAssertRefSnapshotIDValidate(t *testing.T) {
assert.NoError(t, req.Validate(meta))
})
}
+
+func TestRequirementFailuresAreCommitConflicts(t *testing.T) {
+ meta, err :=
table.ParseMetadataBytes([]byte(table.ExampleTableMetadataV2))
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ req table.Requirement
+ meta table.Metadata
+ }{
+ {"table already exists", table.AssertCreate(), meta},
+ {"table uuid mismatch", table.AssertTableUUID(uuid.New()),
meta},
+ {"table metadata missing",
table.AssertTableUUID(meta.TableUUID()), nil},
+ {"ref has changed", table.AssertRefSnapshotID("test",
new(int64(1))), meta},
+ {"ref created concurrently", table.AssertRefSnapshotID("test",
nil), meta},
+ {"ref missing", table.AssertRefSnapshotID("nonexistent",
new(int64(1))), meta},
+ {"last assigned field id changed",
table.AssertLastAssignedFieldID(meta.LastColumnID() + 1), meta},
+ {"current schema id changed",
table.AssertCurrentSchemaID(meta.CurrentSchema().ID + 1), meta},
+ {"last assigned partition id changed",
table.AssertLastAssignedPartitionID(*meta.LastPartitionSpecID() + 1), meta},
+ {"default spec id changed",
table.AssertDefaultSpecID(meta.DefaultPartitionSpec() + 1), meta},
+ {"default sort order id changed",
table.AssertDefaultSortOrderID(meta.DefaultSortOrder() + 1), meta},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.req.Validate(tt.meta)
+ require.Error(t, err)
+ assert.ErrorIs(t, err, table.ErrCommitFailed)
+ assert.Contains(t, strings.ToLower(err.Error()),
"requirement failed")
+ })
+ }
+}
diff --git a/table/requirements.go b/table/requirements.go
index a1abceed6..adcd0fc9f 100644
--- a/table/requirements.go
+++ b/table/requirements.go
@@ -227,6 +227,16 @@ func (b baseRequirement) GetType() string {
return b.Type
}
+func requirementFailed(format string, args ...any) error {
+ return &requirementError{msg: fmt.Sprintf(format, args...)}
+}
+
+type requirementError struct{ msg string }
+
+func (e *requirementError) Error() string { return e.msg }
+
+func (e *requirementError) Unwrap() error { return ErrCommitFailed }
+
type assertCreate struct {
baseRequirement
}
@@ -240,7 +250,7 @@ func AssertCreate() Requirement {
func (a *assertCreate) Validate(meta Metadata) error {
if meta != nil {
- return errors.New("Requirement failed: table already exists")
+ return requirementFailed("Requirement failed: table already
exists")
}
return nil
@@ -261,11 +271,11 @@ func AssertTableUUID(uuid uuid.UUID) Requirement {
func (a *assertTableUuid) Validate(meta Metadata) error {
if meta == nil {
- return errors.New("Requirement failed: current table metadata
does not exist")
+ return requirementFailed("Requirement failed: current table
metadata does not exist")
}
if meta.TableUUID() != a.UUID {
- return fmt.Errorf("Requirement failed: UUID mismatch: %s !=
%s", meta.TableUUID(), a.UUID)
+ return requirementFailed("Requirement failed: UUID mismatch: %s
!= %s", meta.TableUUID(), a.UUID)
}
return nil
@@ -300,7 +310,7 @@ func assertBranchRefSnapshotID(ref string, id *int64)
Requirement {
func (a *assertRefSnapshotID) Validate(meta Metadata) error {
if meta == nil {
- return errors.New("Requirement failed: current table metadata
does not exist")
+ return requirementFailed("Requirement failed: current table
metadata does not exist")
}
var r *SnapshotRef
@@ -318,14 +328,14 @@ func (a *assertRefSnapshotID) Validate(meta Metadata)
error {
}
if a.SnapshotID == nil {
- return fmt.Errorf("requirement failed: %s %q was
created concurrently", r.Type, a.Ref)
+ return requirementFailed("requirement failed: %s %q was
created concurrently", r.Type, a.Ref)
}
if r.SnapshotID != *a.SnapshotID {
- return fmt.Errorf("requirement failed: %s %q has
changed: expected id %d, found %d", r.Type, a.Ref, *a.SnapshotID, r.SnapshotID)
+ return requirementFailed("requirement failed: %s %q has
changed: expected id %d, found %d", r.Type, a.Ref, *a.SnapshotID, r.SnapshotID)
}
} else if a.SnapshotID != nil {
- return fmt.Errorf("requirement failed: branch or tag %q is
missing, expected %d", a.Ref, *a.SnapshotID)
+ return requirementFailed("requirement failed: branch or tag %q
is missing, expected %d", a.Ref, *a.SnapshotID)
}
return nil
@@ -347,11 +357,11 @@ func AssertLastAssignedFieldID(id int) Requirement {
func (a *assertLastAssignedFieldId) Validate(meta Metadata) error {
if meta == nil {
- return errors.New("Requirement failed: current table metadata
does not exist")
+ return requirementFailed("Requirement failed: current table
metadata does not exist")
}
if meta.LastColumnID() != a.LastAssignedFieldID {
- return fmt.Errorf("Requirement failed: last assigned field id
has changed: expected %d, found %d", a.LastAssignedFieldID, meta.LastColumnID())
+ return requirementFailed("Requirement failed: last assigned
field id has changed: expected %d, found %d", a.LastAssignedFieldID,
meta.LastColumnID())
}
return nil
@@ -373,11 +383,11 @@ func AssertCurrentSchemaID(id int) Requirement {
func (a *assertCurrentSchemaId) Validate(meta Metadata) error {
if meta == nil {
- return errors.New("Requirement failed: current table metadata
does not exist")
+ return requirementFailed("Requirement failed: current table
metadata does not exist")
}
if meta.CurrentSchema().ID != a.CurrentSchemaID {
- return fmt.Errorf("Requirement failed: current schema id has
changed: expected %d, found %d", a.CurrentSchemaID, meta.CurrentSchema().ID)
+ return requirementFailed("Requirement failed: current schema id
has changed: expected %d, found %d", a.CurrentSchemaID, meta.CurrentSchema().ID)
}
return nil
@@ -399,7 +409,7 @@ func AssertLastAssignedPartitionID(id int) Requirement {
func (a *assertLastAssignedPartitionId) Validate(meta Metadata) error {
if meta == nil {
- return errors.New("Requirement failed: current table metadata
does not exist")
+ return requirementFailed("Requirement failed: current table
metadata does not exist")
}
lastPartitionSpecID := meta.LastPartitionSpecID()
@@ -408,7 +418,7 @@ func (a *assertLastAssignedPartitionId) Validate(meta
Metadata) error {
}
if *lastPartitionSpecID != a.LastAssignedPartitionID {
- return fmt.Errorf("requirement failed: last assigned partition
id has changed: expected %d, found %d", a.LastAssignedPartitionID,
*lastPartitionSpecID)
+ return requirementFailed("requirement failed: last assigned
partition id has changed: expected %d, found %d", a.LastAssignedPartitionID,
*lastPartitionSpecID)
}
return nil
@@ -430,11 +440,11 @@ func AssertDefaultSpecID(id int) Requirement {
func (a *assertDefaultSpecId) Validate(meta Metadata) error {
if meta == nil {
- return errors.New("Requirement failed: current table metadata
does not exist")
+ return requirementFailed("Requirement failed: current table
metadata does not exist")
}
if meta.DefaultPartitionSpec() != a.DefaultSpecID {
- return fmt.Errorf("Requirement failed: default spec id has
changed: expected %d, found %d", a.DefaultSpecID, meta.DefaultPartitionSpec())
+ return requirementFailed("Requirement failed: default spec id
has changed: expected %d, found %d", a.DefaultSpecID,
meta.DefaultPartitionSpec())
}
return nil
@@ -456,11 +466,11 @@ func AssertDefaultSortOrderID(id int) Requirement {
func (a *assertDefaultSortOrderId) Validate(meta Metadata) error {
if meta == nil {
- return errors.New("requirement failed: current table metadata
does not exist")
+ return requirementFailed("requirement failed: current table
metadata does not exist")
}
if meta.DefaultSortOrder() != a.DefaultSortOrderID {
- return fmt.Errorf("requirement failed: default sort order id
has changed: expected %d, found %d", a.DefaultSortOrderID,
meta.DefaultSortOrder())
+ return requirementFailed("requirement failed: default sort
order id has changed: expected %d, found %d", a.DefaultSortOrderID,
meta.DefaultSortOrder())
}
return nil
diff --git a/table/requirements_internal_test.go
b/table/requirements_internal_test.go
index 5aa772d92..c75531d5f 100644
--- a/table/requirements_internal_test.go
+++ b/table/requirements_internal_test.go
@@ -34,6 +34,7 @@ func TestAssertLastAssignedPartitionIDValidate(t *testing.T) {
err := req.Validate(meta)
require.Error(t, err)
assert.EqualError(t, err, "requirement failed: last assigned
partition id is missing")
+ assert.NotErrorIs(t, err, ErrCommitFailed, "malformed metadata
is not a commit conflict")
})
t.Run("matches metadata last partition id", func(t *testing.T) {
@@ -52,3 +53,14 @@ func TestAssertLastAssignedPartitionIDValidate(t *testing.T)
{
assert.Contains(t, err.Error(), "last assigned partition id has
changed")
})
}
+
+func TestAssertBranchRefSnapshotIDRejectsTagWithoutRetry(t *testing.T) {
+ meta, err := ParseMetadataBytes([]byte(ExampleTableMetadataV2))
+ require.NoError(t, err)
+
+ // "test" is a tag. Refresh cannot make it a valid transaction target.
+ err = assertBranchRefSnapshotID("test",
new(int64(3051729675574597004))).Validate(meta)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "tags cannot be transaction targets")
+ assert.NotErrorIs(t, err, ErrCommitFailed)
+}
diff --git a/table/rewrite_data_files.go b/table/rewrite_data_files.go
index a9ca91016..cda61f6c7 100644
--- a/table/rewrite_data_files.go
+++ b/table/rewrite_data_files.go
@@ -598,8 +598,8 @@ func (t *Transaction) rewriteDataFilesPartial(ctx
context.Context, groups []Comp
if err != nil {
return nil, err
}
- if t.committed {
- return nil, errors.New("transaction has already been committed")
+ if err := t.checkUsable(); err != nil {
+ return nil, err
}
result := &RewriteResult{Table: t.tbl}
if len(meta.updates) > 0 || len(t.reqs) > 0 || len(t.validators) > 0 {
@@ -744,9 +744,10 @@ func (t *Transaction) rewriteDataFilesPartial(ctx
context.Context, groups []Comp
return result, err
}
- // ErrCommitFailed is the only error that proves the
catalog did not
- // commit. Every other error leaves commit state
unknown, so continuing
- // with the old table could apply later batches on
stale state.
+ // Only a conflict lets the loop move on to the next
batch, since each
+ // batch commits through its own transaction built on
the current
+ // table. Any other failure stops the rewrite. It may
be of unknown
+ // commit state, so continuing could apply later
batches on stale state.
if !errors.Is(err, ErrCommitFailed) {
t.committed = true
diff --git a/table/rewrite_files_test.go b/table/rewrite_files_test.go
index df2c512e8..9ddb30683 100644
--- a/table/rewrite_files_test.go
+++ b/table/rewrite_files_test.go
@@ -53,6 +53,9 @@ 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) {
@@ -68,6 +71,10 @@ 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/rewrite_manifests.go b/table/rewrite_manifests.go
index 66a8ef3ba..e2e7dade0 100644
--- a/table/rewrite_manifests.go
+++ b/table/rewrite_manifests.go
@@ -154,21 +154,11 @@ func newRewriteManifestsProducer(txn *Transaction, fs
iceio.WriteFileIO, props i
return prod
}
-// supersededManifests returns the merged manifests orphaned across OCC
retries,
-// safe to delete once the commit resolves. When the commit never landed
-// (committed is false) the final attempt's manifests are orphaned too — a
-// snapshot that never committed references nothing — so they are appended.
-func (r *rewriteManifests) supersededManifests(committed bool) []string {
- if committed {
- return r.superseded
- }
-
- paths := slices.Clone(r.superseded)
- for _, m := range r.added {
- paths = append(paths, m.FilePath())
- }
-
- return paths
+// supersededManifests returns the merged manifests earlier passes wrote and a
+// later pass replaced. The committed snapshot references only the last pass's
+// manifests, so these are safe to delete once the commit lands.
+func (r *rewriteManifests) supersededManifests() []string {
+ return r.superseded
}
func (r *rewriteManifests) parentDependentManifests(_ context.Context, parent
*Snapshot) ([]iceberg.ManifestFile, error) {
@@ -322,15 +312,6 @@ func (r *rewriteManifests) deleteWritten(ctx
context.Context, input, merged []ic
}
func (r *rewriteManifests) record(toRewrite, merged, kept
[]iceberg.ManifestFile) error {
- // A prior pass (a superseded OCC attempt) wrote its own merged
manifests
- // that the catalog never referenced. Carry their paths into the
- // producer's superseded set so commit() can delete them on success;
- // otherwise every retry leaks a full set of merged .avro files.
- for _, m := range r.added {
- r.superseded = append(r.superseded, m.FilePath())
- }
- r.added, r.rewritten = nil, nil
-
inPaths := make(map[string]struct{}, len(toRewrite))
for _, m := range toRewrite {
inPaths[m.FilePath()] = struct{}{}
@@ -343,14 +324,15 @@ func (r *rewriteManifests) record(toRewrite, merged, kept
[]iceberg.ManifestFile
// Bins of a single manifest pass through unchanged; only the manifests
// that actually appear on one side and not the other are
added/replaced.
// This drives the returned RewriteManifestsResult.
+ var added, rewritten []iceberg.ManifestFile
for _, m := range merged {
if _, ok := inPaths[m.FilePath()]; !ok {
- r.added = append(r.added, m)
+ added = append(added, m)
}
}
for _, m := range toRewrite {
if _, ok := outPaths[m.FilePath()]; !ok {
- r.rewritten = append(r.rewritten, m)
+ rewritten = append(rewritten, m)
}
}
@@ -360,11 +342,11 @@ func (r *rewriteManifests) record(toRewrite, merged, kept
[]iceberg.ManifestFile
// rewritten/added diff is equivalent to comparing the full
input/output and
// avoids re-walking the pass-throughs. The processed count doubles as
the
// entries-processed summary value.
- processed, err := manifestActiveFiles(r.base.io, r.rewritten)
+ processed, err := manifestActiveFiles(r.base.io, rewritten)
if err != nil {
return err
}
- written, err := manifestActiveFiles(r.base.io, r.added)
+ written, err := manifestActiveFiles(r.base.io, added)
if err != nil {
return err
}
@@ -372,6 +354,16 @@ func (r *rewriteManifests) record(toRewrite, merged, kept
[]iceberg.ManifestFile
return fmt.Errorf("rewrite manifests changed active file count:
%d before, %d after", processed, written)
}
+ // Publish only after the guard passes. On failure the caller deletes
this
+ // pass's merged files, so the producer must keep the previous pass's
state.
+ // The previous pass's merged manifests were never committed. Carry them
+ // into the superseded set so a successful commit deletes them, or every
+ // retry leaks a full set of merged files.
+ for _, m := range r.added {
+ r.superseded = append(r.superseded, m.FilePath())
+ }
+ r.added, r.rewritten = added, rewritten
+
// Publish into the shared result the caller holds so a retry's counts
win.
r.result.RewrittenManifests = r.rewritten
r.result.AddedManifests = r.added
diff --git a/table/rewrite_manifests_internal_test.go
b/table/rewrite_manifests_internal_test.go
index 642dac9a1..64eaeb029 100644
--- a/table/rewrite_manifests_internal_test.go
+++ b/table/rewrite_manifests_internal_test.go
@@ -22,6 +22,7 @@ import (
"context"
"crypto/sha256"
"encoding/binary"
+ "slices"
"strconv"
"strings"
"sync"
@@ -383,3 +384,55 @@ func TestRewriteManifestsCleansOrphansOnMergeFailure(t
*testing.T) {
require.Truef(t, preexisting, "merged manifest %s from the bin
that succeeded must be cleaned up", path)
}
}
+
+// A pass that fails the file-count guard deletes the files it merged. The
+// producer must keep the previous pass's state, which a resumed commit can
+// reuse, rather than point at the deleted files.
+func TestRewriteManifestsFailedPassKeepsPreviousState(t *testing.T) {
+ spec := iceberg.NewPartitionSpec()
+ schema := simpleSchema()
+ mem := newMemIO(1<<20, errLimitedWrite)
+ txn := createTestTransaction(t, mem, spec)
+
+ prod := newRewriteManifestsProducer(txn, mem, iceberg.Properties{},
rewriteManifestsCfg{targetSizeBytes: 8 << 20})
+ r := prod.producerImpl.(*rewriteManifests)
+
+ inputs := func(name string, addedFiles int32) []iceberg.ManifestFile {
+ out := make([]iceberg.ManifestFile, 2)
+ sequenceNumber := int64(1)
+ for i := range out {
+ df := newTestDataFile(t, spec,
"file://"+name+"-"+string(rune('a'+i))+".parquet", nil)
+ entries := []iceberg.ManifestEntry{
+
iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &prod.snapshotID,
&sequenceNumber, nil, df),
+ }
+ path := "table-location/metadata/" + name + "-" +
string(rune('a'+i)) + ".avro"
+ var buf bytes.Buffer
+ _, err := iceberg.WriteManifest(path, &buf, 2, spec,
schema, prod.snapshotID, entries)
+ require.NoError(t, err)
+ require.NoError(t, mem.WriteFile(path, buf.Bytes()))
+ out[i] = iceberg.NewManifestBuilder(2, path,
int64(buf.Len()), int32(spec.ID()), prod.snapshotID).
+
AddedFiles(addedFiles).Content(iceberg.ManifestContentData).Build()
+ }
+
+ return out
+ }
+
+ _, err := r.processManifests(inputs("good", 1))
+ require.NoError(t, err)
+ added, rewritten, superseded := slices.Clone(r.added),
slices.Clone(r.rewritten), slices.Clone(r.superseded)
+ require.NotEmpty(t, added)
+
+ // A header count that lies trips the guard after the merged file is
written.
+ _, err = r.processManifests(inputs("lying", 99))
+ require.Error(t, err)
+
+ assert.Equal(t, added, r.added)
+ assert.Equal(t, rewritten, r.rewritten)
+ assert.Equal(t, superseded, r.superseded)
+ assert.Equal(t, added, r.result.AddedManifests)
+ assert.Equal(t, rewritten, r.result.RewrittenManifests)
+ files := memKeys(mem)
+ for _, m := range r.added {
+ assert.Containsf(t, files, m.FilePath(), "manifest %s the
producer would commit must exist", m.FilePath())
+ }
+}
diff --git a/table/rewrite_manifests_test.go b/table/rewrite_manifests_test.go
index d89abce91..87de65987 100644
--- a/table/rewrite_manifests_test.go
+++ b/table/rewrite_manifests_test.go
@@ -1000,10 +1000,12 @@ func
TestRewriteManifestsCleansEverySupersededGeneration(t *testing.T) {
assert.Equal(t, 2, removed, "both superseded generations must be
cleaned up")
}
-// TestRewriteManifestsCleansSupersededOnExhaustedRetries asserts that when
every
-// commit attempt fails with ErrCommitFailed, every merged manifest written —
-// the superseded attempts and the final one — is cleaned up rather than
leaked.
-func TestRewriteManifestsCleansSupersededOnExhaustedRetries(t *testing.T) {
+// TestRewriteManifestsKeepsMergedManifestsUntilResumeLands asserts that a
+// rewrite whose retries are exhausted deletes none of its merged manifests,
+// since the transaction can still commit, and that the resume that lands
+// deletes every merged manifest the committed snapshot does not reference,
+// from both calls.
+func TestRewriteManifestsKeepsMergedManifestsUntilResumeLands(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
track := &trackingFS{}
@@ -1011,30 +1013,178 @@ func
TestRewriteManifestsCleansSupersededOnExhaustedRetries(t *testing.T) {
h0, h1, h2 := stagedRewriteHeads(t, ctx, dir, "2", fsF, track) // 3
attempts
- // Never succeed: every attempt conflicts, exhausting the retry budget.
- cat := &stagedHeadCatalog{current: h0, heads: []table.Metadata{h1, h2},
failures: 99, location: dir, fsF: fsF}
+ cat := &stagedHeadCatalog{current: h0, heads: []table.Metadata{h1, h2},
failures: 3, location: dir, fsF: fsF}
tbl := table.New(table.Identifier{"default", "staged"}, h0,
dir+"/metadata/00000.json", fsF, cat)
preExisting := track.snapshotCreated()
txn := tbl.NewTransaction()
- _, err := txn.RewriteManifests(ctx)
+ res, err := txn.RewriteManifests(ctx)
require.NoError(t, err)
_, err = txn.Commit(ctx)
- require.Error(t, err, "commit must fail once retries are exhausted")
require.ErrorIs(t, err, table.ErrCommitFailed)
+ require.NotErrorIs(t, err, table.ErrTransactionUnusable)
- // Three attempts wrote three merged manifests. None is referenced (no
commit
- // succeeded), so all three — the superseded ones and the final
attempt's —
- // must be cleaned on the exhausted-failure path.
merged := mergedManifestsCreated(track, preExisting)
require.Len(t, merged, 3, "three attempts each write one merged
manifest")
+ for _, p := range merged {
+ assert.Falsef(t, track.wasRemoved(p), "merged manifest %s must
be kept while the transaction can commit", p)
+ }
+
+ committed, err := txn.Commit(ctx)
+ require.NoError(t, err)
+ assertCommittedManifestsExist(t, committed, track, res)
+ merged = mergedManifestsCreated(track, preExisting)
+ require.Len(t, merged, 4, "the resume merges once more")
+ referenced := committedManifestPaths(t, committed, track)
for _, p := range merged {
- assert.Truef(t, track.wasRemoved(p), "orphaned manifest %s must
be cleaned on the failure path", p)
+ if _, ok := referenced[p]; ok {
+ continue
+ }
+ assert.Truef(t, track.wasRemoved(p), "superseded manifest %s
must be deleted once the commit lands", p)
}
}
+func committedManifestPaths(t *testing.T, committed *table.Table, fs iceio.IO)
map[string]struct{} {
+ t.Helper()
+ manifests, err := committed.CurrentSnapshot().Manifests(fs)
+ require.NoError(t, err)
+ paths := make(map[string]struct{}, len(manifests))
+ for _, m := range manifests {
+ paths[m.FilePath()] = struct{}{}
+ }
+
+ return paths
+}
+
+// assertCommittedManifestsExist checks that the committed snapshot's
+// manifest list and manifests exist and that res reports the manifests the
+// committed snapshot added.
+func assertCommittedManifestsExist(t *testing.T, committed *table.Table, track
*trackingFS, res *table.RewriteManifestsResult) {
+ t.Helper()
+ snap := committed.CurrentSnapshot()
+ require.NotNil(t, snap)
+ assert.FileExists(t, snap.ManifestList)
+ referenced := committedManifestPaths(t, committed, track)
+ for p := range referenced {
+ assert.FileExistsf(t, p, "committed manifest %s must exist", p)
+ }
+ require.NotEmpty(t, res.AddedManifests)
+ for _, m := range res.AddedManifests {
+ assert.Containsf(t, referenced, m.FilePath(), "result manifest
%s must be in the committed snapshot", m.FilePath())
+ }
+}
+
+func resortedMetadata(t *testing.T, base table.Metadata) table.Metadata {
+ t.Helper()
+ builder, err := table.MetadataBuilderFromBase(base, "")
+ require.NoError(t, err)
+ order, err := table.NewSortOrder(1, []table.SortField{{
+ SourceIDs: []int{1},
+ Transform: iceberg.IdentityTransform{},
+ Direction: table.SortASC,
+ NullOrder: table.NullsFirst,
+ }})
+ require.NoError(t, err)
+ require.NoError(t, builder.AddSortOrder(&order))
+ require.NoError(t, builder.SetDefaultSortOrderID(-1))
+ out, err := builder.Build()
+ require.NoError(t, err)
+
+ return out
+}
+
+func TestRewriteManifestsNonRebasedRequirementFailureKeepsStagedFiles(t
*testing.T) {
+ ctx := context.Background()
+ dir := t.TempDir()
+ track := &trackingFS{}
+ fsF := func(_ context.Context) (iceio.IO, error) { return track, nil }
+
+ h0, _, _ := stagedRewriteHeads(t, ctx, dir, "2", fsF, track)
+
+ cat := &stagedHeadCatalog{current: h0, heads:
[]table.Metadata{resortedMetadata(t, h0)}, failures: 1, location: dir, fsF: fsF}
+ tbl := table.New(table.Identifier{"default", "staged"}, h0,
dir+"/metadata/00000.json", fsF, cat)
+
+ preExisting := track.snapshotCreated()
+
+ txn := tbl.NewTransaction()
+ require.NoError(t, txn.AssertDefaultShape())
+ _, err := txn.RewriteManifests(ctx)
+ require.NoError(t, err)
+
+ _, err = txn.Commit(ctx)
+ require.ErrorIs(t, err, table.ErrCommitFailed)
+ require.ErrorIs(t, err, table.ErrTransactionUnusable)
+ assert.EqualError(t, err, "transaction cannot be committed again:
requirement no longer holds after refresh: "+
+ "requirement failed: default sort order id has changed:
expected 0, found 1")
+ require.EqualValues(t, 1, cat.commitTableCalls.Load(), "the violated
fence must stop the retry loop")
+
+ created := track.snapshotCreated()
+ for p := range created {
+ if _, ok := preExisting[p]; !ok {
+ assert.Falsef(t, track.wasRemoved(p), "staged file %s
must be kept", p)
+ }
+ }
+ require.Len(t, mergedManifestsCreated(track, preExisting), 1)
+
+ cat.current = h0
+ _, err = txn.Commit(ctx)
+ assert.ErrorIs(t, err, table.ErrTransactionUnusable)
+ assert.EqualValues(t, 1, cat.commitTableCalls.Load(), "the unusable
transaction must not reach the catalog")
+ assert.Same(t, h0, cat.current, "nothing may be committed")
+}
+
+// A peer that rolls main back to the transaction's base head between two
+// Commit calls must not let the resume submit the staged snapshot: the
+// rewrite producer has already moved past it and would delete its merged
+// manifests as superseded once the commit lands. On a v1 table the
+// sequence-number check that would otherwise force a rebuild is skipped.
+func TestRewriteManifestsResumeAfterPeerRollsBackToBaseHead(t *testing.T) {
+ ctx := context.Background()
+ dir := t.TempDir()
+ track := &trackingFS{}
+ fsF := func(_ context.Context) (iceio.IO, error) { return track, nil }
+
+ meta, err := table.NewMetadata(rewriteSchema(),
iceberg.UnpartitionedSpec, table.UnsortedSortOrder, dir,
+ iceberg.Properties{
+ table.PropertyFormatVersion: "1",
+ table.ManifestMergeEnabledKey: "false",
+ table.CommitMinRetryWaitMsKey: "0",
+ table.CommitMaxRetryWaitMsKey: "0",
+ table.CommitNumRetriesKey: "1",
+ })
+ require.NoError(t, err)
+ setup := table.New(table.Identifier{"default", "staged"}, meta,
dir+"/metadata/00000.json", fsF, &mergeCatalog{meta: meta})
+ setup = appendSeparateManifests(t, ctx, setup, track, dir, "stale", 3)
+ h0 := setup.Metadata()
+ baseHead := h0.CurrentSnapshot().SnapshotID
+ h1 := appendOne(t, ctx, setup, track, dir, "peer").Metadata()
+
+ // The first call conflicts twice, rebuilding onto the peer's append in
+ // between. Every later submission lands.
+ cat := &stagedHeadCatalog{current: h0, heads: []table.Metadata{h1},
failures: 2, location: dir, fsF: fsF}
+ tbl := table.New(table.Identifier{"default", "staged"}, h0,
dir+"/metadata/00000.json", fsF, cat)
+
+ txn := tbl.NewTransaction()
+ res, err := txn.RewriteManifests(ctx)
+ require.NoError(t, err)
+ _, err = txn.Commit(ctx)
+ require.ErrorIs(t, err, table.ErrCommitFailed)
+
+ peer, err := cat.LoadTable(ctx, table.Identifier{"default", "staged"})
+ require.NoError(t, err)
+ rollback := peer.NewTransaction()
+ require.NoError(t, rollback.RollbackToSnapshot(baseHead))
+ _, err = rollback.Commit(ctx)
+ require.NoError(t, err)
+ require.Equal(t, baseHead, cat.current.CurrentSnapshot().SnapshotID)
+
+ committed, err := txn.Commit(ctx)
+ require.NoError(t, err)
+ assertCommittedManifestsExist(t, committed, track, res)
+}
+
// TestRewriteManifestsClusterByReusesOutputOnOCCRetry verifies that a
// concurrent append does not force an otherwise-valid clustered rewrite to
// read and rewrite every original entry again. The fresh parent retains all
diff --git a/table/snapshot_producers.go b/table/snapshot_producers.go
index 52024b537..d856a6958 100644
--- a/table/snapshot_producers.go
+++ b/table/snapshot_producers.go
@@ -67,10 +67,9 @@ type producerImpl interface {
// supersededAccumulator marks a producer that writes merged manifests OCC
// retries orphan. Only rewrite implements it.
type supersededAccumulator interface {
- // supersededManifests returns paths of merged manifests to delete once
the
- // commit resolves. When committed is false the commit never landed, so
the
- // final attempt's manifests are orphaned too and are included.
- supersededManifests(committed bool) []string
+ // supersededManifests returns paths of merged manifests that earlier
+ // passes wrote and a later pass replaced, to delete once the commit
lands.
+ supersededManifests() []string
}
// retryManifestRebuilder lets a producer reuse parent-independent manifest
diff --git a/table/table.go b/table/table.go
index 75d7d36cd..d0690372b 100644
--- a/table/table.go
+++ b/table/table.go
@@ -46,14 +46,35 @@ import (
"golang.org/x/sync/errgroup"
)
-// ErrCommitFailed is the sentinel error returned by catalogs when a
-// commit fails due to a concurrent modification (e.g. HTTP 409 Conflict
-// from the REST catalog, or a commit requirement that no longer holds).
-// Catalog implementations should wrap this error so that callers using
-// errors.Is(err, table.ErrCommitFailed) can detect retryable commit
-// conflicts.
+// ErrCommitFailed means a concurrent change caused the failure. A new
+// transaction built from a reloaded table may succeed. Catalogs return it
+// when a commit loses to a concurrent modification (e.g. HTTP 409 Conflict
+// from the REST catalog, or a commit requirement that no longer holds), and
+// catalog implementations should wrap it so callers can detect conflicts
+// with errors.Is(err, table.ErrCommitFailed). Calling Transaction.Commit
+// 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.
var ErrCommitFailed = errors.New("commit failed, refresh and try again")
+// ErrTransactionUnusable means this transaction can't be committed again,
+// because after a refresh the commit fails a check that every later attempt
+// would fail too. An error can match both ErrTransactionUnusable and
+// ErrCommitFailed, in which case a new transaction built from a reloaded
+// table may still succeed. The checks are a requirement the retry loop does
+// not rebase (a pinned ref, the schema, partition spec or sort order, or a
+// 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.
+var ErrTransactionUnusable = errors.New("transaction cannot be committed
again")
+
// ErrWriteIORequired is returned by write paths when the table's file system
// does not implement io.WriteFileIO. Commit retries also fail fast on this
// condition because manifest-list rebuilds need write access; skipping that
@@ -568,6 +589,11 @@ type commitOpts struct {
// not be rewritten to the fresh head between retries (see
// Transaction.pinnedRefs).
pinnedRefs map[string]struct{}
+
+ // resumes counts earlier Commit calls of the same transaction that
+ // failed without committing. A resumed commit refreshes and replays
+ // before its first submission, as the next retry attempt would.
+ resumes int
}
type commitOption func(*commitOpts)
@@ -596,6 +622,10 @@ func withCommitPinnedRefs(refs map[string]struct{})
commitOption {
return func(o *commitOpts) { o.pinnedRefs = refs }
}
+func withCommitResume(resumes int) commitOption {
+ return func(o *commitOpts) { o.resumes = resumes }
+}
+
func (t Table) doCommit(ctx context.Context, updates []Update, reqs
[]Requirement, opts ...commitOption) (*Table, error) {
var co commitOpts
for _, apply := range opts {
@@ -638,20 +668,32 @@ func (t Table) doCommit(ctx context.Context, updates
[]Update, reqs []Requiremen
// 0-based counter risks emitting 0 if a second success exit is ever
added.
var attemptsUsed int64 = 1
- // cleanupOrphans controls whether the defer below removes orphaned
manifest-list
- // files on exit. It defaults to true (clean on all safe exits) and is
set to
- // false only for the one unsafe case: a non-ErrCommitFailed error from
- // CommitTable, where the catalog may have silently accepted the commit
and one
- // of the "orphaned" files may actually be the live snapshot.
+ // doCommit deletes only files it wrote that were not committed. The
+ // staged updates' own files belong to the caller, which can commit them
+ // again after a failure, so they are deleted only once the commit
lands.
+ // cleanupOrphans is false when CommitTable fails in an unknown state,
+ // since the catalog may have accepted a snapshot that references them.
cleanupOrphans := true
+ committed := false
+ staged := stagedManifestLists(updates)
+ rebuiltLists := make(map[string]struct{}) // manifest lists this call's
rebuilds wrote
defer func() {
- if !cleanupOrphans || len(orphanedManifests) == 0 {
+ if !cleanupOrphans {
return
}
+ remove := slices.Collect(maps.Keys(rebuiltLists))
+ if committed {
+ remove = orphanedManifests
+ for _, u := range updates {
+ if su, ok := u.(*addSnapshotUpdate); ok &&
su.supersededSource != nil {
+ remove = append(remove,
su.supersededSource.supersededManifests()...)
+ }
+ }
+ }
// The commit may have failed because ctx was canceled; remove
the
// orphans without that cancellation so they are not left
behind.
cleanupCtx := context.WithoutCancel(ctx)
- for _, path := range orphanedManifests {
+ for _, path := range remove {
if removeErr := wfs.Remove(cleanupCtx, path); removeErr
!= nil {
log.Printf("Warning: failed to delete orphaned
manifest list %s: %v", path, removeErr)
}
@@ -667,12 +709,32 @@ func (t Table) doCommit(ctx context.Context, updates
[]Update, reqs []Requiremen
// numRetries counts retries; total attempts = 1 initial + numRetries.
totalAttempts := cfg.numRetries + 1
+ // A rebuilt manifest list is named after its attempt, so number this
+ // call's attempts after every earlier call's. Every call reads the
retry
+ // config from the same base metadata, so totalAttempts is the same for
+ // each of them.
+ attemptBase := co.resumes * int(totalAttempts)
+
+ // A non-replayable commit never rebases its branch, so a resume
+ // fails unless the branch head is still the one it was built on.
+ pinned := co.pinnedRefs
+ if co.noReplay && co.resumes > 0 {
+ pinned = maps.Clone(pinned)
+ if pinned == nil {
+ pinned = make(map[string]struct{}, 1)
+ }
+ pinned[co.branch] = struct{}{}
+ }
+
// commitStart brackets the commit loop itself (not FS resolution above
or
// metadata cleanup below) so TotalDuration times only the CommitTable
// submission loop, matching Java's CommitReport.
commitStart := time.Now()
for attempt := range totalAttempts {
+ // After a refresh, a rejection that does not depend on timing
fails
+ // the same way on every later attempt and every re-commit.
+ refreshed := attempt != 0 || co.resumes > 0
if attempt != 0 {
wait := backoffDuration(attempt-1, cfg.minWaitMs,
cfg.maxWaitMs)
if timer == nil {
@@ -687,7 +749,8 @@ func (t Table) doCommit(ctx context.Context, updates
[]Update, reqs []Requiremen
return nil, context.Cause(retryCtx)
case <-timer.C:
}
-
+ }
+ if refreshed {
// Refresh-and-replay: reload the catalog's current
state,
// run the producers' validators against the fresh
// (base=t.metadata, current=fresh) conflict context,
and
@@ -701,39 +764,62 @@ func (t Table) doCommit(ctx context.Context, updates
[]Update, reqs []Requiremen
}
if t.metadata != nil && fresh.metadata != nil {
if err := validateTableUUID(t.identifier,
t.metadata.TableUUID(), fresh.metadata.TableUUID()); err != nil {
- return nil, err
+ return nil, fmt.Errorf("%w: table was
replaced: %w", ErrTransactionUnusable, err)
}
}
current = fresh.metadata
- reqs = rewriteRefSnapshotRequirements(reqs, co.branch,
current, co.pinnedRefs)
+ reqs = rewriteRefSnapshotRequirements(reqs, co.branch,
current, pinned)
+
+ if err := validateNonRebasedRequirements(reqs,
co.branch, pinned, current); err != nil {
+ if co.noReplay {
+ return nil, fmt.Errorf("%w: requirement
no longer holds after refresh (%s): %w",
+ ErrTransactionUnusable,
noReplayHint, err)
+ }
- // A pinned assertion the fresh catalog state violates
can
- // never succeed — fail now instead of burning the
remaining
- // retries on it.
- if err := validatePinnedRefRequirements(reqs,
co.pinnedRefs, current); err != nil {
- return nil, fmt.Errorf("%w: pinned ref
requirement failed: %w", ErrCommitFailed, err)
+ return nil, fmt.Errorf("%w: requirement no
longer holds after refresh: %w", ErrTransactionUnusable, err)
}
- if err := validateBranchRequirement(reqs, co.branch,
current); err != nil {
- return nil, err
+ }
+
+ if err := validateBranchRequirement(reqs, co.branch, current);
err != nil {
+ if refreshed {
+ return nil, fmt.Errorf("%w: commit branch is no
longer a branch: %w", ErrTransactionUnusable, err)
}
+ return nil, err
+ }
+
+ if refreshed {
// Rebuild snapshot manifest lists to inherit all files
committed
// by concurrent writers since the snapshot was
originally built.
// Without this, the new snapshot's manifest list would
only
// contain its own files and callers scanning the
current snapshot
// would miss every concurrent writer's data.
- rebuiltUpdates, orphaned, rebuildErr :=
rebuildSnapshotUpdates(retryCtx, updates, current, co.branch, wfs, int(attempt))
+ // A resume always rebuilds on its first attempt, so
the submitted
+ // snapshot and the producers' state come from the same
pass. A
+ // producer that kept state from an earlier call could
otherwise
+ // 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)
+ // A failed rebuild still owns the lists it wrote
before failing.
+ for list := range stagedManifestLists(rebuiltUpdates) {
+ if _, ok := staged[list]; !ok {
+ rebuiltLists[list] = struct{}{}
+ }
+ }
if rebuildErr != nil {
+ // 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)
}
orphanedManifests = append(orphanedManifests,
orphaned...)
updates = rebuiltUpdates
}
- if err := validateBranchRequirement(reqs, co.branch, current);
err != nil {
- return nil, err
- }
-
// Pre-flight client-side conflict validation. Producers can
// reject commits whose semantics are violated by concurrent
// peers (partition-filter overlap, referenced-file removal)
@@ -757,10 +843,21 @@ func (t Table) doCommit(ctx context.Context, updates
[]Update, reqs []Requiremen
if ccErr != nil {
// ErrCommitDiverged — terminal, do not retry.
The
// sentinel deliberately does not wrap
ErrCommitFailed.
+ if errors.Is(ccErr, ErrCommitDiverged) {
+ return nil, fmt.Errorf("%w: base
diverged: %w", ErrTransactionUnusable, ccErr)
+ }
+
return nil, ccErr
}
for _, v := range co.validators {
if vErr := v(cc); vErr != nil {
+ // Conflicts with a concurrent commit
end the transaction.
+ // Other validator errors, such as read
failures, stay
+ // resumable.
+ if errors.Is(vErr, ErrCommitFailed) {
+ return nil, fmt.Errorf("%w:
validation conflict: %w", ErrTransactionUnusable, vErr)
+ }
+
return nil, vErr
}
}
@@ -772,6 +869,7 @@ func (t Table) doCommit(ctx context.Context, updates
[]Update, reqs []Requiremen
newMeta, newLoc, err = t.cat.CommitTable(retryCtx,
slices.Clone(t.identifier), reqs, updates)
if err == nil {
+ committed = true
attemptsUsed = int64(attempt) + 1
// Capture elapsed time at the commit boundary, before
orphan
// cleanup and deleteOldMetadata below (both can do
I/O).
@@ -788,7 +886,7 @@ func (t Table) doCommit(ctx context.Context, updates
[]Update, reqs []Requiremen
if !errors.Is(err, ErrCommitFailed) {
cleanupOrphans = false
- return nil, err
+ return nil, &commitStateUnknownError{err: err}
}
// Non-replayable commits fail on the first CAS conflict instead
@@ -796,25 +894,10 @@ func (t Table) doCommit(ctx context.Context, updates
[]Update, reqs []Requiremen
// distinguishes this abort from an exhausted retry budget while
// preserving errors.Is(err, ErrCommitFailed).
if co.noReplay {
- return nil, fmt.Errorf("%w (commit carries
snapshot-relative delete-file removals and cannot be replayed; reload the table
and rebuild the removals)", err)
+ return nil, fmt.Errorf("%w (%s)", err, noReplayHint)
}
}
- // Inner data manifests written by superseded retry attempts (a rewrite
- // re-merges everything on each retry) are orphaned objects. On a safe
- // exhausted-ErrCommitFailed failure (err != nil here) nothing
committed, so
- // the accumulator also folds in the final attempt's manifests. On
success
- // the committed snapshot references those, so they are excluded. The
defer
- // skips cleanup only on the unsafe non-ErrCommitFailed path, which
returned
- // above with cleanupOrphans = false.
- for _, u := range updates {
- su, ok := u.(*addSnapshotUpdate)
- if !ok || su.supersededSource == nil {
- continue
- }
- orphanedManifests = append(orphanedManifests,
su.supersededSource.supersededManifests(err == nil)...)
- }
-
if err != nil {
return nil, err
}
@@ -874,8 +957,8 @@ func (t Table) doCommit(ctx context.Context, updates
[]Update, reqs []Requiremen
//
// Java's SnapshotProducer rewrites the same way between retries. If
// the branch is empty or the new head cannot be resolved (branch
-// deleted underneath us), reqs is returned unchanged — newConflict-
-// Context will surface the divergence on the next pre-flight pass.
+// deleted underneath us), reqs is returned unchanged, and the commit
+// 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
@@ -910,24 +993,17 @@ func rewriteRefSnapshotRequirements(reqs []Requirement,
branch string, fresh Met
return out
}
-// validatePinnedRefRequirements validates every assert-ref-snapshot-id
-// requirement on a pinned branch against the freshly refreshed catalog
-// metadata. A failure means the branch has changed from the required
-// snapshot: the assertion is never rewritten, so it can never hold and
-// the commit must fail instead of retrying.
-func validatePinnedRefRequirements(reqs []Requirement, pinned
map[string]struct{}, fresh Metadata) error {
- if len(pinned) == 0 {
- return nil
- }
+// validateNonRebasedRequirements checks the requirements a retry does not
+// rebase. Only unpinned commit-branch assertions are rebased, so any other
+// requirement that fails against refreshed metadata cannot succeed on retry.
+func validateNonRebasedRequirements(reqs []Requirement, branch string, pinned
map[string]struct{}, fresh Metadata) error {
for _, r := range reqs {
- a, ok := r.(*assertRefSnapshotID)
- if !ok {
- continue
- }
- if _, isPinned := pinned[a.Ref]; !isPinned {
- continue
+ if a, ok := r.(*assertRefSnapshotID); ok && a.Ref == branch {
+ if _, isPinned := pinned[a.Ref]; !isPinned {
+ continue
+ }
}
- if err := a.Validate(fresh); err != nil {
+ if err := r.Validate(fresh); err != nil {
return err
}
}
@@ -935,6 +1011,29 @@ func validatePinnedRefRequirements(reqs []Requirement,
pinned map[string]struct{
return nil
}
+const noReplayHint = "commit carries snapshot-relative delete-file removals
and cannot be replayed; reload the table and rebuild the removals"
+
+// commitStateUnknownError marks a CommitTable failure after which the catalog
+// may have accepted the commit. Every other doCommit failure committed
+// nothing.
+type commitStateUnknownError struct{ err error }
+
+func (e *commitStateUnknownError) Error() string { return e.err.Error() }
+
+func (e *commitStateUnknownError) Unwrap() error { return e.err }
+
+// stagedManifestLists returns the manifest list of every snapshot updates add.
+func stagedManifestLists(updates []Update) map[string]struct{} {
+ lists := make(map[string]struct{})
+ for _, u := range updates {
+ if su, ok := u.(*addSnapshotUpdate); ok && su.Snapshot != nil {
+ lists[su.Snapshot.ManifestList] = struct{}{}
+ }
+ }
+
+ return lists
+}
+
func validateBranchRequirement(reqs []Requirement, branch string, meta
Metadata) error {
for _, req := range reqs {
if ref, ok := req.(*assertRefSnapshotID); ok &&
ref.requireBranch && ref.Ref == branch {
@@ -993,13 +1092,14 @@ func stagedSnapshotStillValid(snap *Snapshot, fresh
Metadata) bool {
// It also returns the manifest-list file paths that were superseded by
// the rebuild (i.e., the paths from the input updates that were replaced).
// These become orphaned objects in object storage and should be removed
-// by the caller after a successful commit.
+// by the caller after a successful commit. On error the updates rebuilt so
+// far are still returned, so the caller can delete the lists they wrote.
//
// This is the manifest-layer "refresh-and-replay" step: the data files
// (already written to object storage) are reused as-is; only the manifest
// list is rewritten to include the fresh parent's manifests so that the
// rebuilt snapshot contains every committed file.
-func rebuildSnapshotUpdates(ctx context.Context, updates []Update, freshMeta
Metadata, branch string, fs icebergio.WriteFileIO, attempt int) (rebuilt
[]Update, orphanedPaths []string, err error) {
+func rebuildSnapshotUpdates(ctx context.Context, updates []Update, freshMeta
Metadata, branch string, fs icebergio.WriteFileIO, attempt int, force bool)
(rebuilt []Update, orphanedPaths []string, err error) {
// Must mirror createSnapshotProducer's attempt-0 fallback, or a
retried new
// branch rebuilds with a nil parent and silently drops main's data.
var freshHead *Snapshot
@@ -1019,7 +1119,8 @@ func rebuildSnapshotUpdates(ctx context.Context, updates
[]Update, freshMeta Met
// dropping the earlier snapshots' data. chainRebuilt forces every
snapshot
// after the first rebuild to rebuild too: its recorded parent is an
earlier
// staged snapshot that has itself just been reparented onto the fresh
head.
- chainRebuilt := false
+ // force rebuilds every snapshot from the start.
+ chainRebuilt := force
for i, u := range result {
su, ok := u.(*addSnapshotUpdate)
if !ok || su.rebuildManifestList == nil {
@@ -1044,7 +1145,7 @@ func rebuildSnapshotUpdates(ctx context.Context, updates
[]Update, freshMeta Met
newSnap, rebuildErr := su.rebuildManifestList(ctx, freshMeta,
freshHead, fs, attempt)
if rebuildErr != nil {
- return nil, nil, rebuildErr
+ return result, orphanedPaths, rebuildErr
}
result[i] = &addSnapshotUpdate{
diff --git a/table/transaction.go b/table/transaction.go
index 58833f665..2de3114b3 100644
--- a/table/transaction.go
+++ b/table/transaction.go
@@ -103,6 +103,25 @@ type Transaction struct {
mx sync.Mutex
committed bool
+
+ unusable bool
+
+ // failedCommits counts Commit calls that failed without committing. The
+ // next Commit resumes from a refresh, as the next retry attempt would.
+ failedCommits int
+}
+
+// The caller must hold t.mx.
+func (t *Transaction) checkUsable() error {
+ if t.unusable {
+ return fmt.Errorf("%w: a previous commit failed, build a new
transaction",
+ ErrTransactionUnusable)
+ }
+ if t.committed {
+ return errors.New("transaction has already been committed")
+ }
+
+ return nil
}
func (t *Transaction) ensureInitialized() error {
@@ -161,8 +180,8 @@ func (t *Transaction) applyLocked(updates []Update, reqs
[]Requirement) error {
return err
}
- if t.committed {
- return errors.New("transaction has already been committed")
+ if err := t.checkUsable(); err != nil {
+ return err
}
var stagedMeta *MetadataBuilder
@@ -746,6 +765,11 @@ 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.
+//
// The "iceberg expire-snapshots" CLI command wraps the same operation.
func (t *Transaction) ExpireSnapshots(opts ...ExpireSnapshotsOpt) error {
var (
@@ -890,7 +914,7 @@ func (t *Transaction) ExpireSnapshots(opts
...ExpireSnapshotsOpt) error {
// 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 transaction that has already been committed.
+ // rejects a committed or unusable transaction.
if len(updates) == 0 {
return t.apply(nil, nil)
}
@@ -2583,6 +2607,13 @@ func (t *Transaction) StagedTable() (*StagedTable,
error) {
// deferred when applying an operation adds no new requirement to the
transaction.
// The staged metadata is validated when a later transaction operation needs an
// immutable metadata view, or while the catalog applies the commit.
+//
+// After a failed commit, calling Commit again refreshes the table and replays
+// the staged changes like the next retry attempt, with a fresh retry budget.
+// The staged files are kept until a commit lands, so an abandoned transaction
+// leaves them, and for a rewrite the manifests later attempts merged, for
+// orphan file removal. TableCommit refuses a transaction whose commit failed,
+// since it does not refresh.
func (t *Transaction) Commit(ctx context.Context) (*Table, error) {
if err := t.checkNotNil(); err != nil {
return nil, err
@@ -2595,8 +2626,8 @@ func (t *Transaction) Commit(ctx context.Context)
(*Table, error) {
return nil, err
}
- if t.committed {
- return nil, errors.New("transaction has already been committed")
+ if err := t.checkUsable(); err != nil {
+ return nil, err
}
if len(meta.updates) > 0 {
@@ -2606,14 +2637,21 @@ func (t *Transaction) Commit(ctx context.Context)
(*Table, error) {
withCommitValidators(t.validators...),
withCommitNoReplay(t.noReplay),
withCommitPinnedRefs(t.pinnedRefs),
+ withCommitResume(t.failedCommits),
)
if err != nil {
- // A clean conflict (ErrCommitFailed) committed nothing
and stays
- // retriable. Any other failure leaves the commit state
unknown
- // (the catalog may have accepted it), so mark it
terminal to
- // avoid a double-apply on retry.
- if !errors.Is(err, ErrCommitFailed) {
+ // A failure that committed nothing stays committable,
and the next
+ // Commit resumes from a refresh. See
ErrTransactionUnusable for
+ // when it is not. A failure of unknown commit state is
terminal to
+ // avoid a double-apply.
+ var unknown *commitStateUnknownError
+ switch {
+ case errors.Is(err, ErrTransactionUnusable):
+ t.unusable = true
+ case errors.As(err, &unknown):
t.committed = true
+ default:
+ t.failedCommits++
}
return tbl, err
diff --git a/table/transaction_assert_ref_test.go
b/table/transaction_assert_ref_test.go
index de6935714..826e20220 100644
--- a/table/transaction_assert_ref_test.go
+++ b/table/transaction_assert_ref_test.go
@@ -191,16 +191,19 @@ func TestNoOpExpireSnapshotsDoesNotBlockRebase(t
*testing.T) {
assert.Equal(t, "42", cat.metadata.Properties()["offsets"])
}
-func TestNoOpExpireSnapshotsRejectsCommittedTransaction(t *testing.T) {
+func TestNoOpExpireSnapshotsRejectsFinishedTransaction(t *testing.T) {
head := int64(100)
base := newConflictTestMetadataWithProps(t, &head, assertRefRetryProps)
tbl, _ := newAssertRefTestTable(t, base, base)
- tx := tbl.NewTransaction()
- require.NoError(t, tx.SetProperties(iceberg.Properties{"offsets":
"42"}))
- _, err := tx.Commit(t.Context())
+ committed := tbl.NewTransaction()
+ _, err := committed.Commit(t.Context())
require.NoError(t, err)
- assert.ErrorContains(t, tx.ExpireSnapshots(), "already been committed")
+ assert.ErrorContains(t, committed.ExpireSnapshots(), "already been
committed")
+
+ unusable := tbl.NewTransaction()
+ unusable.unusable = true
+ assert.ErrorIs(t, unusable.ExpireSnapshots(), ErrTransactionUnusable)
}
// Why: ref assertions are deduplicated by (type, ref), so assertions
diff --git a/website/src/api.md b/website/src/api.md
index 03edf9bef..162d7a726 100644
--- a/website/src/api.md
+++ b/website/src/api.md
@@ -393,7 +393,7 @@ txn, err := tbl.NewTransactionOnBranch("staging")
if err != nil { /* ... */ }
```
-`Commit` retries automatically on conflict (`ErrCommitFailed`) - tune via the
`commit.retry.*` table properties.
+`Commit` can retry on conflict (`ErrCommitFailed`) when
`commit.retry.num-retries` is set. See [Automatic
retry](./concurrent-writes.md#automatic-retry) for the `commit.retry.*` table
properties.
## Schema and partition evolution
diff --git a/website/src/concurrent-writes.md b/website/src/concurrent-writes.md
index 5dcf03847..6e830ba62 100644
--- a/website/src/concurrent-writes.md
+++ b/website/src/concurrent-writes.md
@@ -35,6 +35,9 @@ state. A failed validation surfaces as one of:
retried after refreshing (see below).
- `table.ErrCommitDiverged` — terminal. The base snapshot is no longer on the
branch at all, so a retry cannot reconcile the change.
+- `table.ErrTransactionUnusable` — this transaction can't be committed again.
+ If the error also matches `ErrCommitFailed`, retry in a new transaction from
a
+ reloaded table.
```go
import (
@@ -46,7 +49,9 @@ import (
_, err := txn.Commit(context.Background())
switch {
-case errors.Is(err, table.ErrCommitDiverged):
+case errors.Is(err, table.ErrTransactionUnusable) && errors.Is(err,
table.ErrCommitFailed):
+ // a concurrent change: retry in a new transaction from a reloaded table
+case errors.Is(err, table.ErrCommitDiverged), errors.Is(err,
table.ErrTransactionUnusable):
// unrecoverable: rebuild the operation from the latest table
case errors.Is(err, table.ErrCommitFailed):
// retriable: refresh and try again (the retry loop does this for you)
@@ -94,3 +99,27 @@ _, 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.
+
+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
+refs, must still hold. Otherwise, the commit fails without another attempt and
+the transaction is unusable.
+
+After a conflict, calling `Commit` again on the same transaction refreshes the
+table and replays the staged changes like the next retry attempt, so it can
+rebase onto a peer's commit even with retries disabled. Each call gets the
+configured retry budget. A commit carrying delete-file removals that cannot be
+replayed resubmits only if its branch has not moved. The rejections after a
+refresh that make the transaction unusable are listed on
+`ErrTransactionUnusable`.
+
+A failed commit keeps the files it staged until a later commit lands. A
+transaction you abandon leaves them for orphan file removal, as one with
+retries disabled always has. An abandoned manifest rewrite also leaves the
+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.