Database

Transactions

Run writes in lagoon.Transaction, defer side effects with lagoon.AfterCommit until the commit, and install GORM callbacks from Boot with lagoon.OnDatabase.

On this page

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:

modules/lagoon/example_test.go#publish
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:

modules/lagoon/example_test.go#nested
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:

modules/lagoon/example_test.go#on-database
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
		})
	})
})

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.