MT

mpyw/txbypass

Developer tools
0 stars Quality 57 Trend 20MIT

Go linter that reports a database handle used from inside a transaction it bypasses

Overview

Go linter that reports a database handle used inside a transaction it opened. The callback receives the transaction as tx. r.db is the pool the transaction came from, so the audit log is written outside the transaction. The code compiles. A test where nothing fails passes too. txbypass finds it from the source. Every report ends with the name of its rule. That name is what //txbypass:ignore takes. Pin a version, run through go vet or golangci-lint, or install from an archive mise use pins the version in the project's mise.toml, so every checkout and CI run the same one. Add -g to install it for every project on your machine instead. Through go vet, which runs it with the same package loading as the rest of your vet checks, and caches it: From a release archive, verified against the published checksums:

README

Go linter that reports a database handle used inside a transaction it opened.

Overview

package app

import (
	"context"

	"gorm.io/gorm"
)

func (r *OrderRepo) Place(ctx context.Context, o *Order) error {
	return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		if err := tx.Create(o).Error; err != nil {
			return err
		}
		return r.db.Create(&AuditLog{OrderID: o.ID}).Error
	})
}
$ txbypass ./...
order.go:14:10: r.db bypasses the transaction opened with Transaction; use tx instead [outer-handle]

The callback receives the transaction as tx. r.db is the pool the transaction came from, so the audit log is written outside the transaction.

What goes wrong Why
The write survives a rollback It was never part of the transaction
The write can wait forever It runs on another connection, and may need a lock the transaction holds
The write cannot see the transaction’s rows They are not committed yet

The code compiles. A test where nothing fails passes too. txbypass finds it from the source.

Every report ends with the name of its rule. That name is what //txbypass:ignore takes.

Install

Method Command Needs
mise (recommended) mise use "github:mpyw/[email protected]" Nothing. Installs the prebuilt binary
go tool go get -tool github.com/mpyw/txbypass/cmd/txbypass@latest Go 1.27+
go install go install github.com/mpyw/txbypass/cmd/txbypass@latest Go 1.27+
Release archive See below Nothing
txbypass ./...
txbypass -V=full   # the release this binary was built from

Rules

Rule Default Reports
outer-handle 🟢 On The handle a transaction was opened on, used while the transaction is open→ Use the transaction
outer-context 🟢 On The outer context, used inside a callback whose context carries the transaction→ Use the callback’s context

Each rule has a flag of its name:

txbypass -outer-context=false ./...  # every rule except outer-context

Nothing is reported in a generated file. Test files are checked like any other file.

Supported libraries

Library Callback Begin … Commit
database/sql DB.Begin, DB.BeginTx, Conn.BeginTx
sqlx DB.Beginx, DB.BeginTxx, DB.MustBegin, DB.MustBeginTx, Conn.BeginTxx
pgx v5 BeginFunc, BeginTxFunc Conn.Begin, Conn.BeginTx, Tx.Begin, pgxpool.Pool.Begin, pgxpool.Pool.BeginTx, pgxpool.Conn.Begin, pgxpool.Conn.BeginTx
pgx v4 Conn.BeginFunc, Conn.BeginTxFunc, Tx.BeginFunc, pgxpool.Pool.BeginFunc, pgxpool.Pool.BeginTxFunc Conn.Begin, Conn.BeginTx, Tx.Begin, pgxpool.Pool.Begin, pgxpool.Pool.BeginTx, pgxpool.Conn.Begin, pgxpool.Conn.BeginTx
gorm DB.Transaction DB.Begin
bun DB.RunInTx, Conn.RunInTx, Tx.RunInTx, IDB.RunInTx DB.Begin, DB.BeginTx, Conn.BeginTx, Tx.Begin, Tx.BeginTx, IDB.BeginTx
bob DB.RunInTx DB.Begin, DB.BeginTx, Conn.Begin, Conn.BeginTx
ent Client.Tx, Client.BeginTx, generated into your package
goqu Database.WithTx Database.Begin, Database.BeginTx
go-pg DB.RunInTransaction DB.Begin, DB.BeginContext
pop Connection.Transaction, Connection.Rollback Connection.NewTransaction, Connection.NewTransactionContext, Connection.NewTransactionContextOptions
upper/db Session.Tx, Session.TxContext
xorm v1, v2 Engine.Transaction
go-redis v9 Client.Watch, Client.TxPipelined, Tx.TxPipelined, UniversalClient.Watch, Cmdable.TxPipelined
go-redis v8 Client.Watch, Client.TxPipelined, Tx.TxPipelined, UniversalClient.Watch, Cmdable.TxPipelined
Firestore Client.RunTransaction
Spanner Client.ReadWriteTransaction, Client.ReadWriteTransactionWithOptions
Datastore Client.RunInTransaction Client.NewTransaction
MongoDB v2 Session.WithTransaction, WithSession, read by outer-context
MongoDB v1 Session.WithTransaction, WithSession, read by outer-context
go-rel Repository.Transaction, read by outer-context
go-transaction-manager v2 trm.Manager.Do, trm.Manager.DoWithSettings, manager.Manager.Do, manager.Manager.DoWithSettings, read by outer-context

