diff --git a/dbkit/sqlite/CHANGELOG.md b/dbkit/sqlite/CHANGELOG.md index f74b94a..94ed152 100644 --- a/dbkit/sqlite/CHANGELOG.md +++ b/dbkit/sqlite/CHANGELOG.md @@ -10,9 +10,14 @@ Releases of this module are tagged `dbkit/sqlite/vX.Y.Z`. ## [Unreleased] +### Added + +- `Faults.FailNextRollback`, which makes the next rollback answer the chosen error once and still end its transaction. + ### Changed - The module needs Go 1.27.2. +- Requires `dbkit` 0.3.0. ## [0.1.0] - 2026-10-08 diff --git a/dbkit/sqlite/go.mod b/dbkit/sqlite/go.mod index fba55e9..3c2e425 100644 --- a/dbkit/sqlite/go.mod +++ b/dbkit/sqlite/go.mod @@ -3,7 +3,7 @@ module github.com/gopherium/framework/dbkit/sqlite go 1.27.2 require ( - github.com/gopherium/framework/dbkit v0.1.0 + github.com/gopherium/framework/dbkit v0.3.0 github.com/pressly/goose/v3 v3.27.2 golang.org/x/sys v0.48.0 modernc.org/sqlite v1.60.1 diff --git a/dbkit/sqlite/go.sum b/dbkit/sqlite/go.sum index 4e79e97..205f60d 100644 --- a/dbkit/sqlite/go.sum +++ b/dbkit/sqlite/go.sum @@ -6,8 +6,8 @@ github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFe github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gopherium/framework/dbkit v0.1.0 h1:jeFld4uZu555PkgbZQRKkDK1WOOB8am+Wu2Ar4xX9Jw= -github.com/gopherium/framework/dbkit v0.1.0/go.mod h1:M837GTBK4MLEt0oqvEKhCQydKFNhVLWvsDlAcqT21co= +github.com/gopherium/framework/dbkit v0.3.0 h1:EkMU4dg6YkiIrn3SJH0oJ2KzLNIXrDMu4K+HHoraEIs= +github.com/gopherium/framework/dbkit v0.3.0/go.mod h1:sGsNkpbHe1LRW7k1qEjTzqbeHVYOnoCI5e1l/81TAvQ= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= diff --git a/dbkit/sqlite/intx_test.go b/dbkit/sqlite/intx_test.go new file mode 100644 index 0000000..53e8054 --- /dev/null +++ b/dbkit/sqlite/intx_test.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 + +package sqlite_test + +import ( + "database/sql" + "errors" + "testing" + + "github.com/gopherium/framework/dbkit" + "github.com/gopherium/framework/dbkit/sqlite/sqlitetest" +) + +// intxInsert is a transaction body that writes one row to intx_rows. +func intxInsert(tx *sql.Tx) error { + _, err := tx.Exec("INSERT INTO intx_rows VALUES (1)") + return err +} + +// intxCount returns how many rows intx_rows holds on db. +func intxCount(t *testing.T, db *sql.DB) int64 { + t.Helper() + var n int64 + if err := db.QueryRowContext(t.Context(), "SELECT count(*) FROM intx_rows").Scan(&n); err != nil { + t.Fatalf("count error = %v, want nil", err) + } + return n +} + +func TestInTxJoinsAFailedRollbackToTheBodysError(t *testing.T) { + t.Parallel() + + errBody := errors.New("intx test: the transaction body failed on purpose") + errRollback := errors.New("intx test: the rollback failed on purpose") + faults := &sqlitetest.Faults{} + db := sqlitetest.OpenWithFaults(t, testOptions(), faults) + if _, err := db.ExecContext(t.Context(), "CREATE TABLE intx_rows (a INTEGER NOT NULL)"); err != nil { + t.Fatalf("CREATE TABLE error = %v, want nil", err) + } + faults.FailNextRollback(errRollback) + + err := dbkit.InTx(t.Context(), db, func(tx *sql.Tx) error { + if err := intxInsert(tx); err != nil { + return err + } + return errBody + }) + + if !errors.Is(err, errBody) { + t.Errorf("InTx() error = %v, want it to match the body error", err) + } + if !errors.Is(err, errRollback) { + t.Errorf("InTx() error = %v, want it to match the rollback error", err) + } + if n := intxCount(t, db); n != 0 { + t.Errorf("rows after the failed InTx() = %d, want 0", n) + } + if err := dbkit.InTx(t.Context(), db, intxInsert); err != nil { + t.Errorf("a later InTx() error = %v, want the transaction ended and the write lock free", err) + } + if n := intxCount(t, db); n != 1 { + t.Errorf("rows after the later InTx() = %d, want 1", n) + } +} diff --git a/dbkit/sqlite/sqlitetest/faults.go b/dbkit/sqlite/sqlitetest/faults.go index 57b2be9..41a3acc 100644 --- a/dbkit/sqlite/sqlitetest/faults.go +++ b/dbkit/sqlite/sqlitetest/faults.go @@ -11,12 +11,14 @@ import ( // Faults holds the failures the connections of a handle from OpenWithFaults answer with. type Faults struct { - // mu guards statements and commit. + // mu guards statements, commit and rollback. mu sync.Mutex // statements maps the exact text of each chosen statement to the error it answers with. statements map[string]error // commit is the error the next commit answers with, or nil. commit error + // rollback is the error the next rollback answers with, or nil. + rollback error } // FailStatement makes every statement whose text is exactly query answer err, inside transactions too. @@ -59,6 +61,22 @@ func (f *Faults) takeCommit() error { return err } +// FailNextRollback makes the next rollback end its transaction and answer err. +func (f *Faults) FailNextRollback(err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.rollback = err +} + +// takeRollback returns the error the next rollback answers with and clears it. +func (f *Faults) takeRollback() error { + f.mu.Lock() + defer f.mu.Unlock() + err := f.rollback + f.rollback = nil + return err +} + // wrap returns c answering with the failures of f. func (f *Faults) wrap(c driver.Conn) driver.Conn { return &faultConn{driverConn: c.(driverConn), faults: f} @@ -169,12 +187,12 @@ func (s *faultStmt) QueryContext(ctx context.Context, args []driver.NamedValue) return s.driverStmt.QueryContext(ctx, args) } -// Begin starts a transaction whose commit answers with the next commit failure. +// Begin starts a transaction whose commit and rollback answer with the next matching failure. func (c *faultConn) Begin() (driver.Tx, error) { return c.BeginTx(context.Background(), driver.TxOptions{}) } -// BeginTx starts a transaction whose commit answers with the next commit failure. +// BeginTx starts a transaction whose commit and rollback answer with the next matching failure. func (c *faultConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) { tx, err := c.driverConn.BeginTx(ctx, opts) if err != nil { @@ -183,7 +201,7 @@ func (c *faultConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver. return &faultTx{Tx: tx, faults: c.faults}, nil } -// faultTx is a driver transaction whose commit answers with the next commit failure. +// faultTx is a driver transaction whose commit and rollback answer with the next matching failure. type faultTx struct { // Tx is the driver's own transaction. driver.Tx @@ -194,7 +212,15 @@ type faultTx struct { // Commit rolls the transaction back and answers the next commit failure, or commits when none is set. func (t *faultTx) Commit() error { if err := t.faults.takeCommit(); err != nil { - return errors.Join(err, t.Rollback()) + return errors.Join(err, t.Tx.Rollback()) } return t.Tx.Commit() } + +// Rollback ends the transaction and answers the driver's answer, joined with the next rollback failure if one is set. +func (t *faultTx) Rollback() error { + if err := t.faults.takeRollback(); err != nil { + return errors.Join(err, t.Tx.Rollback()) + } + return t.Tx.Rollback() +} diff --git a/dbkit/sqlite/sqlitetest/faults_test.go b/dbkit/sqlite/sqlitetest/faults_test.go index c51da44..8a53c54 100644 --- a/dbkit/sqlite/sqlitetest/faults_test.go +++ b/dbkit/sqlite/sqlitetest/faults_test.go @@ -164,6 +164,103 @@ func TestFaultFailsTheChosenCommit(t *testing.T) { mustCount(t, db, 1) } +func TestFaultFailsTheChosenRollback(t *testing.T) { + t.Parallel() + + faults := &sqlitetest.Faults{} + db := faultOpen(t, faults) + faults.FailNextRollback(errInjected) + tx := mustBegin(t, db) + mustExec(t, tx, faultInsert) + + err := tx.Rollback() + + if !errors.Is(err, errInjected) { + t.Errorf("Rollback() error = %v, want the injected error", err) + } + mustCount(t, db, 0) + next := mustBegin(t, db) + mustExec(t, next, "INSERT INTO fault_rows VALUES (2)") + if err := next.Rollback(); err != nil { + t.Errorf("the next Rollback() error = %v, want nil once the fault answered", err) + } + mustCount(t, db, 0) +} + +func TestFaultKeepsTheDriversRollbackError(t *testing.T) { + t.Parallel() + + faults := &sqlitetest.Faults{} + db := faultOpen(t, faults) + tx := mustBegin(t, db) + mustExec(t, tx, faultInsert) + mustExec(t, tx, "ROLLBACK") + faults.FailNextRollback(errInjected) + + err := tx.Rollback() + + if !errors.Is(err, errInjected) { + t.Errorf("Rollback() error = %v, want the injected error", err) + } + if err == nil || !strings.Contains(err.Error(), "no transaction is active") { + t.Errorf("Rollback() error = %v, want the driver's own error beside the injected one", err) + } + mustCount(t, db, 0) + next := mustBegin(t, db) + mustExec(t, next, "ROLLBACK") + if err := next.Rollback(); err == nil || !strings.Contains(err.Error(), "no transaction is active") { + t.Errorf("the next Rollback() error = %v, want the driver's own error with no fault set", err) + } +} + +func TestFaultKeepsTheRollbackFailureThroughACommit(t *testing.T) { + t.Parallel() + + faults := &sqlitetest.Faults{} + db := faultOpen(t, faults) + faults.FailNextRollback(errInjected) + tx := mustBegin(t, db) + mustExec(t, tx, faultInsert) + if err := tx.Commit(); err != nil { + t.Fatalf("Commit() error = %v, want nil", err) + } + + err := mustBegin(t, db).Rollback() + + if !errors.Is(err, errInjected) { + t.Errorf("the later Rollback() error = %v, want the injected error still armed", err) + } + mustCount(t, db, 1) +} + +func TestFaultKeepsTheRollbackFailureThroughAFailedCommit(t *testing.T) { + t.Parallel() + + errRollback := errors.New("sqlitetest test: the rollback fault answered on purpose") + faults := &sqlitetest.Faults{} + db := faultOpen(t, faults) + faults.FailNextCommit(errInjected) + faults.FailNextRollback(errRollback) + tx := mustBegin(t, db) + mustExec(t, tx, faultInsert) + + err := tx.Commit() + + if !errors.Is(err, errInjected) || errors.Is(err, errRollback) { + t.Errorf("Commit() error = %v, want the commit failure alone", err) + } + mustCount(t, db, 0) + if err := mustBegin(t, db).Rollback(); !errors.Is(err, errRollback) { + t.Errorf("the later Rollback() error = %v, want the rollback failure still armed", err) + } + failed := mustBegin(t, db) + mustExec(t, failed, "ROLLBACK") + faults.FailNextCommit(errInjected) + if err := failed.Commit(); !errors.Is(err, errInjected) || !strings.Contains(err.Error(), "no transaction is active") { + t.Errorf("a second failed Commit() error = %v, want the injected error beside the driver's rollback error", err) + } +} + func TestFaultFailsTheChosenStatementInsideATransaction(t *testing.T) { t.Parallel() @@ -719,18 +816,30 @@ func faultRace(ctx context.Context, db *sql.DB, faults *sqlitetest.Faults, own s return nil } -func TestFaultsServeEveryConnectionAtOnce(t *testing.T) { - t.Parallel() +// rollbackRace arms a rollback failure on faults and rolls a read-only transaction back on db for faultRounds rounds. +func rollbackRace(ctx context.Context, db *sql.DB, faults *sqlitetest.Faults, start <-chan struct{}) error { + <-start + for range faultRounds { + faults.FailNextRollback(errInjected) + tx, err := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return fmt.Errorf("BeginTx() error = %w, want nil", err) + } + if err := tx.Rollback(); err != nil && !errors.Is(err, errInjected) { + return fmt.Errorf("Rollback() error = %w, want nil or the injected error", err) + } + } + return nil +} - faults := &sqlitetest.Faults{} - db := faultOpen(t, faults) +// atOnce runs race on testMaxConns goroutines that start together and fails the test on each error. +func atOnce(t *testing.T, race func(start <-chan struct{}, i int) error) { + t.Helper() start, done := make(chan struct{}), make(chan error, testMaxConns) for i := range testMaxConns { - go func() { done <- faultRace(t.Context(), db, faults, fmt.Sprintf("SELECT %d", i), start) }() + go func() { done <- race(start, i) }() } - close(start) - for range testMaxConns { if err := <-done; err != nil { t.Error(err) @@ -738,6 +847,26 @@ func TestFaultsServeEveryConnectionAtOnce(t *testing.T) { } } +func TestFaultsServeEveryRollbackAtOnce(t *testing.T) { + t.Parallel() + + faults := &sqlitetest.Faults{} + db := faultOpen(t, faults) + + atOnce(t, func(start <-chan struct{}, _ int) error { return rollbackRace(t.Context(), db, faults, start) }) +} + +func TestFaultsServeEveryConnectionAtOnce(t *testing.T) { + t.Parallel() + + faults := &sqlitetest.Faults{} + db := faultOpen(t, faults) + + atOnce(t, func(start <-chan struct{}, i int) error { + return faultRace(t.Context(), db, faults, fmt.Sprintf("SELECT %d", i), start) + }) +} + func TestABusyBeginKeepsItsErrorThroughTheWrapper(t *testing.T) { t.Parallel()