diff --git a/dbkit/sqlite/busy.go b/dbkit/sqlite/busy.go index 47b53bc..03c8ba7 100644 --- a/dbkit/sqlite/busy.go +++ b/dbkit/sqlite/busy.go @@ -169,7 +169,35 @@ 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, wait: c.wait, ctx: ctx}, nil +} + +// busyTx is a transaction whose commit waits under the context BeginTx was given. +// +// database/sql's Tx.Commit takes no context, so without this wrapper a commit +// that waits for a lock would run with no span and wait up to BusyTimeout. The +// rollback stays the driver's: database/sql rolls back on an ended context, and +// a rollback that stopped early would return a connection still inside a +// transaction to the pool. +type busyTx struct { + // Tx is the driver's transaction. + driver.Tx + // wait is the connection's wait. + wait *busyWait + // ctx is the context BeginTx was given. + ctx context.Context +} + +// Commit commits the transaction, and a wait for a lock ends with the transaction's context. +func (t *busyTx) Commit() error { + defer t.wait.during(t.ctx)() + err := t.Tx.Commit() + if err == nil { + // The commit took effect. A context that ended meanwhile must not turn + // it into an error the caller would retry. + return nil + } + return ended(t.ctx, err) } // ExecContext runs a statement, and answers the error of ctx alone when it failed after ctx ended. diff --git a/dbkit/sqlite/busy_test.go b/dbkit/sqlite/busy_test.go index 71a91e2..0dc263d 100644 --- a/dbkit/sqlite/busy_test.go +++ b/dbkit/sqlite/busy_test.go @@ -565,6 +565,34 @@ func TestClosingTheRowsOfAWriteEndsItsWaitWithItsContext(t *testing.T) { } } +func TestACommitEndsItsWaitWithTheContextOfBeginTx(t *testing.T) { + t.Parallel() + + pinned := busyAttachHeld(t, rulesOpen(t, busyOptions(busyPoll)), "BEGIN", busyAuxRead) + ctx, cancel := context.WithTimeout(t.Context(), busyDeadline) + defer cancel() + + tx, err := pinned.BeginTx(ctx, nil) + if err != nil { + t.Fatalf("BeginTx() error = %v, want nil", err) + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, "INSERT INTO aux.aux_rows VALUES (1)"); err != nil { + t.Fatalf("ExecContext() error = %v, want nil", err) + } + began := time.Now() + + err = tx.Commit() + + waited := time.Since(began) + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("Commit() error = %v, want one matching context.DeadlineExceeded", err) + } + if waited > busyBound { + t.Errorf("Commit() answered after %v, want within a tenth of the busy timeout %v", waited, optionsBusyTimeout) + } +} + // busyColumn is what database/sql reports of one column. type busyColumn struct { // name is the column's name.