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} }