From 8e0083ed41dc4ec49d6d93f043b01991a19f2518 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 30 Sep 2026 21:00:20 +0200 Subject: [PATCH] fix(11-08): document foreign and nested transaction refusal - lagoon.Transaction doc and README state that a nested call over a root handle returns an error instead of opening an independent transaction - beachcomber README no longer promises an immediate sync inside a plain GORM transaction; it is warned and skipped since 11-08 --- modules/beachcomber/README.md | 2 +- modules/lagoon/README.md | 2 +- modules/lagoon/transaction.go | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/modules/beachcomber/README.md b/modules/beachcomber/README.md index 6b41cf2..a3597d6 100644 --- a/modules/beachcomber/README.md +++ b/modules/beachcomber/README.md @@ -31,7 +31,7 @@ The package itself knows no search server. An engine package registers itself fr ## Sync semantics -- **After commit.** The callbacks register the sync with `lagoon.AfterCommit`. Inside `lagoon.Transaction` it runs after that transaction commits, and not at all when it rolls back. A single-statement write, for which GORM opens its own transaction, syncs after that commit and not when the write fails. Inside a plain `gorm` transaction there is no commit hook, so the sync runs immediately through the transaction's handle. Its reads run in a savepoint, so a failed read never aborts the caller's transaction, including a read that the application Gate swallows and counts as off. +- **After commit.** The callbacks register the sync with `lagoon.AfterCommit`. Inside `lagoon.Transaction` it runs after that transaction commits, and not at all when it rolls back. A single-statement write, for which GORM opens its own transaction, syncs after that commit and not when the write fails. Inside a plain `gorm` transaction Lagoon cannot observe the commit, so `lagoon.AfterCommit` logs a warning and the sync is skipped; wrap such writes in `lagoon.Transaction`, or call `Sync` after the commit. The sync's reads run in a savepoint, so when `Sync` or `Remove` is handed a transaction a failed read never aborts it, including a read that the application Gate swallows and counts as off. - **Inline and non-fatal.** The sync runs in the writing goroutine, after the commit, so a create followed by a search sees the document. Every engine request is bounded by the engine's timeout (`search.typesense.connection_timeout_seconds`), and the caller's context cancellation does not abandon it. A failure, a timeout or a panic is logged at Warn as `search: sync failed` with the index, key and operation. The write is already committed and stays so. The log never carries the document or the API key. - **Three gates, before any request.** Nothing is sent when: 1. the engine is not configured (the `null` engine, or Typesense with an empty `search.typesense.api_key`); diff --git a/modules/lagoon/README.md b/modules/lagoon/README.md index 6d5d732..c910779 100644 --- a/modules/lagoon/README.md +++ b/modules/lagoon/README.md @@ -14,7 +14,7 @@ Postgres data layer: the shared GORM connection, per-plugin migrations, model he - One shared pool: `lagoon.Open`, `lagoon.Use` and `lagoon.OpenFromApp` return a `*sql.DB` and a `*gorm.DB` built on that same pool; `lagoon.Publish` makes both available on the `backpack.App`. - Database-ready hooks: `lagoon.OnDatabase` runs a callback with the pool and GORM handle as soon as the database is published, immediately when it already is, otherwise when `lagoon.Publish` runs. Plugins register GORM callbacks through it from Boot, which runs before the `serve` command publishes the database. -- After-commit work: `lagoon.Transaction` runs a function in a transaction and then the callbacks registered with `lagoon.AfterCommit`, in order, only after the commit succeeds; a nested `lagoon.Transaction` is a savepoint whose callbacks are dropped with it when it fails. A single-statement write for which GORM opens its own implicit transaction runs its callbacks from `lagoon:after_commit` once GORM commits, and never when the write fails. A callback registered inside a foreign plain GORM transaction is unsafe because Lagoon cannot observe its commit, so `lagoon.AfterCommit` warns and skips it. Outside a transaction, callbacks run immediately. The handle a supported callback receives always has an empty statement on the connection its work belongs to. A panicking callback is logged and never turns a committed write into an error. +- After-commit work: `lagoon.Transaction` runs a function in a transaction and then the callbacks registered with `lagoon.AfterCommit`, in order, only after the commit succeeds; a nested `lagoon.Transaction` is a savepoint whose callbacks are dropped with it when it fails. A nested `lagoon.Transaction` must be given the outer transaction's handle: given a root handle it returns an error without running its function, rather than open an independent transaction whose callbacks would wait on the outer one. A single-statement write for which GORM opens its own implicit transaction runs its callbacks from `lagoon:after_commit` once GORM commits, and never when the write fails. A callback registered inside a foreign plain GORM transaction is unsafe because Lagoon cannot observe its commit, so `lagoon.AfterCommit` warns and skips it. Outside a transaction, callbacks run immediately. The handle a supported callback receives always has an empty statement on the connection its work belongs to. A panicking callback is logged and never turns a committed write into an error. - Database check at connect time: `lagoon.CheckLocale` refuses a database whose default collation is not the ICU `pl-PL` locale, so ordering matches the database default without per-query `COLLATE`. - Per-plugin migrations: `lagoon.Migrate` runs the framework's `system_files` set (`attach.Migrations`), backend admin identity set (`lagoon.BackendAdminMigrations`) and job-queue set (`lagoon.QueueMigrations`: River's schema pinned at `lagoon.RiverSchemaVersion`, then the `lagoon.JobsTable` record table, under the `lagoon.QueueHistoryID` history), then every `pact.HasMigrations` set in plugin activation order, each in its own `summer_migrations_` history table (`lagoon.HistoryTableName`). `lagoon.RollbackLast` and `lagoon.Status` cover rollback and history. - Mass assignment: `lagoon.Fill` copies only allow-listed keys onto a model by GORM column name and silently drops the rest, logging each dropped key once outside production. A `json.Number` (from a decoder using `UseNumber`) fills integer, unsigned and float fields. A value that does not fit its column (a fraction, an exponent or an overflow for an integer field, or a value of the wrong type) is a `lagoon.FillTypeError` naming the key, so a caller can answer it as a validation failure on that field. `lagoon.HasFillable` and `lagoon.HasHidden` are the Go forms of `$fillable` and `$hidden`. diff --git a/modules/lagoon/transaction.go b/modules/lagoon/transaction.go index 1f2a220..98f9ca6 100644 --- a/modules/lagoon/transaction.go +++ b/modules/lagoon/transaction.go @@ -42,6 +42,9 @@ func (b *afterCommitBuffer) take() []func(context.Context, *gorm.DB) { // use the ctx and tx it receives. A Transaction nested in another becomes a // savepoint: its callbacks join the outer transaction's only when fn // succeeds, so work dropped with the savepoint never runs its callbacks. +// A nested Transaction must receive the outer transaction's handle; given +// a root handle it returns an error without running fn, rather than open +// an independent transaction whose callbacks would wait on the outer one. // A panicking callback is logged and never turns a committed write into an // error. func Transaction(ctx context.Context, gdb *gorm.DB, fn func(ctx context.Context, tx *gorm.DB) error) error {