fix(11-07): roll back the savepoint when a swallowed read failed

- beachcomber and lighthouse released their savepoint whenever the inner
  function reported no error; a Gate that counts a failed read as off,
  or a channel function or delete snapshot that swallows one, left the
  caller's Postgres transaction aborted (25P02) and failed the write
- a failed RELEASE now rolls back to the savepoint, as the READMEs promise
- beachcomber gets its testcontainers harness and sync tests
  (TestSyncGates, TestSyncAfterCommit, TestSyncDeleteAndSoftDelete,
  TestSyncFailuresNonFatal, TestServiceSetup); lighthouse gets
  TestBroadcastSwallowedReadFailure
This commit is contained in:
Jakub Zych
2026-09-30 14:26:51 +02:00
parent 6dadbf6957
commit 6df43d45b8
7 changed files with 890 additions and 4 deletions

View File

@@ -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.
- **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.
- **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`);