fix(mothership): bound stream recovery with a per-run restart budget - #8546
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 11 files
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
|
A recovered Chat controller that failed to persist an event for any reason other than a budget refusal was treated as superseded, so it left its run recoverable without finalizing. Every reconnect poll then claimed the run again and re-POSTed it to the worker, for as long as a tab stayed open. - Only a lease another controller holds is a hand-off. A failed leased append latches StreamPersistenceFailedError in the writer, like a budget refusal: the turn ends as an error through the existing terminal path, which settles the run and stops the worker run. An unreadable lease proves nothing, so the fenced writes that follow decide ownership. - Refusals, persistence failures and recovery exhaustion share one StreamTurnFailure base that the lifecycle classifies as an error, never a cancellation or a hand-off. - Recovery takeovers carry a per-run budget in the run's request context, written in the same token-compared claim UPDATE, so it covers every pod, tab and caller. The first takeover is immediate; later ones wait an exponential backoff (1 s base, 60 s cap). After 5 takeovers without a controller staying alive for 5 minutes, the next claim ends the run as an error and stops the worker run. Each claim logs one line with its attempt number.
…e recovery budget Ending the turn on any leased append error that was not a lost lease turned a short Redis blip (a failover READONLY or a connection reset that outlasts the append retries) into a failed Chat turn and a worker stop. Before, that turn was handed off and a successor completed it. - A failed append that is not a budget refusal hands off again, as on staging. The per-run recovery budget bounds a persistent failure: MAX_RECOVERY_ATTEMPTS takeovers with backoff, and then the run ends as stream_recovery_exhausted. - Drop StreamPersistenceFailedError and stream_persistence_failed. The writer only latches budget refusals again, and finalizeAsError surfaces a failed terminal append as it did before. - Keep the change that an unreadable lease is not a hand-off. Its test now fails a lease read alone: a corrupted lock key also fails the fenced append, which is now a hand-off. - Storm suite: a single failed append hands off and the next controller completes the turn. A persistent append failure exhausts the budget, the same as a lease lost with no successor.
Recovery claimed the run before reading its replay, resolving billing and checking access. A takeover that then failed released the lock but had already spent an attempt and refreshed the run, so an exhausted run whose takeover kept failing there stayed active, and the refreshed row kept the orphan sweep away. Claim last: a takeover that fails before it starts leaves the run untouched, and an exhausted claim always reaches the terminal path.
e368316 to
6c7bf90
Compare
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 11 files
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
Summary
Problem. A recovered Chat controller that failed to persist a replay event for any reason other than a budget refusal threw
StreamControllerSupersededErrorand left its run recoverable without finalizing. Every reconnect poll (tail, batch GET, replay hold, every open tab, every pod) then claimed the run again and re-POSTed it to the worker. Nothing bounded it: no cooldown, no attempt cap, and since the tail no longer ends at 60 minutes, a storm ran as long as a tab stayed open. Each re-POST made the worker re-hand the run, so a single stuck run could drive sustained worker load.Root cause. Recovery treated "this controller failed" as "another controller took over", and
readChatStreamclaimed any non-terminal run with a free chat lock on every read. The only guards were the chat lock (one live controller at a time) and the controller-token compare-and-swap, neither of which limits how often a dying controller is replaced.Fix (restart limit with exponential backoff, the CrashLoopBackOff / supervisor pattern):
planRecovery+claimRunController: each run carries arecoveryBackoff(attempts,claimedAt,notBefore) in its request context, written in the same token-compared claim UPDATE, so it is as atomic as the claim and covers every pod, tab and caller. The first takeover is immediate; later ones waitbackoffWithJitter(1 s base, 60 s cap). A takeover more than 5 minutes after the previous one starts a fresh budget.MAX_RECOVERY_ATTEMPTS(5) takeovers, the next claim ends the run asstream_recovery_exhaustedthrough the existing terminal path: the run is settled as an error and the worker gets the same explicit abort a replay refusal sends. The worker is not re-POSTed for that claim.StreamTurnFailureis the shared base for replay refusals and recovery exhaustion, so the lifecycle classifies both as errors, never as a cancellation or a hand-off.Claimed stream run for recoverylog line per claim, with the attempt number. Before this, recovery claims were invisible on the Sim side.Behaviour changes
Deploy / compat
recoveryBackoffis a new key in the existingrequestContextjsonb; runs without it parse as a fresh budget, so in-flight runs are unaffected on rollout and an older pod simply ignores the key (jsonb ||keeps the other keys).Type of Change
Testing
recovery-storm.integration.ts(real Postgres + Redis, the real chat stream GET route and chat lifecycle, a stub worker): immediate first takeover, one failed append handed off and completed by the next controller, persistent append failure / thrown tool / lost lease exhausting afterMAX_RECOVERY_ATTEMPTSwith exponential gaps and exactly one worker stop (1 and 3 tails), waiting out a recent takeover's backoff, a takeover that fails before it starts leaving the run untouched and the next one ending the exhausted run, budget reset after the window, and a parked run with a live controller never being taken over.activeindefinitely; after, 6 POSTs in total, then the run iserrorand the worker is stopped.bun run test:integrationforrecovery-storm,replay-budget,replay-gap,stream-recovery,buffer-ttl,workflow-client-settlement,orphaned-runs: 90/90 passed (77/77 for the mothership suites after the review round).bun run --cwd apps/sim test lib/mothership/request lib/mothership/chat: 1198 passed.bun run lint,bun run check:audits(54 audits),docs-manifest:check, block-registry check,apps/simtype-check: all pass.Checklist
test-auditauthoring gate)