connection transactions: deactivate the ALS store and refuse nesting - #845
Merged
sroussey merged 1 commit intoAug 20, 2026
Conversation
`withConnectionTransaction` had no nesting guard. A nested call from an already-enlisted owner was classified "inline" by the connection mutex and returned `fn()` with no store scope, so the inner transaction re-ran `begin()` inside the outer's still-live store — corrupting the outer boundary differently on each backend: - SQLite: the inner BEGIN throws from outside the try, so no ROLLBACK runs, and the inner teardown clears `inTransaction` on the OUTER's participants — every later write in the outer body silently takes its own BEGIN. - PGlite: the nested BEGIN only warns, so the inner COMMIT commits the outer's work and the outer ROLLBACK no-ops. - Real pg.Pool: the inner call checks out a second client and overwrites the shared store's txQuery, and its teardown clears it — the outer's remaining writes fall through to the pool and autocommit. The root cause behind that and two related defects is that the ALS store outlives the transaction: `store.run(...)` is awaited by the mutex, so the store is still reachable from `afterCommit` and from any continuation of the body, and every accessor treated "store present" as "transaction open". Changes: - `AlsContext` gains `active` (cleared at COMMIT/ROLLBACK) and `groupHandle` (the physical connection, separate from the chain-slot key). The store is deactivated from INSIDE the ALS scope, with the mutex's own `finally` mutating the context directly as a backstop. - Accessors split on `active`: `connectionTxQuery`, `isEnlistedInConnectionTx`, `enqueueDeferredPut` and `activeConnectionTxGroupHandle` honor it; `takeDeferredPuts` / `discardDeferredPuts` deliberately do not, since they run in the deactivated window by design. - `assertSharedConnectionHandle` — the one choke point every backend reaches before checking out a client or issuing BEGIN — throws `NestedConnectionTransactionError` when a live transaction already owns this connection. Keyed on connection identity, so a transaction on a different database may still nest, and on `active`, so a sequential second transaction is not nesting. - Teardown order is now commit/rollback -> deactivate -> `onDeactivate` (backends clear `inTransaction`) -> `afterCommit`. A `put` listener that writes in response now commits normally instead of queueing onto a fresh buffer nothing drains, and on SQLite instead of running with no transaction. - `withConnectionTransaction([])` throws instead of silently running `fn` unwrapped; an all-best-effort list keeps the documented no-op and logs it. - Provider-package seam exports are annotated `@internal`; the dead `setConnectionTxQuery(undefined)` in the pool branch is removed. - `assertSharedConnectionHandle`'s parameter no longer declares a public `table` property. It is `protected` on every storage, so all three provider packages failed `build-types` against the previous signature. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SGqCtLFGeSJM2nxMbsaDkJ
sroussey
merged commit Aug 20, 2026
c87d865
into
claude/multi-repo-transactions-708
10 of 11 checks passed
sroussey
deleted the
claude/branch-security-review-xs0tph-libs-tx-nesting
branch
August 24, 2026 18:50
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #842 (
claude/multi-repo-transactions-708). This is PR A of a three-PR sequence; it is the blocking one for the downstreamwithConnectionTransactionconsumers.The CRITICAL defect:
withConnectionTransactionhas no nesting guardA nested
withConnectionTransactioncall made from an already-enlisted owner is classified"inline"by the connection mutex (defineConnectionMutex.ts), which returnsfn()with no store scope of its own. The innerrunNativeConnectionTransactiontherefore re-runsbegin()inside the outer transaction's still-live ALS store. There is no autonomousBEGINin SQLite or PostgreSQL, so the outer transaction boundary is corrupted — differently on each backend, and silently on two of the three:BEGINthrows from outside thetryinrunNativeConnectionTransaction, so noROLLBACKruns. The inner teardown's trailing.finallythen clearsinTransactionon the outer's participants, so every later write in the outer body silently takes its ownBEGIN— outside the transaction it believes it is in.BEGINonly warns. The innerCOMMITcommits the outer's work, and the outer's eventualROLLBACKis a no-op. Data that was supposed to roll back is durable.pg.PoolsetConnectionTxQueryoverwrites the shared (still-active) store'stxQuery; the inner teardown then clears it. The outer's remaining writes fall throughdbtothis.pooland autocommit on arbitrary pooled clients.Verified end to end: the new SQLite integration test fails on the base branch with
SqliteError: cannot start a transaction within a transaction, and the new PGlite one fails withexpected undefined to be an instance of NestedConnectionTransactionError— i.e. the nested call raises nothing at all.Root cause, shared with two other defects
store.run(...)is awaited by the mutex, so the ALS store is still reachable from every continuation of the transaction body and from theafterCommitcallbacks — long afterCOMMITran. Every accessor treated "a store is present" as "a transaction is open", which is what makes the nesting case reachable and also causes:setConnectionTxQuery(undefined)never runs. The pool branch'sfinallysits outsiderunNativeConnectionTransaction, so its continuation executes in the caller's async context wheregetAlsStore()isundefinedand the setter early-returns. The store survived withtxQuerybound to a client that had already beenrelease()d back to the pool.flushAlsDeferredPutsemits inside the still-active store. Aputlistener that writes in response re-enteredenqueueDeferredPut, which — still active, owner still enlisted — pushed onto a fresh queue nothing drains. On SQLite that listener's write also still sawinTransaction === true(cleared only by the trailing.finally), so it skipped its ownBEGINand ran with no transaction at all.The fix
Structural.
AlsContextgains two fields:active—falseonceCOMMIT/ROLLBACKhas run. Cleared bydeactivateConnectionTxStore()called from inside the ALS scope (a mutation from the caller's context would not be visible to descendants); the mutex's ownfinallyadditionally mutates the context object directly as a backstop for a body that threw before reaching the deactivation point.groupHandle— the physical connection, kept separate fromhandle(the chain-slot key).runInTransactionOnConnectiontakes an optional 4thgroupHandleparam, defaulting tohandle, so nesting detection can key on connection identity while chaining keys on the slot.runNativeConnectionTransactiongains the matchingchainHandleoption.Accessor gating — the split is deliberate and documented in the module header:
activeconnectionTxQueryisEnlistedInConnectionTxenqueueDeferredPutactiveConnectionTxGroupHandletakeDeferredPutsdiscardDeferredPutsThe guard. Every backend calls
assertSharedConnectionHandlefirst, and beforepool.connect()/BEGIN, so it is the single choke point where nesting can be refused while the outer transaction is still intact. It now throwsNestedConnectionTransactionErrorwhenactiveConnectionTxGroupHandle() === handle. Three properties are load-bearing:groupHandle, not the chain key, so the pool path stays covered once PR B re-keys the chain onto the client;active, so a sequential second transaction is not nesting.Teardown order is now
commit/rollback→ deactivate →onDeactivate(new option; backends clearinTransactionhere) →afterCommit/afterRollback, on every exit path including a failedBEGIN(which correctly issues noROLLBACK). Backends keep their trailing.finallyas belt-and-braces. That ordering is what makes a deferred-put listener's own write commit normally and emit its own event.Smaller items in scope:
withConnectionTransaction([])now throws instead of silently runningfnunwrapped (a dynamically-built list that came out empty asked for atomicity and would not get it); an all-best-effort list keeps its documented no-op but now logs it at debug; the provider-package seam exports incommon-server.ts/browser.tsare annotated@internalwith a comment above each group; the now-deadsetConnectionTxQuery(undefined)is removed from the pool branch.One fix outside the plan
assertSharedConnectionHandle's parameter declared& { readonly table?: string }.tableisprotectedon every concrete storage, so it cannot satisfy a public structural type — all three provider packages failedbuild-typeson the base branch withTS2345. The label is now read through the same cast the rest of the function already used.@workglow/sqlite,@workglow/postgresand@workglow/duckdball build types clean again.Tests
Every test below was verified in both directions — run against a deliberately degraded (pre-fix) implementation with the test files unchanged, then against the fix.
New
packages/storage/src/tabular/__tests__/runNativeConnectionTransaction.test.ts(9 tests). The observers are registered as promise continuations from inside the transaction body, because an observer called from the test body carries no store and would report "not enlisted" no matter what the code does:txQuery/activeConnectionTxGroupHandleall go dead once the transaction settlesonDeactivateruns beforeafterCommit; the body's queued put is still drainable there but a new put is refusedBEGIN(asserting noROLLBACKis attempted)isSynchronousAls()distinguishes the shim from a realAsyncLocalStorageNew
packages/storage/src/tabular/__tests__/withConnectionTransaction.test.ts(2 tests): empty list rejects and never callsfn; all-best-effort resolves and logs at debug.SqliteTabularStorage.integration.test.ts(4 new): nesting rejected with the outer's own write still committing; a write after a rejected nesting attempt still joins the outerBEGINand still rolls back with it; a transaction on a secondSqlite.Databasemay nest; aputlistener that writes back through the same storage persists and its ownputevent fires.PostgresTabularStorage.integration.test.ts(1 new): the PGlite mirror of the nesting rejection.Full suites, after the fix (Node 24.19,
bun scripts/test.ts storage unit vitestplus the integration files by name):One pre-existing note:
PostgresTabularStorage.integration.test.ts > shared-connection safety (PGlite path) > sibling single-op throws …exceeds the 15 s default timeout in this container. Confirmed identical on the base branch with these changes stashed — PGlite instantiation alone is slower than the timeout here; it passes at--testTimeout=120000.Downstream impact (embarc-data #53/#55)
The
withConnectionTransaction(participants, fn)signature is unchanged, but four behaviors change:withConnectionTransaction([])now throws. Guard dynamically-built participant lists before calling.withConnectionTransactionon the same handle now throwsNestedConnectionTransactionError. Hoist the inner participants into the outer call (enlisted writes join the openBEGIN, so the inner call is unnecessary), or use aSAVEPOINT.storage.withTransaction()inside a connection transaction will throw on Postgres — embarc-data reverted to exactly this pattern inb038de3. (PR B, not this PR.) Ordinaryput/putBulkjoin the transaction and need no change.pg.Poolwill run in parallel instead of serializing, so overlapping writes can hit real row-lock contention. (PR B, not this PR.)sec/embarc-dataon SQLite exercise only the single-session path. On Postgres they build a realpg.Pool, so the entire pool path is live for them and is currently untested. Findings 1, 2 and 8 (this PR) must land before #53/#55 are correct at all; findings 4, 5 and 7 (PR B) must land before either is deployed against Postgres.Sequencing
@internal. Backend-agnostic, fully testable on SQLite + PGlite. Blocking for downstream.pg.Pool: parallel transactions (chainHandle),isSynchronousAls()fail-fast,withTransaction-inside rejection, poisoned-client release. Depends on this PR; confined toPostgresTabularStorage.ts. ThechainHandle/onDeactivate/isSynchronousAlsplumbing added here is unused until then, by design — splitting it out would mean changing these same signatures twice.pg.Poolpath: an env-gated block plus apostgres:16CI service. Every line of the pool branch is currently unexecuted (PGlite as unknown as Poolexposes noconnect(), soserializeOpsis always true, and noPG_URL-style variable exists anywhere in the repo).Generated by Claude Code