Files
summercms/docs/database/transactions.md
Jakub Zych efb35a2d35 feat(11.1-04): add the Database section and the core Services pages
- docs/database: models, migrations, queries and pagination, relations,
  casts and validation, attachments and transactions (lagoon.Transaction,
  lagoon.AfterCommit, nested savepoints, lagoon.OnDatabase)
- docs/services: configuration, events, routing with auth groups, rate
  limiting, authentication, the OAuth server, mail and localization
- runnable Examples for lagoon, attach, compass, surf, wire, bouncer,
  wristband, postcard, phrasebook and festival; lagoon TestDocs* regions
  run on the package's Postgres harness through DocsDB
- 15 new required pages
2026-09-30 22:59:25 +02:00

5.4 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, without an error.

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.