Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions dbkit/sqlite/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion dbkit/sqlite/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions dbkit/sqlite/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
64 changes: 64 additions & 0 deletions dbkit/sqlite/intx_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
36 changes: 31 additions & 5 deletions dbkit/sqlite/sqlitetest/faults.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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()
}
143 changes: 136 additions & 7 deletions dbkit/sqlite/sqlitetest/faults_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -719,25 +816,57 @@ 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)
}
}
}

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()

Expand Down
Loading