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 c5c427290 fix(catalog/sql): widen the write-retry budget for lock 
contention (#2036)
c5c427290 is described below

commit c5c4272905234a73c6d68d7b4e86e6b340013f58
Author: Matt Topol <[email protected]>
AuthorDate: Thu Oct 1 14:59:26 2026 -0400

    fix(catalog/sql): widen the write-retry budget for lock contention (#2036)
    
    * fix(catalog/sql): widen the write-retry budget for lock contention
    
    Concurrent CreateTable/CreateView against a SQLite catalog could fail with
    SQLITE_BUSY instead of ErrTableAlreadyExists/ErrViewAlreadyExists.
    
    SQLite serializes writers, and the write transaction in these paths reads
    (namespace existence, identifier availability) before it inserts. A lock
    conflict on that upgrade is reported immediately and does not consult the 
busy
    handler, so a busy timeout cannot absorb it -- only retrying the whole
    transaction can, which retrySerializableWriteTx already does. The budget was
    just too small: 3 attempts with 10ms/20ms backoff is 30ms of waiting, and a
    loaded machine holds the write lock longer than that. When the budget ran 
out
    before the winning transaction committed, the losing caller saw the driver's
    lock error rather than the catalog conflict it should report.
    
    Raise the budget to 6 attempts (~310ms). It costs nothing without 
contention,
    since the delays only apply between failed attempts.
    
    This was observed as a flaky 
TestConcurrentTableViewCollisionReturnsCatalogSentinel,
    which failed on main and on unrelated pull requests.
    
    The new regression test makes the contention deterministic by holding the
    database write lock for longer than the old budget; it fails before this 
change
    and passes after.
    
    Signed-off-by: Matt Topol <[email protected]>
    
    * fix(catalog/sql): tighten contention test margin, document cross-dialect 
budget
    
    ---------
    
    Signed-off-by: Matt Topol <[email protected]>
---
 catalog/sql/sql.go      | 15 +++++++++-
 catalog/sql/sql_test.go | 74 +++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 88 insertions(+), 1 deletion(-)

diff --git a/catalog/sql/sql.go b/catalog/sql/sql.go
index 5a29937ab..6ca265b1c 100644
--- a/catalog/sql/sql.go
+++ b/catalog/sql/sql.go
@@ -307,7 +307,20 @@ func withWriteTx(ctx context.Context, db *bun.DB, fn 
func(context.Context, bun.T
 }
 
 const (
-       serializableWriteMaxAttempts = 3
+       // The retry budget has to outlast a competing writer's transaction, 
not just a
+       // scheduling hiccup. SQLite serializes writers and reports a lock 
conflict on
+       // upgrade immediately, without consulting the busy handler, so a losing
+       // CreateTable/CreateView only observes the catalog-level conflict it 
should
+       // report once the winner commits. At 3 attempts the budget was 30ms, 
which a
+       // loaded machine exceeds, surfacing SQLITE_BUSY instead of
+       // ErrTableAlreadyExists/ErrViewAlreadyExists.
+       //
+       // Attempts fire at 0/10/30/70/150/310ms (delay doubles per attempt), 
so the
+       // worst case is ~310ms of waiting, and nothing when there is no 
contention.
+       // The same budget gates retrySerializableWriteTx for every dialect that
+       // isRetryableSerializableError recognises (Postgres 40001/40P01, MySQL
+       // 1205/1213, ...), so changing either constant changes their worst 
case too.
+       serializableWriteMaxAttempts = 6
        serializableWriteRetryDelay  = 10 * time.Millisecond
 )
 
diff --git a/catalog/sql/sql_test.go b/catalog/sql/sql_test.go
index 58cb293db..c41bba3c8 100644
--- a/catalog/sql/sql_test.go
+++ b/catalog/sql/sql_test.go
@@ -35,6 +35,7 @@ import (
        "sync"
        "sync/atomic"
        "testing"
+       "time"
 
        "github.com/apache/arrow-go/v18/arrow"
        "github.com/apache/arrow-go/v18/arrow/array"
@@ -2772,6 +2773,79 @@ func (s *SqliteCatalogTestSuite) 
TestConcurrentTableViewCollisionReturnsCatalogS
        }
 }
 
+// TestConcurrentTableViewCollisionUnderLockContention pins the lock-retry 
budget in
+// retrySerializableWriteTx. SQLite serializes writers and reports a lock 
conflict on
+// upgrade immediately, without consulting the busy handler, so the losing 
create only
+// observes the catalog-level collision once the winner commits. Holding the 
write lock
+// for longer than the retry budget used to surface SQLITE_BUSY to the caller 
instead of
+// ErrTableAlreadyExists/ErrViewAlreadyExists.
+func (s *SqliteCatalogTestSuite) 
TestConcurrentTableViewCollisionUnderLockContention() {
+       // Longer than the former 30ms budget. Attempts fire at 
0/10/30/70/150/310ms,
+       // so a 50ms hold is contended through attempt 3 (70ms) and leaves 
attempts 4
+       // and 5 for the loser to observe the committed winner; the hold would 
have to
+       // drift past 150ms before the collision could land on the last attempt.
+       const holdWriteLockFor = 50 * time.Millisecond
+
+       ctx := context.Background()
+       sqlDB := s.getDB()
+       _, err := sqlDB.Exec("PRAGMA journal_mode=WAL")
+       s.Require().NoError(err)
+       _, err = sqlDB.Exec("CREATE TABLE IF NOT EXISTS lock_holder(x)")
+       s.Require().NoError(err)
+       s.Require().NoError(sqlDB.Close())
+
+       db := s.getCatalogSqlite()
+       identifier := s.randomTableIdentifier()
+       s.Require().NoError(db.CreateNamespace(ctx, 
catalog.NamespaceFromIdent(identifier), nil))
+
+       // Hold the database-wide write lock so both creates begin while 
contended.
+       holder := s.getDB()
+       tx, err := holder.BeginTx(ctx, nil)
+       s.Require().NoError(err)
+       _, err = tx.ExecContext(ctx, "INSERT INTO lock_holder VALUES (1)")
+       s.Require().NoError(err)
+
+       released := make(chan struct{})
+       time.AfterFunc(holdWriteLockFor, func() {
+               _ = tx.Rollback()
+               _ = holder.Close()
+               close(released)
+       })
+
+       type createResult struct {
+               objectType string
+               err        error
+       }
+       start := make(chan struct{})
+       results := make(chan createResult, 2)
+       go func() {
+               <-start
+               _, err := db.CreateTable(ctx, identifier, tableSchemaNested)
+               results <- createResult{objectType: sqlcat.TableType, err: err}
+       }()
+       go func() {
+               <-start
+               err := db.CreateView(ctx, identifier, tableSchemaNested, 
"SELECT 1", nil)
+               results <- createResult{objectType: sqlcat.ViewType, err: err}
+       }()
+       close(start)
+
+       first, second := <-results, <-results
+       <-released
+
+       s.Require().NotEqual(first.err == nil, second.err == nil,
+               "expected exactly one concurrent create to succeed; table/view 
results: %v / %v", first.err, second.err)
+       if first.err != nil {
+               first, second = second, first
+       }
+       s.Require().NoError(first.err)
+       if first.objectType == sqlcat.TableType {
+               s.ErrorIs(second.err, catalog.ErrTableAlreadyExists)
+       } else {
+               s.ErrorIs(second.err, catalog.ErrViewAlreadyExists)
+       }
+}
+
 func (s *SqliteCatalogTestSuite) 
TestCreateRemovesMetadataIfCatalogInsertFails() {
        ctx := context.Background()
        db := s.getCatalogSqlite()

Reply via email to