laskoviymishka commented on code in PR #1635:
URL: https://github.com/apache/iceberg-go/pull/1635#discussion_r3739117266
##########
catalog/sql/sql.go:
##########
@@ -1269,11 +1269,30 @@ func (c *Catalog) CreateNamespace(ctx context.Context,
namespace table.Identifie
})
}
- _, err := tx.NewInsert().Model(&toInsert).Exec(ctx)
+ // Run the insert in a savepoint so a failure can be rolled
back without
+ // aborting the whole transaction (Postgres); the re-check then
still runs.
+ sp, err := tx.BeginTx(ctx, nil)
if err != nil {
+ return fmt.Errorf("error creating savepoint for
namespace '%s': %w", namespace, err)
+ }
+
+ if _, err = sp.NewInsert().Model(&toInsert).Exec(ctx); err !=
nil {
+ if rbErr := sp.Rollback(); rbErr != nil {
+ return fmt.Errorf("error inserting namespace
properties for namespace '%s': %w", namespace, err)
+ }
+ // A concurrent writer may have won the race; if the
re-check confirms
+ // it, return the sentinel joined with the cause, else
the insert error.
+ if _, exists, checkErr :=
c.resolveNamespaceKeyInTx(ctx, tx, namespace); checkErr == nil && exists {
+ return errors.Join(fmt.Errorf("%w: %s",
catalog.ErrNamespaceAlreadyExists, strings.Join(namespace, ".")), err)
Review Comment:
The race-loser here returns `errors.Join(sentinel, err)`, but the pre-check
path a few lines up returns a plain `fmt.Errorf("%w: %s",
catalog.ErrNamespaceAlreadyExists, …)`. `errors.Is` holds either way so the 409
mapping is safe, but `.Error()` now carries the raw insert error on a second
line, and `errors.As` will unwrap to the driver's constraint error only on the
race path — so two paths that mean the same thing produce structurally
different errors depending on scheduling.
I'd drop `err` from the join and return the same form as the pre-check so
they're identical. If we want the cause for logs, `%v` keeps it in the string
without putting it in the unwrap chain.
##########
catalog/sql/sql_test.go:
##########
@@ -1911,6 +1917,249 @@ func (s *SqliteCatalogTestSuite)
TestLoadEmptyNamespaceProperties() {
}
}
+func (s *SqliteCatalogTestSuite) TestCreateNamespaceConcurrent() {
+ // Two callers creating the same namespace: exactly one succeeds and
the other
+ // gets ErrNamespaceAlreadyExists, not the driver's duplicate-key error.
+ const writers = 8
+
+ // A busy timeout so the writers queue on the sqlite lock instead of
failing
+ // with SQLITE_BUSY, which is a different contention problem to this
one.
+ loaded, err := catalog.Load(context.Background(), "default",
iceberg.Properties{
+ "uri": s.catalogUri() + "?_pragma=" +
url.QueryEscape("busy_timeout(10000)"),
+ sqlcat.DriverKey: sqliteshim.ShimName,
+ sqlcat.DialectKey: string(sqlcat.SQLite),
+ "type": "sql",
+ "warehouse": "file://" + s.warehouse,
+ })
+ s.Require().NoError(err)
+
+ cat := loaded.(*sqlcat.Catalog)
+ ctx := context.Background()
+ namespace := table.Identifier{databaseName()}
+
+ start := make(chan struct{})
+ errs := make(chan error, writers)
+
+ var wg sync.WaitGroup
+ for range writers {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ <-start
+ errs <- cat.CreateNamespace(ctx, namespace, nil)
+ }()
+ }
+ close(start)
+ wg.Wait()
+ close(errs)
+
+ created, alreadyExists := 0, 0
+ var unexpected []error
+ for err := range errs {
+ switch {
+ case err == nil:
+ created++
+ case errors.Is(err, catalog.ErrNamespaceAlreadyExists):
+ alreadyExists++
+ default:
+ unexpected = append(unexpected, err)
+ }
+ }
+
+ s.Empty(unexpected, "want nil or ErrNamespaceAlreadyExists")
+ s.Equal(1, created)
+ s.Equal(writers-1, alreadyExists)
+}
+
+// insertFailure selects how the emulated namespace insert fails.
+type insertFailure int
+
+const (
+ // failUnique: insert trips the unique constraint and the namespace
exists on
+ // the re-check -- the raced create-if-absent that the fix must recover.
+ failUnique insertFailure = iota
+ // failUnrelated: insert fails for an unrelated reason and the
namespace does
+ // not exist -- the original error must survive, not become "already
exists".
+ failUnrelated
+)
+
+var (
+ errEmulatedUnique = errors.New("UNIQUE constraint failed:
iceberg_namespace_properties.catalog_name,
iceberg_namespace_properties.namespace,
iceberg_namespace_properties.property_key")
+ errEmulatedUnrelated = errors.New("disk I/O error")
+ errEmulatedAborted = errors.New("current transaction is aborted,
commands ignored until end of transaction block")
+)
+
+// pgAbortDriver emulates Postgres for the create race: after the namespace
+// insert fails it refuses statements until a ROLLBACK TO SAVEPOINT clears it.
+type pgAbortDriver struct {
+ base driver.Driver
+ mode insertFailure
+ insertAttempted atomic.Bool
+ aborted atomic.Bool
+}
+
+func (d *pgAbortDriver) Open(dsn string) (driver.Conn, error) {
+ conn, err := d.base.Open(dsn)
+ if err != nil {
+ return nil, err
+ }
+
+ return &pgAbortConn{Conn: conn, drv: d}, nil
+}
+
+type pgAbortConn struct {
+ driver.Conn
+ drv *pgAbortDriver
+}
+
+func isNamespaceInsert(query string) bool {
+ return strings.HasPrefix(strings.ToUpper(strings.TrimSpace(query)),
"INSERT") &&
+ strings.Contains(query, "iceberg_namespace_properties")
+}
+
+func isNamespaceExistsProbe(query string) bool {
+ return strings.Contains(strings.ToUpper(query), "EXISTS") &&
+ strings.Contains(query, "iceberg_namespace_properties")
+}
+
+func isRollbackToSavepoint(query string) bool {
Review Comment:
Small robustness thing on the emulation: both `isRollbackToSavepoint` and
`isNamespaceExistsProbe` match bun's exact SQL text (the `ROLLBACK TO
SAVEPOINT` prefix, the literal `EXISTS`). Postgres and SQLite also accept
`ROLLBACK TO <name>` without the keyword, and if a bun version ever changes
either shape the `aborted` flag never clears or the probe stops firing, and the
test falls through to an opaque `ErrorIs` failure instead of exercising the
recovery.
Not blocking, but a one-line comment naming the bun format these depend on —
or asserting the savepoint query was actually seen — would save the next person
the debugging. The driver-scoped `aborted`/`insertAttempted` state is fine
given `SetMaxOpenConns(1)` and you've already documented that, so I'd leave it.
##########
catalog/sql/sql_test.go:
##########
@@ -1911,6 +1917,249 @@ func (s *SqliteCatalogTestSuite)
TestLoadEmptyNamespaceProperties() {
}
}
+func (s *SqliteCatalogTestSuite) TestCreateNamespaceConcurrent() {
+ // Two callers creating the same namespace: exactly one succeeds and
the other
+ // gets ErrNamespaceAlreadyExists, not the driver's duplicate-key error.
+ const writers = 8
+
+ // A busy timeout so the writers queue on the sqlite lock instead of
failing
+ // with SQLITE_BUSY, which is a different contention problem to this
one.
+ loaded, err := catalog.Load(context.Background(), "default",
iceberg.Properties{
+ "uri": s.catalogUri() + "?_pragma=" +
url.QueryEscape("busy_timeout(10000)"),
+ sqlcat.DriverKey: sqliteshim.ShimName,
+ sqlcat.DialectKey: string(sqlcat.SQLite),
+ "type": "sql",
+ "warehouse": "file://" + s.warehouse,
+ })
+ s.Require().NoError(err)
+
+ cat := loaded.(*sqlcat.Catalog)
+ ctx := context.Background()
+ namespace := table.Identifier{databaseName()}
+
+ start := make(chan struct{})
+ errs := make(chan error, writers)
+
+ var wg sync.WaitGroup
+ for range writers {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ <-start
+ errs <- cat.CreateNamespace(ctx, namespace, nil)
+ }()
+ }
+ close(start)
+ wg.Wait()
+ close(errs)
+
+ created, alreadyExists := 0, 0
+ var unexpected []error
+ for err := range errs {
+ switch {
+ case err == nil:
+ created++
+ case errors.Is(err, catalog.ErrNamespaceAlreadyExists):
+ alreadyExists++
+ default:
+ unexpected = append(unexpected, err)
+ }
+ }
+
+ s.Empty(unexpected, "want nil or ErrNamespaceAlreadyExists")
+ s.Equal(1, created)
+ s.Equal(writers-1, alreadyExists)
+}
+
+// insertFailure selects how the emulated namespace insert fails.
+type insertFailure int
+
+const (
+ // failUnique: insert trips the unique constraint and the namespace
exists on
+ // the re-check -- the raced create-if-absent that the fix must recover.
+ failUnique insertFailure = iota
+ // failUnrelated: insert fails for an unrelated reason and the
namespace does
+ // not exist -- the original error must survive, not become "already
exists".
+ failUnrelated
+)
+
+var (
+ errEmulatedUnique = errors.New("UNIQUE constraint failed:
iceberg_namespace_properties.catalog_name,
iceberg_namespace_properties.namespace,
iceberg_namespace_properties.property_key")
+ errEmulatedUnrelated = errors.New("disk I/O error")
+ errEmulatedAborted = errors.New("current transaction is aborted,
commands ignored until end of transaction block")
+)
+
+// pgAbortDriver emulates Postgres for the create race: after the namespace
+// insert fails it refuses statements until a ROLLBACK TO SAVEPOINT clears it.
+type pgAbortDriver struct {
+ base driver.Driver
+ mode insertFailure
+ insertAttempted atomic.Bool
+ aborted atomic.Bool
+}
+
+func (d *pgAbortDriver) Open(dsn string) (driver.Conn, error) {
+ conn, err := d.base.Open(dsn)
+ if err != nil {
+ return nil, err
+ }
+
+ return &pgAbortConn{Conn: conn, drv: d}, nil
+}
+
+type pgAbortConn struct {
+ driver.Conn
+ drv *pgAbortDriver
+}
+
+func isNamespaceInsert(query string) bool {
+ return strings.HasPrefix(strings.ToUpper(strings.TrimSpace(query)),
"INSERT") &&
+ strings.Contains(query, "iceberg_namespace_properties")
+}
+
+func isNamespaceExistsProbe(query string) bool {
+ return strings.Contains(strings.ToUpper(query), "EXISTS") &&
+ strings.Contains(query, "iceberg_namespace_properties")
+}
+
+func isRollbackToSavepoint(query string) bool {
+ return strings.HasPrefix(strings.ToUpper(strings.TrimSpace(query)),
"ROLLBACK TO SAVEPOINT")
+}
+
+func (c *pgAbortConn) ExecContext(ctx context.Context, query string, args
[]driver.NamedValue) (driver.Result, error) {
+ execer, ok := c.Conn.(driver.ExecerContext)
+ if !ok {
+ return nil, driver.ErrSkip
+ }
+
+ if c.drv.aborted.Load() {
+ if isRollbackToSavepoint(query) {
+ c.drv.aborted.Store(false)
+
+ return execer.ExecContext(ctx, query, args)
+ }
+
+ return nil, errEmulatedAborted
+ }
+
+ if isNamespaceInsert(query) {
+ c.drv.insertAttempted.Store(true)
+ c.drv.aborted.Store(true)
+ if c.drv.mode == failUnique {
+ return nil, errEmulatedUnique
+ }
+
+ return nil, errEmulatedUnrelated
+ }
+
+ return execer.ExecContext(ctx, query, args)
+}
+
+func (c *pgAbortConn) QueryContext(ctx context.Context, query string, args
[]driver.NamedValue) (driver.Rows, error) {
+ queryer, ok := c.Conn.(driver.QueryerContext)
+ if !ok {
+ return nil, driver.ErrSkip
+ }
+
+ if c.drv.aborted.Load() {
+ return nil, errEmulatedAborted
+ }
+
+ // The namespace exists on the re-check only in the unique-violation
case: a
+ // concurrent winner committed the row. An unrelated failure leaves it
absent.
+ if isNamespaceExistsProbe(query) {
+ return &boolRows{val: c.drv.insertAttempted.Load() &&
c.drv.mode == failUnique}, nil
+ }
+
+ return queryer.QueryContext(ctx, query, args)
+}
+
+func (c *pgAbortConn) BeginTx(ctx context.Context, opts driver.TxOptions)
(driver.Tx, error) {
+ beginTx, ok := c.Conn.(driver.ConnBeginTx)
+ if !ok {
+ return nil, driver.ErrBadConn
+ }
+
+ return beginTx.BeginTx(ctx, opts)
+}
+
+func (c *pgAbortConn) PrepareContext(ctx context.Context, query string)
(driver.Stmt, error) {
+ prepCtx, ok := c.Conn.(driver.ConnPrepareContext)
+ if !ok {
+ return nil, driver.ErrBadConn
+ }
+
+ return prepCtx.PrepareContext(ctx, query)
+}
+
+// boolRows is a single-row, single-column result carrying a SQL EXISTS answer.
+type boolRows struct {
+ val bool
+ done bool
+}
+
+func (r *boolRows) Columns() []string { return []string{"exists"} }
+func (r *boolRows) Close() error { return nil }
+func (r *boolRows) Next(dest []driver.Value) error {
Review Comment:
`Next` returns a row (with `int64(0)`) when `val` is false rather than
signaling EOF, so this only reads as "not exists" if bun's existence check
scans the column value rather than row presence. If it ever keys on presence,
the `failUnrelated` case would see a row, conclude the namespace exists, and
hand back `ErrNamespaceAlreadyExists` — the exact thing that test asserts
against — so it'd fail opaquely instead of catching a regression.
Simplest way to make it robust either way is to return `io.EOF` when `!val`
so "not exists" is genuinely an empty result:
```go
func (r *boolRows) Next(dest []driver.Value) error {
if r.done || !r.val {
return io.EOF
}
r.done = true
dest[0] = int64(1)
return nil
}
```
Worth a quick check that reverting the recovery in `sql.go` still fails
`TestCreateNamespaceInsertFailureSurfacesOriginalError` — if it does, the
negative case is genuinely pinned.
##########
catalog/sql/sql.go:
##########
@@ -1269,11 +1269,30 @@ func (c *Catalog) CreateNamespace(ctx context.Context,
namespace table.Identifie
})
}
- _, err := tx.NewInsert().Model(&toInsert).Exec(ctx)
+ // Run the insert in a savepoint so a failure can be rolled
back without
+ // aborting the whole transaction (Postgres); the re-check then
still runs.
+ sp, err := tx.BeginTx(ctx, nil)
if err != nil {
+ return fmt.Errorf("error creating savepoint for
namespace '%s': %w", namespace, err)
+ }
+
+ if _, err = sp.NewInsert().Model(&toInsert).Exec(ctx); err !=
nil {
+ if rbErr := sp.Rollback(); rbErr != nil {
Review Comment:
Two failure branches in this block drop their cause. On rollback failure we
return the insert `err` and throw away `rbErr` — but a failed `ROLLBACK TO
SAVEPOINT` is a different and worse condition than a failed insert, and on
Postgres it's exactly what leaves the tx poisoned, so that's the diagnostic I'd
most want to keep. Same shape at the re-check just below: when `checkErr !=
nil` we fall straight through to the insert-error return and lose that the
re-check itself failed.
I'd `errors.Join(err, rbErr)` on the rollback path and thread `checkErr`
into the fall-through return so neither cause vanishes. wdyt?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]