cicadel

Transactions and post-commit hooks

database.Atomic runs related writes in one PostgreSQL transaction, like Django’s transaction.atomic. database.OnCommit schedules work that must happen only after those writes commit, like Django’s transaction.on_commit.

Use OnCommit for sending an email, enqueueing a job on another system or invalidating a cache. A rolled-back transaction never sends the email.

#Ask your agent

Text
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

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

database.Atomic(ctx, db, fn) begins a transaction and passes it to fn.

  • When fn returns nil, it commits.
  • When fn returns an error or panics, everything rolls back. The error or panic propagates unchanged.
  • Use the supplied tx for every query.

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

#Nested calls are savepoints

Pass a tx from an enclosing Atomic, and Atomic creates a savepoint instead of a new transaction. If the inner function fails, only its writes roll back. 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))
	})
})

#Hooks in model forms

These saves already run through database.Atomic:

  • model form saves (Bound.Create and Update),
  • administration saves,
  • formset saves (from v0.2.4),
  • account service operations in contrib/accounts.

So a model-form hook 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. Do not capture models that later code may change.

In a formset Save, hooks run after the whole formset commits. They are dropped if any row fails.

#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 returns 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.

Warning

A crash between commit and hook leaves the hook’s work undone. When the effect must not be lost, use a transactional outbox instead: insert a job in the same transaction with jobs.InsertTx.

#Transactions started elsewhere

Atomic tracks the transactions it begins. Inside a transaction that GORM or your code began directly:

  • Atomic still creates savepoints,
  • OnCommit returns ErrNoTransaction.

So start the outermost transaction with database.Atomic wherever hooks may be registered. This includes before model.Form.InTransaction(tx).

If GORM’s DisableNestedTransaction is set, nested calls create no savepoint. Their writes and hooks stand or fall with the enclosing transaction.

#Test hooks

Hooks run on commit. Test them with a committed transaction in a disposable database from apptest/postgres.New. Do not use 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.