cicadel

Transactions and post-commit hooks

Run related writes in one PostgreSQL transaction with database.Atomic, and schedule work that must happen only after those writes are committed — sending an email, enqueueing a job on another system, invalidating a cache — with database.OnCommit. A rolled-back transaction never sends the email.

#Ask your agent

In my Cicadel project, send a welcome email after a new member is saved. Use the model form’s AfterSave hook to register a database.OnCommit callback, so the email goes out only after the transaction commits. Add a PostgreSQL test showing that a rejected save sends nothing.

#Run writes atomically

database.Atomic(ctx, db, fn) begins a transaction, passes it to fn and commits when fn returns nil. An error or panic rolls everything back; the error (or panic) propagates unchanged. Use the supplied tx for every query.

Go
err := database.Atomic(r.Context(), db, func(tx *gorm.DB) error {
	if err := tx.Create(&order).Error; err != nil {
		return err
	}
	return tx.Model(&stock).Where("id = ?", order.ItemID).
		Update("count", gorm.Expr("count - 1")).Error
})

It wraps GORM’s db.Transaction and accepts the same optional *sql.TxOptions (for example &sql.TxOptions{Isolation: sql.LevelSerializable}).

#Nested calls are savepoints

Called with a tx from an enclosing Atomic, Atomic creates a savepoint instead of a new transaction. If the inner function fails, only its writes roll back and the outer function decides what to do with the error:

Go
err := database.Atomic(ctx, db, func(tx *gorm.DB) error {
	tx.Create(&invoice)
	if err := database.Atomic(ctx, tx, func(tx *gorm.DB) error {
		return tx.Create(&optionalDiscount).Error
	}); err != nil {
		log.Printf("discount skipped: %v", err) // invoice still commits
	}
	return nil
})

#Run code after commit

Register a hook inside the transaction:

Go
err := database.Atomic(ctx, db, func(tx *gorm.DB) error {
	if err := tx.Create(&member).Error; err != nil {
		return err
	}
	return database.OnCommit(tx, func(ctx context.Context) error {
		return sender.Send(ctx, welcomeMessage(member.Email))
	})
})

Model form saves (Bound.Create/Update) and administration saves run through database.Atomic, so model-form hooks can register directly:

Go
form, err := model.New[Member](db, model.Config[Member]{
	Fields: []model.Field{{Name: "Email", Required: true}},
	AfterSave: func(ctx context.Context, tx *gorm.DB, m *Member) error {
		to := m.Email
		return database.OnCommit(tx, func(ctx context.Context) error {
			return sender.Send(ctx, welcomeMessage(to))
		})
	},
})

Copy the values a hook needs, as above, instead of capturing models that later code may change.

#Semantics

SituationBehavior
Outermost transaction commitsHooks run in registration order, after commit and before Atomic returns.
fn returns an error, panics, or the commit failsNo hook runs; every hook registered in the transaction is discarded.
A savepoint (nested Atomic) failsHooks registered in it, and in savepoints inside it, are discarded. Hooks registered before or after it in the enclosing transaction survive.
A savepoint succeedsIts hooks join the enclosing transaction and run only when the outermost transaction commits.
OnCommit outside an Atomic transactionReturns database.ErrNoTransaction; the hook is never run. This includes transactions from db.Begin, a raw db.Transaction and apptest/postgres.Transaction, whose commit Cicadel cannot observe.
A hook returns an error or panicsThe commit stands. The failure (with the panic’s stack) is logged with slog.Default() at error level, later hooks still run and Atomic returns nil.
ctx is cancelled before commitPostgreSQL rolls the transaction back; Atomic returns the error and no hook runs.
ctx is cancelled after commitHooks still run. They receive context.WithoutCancel(ctx): request values (such as the request ID) remain, cancellation and deadline do not. Set your own timeout in a hook that needs one.
Concurrent transactionsEach transaction has its own hooks; they never mix.

Hooks run after the connection is returned to the pool, so they must not use tx. A hook that needs to write starts a new database.Atomic. Registering on a finished transaction returns ErrNoTransaction.

Because hooks run after commit, a crash between commit and hook leaves the work undone. When the effect must not be lost, insert a job in the same transaction with jobs.InsertTx (a transactional outbox) instead of a hook.

#Transactions started elsewhere

Atomic tracks the transactions it begins. Within a transaction that GORM or your code began directly, Atomic still creates savepoints, but OnCommit returns ErrNoTransaction. Start the outermost transaction with database.Atomic wherever hooks may be registered — including before model.Form.InTransaction(tx). If GORM’s DisableNestedTransaction is set, nested calls create no savepoint, so their writes and hooks stand or fall with the enclosing transaction.

Formset saves (from v0.2.4) also run through database.Atomic, so model-form hooks that call OnCommit during a formset Save run after the whole formset commits, and are dropped if any row fails.

#Test hooks

Hooks run on commit, so test them with a committed transaction in a disposable database from apptest/postgres.New, not inside postgres.Transaction (which always rolls back):

Go
func TestWelcomeEmail(t *testing.T) {
	db := postgres.New(t).DB
	outbox := &mail.Capture{}
	// ... apply migrations, build the form with mail.Sender{Transport: outbox}
	if _, err := form.Bind(url.Values{"email": {"ada@example.com"}}).Create(ctx, Member{}); err != nil {
		t.Fatal(err)
	}
	if len(outbox.Messages()) != 1 {
		t.Fatal("welcome email not sent after commit")
	}
}

See model forms, email and testing.

Type to search this version.