feat: expose transaction failure stages and conservative outcomes
This commit is contained in:
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}
|
||||
}
|
||||
Reference in New Issue
Block a user