1
0
mirror of https://github.com/rancher/steve.git synced 2025-09-18 00:08:17 +00:00

sql: use a closure to wrap transactions (#469)

This introduces the a `WithTransaction` function, which is then used for all transactional work in Steve.

Because `WithTransaction` takes care of all `Begin`s, `Commit`s and `Rollback`s, it eliminates the problem where forgotten open transactions can block all other operations (with long stalling and `SQLITE_BUSY` errors).

This also:

- merges together the disparate `DBClient` interfaces in one only `db.Client` interface with one unexported non-test implementation. I found this much easier to follow
- refactors the transaction package in order to make it as minimal as possible, and as close to the wrapped `sql.Tx` and `sql.Stmt` functions as possible, in order to reduce cognitive load when working with this part of the codebase
- simplifies tests accordingly
- adds a couple of known files to `.gitignore`
    
Credits to @tomleb for suggesting the approach: https://github.com/rancher/lasso/pull/121#pullrequestreview-2515872507
This commit is contained in:
Silvio Moioli
2025-02-05 10:05:52 +01:00
committed by GitHub
parent 6a46a1e091
commit 772dc7577e
28 changed files with 1543 additions and 2059 deletions

View File

@@ -1,92 +1,44 @@
/*
Package transaction provides a client for a live transaction, and interfaces for some relevant sql types. The transaction client automatically performs rollbacks on failures.
The use of this package simplifies testing for callers by making the underlying transaction mock-able.
Package transaction provides mockable interfaces of sql package struct types.
*/
package transaction
import (
"context"
"database/sql"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
// Client provides a way to interact with the underlying sql transaction.
type Client struct {
sqlTx SQLTx
}
// SQLTx represents a sql transaction
type SQLTx interface {
// Client is an interface over a subset of sql.Tx methods
// rationale 1: explicitly forbid direct access to Commit and Rollback functionality
// as that is exclusively dealt with by WithTransaction in ../db
// rationale 2: allow mocking
type Client interface {
Exec(query string, args ...any) (sql.Result, error)
Stmt(stmt *sql.Stmt) *sql.Stmt
Commit() error
Rollback() error
Stmt(stmt *sql.Stmt) Stmt
}
// Stmt represents a sql stmt. It is used as a return type to offer some testability over returning sql's Stmt type
// because we are able to mock its outputs and do not need an actual connection.
// client is the main implementation of Client, delegates to sql.Tx
// other implementations exist for testing purposes
type client struct {
tx *sql.Tx
}
func NewClient(tx *sql.Tx) Client {
return &client{tx: tx}
}
func (c client) Exec(query string, args ...any) (sql.Result, error) {
return c.tx.Exec(query, args...)
}
func (c client) Stmt(stmt *sql.Stmt) Stmt {
return c.tx.Stmt(stmt)
}
// Stmt is an interface over a subset of sql.Stmt methods
// rationale: allow mocking
type Stmt interface {
Exec(args ...any) (sql.Result, error)
Query(args ...any) (*sql.Rows, error)
QueryContext(ctx context.Context, args ...any) (*sql.Rows, error)
}
// NewClient returns a Client with the given transaction assigned.
func NewClient(tx SQLTx) *Client {
return &Client{sqlTx: tx}
}
// Commit commits the transaction and then unlocks the database.
func (c *Client) Commit() error {
return c.sqlTx.Commit()
}
// Exec uses the sqlTX Exec() with the given stmt and args. The transaction will be automatically rolled back if Exec()
// returns an error.
func (c *Client) Exec(stmt string, args ...any) error {
_, err := c.sqlTx.Exec(stmt, args...)
if err != nil {
return c.rollback(c.sqlTx, err)
}
return nil
}
// Stmt adds the given sql.Stmt to the client's transaction and then returns a Stmt. An interface is being returned
// here to aid in testing callers by providing a way to configure the statement's behavior.
func (c *Client) Stmt(stmt *sql.Stmt) Stmt {
s := c.sqlTx.Stmt(stmt)
return s
}
// StmtExec Execs the given statement with the given args. It assumes the stmt has been added to the transaction. The
// transaction is rolled back if Stmt.Exec() returns an error.
func (c *Client) StmtExec(stmt Stmt, args ...any) error {
_, err := stmt.Exec(args...)
if err != nil {
logrus.Debugf("StmtExec failed: query %s, args: %s, err: %s", stmt, args, err)
return c.rollback(c.sqlTx, err)
}
return nil
}
// rollback handles rollbacks and wraps errors if needed
func (c *Client) rollback(tx SQLTx, err error) error {
rerr := tx.Rollback()
if rerr != nil {
return errors.Wrapf(err, "Encountered error, then encountered another error while rolling back: %v", rerr)
}
return errors.Wrapf(err, "Encountered error, successfully rolled back")
}
// Cancel rollbacks the transaction without wrapping an error. This only needs to be called if Client has not returned
// an error yet or has not committed. Otherwise, transaction has already rolled back, or in the case of Commit() it is too
// late.
func (c *Client) Cancel() error {
rerr := c.sqlTx.Rollback()
if rerr != sql.ErrTxDone {
return rerr
}
return nil
}