Files
summercms/docs/database/transactions.md
Jakub Zych 36092f542c fix(lagoon): warn on after-commit work registered after lagoon:after_commit; correct the OnDatabase example and pivot read guidance (quick-261028-r16)
- flushStatementAfterCommit marks the statement; AfterCommit logs a warning
  when work is buffered on a flushed statement that no longer holds GORM's
  transaction. Log-only: the work still never runs, the write succeeds
- subtests pin the late callback warning, a callback between commit and
  flush, and a reused chained statement staying silent
- README OnDatabase example registers After(gorm:create).Before(commit),
  matching docs/database/transactions.md
- RegisterJoinTable: pivot-ordered reads join the pivot table explicitly;
  a Preload cannot be ordered by a pivot column
2026-10-28 19:45:57 +01:00

5.5 KiB

title, description, section, order
title description section order
Transactions Run writes in lagoon.Transaction, defer side effects with lagoon.AfterCommit until the commit, and install GORM callbacks from Boot with lagoon.OnDatabase. database 70

Transactions

Laravel's DB::transaction runs a closure in a transaction, and DB::afterCommit defers work until it commits. lagoon has the same pair, lagoon.Transaction and lagoon.AfterCommit, and the rest of the framework relies on them: realtime broadcasts, search index updates and blob deletions wait for the commit, so no client hears about a row that was rolled back.

Running a transaction

lagoon.Transaction runs a function in a transaction and commits when it returns nil. Inside the function, use the ctx and tx it receives for every write. Work registered with lagoon.AfterCommit runs, in registration order, only after the commit succeeds; when the function returns an error, the transaction rolls back and the work is dropped:

return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
	if err := tx.Model(&Post{}).Where("id = ?", id).Update("title", "Published").Error; err != nil {
		return err
	}
	lagoon.AfterCommit(ctx, tx, func(ctx context.Context, db *gorm.DB) {
		*log = append(*log, fmt.Sprintf("post %d published", id)) // broadcast, index, send mail...
	})
	if fail {
		return errors.New("rolled back") // the AfterCommit work never runs
	}
	return nil
})

The callback receives a database handle with an empty statement, so a query it runs never continues from the written model's statement. A panicking callback is logged and does not turn a committed write into an error.

lagoon.AfterCommit behaves differently depending on where it is called:

Called The work runs
Inside lagoon.Transaction After the outermost transaction commits; never after a rollback.
In a GORM callback of a single-statement write (GORM's own implicit transaction) After GORM commits that write; never when the write fails.
Inside a plain gorm.DB.Transaction or another transaction lagoon did not open Never. lagoon cannot see whether that transaction commits, so it logs a warning and skips the work.
Outside any transaction Immediately.

The third row is deliberate: running the work early could announce a write that later rolls back. When code in a transaction needs after-commit work, open the transaction with lagoon.Transaction.

Nested transactions

A lagoon.Transaction inside another becomes a savepoint. Its after-commit work joins the outer transaction's only when its own function succeeds, so work dropped with a failed savepoint never runs:

return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
	lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "outer") })

	// A nested Transaction is a savepoint. Pass it the outer tx: given
	// the root db handle it returns an error instead.
	_ = lagoon.Transaction(ctx, tx, func(ctx context.Context, tx *gorm.DB) error {
		lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "dropped") })
		return errors.New("savepoint rolled back")
	})
	return lagoon.Transaction(ctx, tx, func(ctx context.Context, tx *gorm.DB) error {
		lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "inner") })
		return nil
	})
})

Pass the nested call the outer transaction's tx. Given the root database handle instead, the nested lagoon.Transaction returns an error without running its function. Otherwise it would open a second, independent transaction whose after-commit work would wait for the outer one.

Callbacks registered at boot

A hook that calls a service, such as a broadcast or a job dispatch, is a GORM callback rather than a model method (see Models). A plugin registers it from Boot, but Boot runs before the serve command opens the database. lagoon.OnDatabase bridges the gap: it runs your function as soon as the database is published, immediately when it already is.

Register the callback before GORM's gorm:commit_or_rollback_transaction step and defer its side effect with lagoon.AfterCommit:

return lagoon.OnDatabase(app, func(_ *sql.DB, gdb *gorm.DB) error {
	return gdb.Callback().Create().After("gorm:create").Before("gorm:commit_or_rollback_transaction").Register("acme:post_created", func(db *gorm.DB) {
		post, ok := db.Statement.Dest.(*Post)
		if db.Error != nil || !ok {
			return
		}
		lagoon.AfterCommit(db.Statement.Context, db, func(ctx context.Context, db *gorm.DB) {
			*log = append(*log, "created "+post.Slug) // runs only once the insert is committed
		})
	})
})

Warning

Register such a callback with a Before("gorm:commit_or_rollback_transaction") constraint, as above. lagoon runs a single-statement write's after-commit work from its own callback right after that commit step; a callback that GORM sorts after it buffers work that is never run. The write still succeeds, and lagoon logs a warning when that happens.

Boot-time work that needs the database

lagoon.OnDatabase is also the place for anything else a plugin must do with the database handle at start-up, such as registering a join table with lagoon.RegisterJoinTable. The error of a queued function is returned by lagoon.Publish, so a failing callback stops the start-up.