442 lines
14 KiB
Go
442 lines
14 KiB
Go
package database_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"database/sql/driver"
|
|
"errors"
|
|
"fmt"
|
|
"runtime"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.yuntuo.hk/k2safe/go-omni/database"
|
|
)
|
|
|
|
// The driver can persist a write and lose the commit acknowledgement, or fail
|
|
// before persisting it. Both must be indistinguishable to the generic wrapper.
|
|
type txFixture struct {
|
|
beginErr, commitErr, rollbackErr error
|
|
persistBeforeError bool
|
|
onBegin func(context.Context, driver.TxOptions)
|
|
onCommit func()
|
|
onRollback func()
|
|
begins, commits, rollbacks, saved atomic.Int64
|
|
}
|
|
|
|
func (f *txFixture) Connect(context.Context) (driver.Conn, error) { return &txConn{owner: f}, nil }
|
|
func (f *txFixture) Driver() driver.Driver { return f }
|
|
func (f *txFixture) Open(string) (driver.Conn, error) { return f.Connect(context.Background()) }
|
|
|
|
type txConn struct {
|
|
owner *txFixture
|
|
pending int64
|
|
}
|
|
|
|
func (c *txConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("not used") }
|
|
func (c *txConn) Close() error { return nil }
|
|
func (c *txConn) Begin() (driver.Tx, error) {
|
|
return c.BeginTx(context.Background(), driver.TxOptions{})
|
|
}
|
|
func (c *txConn) BeginTx(ctx context.Context, options driver.TxOptions) (driver.Tx, error) {
|
|
c.owner.begins.Add(1)
|
|
if c.owner.onBegin != nil {
|
|
c.owner.onBegin(ctx, options)
|
|
}
|
|
if c.owner.beginErr != nil {
|
|
return nil, c.owner.beginErr
|
|
}
|
|
c.pending = 0
|
|
return &txDriver{conn: c}, nil
|
|
}
|
|
func (c *txConn) ExecContext(context.Context, string, []driver.NamedValue) (driver.Result, error) {
|
|
c.pending++
|
|
return driver.RowsAffected(1), nil
|
|
}
|
|
|
|
type txDriver struct{ conn *txConn }
|
|
|
|
func (tx *txDriver) Commit() error {
|
|
f := tx.conn.owner
|
|
f.commits.Add(1)
|
|
if f.commitErr == nil || f.persistBeforeError {
|
|
f.saved.Add(tx.conn.pending)
|
|
}
|
|
if f.onCommit != nil {
|
|
f.onCommit()
|
|
}
|
|
return f.commitErr
|
|
}
|
|
func (tx *txDriver) Rollback() error {
|
|
f := tx.conn.owner
|
|
f.rollbacks.Add(1)
|
|
if f.onRollback != nil {
|
|
f.onRollback()
|
|
}
|
|
return f.rollbackErr
|
|
}
|
|
|
|
func txPool(t testing.TB, f *txFixture) *database.DB {
|
|
t.Helper()
|
|
pool := sql.OpenDB(f)
|
|
pool.SetMaxOpenConns(8)
|
|
t.Cleanup(func() { _ = pool.Close() })
|
|
return &database.DB{DB: pool}
|
|
}
|
|
|
|
type txCause struct{ name string }
|
|
|
|
func (e *txCause) Error() string { return e.name }
|
|
|
|
func checkTxError(t *testing.T, err error, stage database.TxStage, outcome database.TxOutcome, cause, rollback error) *database.TxError {
|
|
t.Helper()
|
|
var txErr *database.TxError
|
|
if !errors.As(fmt.Errorf("repository: %w", err), &txErr) {
|
|
t.Fatalf("missing typed transaction error: %v", err)
|
|
}
|
|
if txErr.Stage != stage || txErr.Outcome != outcome || txErr.Err != cause || txErr.RollbackErr != rollback {
|
|
t.Fatalf("unexpected transaction error: %+v", txErr)
|
|
}
|
|
if cause != nil && !errors.Is(err, cause) || rollback != nil && !errors.Is(err, rollback) {
|
|
t.Fatalf("cause chain lost: %v", err)
|
|
}
|
|
return txErr
|
|
}
|
|
|
|
func TestInTxStagesAndCauses(t *testing.T) {
|
|
primary, rollback := &txCause{"primary"}, &txCause{"rollback"}
|
|
for _, tc := range []struct {
|
|
name string
|
|
beginErr, workErr, commitErr, rb error
|
|
stage database.TxStage
|
|
outcome database.TxOutcome
|
|
wantWork, wantCommit, wantRB int64
|
|
}{
|
|
{"begin", primary, nil, nil, nil, database.TxStageBegin, database.TxOutcomeNotStarted, 0, 0, 0},
|
|
{"work", nil, primary, nil, nil, database.TxStageWork, database.TxOutcomeRolledBack, 1, 0, 1},
|
|
{"workAndRollback", nil, primary, nil, rollback, database.TxStageWork, database.TxOutcomeUnknown, 1, 0, 1},
|
|
{"commit", nil, nil, primary, nil, database.TxStageCommit, database.TxOutcomeUnknown, 1, 1, 0},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f := &txFixture{beginErr: tc.beginErr, commitErr: tc.commitErr, rollbackErr: tc.rb}
|
|
pool := txPool(t, f)
|
|
var calls int64
|
|
err := pool.InTx(context.Background(), nil, func(tx *sql.Tx) error {
|
|
calls++
|
|
_, err := tx.ExecContext(context.Background(), "write")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return tc.workErr
|
|
})
|
|
rb := tc.rb
|
|
if tc.stage == database.TxStageCommit {
|
|
rb = sql.ErrTxDone // No driver rollback happened after Commit.
|
|
}
|
|
checkTxError(t, err, tc.stage, tc.outcome, primary, rb)
|
|
var typedCause *txCause
|
|
if !errors.As(err, &typedCause) || typedCause != primary {
|
|
t.Fatal("primary typed cause not first in chain")
|
|
}
|
|
if calls != tc.wantWork || f.commits.Load() != tc.wantCommit || f.rollbacks.Load() != tc.wantRB || f.saved.Load() != 0 {
|
|
t.Fatal("unexpected callback/commit/rollback calls or persisted write")
|
|
}
|
|
})
|
|
}
|
|
t.Run("rollbackTypedCause", func(t *testing.T) {
|
|
pool := txPool(t, &txFixture{rollbackErr: rollback})
|
|
cause := errors.New("work failed")
|
|
err := pool.InTx(nil, nil, func(*sql.Tx) error { return cause })
|
|
var found *txCause
|
|
if !errors.As(err, &found) || found != rollback {
|
|
t.Fatal("typed rollback cause lost")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestInTxCommitOutcomeUnknown(t *testing.T) {
|
|
for _, cause := range []error{&txCause{"lost acknowledgement"}, context.DeadlineExceeded, driver.ErrBadConn} {
|
|
for _, persisted := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("%s/persisted=%t", cause, persisted), func(t *testing.T) {
|
|
f := &txFixture{commitErr: cause, persistBeforeError: persisted}
|
|
pool := txPool(t, f)
|
|
calls := 0
|
|
err := pool.InTx(nil, nil, func(tx *sql.Tx) error {
|
|
calls++
|
|
_, err := tx.ExecContext(context.Background(), "register object")
|
|
return err
|
|
})
|
|
checkTxError(t, err, database.TxStageCommit, database.TxOutcomeUnknown, cause, sql.ErrTxDone)
|
|
wantSaved := int64(0)
|
|
if persisted {
|
|
wantSaved = 1
|
|
}
|
|
if f.saved.Load() != wantSaved || calls != 1 || f.commits.Load() != 1 || f.rollbacks.Load() != 0 {
|
|
t.Fatal("commit was retried, rolled back, or incorrectly modeled")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInTxCancellationAndOwnership(t *testing.T) {
|
|
t.Run("beforeBegin", func(t *testing.T) {
|
|
f := &txFixture{}
|
|
pool := txPool(t, f)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
err := pool.InTx(ctx, nil, func(*sql.Tx) error { t.Fatal("work called"); return nil })
|
|
checkTxError(t, err, database.TxStageBegin, database.TxOutcomeNotStarted, context.Canceled, nil)
|
|
if f.begins.Load() != 0 {
|
|
t.Fatal("driver began a canceled transaction")
|
|
}
|
|
})
|
|
for _, returnedWorkError := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("automaticRollback/workError=%t", returnedWorkError), func(t *testing.T) {
|
|
rolledBack := make(chan struct{})
|
|
rollbackCause := errors.New("rollback connection lost")
|
|
f := &txFixture{rollbackErr: rollbackCause, onRollback: func() { close(rolledBack) }}
|
|
pool := txPool(t, f)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
err := pool.InTx(ctx, nil, func(*sql.Tx) error {
|
|
cancel()
|
|
select {
|
|
case <-rolledBack:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("automatic rollback did not run")
|
|
}
|
|
if returnedWorkError {
|
|
return context.Canceled
|
|
}
|
|
return nil
|
|
})
|
|
stage, primary := database.TxStageCommit, error(sql.ErrTxDone)
|
|
if returnedWorkError {
|
|
stage, primary = database.TxStageWork, context.Canceled
|
|
}
|
|
checkTxError(t, err, stage, database.TxOutcomeUnknown, primary, sql.ErrTxDone)
|
|
// database/sql owns this asynchronous rollback and does not expose
|
|
// its driver error. Do not pretend ErrTxDone means successful rollback.
|
|
if errors.Is(err, rollbackCause) || f.rollbacks.Load() != 1 || f.commits.Load() != 0 {
|
|
t.Fatal("unexpected automatic rollback accounting")
|
|
}
|
|
})
|
|
}
|
|
t.Run("cancelDuringCommit", func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
f := &txFixture{commitErr: context.Canceled, persistBeforeError: true, onCommit: cancel}
|
|
pool := txPool(t, f)
|
|
err := pool.InTx(ctx, nil, func(tx *sql.Tx) error { _, err := tx.ExecContext(ctx, "write"); return err })
|
|
checkTxError(t, err, database.TxStageCommit, database.TxOutcomeUnknown, context.Canceled, sql.ErrTxDone)
|
|
if f.saved.Load() != 1 || f.rollbacks.Load() != 0 {
|
|
t.Fatal("cancellation hid a committed write")
|
|
}
|
|
})
|
|
for _, action := range []string{"commit", "rollback"} {
|
|
t.Run("manual/"+action, func(t *testing.T) {
|
|
f := &txFixture{}
|
|
pool := txPool(t, f)
|
|
cause := errors.New("callback failed")
|
|
err := pool.InTx(nil, nil, func(tx *sql.Tx) error {
|
|
var err error
|
|
if action == "commit" {
|
|
err = tx.Commit()
|
|
} else {
|
|
err = tx.Rollback()
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return cause
|
|
})
|
|
checkTxError(t, err, database.TxStageWork, database.TxOutcomeUnknown, cause, sql.ErrTxDone)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInTxSuccessPanicAndConcurrency(t *testing.T) {
|
|
t.Run("success", func(t *testing.T) {
|
|
type key struct{}
|
|
ctx := context.WithValue(context.Background(), key{}, "value")
|
|
f := &txFixture{onBegin: func(received context.Context, opts driver.TxOptions) {
|
|
if received.Value(key{}) != "value" || !opts.ReadOnly || opts.Isolation != driver.IsolationLevel(sql.LevelSerializable) {
|
|
t.Error("context or options changed")
|
|
}
|
|
}}
|
|
pool := txPool(t, f)
|
|
if err := pool.InTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable, ReadOnly: true}, func(*sql.Tx) error { return nil }); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.commits.Load() != 1 || f.rollbacks.Load() != 0 {
|
|
t.Fatal("unexpected successful transaction calls")
|
|
}
|
|
})
|
|
for _, rbErr := range []error{nil, errors.New("rollback failed during unwind")} {
|
|
t.Run(fmt.Sprintf("panic/%v", rbErr), func(t *testing.T) {
|
|
f := &txFixture{rollbackErr: rbErr}
|
|
pool := txPool(t, f)
|
|
value := &struct{ text string }{"original panic"}
|
|
func() {
|
|
defer func() {
|
|
if recover() != value {
|
|
t.Error("panic identity lost")
|
|
}
|
|
}()
|
|
_ = pool.InTx(nil, nil, func(*sql.Tx) error { panic(value) })
|
|
}()
|
|
if f.rollbacks.Load() != 1 || f.commits.Load() != 0 {
|
|
t.Fatal("panic did not roll back exactly once")
|
|
}
|
|
})
|
|
}
|
|
t.Run("goexit", func(t *testing.T) {
|
|
f := &txFixture{}
|
|
pool := txPool(t, f)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
_ = pool.InTx(nil, nil, func(*sql.Tx) error { runtime.Goexit(); return nil })
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("Goexit did not finish")
|
|
}
|
|
if f.rollbacks.Load() != 1 || f.commits.Load() != 0 {
|
|
t.Fatal("Goexit did not roll back exactly once")
|
|
}
|
|
})
|
|
t.Run("concurrent", func(t *testing.T) {
|
|
f := &txFixture{}
|
|
pool := txPool(t, f)
|
|
var group sync.WaitGroup
|
|
for i := 0; i < 64; i++ {
|
|
group.Add(1)
|
|
go func(i int) {
|
|
defer group.Done()
|
|
cause := &txCause{fmt.Sprint(i)}
|
|
err := pool.InTx(nil, nil, func(*sql.Tx) error { return cause })
|
|
var txErr *database.TxError
|
|
if !errors.As(err, &txErr) || txErr.Err != cause || txErr.Outcome != database.TxOutcomeRolledBack || !errors.Is(err, cause) {
|
|
t.Error("cross-request error state")
|
|
}
|
|
}(i)
|
|
}
|
|
group.Wait()
|
|
if f.rollbacks.Load() != 64 || f.commits.Load() != 0 {
|
|
t.Fatal("incorrect concurrent calls")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestInTxPreconditionsAndNestedErrors(t *testing.T) {
|
|
for _, pool := range []*database.DB{nil, {}} {
|
|
err := pool.InTx(nil, nil, func(*sql.Tx) error { t.Fatal("unexpected callback"); return nil })
|
|
var txErr *database.TxError
|
|
if !errors.As(err, &txErr) || txErr.Stage != database.TxStageBegin || txErr.Outcome != database.TxOutcomeNotStarted || txErr.Err == nil {
|
|
t.Fatal("uninitialized database was not classified")
|
|
}
|
|
}
|
|
f := &txFixture{}
|
|
pool := txPool(t, f)
|
|
err := pool.InTx(nil, nil, nil)
|
|
var txErr *database.TxError
|
|
if !errors.As(err, &txErr) || txErr.Stage != database.TxStageBegin || f.begins.Load() != 0 {
|
|
t.Fatal("nil callback began a transaction")
|
|
}
|
|
inner := &database.TxError{Stage: database.TxStageCommit, Outcome: database.TxOutcomeUnknown, Err: errors.New("other transaction")}
|
|
err = pool.InTx(nil, nil, func(*sql.Tx) error { return inner })
|
|
outer := checkTxError(t, err, database.TxStageWork, database.TxOutcomeRolledBack, inner, nil)
|
|
if outer == inner || !errors.As(outer.Err, &txErr) || txErr != inner {
|
|
t.Fatal("nested error was flattened")
|
|
}
|
|
// Empty or typed-nil error values must not panic or expose nil unwrap entries.
|
|
for _, value := range []*database.TxError{nil, {}, {RollbackErr: inner}} {
|
|
_ = value.Error()
|
|
for _, child := range value.Unwrap() {
|
|
if child == nil {
|
|
t.Fatal("nil unwrap child")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInTxCancellationRace(t *testing.T) {
|
|
const count = 128
|
|
rollbackCause := errors.New("rollback acknowledgement unavailable")
|
|
var rollbacks sync.WaitGroup
|
|
rollbacks.Add(count)
|
|
f := &txFixture{rollbackErr: rollbackCause, onRollback: rollbacks.Done}
|
|
pool := txPool(t, f)
|
|
for i := 0; i < count; i++ {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
workFailure := i%2 == 0
|
|
err := pool.InTx(ctx, nil, func(*sql.Tx) error {
|
|
cancel()
|
|
if i%3 == 0 {
|
|
runtime.Gosched() // Let the automatic rollback race explicit cleanup.
|
|
}
|
|
if workFailure {
|
|
return context.Canceled
|
|
}
|
|
return nil
|
|
})
|
|
cancel()
|
|
var txErr *database.TxError
|
|
if !errors.As(err, &txErr) || txErr.Outcome != database.TxOutcomeUnknown || f.commits.Load() != 0 {
|
|
t.Fatal("cancellation was treated as confirmed rollback", err)
|
|
}
|
|
wantStage := database.TxStageCommit
|
|
if workFailure {
|
|
wantStage = database.TxStageWork
|
|
}
|
|
if txErr.Stage != wantStage || txErr.RollbackErr != rollbackCause && txErr.RollbackErr != sql.ErrTxDone {
|
|
t.Fatal("unexpected cancellation diagnostics", err)
|
|
}
|
|
if txErr.RollbackErr == rollbackCause && !errors.Is(err, rollbackCause) {
|
|
t.Fatal("observed rollback failure lost")
|
|
}
|
|
}
|
|
done := make(chan struct{})
|
|
go func() { rollbacks.Wait(); close(done) }()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("canceled transactions did not finish rollback")
|
|
}
|
|
}
|
|
|
|
func BenchmarkInTx(b *testing.B) {
|
|
cause := errors.New("example failure")
|
|
for _, name := range []string{"success", "beginFailure", "workFailure", "rollbackFailure", "commitUnknown"} {
|
|
b.Run(name, func(b *testing.B) {
|
|
f := &txFixture{}
|
|
var workErr error
|
|
switch name {
|
|
case "beginFailure":
|
|
f.beginErr = cause
|
|
case "workFailure":
|
|
workErr = cause
|
|
case "rollbackFailure":
|
|
workErr, f.rollbackErr = cause, cause
|
|
case "commitUnknown":
|
|
f.commitErr = cause
|
|
}
|
|
pool := txPool(b, f)
|
|
work := func(*sql.Tx) error { return workErr }
|
|
b.ReportAllocs()
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
err := pool.InTx(context.Background(), nil, work)
|
|
if (err == nil) != (name == "success") {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|