diff --git a/dbkit/sqlite/CHANGELOG.md b/dbkit/sqlite/CHANGELOG.md index 00ded8d..4a2f9f8 100644 --- a/dbkit/sqlite/CHANGELOG.md +++ b/dbkit/sqlite/CHANGELOG.md @@ -8,6 +8,12 @@ v0.x, minor releases may contain breaking changes. Releases of this module are tagged `dbkit/sqlite/vX.Y.Z`. +## [Unreleased] + +### Fixed + +- A transaction's commit waiting for a lock stops when its `BeginTx` context ends. Rollback still completes after cancellation. + ## [0.2.0] - 2026-10-20 ### Added diff --git a/dbkit/sqlite/busy.go b/dbkit/sqlite/busy.go index 47b53bc..ada6cc2 100644 --- a/dbkit/sqlite/busy.go +++ b/dbkit/sqlite/busy.go @@ -169,7 +169,26 @@ func (c *busyConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.T if err != nil { return nil, ended(ctx, err) } - return tx, nil + return &busyTx{Tx: tx, ctx: ctx, wait: c.wait}, nil +} + +// busyTx is a driver transaction whose commit waits under the context it began with. +type busyTx struct { + // Tx is the driver's transaction; its Rollback must finish even after ctx ends. + driver.Tx + // ctx is the context BeginTx was given, not database/sql's internal transaction context. + ctx context.Context + // wait is the wait of the connection the transaction uses. + wait *busyWait +} + +// Commit waits under the transaction's context and marks a failure with its error once it ends. +func (t *busyTx) Commit() error { + defer t.wait.during(t.ctx)() + if err := t.Tx.Commit(); err != nil { + return ended(t.ctx, err) + } + return nil } // ExecContext runs a statement, and answers the error of ctx alone when it failed after ctx ended. diff --git a/dbkit/sqlite/busy_tx_test.go b/dbkit/sqlite/busy_tx_test.go new file mode 100644 index 0000000..5a7a38f --- /dev/null +++ b/dbkit/sqlite/busy_tx_test.go @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sqlite_test + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "testing" + "time" + + "github.com/gopherium/framework/dbkit" + "github.com/gopherium/framework/dbkit/sqlite" +) + +// busyAuxInsert writes to the attached rollback journal database without committing yet. +const busyAuxInsert = "INSERT INTO aux.aux_rows VALUES (1)" + +// busyCommitRolledBack checks that the write is gone and another transaction can use the same connection. +func busyCommitRolledBack(t *testing.T, pinned *sql.Conn) { + t.Helper() + var n int64 + if err := pinned.QueryRowContext(t.Context(), "SELECT count(*) FROM aux.aux_rows").Scan(&n); err != nil { + t.Fatalf("reading aux after rollback error = %v, want nil", err) + } + if n != 0 { + t.Errorf("rows after rollback = %d, want 0", n) + } + tx, err := pinned.BeginTx(t.Context(), nil) + if err != nil { + t.Fatalf("BeginTx() after rollback error = %v, want a clean connection", err) + } + if err := tx.Rollback(); err != nil { + t.Fatalf("Rollback() of the next transaction error = %v, want nil", err) + } +} + +// TestABusyCommitRollsBackBeforeReturning verifies a failed commit rolls back its write +// while the reader keeps its lock, leaving the connection ready for another transaction. +func TestABusyCommitRollsBackBeforeReturning(t *testing.T) { + t.Parallel() + + opts := busyOptions(busyPoll) + opts.BusyTimeout = rulesShortBusyTimeout + pinned := busyAttachHeld(t, rulesOpen(t, opts), "BEGIN", busyAuxRead) + tx, err := pinned.BeginTx(t.Context(), nil) + if err != nil { + t.Fatal(err) + } + if _, err := tx.ExecContext(t.Context(), busyAuxInsert); err != nil { + t.Fatal(err) + } + began := time.Now() + + err = tx.Commit() + + if !errors.Is(sqlite.Classify(err), dbkit.ErrBusy) { + t.Errorf("Commit() error = %v, want a busy error while the reader keeps its lock", err) + } + if waited := time.Since(began); waited > busyBound { + t.Errorf("Commit() and its rollback ran for %v, want within %v", waited, busyBound) + } + busyCommitRolledBack(t, pinned) +} + +// TestARollbackAfterCancellationStillEndsTheTransaction verifies rollback finishes +// after cancellation and leaves no pending writes or active transaction. +func TestARollbackAfterCancellationStillEndsTheTransaction(t *testing.T) { + t.Parallel() + + pinned := busyAttachHeld(t, rulesOpen(t, busyOptions(busyPoll)), "BEGIN", busyAuxRead) + err := pinned.Raw(func(raw any) error { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + tx, err := raw.(driver.ConnBeginTx).BeginTx(ctx, driver.TxOptions{}) + if err != nil { + return err + } + if _, err := raw.(driver.ExecerContext).ExecContext(ctx, busyAuxInsert, nil); err != nil { + _ = tx.Rollback() + return err + } + cancel() + return tx.Rollback() + }) + if err != nil { + t.Fatalf("Rollback() after cancellation error = %v, want nil", err) + } + busyCommitRolledBack(t, pinned) +} + +// TestABusyCommitEndsWithItsTransactionContext verifies cancellation and deadlines +// end commit lock waits at both polling intervals and leave the connection reusable. +func TestABusyCommitEndsWithItsTransactionContext(t *testing.T) { + t.Parallel() + + ends := []struct { + name string + end func(context.Context) (context.Context, context.CancelFunc) + want error + }{ + {"deadline", busyDeadlineOf, context.DeadlineExceeded}, + {"cancellation", busyCancelOf, context.Canceled}, + } + for _, end := range ends { + for _, poll := range []time.Duration{busyPoll, optionsBusyTimeout} { + t.Run(end.name+" at poll "+poll.String(), func(t *testing.T) { + t.Parallel() + pinned := busyAttachHeld(t, rulesOpen(t, busyOptions(poll)), "BEGIN", busyAuxRead) + ctx, cancel := end.end(t.Context()) + defer cancel() + tx, err := pinned.BeginTx(ctx, nil) + if err != nil { + t.Fatal(err) + } + if _, err := tx.ExecContext(ctx, busyAuxInsert); err != nil { + t.Fatal(err) + } + began := time.Now() + + err = tx.Commit() + + busyMustEnd(t, busyShape{name: "Commit", busy: true}, + busyEnd{err: err, waited: time.Since(began)}, end.want) + busyCommitRolledBack(t, pinned) + }) + } + } +}