feat: expose transaction failure stages and conservative outcomes
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
- `Config`:驱动、DSN 与连接池参数;嵌入项目配置,通过文件显式引用变量。
|
||||
- `Open` / `OpenConnector`:通过 DSN 或标准 Connector 创建连接池、设置连接上限并在返回前执行 `PingContext`。
|
||||
- `Health`:用于就绪检查,等待连接和 Ping 均受 `PingTimeout` 限制。
|
||||
- `InTx`:统一事务提交和回滚;工作函数返回错误或 panic 时回滚。
|
||||
- `InTx`:统一事务提交和回滚;工作函数返回错误或 panic 时尝试回滚。返回 `TxError` 区分阶段与结果,提交错误不代表已回滚。
|
||||
- `Close`:由嵌入的 `*sql.DB` 提供,应用退出时显式调用。
|
||||
|
||||
框架不导入 MySQL、PostgreSQL 等具体驱动。应用负责注册驱动:
|
||||
@@ -63,6 +63,55 @@ python3 scripts/verifyDatabase.py --version v0.2.0
|
||||
|
||||
配置结构作为项目 Config 的字段,Default() 初始化连接池,文件按需覆盖。DSN 可使用 `dsn: "${DB_DSN}"`;database.Config 的 JSON 序列化不输出 DSN 原值。不要打印原始配置对象。当前 API 脚手架使用结构化 MySQL 适配器,由 svc 统一初始化与关闭。
|
||||
|
||||
## 事务阶段与结果
|
||||
|
||||
v0.25 起,`db.InTx(ctx, options, work)` 签名不变,所有普通错误返回都是 `*database.TxError`。返回 nil 表示 Commit 返回成功;框架不重试回调、提交或整个事务。业务通过标准库 errors.As/Is 判断,不解析错误文本:
|
||||
|
||||
```go
|
||||
err := db.InTx(ctx, nil, func(tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, "INSERT INTO objects (id, storage_key) VALUES (?, ?)", id, key)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
var txErr *database.TxError
|
||||
if errors.As(err, &txErr) {
|
||||
switch txErr.Outcome {
|
||||
case database.TxOutcomeNotStarted, database.TxOutcomeRolledBack:
|
||||
// 当前受管理事务未执行回调或已确认回滚。
|
||||
// 业务结合文件归属、并发操作和其他副作用,决定是否清理。
|
||||
default:
|
||||
// 包括 TxOutcomeUnknown:保留文件,交给业务后续核查。
|
||||
// 不因为收到提交错误就删除文件或直接重试整个操作。
|
||||
}
|
||||
// txErr.Stage 是 begin / work / commit。
|
||||
// txErr.Err 是主要原因;txErr.RollbackErr 是回滚调用返回的原因。
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
// 说明错误链包含超时,不意味着数据库没有提交。
|
||||
}
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
`errors` 为标准库包,业务自己的数据库驱动和 SQL 参数规则不变。完整可运行示例见 [examples/transactions](../examples/transactions/README.md)。
|
||||
|
||||
| 情况 | Stage | Outcome | RollbackErr |
|
||||
| --- | --- | --- | --- |
|
||||
| 未初始化数据库、nil 回调、BeginTx 报错 | TxStageBegin | TxOutcomeNotStarted | nil;框架没有拿到事务,不执行回调 |
|
||||
| 回调报错,显式 Rollback 成功 | TxStageWork | TxOutcomeRolledBack | nil |
|
||||
| 回调报错,Rollback 失败或返回 ErrTxDone | TxStageWork | TxOutcomeUnknown | 原始回滚错误 |
|
||||
| Commit 报错,包括取消/断网/驱动错误 | TxStageCommit | TxOutcomeUnknown | 后续清理的 Rollback 返回值,可能为 ErrTxDone 或 nil |
|
||||
|
||||
`Stage` 描述主要失败发生的位置;`Outcome` 只描述框架掌握的结果,二者独立。提交调用返回错误后一律保守标为 unknown,即使某个驱动的某种错误意味着没有提交,通用层也不根据错误文本猜测。标准库在 Commit 后通常已经结束本地事务对象,后续 Rollback 返回 `sql.ErrTxDone`;它不能证明数据库已回滚,框架保留该原因。
|
||||
|
||||
`TxError.Err` 与 `RollbackErr` 分开保存;`Unwrap() []error` 按主要原因、回滚原因顺序暴露,所以 errors.Is/As 可查两条链。需要区分来源时对 `txErr.Err` 或 `txErr.RollbackErr` 单独判断。回调返回另一个事务的 TxError 时外层不把它压平:外层结果只属于外层事务,内部事务的结果仍在 Err 中。
|
||||
|
||||
**结果保证的范围:** 回调使用给定 `*sql.Tx` 完成事务操作,不自行 Commit/Rollback,不执行会隐式提交的 SQL,不将事务交给后台 goroutine。非事务表、其他连接、嵌套的独立事务、文件和第三方调用的副作用不在这份保证内。框架没有删除文件、入队补偿或分布式事务规则。
|
||||
|
||||
**取消与异常边界:** context 取消时 database/sql 可能自行异步回滚,且不公开那次驱动 Rollback 的原始错误。框架若只观察到 ErrTxDone,就返回 unknown,不能补造成功证据或不存在的底层原因。正常返回路径上,框架实际观察到的主要/回滚原因全部保留。panic/runtime.Goexit 保持原有传播行为并尝试回滚,不转换为普通 error;这类展开没有错误返回值,因此不能把回滚原因作为返回值交给调用方。nil context 使用 Background,nil 回调在开启事务前报错。
|
||||
|
||||
**兼容说明:** 已有 errors.Is/As 继续生效;以前直接用 `err == sentinel`、`err.(*SomeError)` 或匹配文本的调用要改成 errors.Is/As。业务 APIError、Go 错误目录和参数提示仍可穿过包装解析,驱动错误不会自动进入公开提示。TxError.Error 包含内部原因,仅供内部诊断。GORM 自己的 Transaction 不经过本方法,也不会自动变成 TxError;可选 gormx 的连接池接入不变。
|
||||
|
||||
## Connector 接入(v0.9.1-preview)
|
||||
|
||||
项目已有 `database/sql/driver.Connector` 时使用 `OpenConnector`。这是标准驱动接口,核心包不增加具体驱动依赖。项目可以在同一进程创建多个使用不同证书、日志、拨号策略的连接池。
|
||||
|
||||
@@ -107,27 +107,3 @@ func (db *DB) Health(ctx context.Context) error {
|
||||
defer cancel()
|
||||
return db.PingContext(checkCtx)
|
||||
}
|
||||
|
||||
// InTx commits only when work returns nil. Any work error or panic rolls back.
|
||||
func (db *DB) InTx(ctx context.Context, options *sql.TxOptions, work func(*sql.Tx) error) (err error) {
|
||||
if db == nil || db.DB == nil {
|
||||
return errors.New("database is not initialized")
|
||||
}
|
||||
tx, err := db.BeginTx(ctx, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
rollbackErr := tx.Rollback()
|
||||
if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
|
||||
err = errors.Join(err, fmt.Errorf("rollback transaction: %w", rollbackErr))
|
||||
}
|
||||
}()
|
||||
if err = work(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit transaction: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
@@ -166,11 +167,64 @@ func TestMySQLConnectorTLSAndPoolIsolation(t *testing.T) {
|
||||
return rollbackErr
|
||||
}); !errors.Is(err, rollbackErr) {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
var txErr *database.TxError
|
||||
if !errors.As(err, &txErr) || txErr.Stage != database.TxStageWork || txErr.Outcome != database.TxOutcomeRolledBack {
|
||||
t.Fatal("real MySQL rollback was not classified", err)
|
||||
}
|
||||
}
|
||||
var count int
|
||||
if err := second.QueryRowContext(ctx, "SELECT COUNT(*) FROM connector_check").Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("transaction count=%d err=%v", count, err)
|
||||
}
|
||||
// Commit/Rollback run against real MySQL, then a driver-boundary wrapper
|
||||
// suppresses the acknowledgement. This is deterministic fault injection,
|
||||
// not a claim to reproduce every wire-level disconnect timing.
|
||||
for _, loseCommit := range []bool{true, false} {
|
||||
name, id := "commitAcknowledgementLost", 3
|
||||
if !loseCommit {
|
||||
name, id = "rollbackAcknowledgementLost", 4
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
connector, err := mysql.NewConnector(cfg.Clone())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ackErr, workErr := errors.New("injected lost acknowledgement"), errors.New("work rejected")
|
||||
fault := &ackLossConnector{Connector: connector, loseCommit: loseCommit, ackErr: ackErr}
|
||||
faultPool, err := database.OpenConnector(ctx, fault, pool)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer faultPool.Close()
|
||||
err = faultPool.InTx(ctx, nil, func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx, "INSERT INTO connector_check VALUES (?)", id); err != nil {
|
||||
return err
|
||||
}
|
||||
if !loseCommit {
|
||||
return workErr
|
||||
}
|
||||
return nil
|
||||
})
|
||||
var txErr *database.TxError
|
||||
if !errors.As(err, &txErr) || txErr.Outcome != database.TxOutcomeUnknown || !errors.Is(err, ackErr) {
|
||||
t.Fatal("unknown acknowledgement was treated as definite outcome", err)
|
||||
}
|
||||
wantCount := 0
|
||||
if loseCommit {
|
||||
wantCount = 1
|
||||
if txErr.Stage != database.TxStageCommit || txErr.Err != ackErr || txErr.RollbackErr != sql.ErrTxDone || fault.commits.Load() != 1 || fault.rollbacks.Load() != 0 {
|
||||
t.Fatal("commit failure was retried or misclassified", err)
|
||||
}
|
||||
} else if txErr.Stage != database.TxStageWork || !errors.Is(err, workErr) || txErr.RollbackErr != ackErr || fault.commits.Load() != 0 || fault.rollbacks.Load() != 1 {
|
||||
t.Fatal("rollback failure lost its primary/secondary causes", err)
|
||||
}
|
||||
var stored int
|
||||
if err := second.QueryRowContext(ctx, "SELECT COUNT(*) FROM connector_check WHERE id = ?", id).Scan(&stored); err != nil || stored != wantCount {
|
||||
t.Fatal("independent connection observed unexpected persistence", stored, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -178,3 +232,58 @@ func TestMySQLConnectorTLSAndPoolIsolation(t *testing.T) {
|
||||
t.Fatal("closing first pool affected second:", err)
|
||||
}
|
||||
}
|
||||
|
||||
type ackLossConnector struct {
|
||||
driver.Connector
|
||||
loseCommit bool
|
||||
ackErr error
|
||||
commits, rollbacks atomic.Int32
|
||||
}
|
||||
|
||||
func (c *ackLossConnector) Connect(ctx context.Context) (driver.Conn, error) {
|
||||
conn, err := c.Connector.Connect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ackLossConn{Conn: conn, owner: c}, nil
|
||||
}
|
||||
|
||||
type ackLossConn struct {
|
||||
driver.Conn
|
||||
owner *ackLossConnector
|
||||
}
|
||||
|
||||
func (c *ackLossConn) Ping(ctx context.Context) error { return c.Conn.(driver.Pinger).Ping(ctx) }
|
||||
func (c *ackLossConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) {
|
||||
tx, err := c.Conn.(driver.ConnBeginTx).BeginTx(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ackLossTx{Tx: tx, owner: c.owner}, nil
|
||||
}
|
||||
|
||||
type ackLossTx struct {
|
||||
driver.Tx
|
||||
owner *ackLossConnector
|
||||
}
|
||||
|
||||
func (tx *ackLossTx) Commit() error {
|
||||
tx.owner.commits.Add(1)
|
||||
if err := tx.Tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
if tx.owner.loseCommit {
|
||||
return tx.owner.ackErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (tx *ackLossTx) Rollback() error {
|
||||
tx.owner.rollbacks.Add(1)
|
||||
if err := tx.Tx.Rollback(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !tx.owner.loseCommit {
|
||||
return tx.owner.ackErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
126
database/transaction.go
Normal file
126
database/transaction.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// TxStage identifies the operation which produced the primary error.
|
||||
type TxStage string
|
||||
|
||||
const (
|
||||
TxStageBegin TxStage = "begin"
|
||||
TxStageWork TxStage = "work"
|
||||
TxStageCommit TxStage = "commit"
|
||||
)
|
||||
|
||||
// TxOutcome describes only what InTx can confirm about its managed transaction.
|
||||
// An absent/unrecognized value must be treated as unknown, never as rolled back.
|
||||
type TxOutcome string
|
||||
|
||||
const (
|
||||
TxOutcomeUnknown TxOutcome = "unknown"
|
||||
TxOutcomeNotStarted TxOutcome = "notStarted"
|
||||
TxOutcomeRolledBack TxOutcome = "rolledBack"
|
||||
)
|
||||
|
||||
// TxError retains the primary failure and any rollback failure independently.
|
||||
// Use errors.As to inspect it. errors.Is/As traverse Err first, then RollbackErr;
|
||||
// inspect Err alone when the distinction between the two causes matters.
|
||||
// Error text is for internal diagnostics and may include driver/business details.
|
||||
type TxError struct {
|
||||
Stage TxStage
|
||||
Outcome TxOutcome
|
||||
Err error
|
||||
RollbackErr error
|
||||
}
|
||||
|
||||
func (e *TxError) Error() string {
|
||||
if e == nil {
|
||||
return "transaction error"
|
||||
}
|
||||
message := string(e.Stage) + " transaction (" + string(e.Outcome) + ")"
|
||||
if e.Err != nil {
|
||||
message += ": " + e.Err.Error()
|
||||
}
|
||||
if e.RollbackErr != nil {
|
||||
message += "; rollback transaction: " + e.RollbackErr.Error()
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
// Unwrap preserves both causes, including sql.ErrTxDone. That sentinel means
|
||||
// the local transaction is finished, not that the database confirmed rollback.
|
||||
func (e *TxError) Unwrap() []error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
if e.Err == nil {
|
||||
if e.RollbackErr == nil {
|
||||
return nil
|
||||
}
|
||||
return []error{e.RollbackErr}
|
||||
}
|
||||
if e.RollbackErr == nil {
|
||||
return []error{e.Err}
|
||||
}
|
||||
return []error{e.Err, e.RollbackErr}
|
||||
}
|
||||
|
||||
// InTx begins a transaction, calls work once, and commits only if work returns
|
||||
// nil. Every returned error is a *TxError; a nil result confirms Commit succeeded.
|
||||
// Commit failures always have an unknown outcome, even for cancellation or a
|
||||
// driver error normally associated with a lost connection. InTx does not retry.
|
||||
//
|
||||
// work must use only the supplied transaction for transactional operations and
|
||||
// must not Commit/Rollback it, issue transaction-control/implicit-commit SQL, or
|
||||
// let it escape to background goroutines. Outcome describes this transaction,
|
||||
// not files, other connections, nontransactional tables or external side effects.
|
||||
//
|
||||
// Panic and runtime.Goexit still attempt rollback and propagate unchanged; they
|
||||
// do not return a TxError. Rollback errors during those unwinds cannot be returned.
|
||||
// A nil context is treated as context.Background().
|
||||
func (db *DB) InTx(ctx context.Context, options *sql.TxOptions, work func(*sql.Tx) error) error {
|
||||
if db == nil || db.DB == nil {
|
||||
return &TxError{Stage: TxStageBegin, Outcome: TxOutcomeNotStarted, Err: errors.New("database is not initialized")}
|
||||
}
|
||||
if work == nil {
|
||||
return &TxError{Stage: TxStageBegin, Outcome: TxOutcomeNotStarted, Err: errors.New("transaction callback is nil")}
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
tx, err := db.BeginTx(ctx, options)
|
||||
if err != nil {
|
||||
return &TxError{Stage: TxStageBegin, Outcome: TxOutcomeNotStarted, Err: err}
|
||||
}
|
||||
finished := false
|
||||
defer func() {
|
||||
if !finished {
|
||||
// Only the panic/Goexit path reaches here without explicit cleanup.
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
if err = work(tx); err != nil {
|
||||
failure := txFailure(tx, TxStageWork, err)
|
||||
finished = true
|
||||
return failure
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
failure := txFailure(tx, TxStageCommit, err)
|
||||
finished = true
|
||||
return failure
|
||||
}
|
||||
finished = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func txFailure(tx *sql.Tx, stage TxStage, cause error) *TxError {
|
||||
rollbackErr := tx.Rollback()
|
||||
outcome := TxOutcomeUnknown
|
||||
if stage == TxStageWork && rollbackErr == nil {
|
||||
outcome = TxOutcomeRolledBack
|
||||
}
|
||||
return &TxError{Stage: stage, Outcome: outcome, Err: cause, RollbackErr: rollbackErr}
|
||||
}
|
||||
56
database/transactionResponse_test.go
Normal file
56
database/transactionResponse_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
func TestInTxPublicErrorCompatibility(t *testing.T) {
|
||||
private := errors.New("private database details")
|
||||
for _, scenario := range []string{"business", "businessAndRollback", "commit"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
f := &txFixture{}
|
||||
if scenario == "businessAndRollback" {
|
||||
f.rollbackErr = private
|
||||
} else if scenario == "commit" {
|
||||
f.commitErr = private
|
||||
}
|
||||
pool := txPool(t, f)
|
||||
app := omni.New(omni.WithResponse(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"}),
|
||||
omni.WithErrors(apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{
|
||||
"INVALID": {Code: 400002, Message: "{field} is invalid", Params: []apierrors.Parameter{{Name: "field", Type: "string"}}},
|
||||
}}))
|
||||
defer app.Close()
|
||||
app.POST("/record", func(c *omni.Context) error {
|
||||
return pool.InTx(c.Context(), nil, func(*sql.Tx) error {
|
||||
if scenario == "commit" {
|
||||
return nil
|
||||
}
|
||||
return apierrors.ByCode("INVALID").WithParams(map[string]any{"field": "name"}).WithCause(private)
|
||||
})
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, httptest.NewRequest("POST", "/record", nil))
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantStatus, wantCode, wantMessage := 200, float64(400002), "name is invalid"
|
||||
if scenario == "commit" {
|
||||
wantStatus, wantCode, wantMessage = 500, 9999, "internal server error"
|
||||
}
|
||||
data, hasData := body["data"]
|
||||
if w.Code != wantStatus || body["code"] != wantCode || body["msg"] != wantMessage || !hasData || data != nil || strings.Contains(w.Body.String(), "private") {
|
||||
t.Fatal("transaction wrapper changed public error response:", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
441
database/transaction_test.go
Normal file
441
database/transaction_test.go
Normal file
@@ -0,0 +1,441 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user