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

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


The following commit(s) were added to refs/heads/main by this push:
     new 00acae94e fix(table): mark transaction committed only after successful 
commit (#1603)
00acae94e is described below

commit 00acae94e06b8c387b1326c040d16ef51b2a99d8
Author: Badal Prasad Singh <[email protected]>
AuthorDate: Fri Jul 31 02:10:32 2026 +0530

    fix(table): mark transaction committed only after successful commit (#1603)
    
    ## Description
    
    Fixes #1602, issue of setting `t.committed = true` before invoking
    `doCommit`.
    
    Moved `t.committed = true` so it is set only after `doCommit` returns
    successfully. The transaction is left usable for retry. Add a test for
    it.
    
    ---------
    
    Signed-off-by: badalprasadsingh <[email protected]>
---
 table/commit_retry_test.go | 96 +++++++++++++++++++++++++++++++++++++++++++++-
 table/transaction.go       | 21 ++++++++--
 2 files changed, 112 insertions(+), 5 deletions(-)

diff --git a/table/commit_retry_test.go b/table/commit_retry_test.go
index 87b740a07..e698ed08b 100644
--- a/table/commit_retry_test.go
+++ b/table/commit_retry_test.go
@@ -50,6 +50,7 @@ type sequentialCatalog struct {
        loadMeta Metadata // optional: returned by LoadTable if non-nil
        errs     []error
        attempts atomic.Int32
+       lastReqs []Requirement
 }
 
 func (c *sequentialCatalog) LoadTable(_ context.Context, ident Identifier) 
(*Table, error) {
@@ -62,7 +63,8 @@ func (c *sequentialCatalog) LoadTable(_ context.Context, 
ident Identifier) (*Tab
                func(context.Context) (iceio.IO, error) { return 
iceio.LocalFS{}, nil }, c), nil
 }
 
-func (c *sequentialCatalog) CommitTable(_ context.Context, _ Identifier, _ 
[]Requirement, updates []Update) (Metadata, string, error) {
+func (c *sequentialCatalog) CommitTable(_ context.Context, _ Identifier, reqs 
[]Requirement, updates []Update) (Metadata, string, error) {
+       c.lastReqs = reqs
        n := int(c.attempts.Add(1)) - 1 // 0-indexed
        if n < len(c.errs) && c.errs[n] != nil {
                return nil, "", c.errs[n]
@@ -944,3 +946,95 @@ func TestDoCommit_RetryProgressesFreshMeta(t *testing.T) {
        require.NotContains(t, wfs.files, cat.observedManifestLists[1],
                "attempt-1 rebuild manifest list must be cleaned as orphan 
after success")
 }
+
+// To verify a retried commit does not accumulate duplicates.
+func countAssertTableUUID(reqs []Requirement) int {
+       n := 0
+       for _, r := range reqs {
+               if r.GetType() == "assert-table-uuid" {
+                       n++
+               }
+       }
+
+       return n
+}
+
+func TestTransactionCommit_RetriableAfterCleanConflict(t *testing.T) {
+       // Default retry config (numRetries == 0): each Commit is a single
+       // CommitTable attempt. The first fails with a clean conflict, the 
second succeeds.
+       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.Commit(t.Context())
+       require.ErrorIs(t, err, ErrCommitFailed, "first commit must surface the 
clean conflict")
+       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.
+       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.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")
+
+       assert.Equal(t, 1, countAssertTableUUID(cat.lastReqs),
+               "retry must not append a duplicate AssertTableUUID")
+
+       _, err = tx.Commit(t.Context())
+       assert.ErrorContains(t, err, "already been committed")
+}
+
+// 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
+       // exhausts its internal retries and returns ErrCommitFailed.
+       cat := &sequentialCatalog{
+               errs: []error{ErrCommitFailed, ErrCommitFailed, 
ErrCommitFailed},
+       }
+       tbl := newRetryTestTable(t, cat, iceberg.Properties{
+               CommitNumRetriesKey:     "2",
+               CommitMinRetryWaitMsKey: "1",
+               CommitMaxRetryWaitMsKey: "2",
+       })
+       cat.metadata = tbl.Metadata()
+
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.SetProperties(map[string]string{"key": "value"}))
+
+       _, err := tx.Commit(t.Context())
+       require.ErrorIs(t, err, ErrCommitFailed)
+       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")
+}
+
+func TestTransactionCommit_TerminalOnUnknownState(t *testing.T) {
+       cat := &sequentialCatalog{
+               errs: []error{errors.New("simulated 5xx: internal server 
error")},
+       }
+       tbl := newRetryTestTable(t, cat, nil)
+       cat.metadata = tbl.Metadata()
+
+       tx := tbl.NewTransaction()
+       require.NoError(t, tx.SetProperties(map[string]string{"key": "value"}))
+
+       _, err := tx.Commit(t.Context())
+       require.Error(t, err)
+       assert.NotErrorIs(t, err, ErrCommitFailed, "test must exercise the 
unknown-state path")
+       assert.True(t, tx.committed, "unknown-state failure must mark the 
transaction terminal")
+
+       // A retry must be rejected — retrying could double-apply if the first
+       // attempt actually landed at the catalog.
+       _, err = tx.Commit(t.Context())
+       assert.ErrorContains(t, err, "already been committed")
+       assert.Equal(t, int32(1), cat.attempts.Load(), "terminal failure must 
not reach the catalog again")
+}
diff --git a/table/transaction.go b/table/transaction.go
index 6e91a35ca..b5648c909 100644
--- a/table/transaction.go
+++ b/table/transaction.go
@@ -2372,18 +2372,29 @@ func (t *Transaction) Commit(ctx context.Context) 
(*Table, error) {
                return nil, errors.New("transaction has already been committed")
        }
 
-       t.committed = true
-
        if len(meta.updates) > 0 {
-               t.reqs = append(t.reqs, AssertTableUUID(meta.uuid))
-               tbl, err := t.tbl.doCommit(ctx, meta.updates, t.reqs,
+               reqs := append(slices.Clone(t.reqs), AssertTableUUID(meta.uuid))
+               tbl, err := t.tbl.doCommit(ctx, meta.updates, reqs,
                        withCommitBranch(t.branch),
                        withCommitValidators(t.validators...),
                )
                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) {
+                               t.committed = true
+                       }
+
                        return tbl, err
                }
 
+               // Mark committed after the catalog accepts but before 
PostCommit runs.
+               // A PostCommit failure must not leave the transaction 
retriable;
+               // otherwise a retry would re-run the catalog commit.
+               t.committed = true
+
                for _, u := range meta.updates {
                        if perr := u.PostCommit(ctx, t.tbl, tbl); perr != nil {
                                err = errors.Join(err, perr)
@@ -2393,6 +2404,8 @@ func (t *Transaction) Commit(ctx context.Context) 
(*Table, error) {
                return tbl, err
        }
 
+       t.committed = true
+
        return t.tbl, nil
 }
 

Reply via email to