Query builders that take the handle as an argument, such as jet and squirrel, need nothing of their own. Passing the outer handle to them is a use. sqlc is covered through WithTx.

The exact receivers and signatures are in design/rules.md.

outer-handle

Inside a transaction, every use of the handle it was opened on is reported. The transaction can be opened in two ways.

A handle is a value and the fields selected from it. Two expressions are the same handle when they hold the same value. The SSA form of the function says so. A copy of the handle is the handle.

Firestore references

Firestore’s client builds the references a transaction reads and writes. Building one runs nothing, so only a use that runs is reported:

sqlc’s WithTx

q.WithTx(tx) rebinds sqlc’s generated Queries to the transaction. Code that calls it means to run on the transaction, so every other use of q there is reported:

tx, err := db.BeginTx(ctx, nil)
if err != nil {
	return err
}
defer tx.Rollback()
qtx := q.WithTx(tx)
if err := qtx.CreateUser(ctx, a); err != nil {
	return err
}
if err := q.CreateUser(ctx, b); err != nil { // q bypasses the transaction opened with BeginTx; use qtx instead
	return err
}
return tx.Commit()

Any method named WithTx qualifies, when it takes the transaction and returns its receiver’s type. Its receiver must hold nothing but the database, as sqlc’s Queries does. A repository that holds more, as metrics or a logger, is not rebound: its other members run no statement.

[!TIP] Some writes must survive a rollback, such as an audit log of a failed attempt. Write an ignore with the reason:

//txbypass:ignore outer-handle // the audit log must survive a rollback
db.Create(&AuditLog{Action: "failed"})

Fix

Use the transaction:

return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
	if err := tx.Create(o).Error; err != nil {
		return err
	}
	return tx.Create(&AuditLog{OrderID: o.ID}).Error
})

A helper that takes the handle takes the transaction instead:

outer-context

Some openers carry the transaction in the callback’s context, not in a value. A statement run with the outer context runs outside the transaction:

_, err := sess.WithTransaction(ctx, func(sc context.Context) (any, error) {
	if _, err := orders.InsertOne(sc, o); err != nil {
		return nil, err
	}
	return audits.InsertOne(ctx, a) // ctx bypasses the transaction opened with WithTransaction; use sc instead
})

Fix

Use the callback’s context. Name it as the outer one, so the outer one cannot be reached:

_, err := sess.WithTransaction(ctx, func(ctx context.Context) (any, error) {
	if _, err := orders.InsertOne(ctx, o); err != nil {
		return nil, err
	}
	return audits.InsertOne(ctx, a)
})

Agent skill

skills/txbypass-authoring is a skill for an AI agent writing code under txbypass. It says how to fix each report, which fixes only hide a problem, and when an ignore is right. The binary carries it:

txbypass skill install                                # the agents already set up in this project
txbypass skill install --agent claude-code --scope user
txbypass skill list                                   # where it is, and whether it is current

Without the binary, gh skill install mpyw/txbypass txbypass-authoring --agent claude-code writes to the same directories. The installer is go-skill-embed.

Ignoring a report

Write //txbypass:ignore with the rules to silence and a reason after //.

//txbypass:ignore outer-handle // the audit log must survive a rollback
db.Create(&AuditLog{Action: "failed"})
Placement Silences
On a line of its own The line below
After code Its own line
Directive Result
//txbypass:ignore outer-handle // reason Silences outer-handle
//txbypass:ignore // reason Silences every rule
No reason Reported, and silences nothing
An unknown rule name Reported
An ignore that silences nothing Reported as unused, unless every rule it names is turned off

Limits

⚠️ marks code that bypasses the transaction, but is not reported:

These are reported, although the transaction may have ended before the use:

The full specification, with every limit, is design/rules.md.

License

MIT

The Go gopher was designed by Renée French, under CC BY 4.0.

View this README on GitHub

Recommended Tools

Try a different keyword or remove a filter.

Install

npx skillfish add mpyw/txbypass