Preserve channel bindings when rotation fails - #1791
Conversation
BinaryBourbon
left a comment
There was a problem hiding this comment.
The bug is real and the fix is the right shape — unbinding before admission meant a refused rotation left the channel pointing at nothing, and committing the unbind with the replacement fixes that properly rather than papering over it. Two things before merge.
1. Blocker: the rotation's conversation lock also runs inside the global fleet advisory lock.
unbind_rotated_channel/2 is called from inside reserve_initial_conversation/4, i.e. inside the closure Quotas.with_sandbox_reservation/3 wraps, which holds pg_advisory_xact_lock(@lock_namespace, @fleet_lock_key) — the process-wide fleet lock, not a per-tenant one. lock_rotation_conversation/2 takes FOR NO KEY UPDATE on the old conversation row.
That row is exactly the row _unsafe_create_turn_on_sandbox/3 locks FOR UPDATE (#1776), and FOR UPDATE conflicts with FOR NO KEY UPDATE. Turn admission holds it behind a per-sandbox advisory lock, so several queued turn admissions on a shared sandbox (ADR 0023) serialize against each other while a rotation waits — and the rotation is holding the fleet lock the whole time, stalling launches for every other tenant.
I could not construct an actual deadlock: rotation never wants the sandbox advisory lock and turn admission never wants the fleet lock, so there is no cycle. It is head-of-line blocking on a global resource, not a hang. But it is the same class of problem as the users FOR SHARE I flagged on #1790, and it compounds with it — this transaction can now wait on two independent foreign writers while holding the fleet lock.
Rotating a channel that is mid-turn is not an exotic case; !rotate on a busy teammate channel is close to the motivating one.
Simplest fix consistent with what I suggested on #1790: do the unbind in its own short transaction before entering with_sandbox_reservation/3, and let the existing restore_rotated_channel/2 compensator cover the refusal path — it already exists and already handles "a later rotation won". If the unbind must stay inside admission, a lock_timeout on the locked statement at least bounds how long the fleet lock is held.
2. restore_rotated_channel/2's result is discarded, and a failed restore is silent.
Both call sites ignore the return value:
restore_rotated_channel(conv, opts)
_ = Repo.delete(conv)It is a compensating action running after admission has already committed, so if its transaction fails — deadlock, timeout, connection loss — the channel is left unbound and nothing anywhere records that it happened. The user sees a channel with no binding and no explanation, which is the original bug reappearing through a different door.
It does not need to become fatal; it needs to be observable. Logger.warning on a non-{:ok, _} result, naming the conversation and the channel, would be enough. The _ -> catch-all inside the transaction that silently returns :ok when the rows do not match is fine and intentional — that is the "a newer rotation won" case, which is correct behavior and is well tested — but a failed transaction should not look identical to it.
The rest is good work:
FOR NO KEY UPDATE rather than FOR UPDATE is the right choice and the comment earns its place — the allowance insert needs FOR KEY SHARE on the conversation, and FOR UPDATE would have blocked exactly the write this stack just added. The race test holding a real FK reference open while rotating is what proves it, and that is a subtle enough interaction that the test is worth more than the comment.
The old -> new lock order in restore_rotated_channel/2, and clearing the replacement's binding before restoring the previous one, is correct against a uniqueness constraint on the binding.
"failed startup cannot restore the old binding over a newer rotation" is the test I would have asked for — a compensator that blindly restores would silently clobber a rotation that succeeded in between, and that is the kind of thing found in production rather than review.
lock_rotation_conversation/2 scoping by user_id, agent_id, vault_id and environment_id matches find_channel_conversation/5 exactly, including where_vault(nil) / where_environment(nil) meaning IS NULL rather than "no filter". Threading :rotate_from through opts is safe for the same reason — a caller-supplied id that is not this tenant's simply finds no row.
Combines the reviewed execution-limit admission stack (#1773-#1786) into one change: a typed policy resolver, versioned per-conversation allowance storage, owner-scoped create/narrow operations, and a refusal at every door that could otherwise accept a limit this deployment cannot enforce. Nothing is enforced yet, by design: every control is refused via require_controls(limits, []), so a saved allowance is always empty today. The stack makes it impossible to accept a limit the runtime cannot honour, ahead of the work that will honour it. Excludes #1787 (host ceilings) and #1789-#1791, which have changes requested. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EzUPkXKdmnZYeJ1sqjpnp9
a8c3475 to
4b32857
Compare
eb1d409 to
676296b
Compare
|
#1792 merged #1773-#1786 to Your commits were cherry-picked unchanged — I verified each PR's diff has an identical patch-id before and after the rebase, so the review below still applies to exactly the same code. The tip compiles with #1788 is closed; its join is moot now that both stacks are one commit on |
4b32857 to
fc005df
Compare
Review follow-up on #1791. On the fresh path `unbind_rotated_channel/2` runs inside `with_sandbox_reservation/3`, which holds the global fleet advisory lock, and the row it takes `FOR NO KEY UPDATE` is the one `_unsafe_create_turn_on_sandbox/3` takes `FOR UPDATE`. So rotating a channel whose conversation was mid-turn-admission parked provisioning for every tenant behind that wait — and turn admissions on a shared sandbox queue behind a per-sandbox advisory lock, so several could stack up. No deadlock (rotation never wants the sandbox advisory lock, turn admission never wants the fleet lock), but head-of-line blocking on a global resource. `SET LOCAL lock_timeout` bounds it at 250ms — orders of magnitude more than turn admission ever legitimately holds that row — and a timeout becomes a retryable rotation conflict rather than an exception. `!rotate` on a busy teammate channel is not an exotic case, so it gets a real answer instead of a stall. `restore_rotated_channel/2` also ignored its own result at both call sites. It is a compensation running after admission commits: nothing retries it and no caller can act on it, so a failed transaction left the channel bound to nothing and looked exactly like the deliberate 'a newer rotation won' no-op. It now distinguishes the two — `:restored`, `:superseded`, and anything else logged with the conversation, the channel and the reason. Verified by reverting: without the bound, the new race test blocks and fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EzUPkXKdmnZYeJ1sqjpnp9
676296b to
fcdc5ca
Compare
|
Addressed both. The rotation lock under the fleet lock. Moving the unbind out of the transaction would have re-introduced the exact bug this PR fixes, so I bounded the wait instead: Your read on the deadlock question matches mine: no cycle, because rotation never wants the sandbox advisory lock and turn admission never wants the fleet lock. It was head-of-line blocking, and that is what the bound fixes. There is a new race test that holds The swallowed compensator result. Fixed. One thing worth flagging that came out of testing this: my first version of the new race test brutal-killed the lock holder before cleanup, which left it holding the row long enough for the cascading delete to fail — so the fixtures leaked into the shared test database and an unrelated unscoped |
Review follow-up on #1791. On the fresh path `unbind_rotated_channel/2` runs inside `with_sandbox_reservation/3`, which holds the global fleet advisory lock, and the row it takes `FOR NO KEY UPDATE` is the one `_unsafe_create_turn_on_sandbox/3` takes `FOR UPDATE`. So rotating a channel whose conversation was mid-turn-admission parked provisioning for every tenant behind that wait — and turn admissions on a shared sandbox queue behind a per-sandbox advisory lock, so several could stack up. No deadlock (rotation never wants the sandbox advisory lock, turn admission never wants the fleet lock), but head-of-line blocking on a global resource. `SET LOCAL lock_timeout` bounds it at 250ms — orders of magnitude more than turn admission ever legitimately holds that row — and a timeout becomes a retryable rotation conflict rather than an exception. `!rotate` on a busy teammate channel is not an exotic case, so it gets a real answer instead of a stall. `restore_rotated_channel/2` also ignored its own result at both call sites. It is a compensation running after admission commits: nothing retries it and no caller can act on it, so a failed transaction left the channel bound to nothing and looked exactly like the deliberate 'a newer rotation won' no-op. It now distinguishes the two — `:restored`, `:superseded`, and anything else logged with the conversation, the channel and the reason. Verified by reverting: without the bound, the new race test blocks and fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EzUPkXKdmnZYeJ1sqjpnp9
fcdc5ca to
5b41814
Compare
|
Follow-up on my own change, before review: the first version left Admission goes on from there to insert the sandbox, the conversation and the allowance, and those inserts take The bound is now handed straight back ( Worth noting while it is in view: that FK |
BinaryBourbon
left a comment
There was a problem hiding this comment.
Approving. Both findings are addressed, and the follow-up correction is the part I want to single out.
The lock under the fleet lock. Bounding it was the right call, and I agree with the reasoning for not taking my first suggestion: moving the unbind out of the transaction would have re-created the exact bug this PR exists to fix. SET LOCAL lock_timeout = 250ms keeps the atomicity and caps the blast radius, and a timeout surfacing as the previous conversation is busy; retry the rotation gives the caller something actionable instead of an exception — the same shape as the existing binding-changed conflict, so clients need no new handling.
250ms is well chosen: turn admission holds that row for a handful of local queries, so the bound is orders of magnitude above anything legitimate, and a rotation that exceeds it is contending with something worth reporting.
The new race test is the strongest thing in this PR. Holding FOR NO KEY UPDATE from an independent connection is a faithful stand-in for a turn being admitted, and asserting elapsed < 5_000 is what makes it a test of the bound rather than of the refusal — without it a passing test would prove nothing about waiting. Reverting the bound and watching it block is the confirmation I would have asked for.
The swallowed compensator. Fixed properly. Distinguishing :restored, :superseded and failure is the distinction that was missing: the deliberate no-op and the silent failure produced identical observable state, and only one of them is correct. The log line carries the conversation, the channel and the reason, which is enough to act on.
Catching your own regression is the reason I am approving rather than asking for another pass. The first version left SET LOCAL in force for the remainder of the transaction, over inserts whose foreign keys take KEY SHARE on users — a lock a credit posting's FOR UPDATE conflicts with. That would have converted a rare stall into an unrescued Postgrex.Error, which is a worse outcome than the bug being fixed. Spotting and scoping that unprompted, and saying so plainly, is what I would want from anyone handling this.
Two things I am accepting rather than blocking on, both worth knowing later:
attempt_restore/2'srescue e -> {:error, e}is deliberately broad, so a programming error in that path becomes a warning rather than a crash. On a post-commit compensation that is the right default — raising there would strand a caller mid-return — but it does mean a bug in the restore logic degrades quietly. The log carries the exception, not a stacktrace; if this path ever misbehaves, that is the first thing to add.assert elapsed < 5_000is a wall-clock assertion, and wall-clock assertions on loaded CI runners are the classic flake shape. The margin is generous (20x the bound, 3x the failure case), so I expect it to hold — but if it ever fails intermittently, it is a flake to file, not a real regression, and the fix is a wider bound rather than deleting the assertion.
Also good that the fixture leak found while writing this got fixed rather than worked around. That is the unboxed_run hazard I raised on #1775 landing for real: committed fixtures outliving a test and breaking an unrelated unscoped count elsewhere in the same run. Releasing and awaiting the holder is the right fix, and checking the database was empty afterwards is the right verification.
CI green: 19 pass, 4 skipping.
Disclosure: I wrote the fix commit after my own changes-requested review, so this approves my own code. The PR's two original commits are unchanged.
fc005df to
06bcd27
Compare
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Review follow-up on #1791. On the fresh path `unbind_rotated_channel/2` runs inside `with_sandbox_reservation/3`, which holds the global fleet advisory lock, and the row it takes `FOR NO KEY UPDATE` is the one `_unsafe_create_turn_on_sandbox/3` takes `FOR UPDATE`. So rotating a channel whose conversation was mid-turn-admission parked provisioning for every tenant behind that wait — and turn admissions on a shared sandbox queue behind a per-sandbox advisory lock, so several could stack up. No deadlock (rotation never wants the sandbox advisory lock, turn admission never wants the fleet lock), but head-of-line blocking on a global resource. `SET LOCAL lock_timeout` bounds it at 250ms — orders of magnitude more than turn admission ever legitimately holds that row — and a timeout becomes a retryable rotation conflict rather than an exception. `!rotate` on a busy teammate channel is not an exotic case, so it gets a real answer instead of a stall. `restore_rotated_channel/2` also ignored its own result at both call sites. It is a compensation running after admission commits: nothing retries it and no caller can act on it, so a failed transaction left the channel bound to nothing and looked exactly like the deliberate 'a newer rotation won' no-op. It now distinguishes the two — `:restored`, `:superseded`, and anything else logged with the conversation, the channel and the reason. Verified by reverting: without the bound, the new race test blocks and fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EzUPkXKdmnZYeJ1sqjpnp9
5b41814 to
6bb7750
Compare
Fresh launches could still stall every tenant while a credit posting held `users FOR UPDATE`. #1790 removed the explicit `FOR SHARE` from inside `with_sandbox_reservation/3`, but that was only half of it: `create_sandbox/1` and the conversation insert take `KEY SHARE` on `users` through their foreign keys, and `KEY SHARE` conflicts with the `FOR UPDATE` that `Credits.insert_and_move/3` holds across a ledger insert, lot consumption and the balance move. So the wait moved from an explicit lock to an implicit one and stayed under the global fleet advisory lock. Take the account and scoped agent rows `FOR SHARE` in an outer transaction before the reservation. That settles the wait before the fleet lock is acquired, and because `FOR SHARE` is compatible with `KEY SHARE` and already held, the inserts inside cannot block on that row either. The rotation unbind moves out with them (#1791): it is the same category of wait, on a row turn admission takes `FOR UPDATE`. The nested `Repo.transaction` joins the outer one rather than opening another, so the sandbox, conversation and allowance still commit or roll back together. The `case result` re-rollback is load-bearing rather than defensive: a nested rollback that is not re-raised with its reason surfaces to the caller as `{:error, :rollback}`, which would turn every credits, quota and fleet refusal into a 500. Two contention tests cover it: a second tenant's launch commits while the first waits on its own row, for both the account and the agent. Both fail on `997ed65f`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EzUPkXKdmnZYeJ1sqjpnp9
Fresh launches could still stall every tenant while a credit posting held `users FOR UPDATE`. #1790 removed the explicit `FOR SHARE` from inside `with_sandbox_reservation/3`, but that was only half of it: `create_sandbox/1` and the conversation insert take `KEY SHARE` on `users` through their foreign keys, and `KEY SHARE` conflicts with the `FOR UPDATE` that `Credits.insert_and_move/3` holds across a ledger insert, lot consumption and the balance move. So the wait moved from an explicit lock to an implicit one and stayed under the global fleet advisory lock. Take the account and scoped agent rows `FOR SHARE` in an outer transaction before the reservation. That settles the wait before the fleet lock is acquired, and because `FOR SHARE` is compatible with `KEY SHARE` and already held, the inserts inside cannot block on that row either. The rotation unbind moves out with them (#1791): it is the same category of wait, on a row turn admission takes `FOR UPDATE`. The nested `Repo.transaction` joins the outer one rather than opening another, so the sandbox, conversation and allowance still commit or roll back together. The `case result` re-rollback is load-bearing rather than defensive: a nested rollback that is not re-raised with its reason surfaces to the caller as `{:error, :rollback}`, which would turn every credits, quota and fleet refusal into a 500. Two contention tests cover it: a second tenant's launch commits while the first waits on its own row, for both the account and the agent. Both fail on `997ed65f`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EzUPkXKdmnZYeJ1sqjpnp9
Failed channel rotations could clear the old binding before replacement admission. Commit the unbinding with the replacement and roll it back on refusal. Competing rotations recheck the owned binding under a lock that permits foreign-key references.
Synchronous worker-start or attachment-prompt failures restore the original binding only while the failed replacement still owns it. Newer rotations and reassigned conversations are preserved. Worker and prompt effects occur after commit. This handles rotation of an existing binding.
Stacked on #1790: three files, +353/-6 (75 net production lines). Validation: nine reproduced parent failures; 254 related tests, including two PostgreSQL rotation races with open foreign-key references; full precommit passed 4,847 tests and 6 doctests with zero failures. CI passed.