fix(sandbox): let a reconnecting worker adopt its run, not restart it - #7012
Merged
Conversation
added 3 commits
September 4, 2026 14:12
The daemon has no attach verb. Reconnecting to a run and starting a
competing turn are the SAME wire call — `POST /_sandbox/dispatch` — so
the only thing separating them was whether the previous connection still
looked open. That signal describes a socket, not a worker.
When KEDA scales in the worker holding a run, DBOS recovers the workflow
and re-dispatches within a second or two, routinely faster than the dying
pod's TCP close is observed here. The daemon saw a live client, called
the reconnect a second writer, SIGKILLed a healthy harness and re-ran the
turn. Observed on ELEC-242: `error: "superseded"` at chunk 197 after
295s, with the replacement resuming `fromSeq: 199`.
There is no second writer in that case, only a slow-closing one. A
dispatch for a live run now waits `supersedeGrace` (5s) for the incumbent
to hang up, and reattaches if it does. A genuine double-dispatch still
supersedes, just that much later — the "two agents, one worktree"
guarantee is unchanged, and a run that finishes inside the grace is
collected rather than re-run.
This is a mitigation, not the fix: the protocol should carry an explicit
attach (`GET /runs/{id}/stream?fromSeq=N`) so a reconnect can never be
read as a competing turn. That needs the frame buffer to serve an
arbitrary seq, which today only spans from the moment of detach.
…caling in a busy worker Two edge paths in the supersede grace, and the trigger that causes it. The grace's wait is lock-free, so its result described a pointer that could already be stale. Two ways that went wrong: - A run that finished with its client STILL READING has delivered its terminal and `release` retains nothing (no buffer to hand on). The dispatch behind it adopted that drained entry, streamed zero frames and no terminal, and the thread never settled. - An incumbent replaced during the wait was displaced on the strength of the OLD entry's state. An already-detached replacement — another reconnect — got SIGKILLed, the exact case the grace exists to prevent. Both are now impossible by construction: the wait reports nothing, and the decision is read from the registry under the lock afterwards. In finishedRuns → collect; gone from the map → this is a new turn; still there → detached ? reattach : supersede. The trigger, too. `/dbos-queue-depth/:queue` reported ENQUEUED only, so a run streaming for minutes with nothing behind it read `0/5 (avg)` and KEDA scaled in the pod holding a live DBOS workflow. It now also reports `in_flight` (PENDING); the ScaledObject gains a trigger on it, targeted at each queue's `workerConcurrency`, so running work sets a replica FLOOR without ever being a source of scale-up. Chart side is decocms/deco-apps-cd — deploy this first, or KEDA's scaler errors on a field the worker does not serve yet.
Review pass on the previous two commits. The 5s grace widened a pre-existing tombstone window into a user-reachable bug: DELETE is exactly what ENDS the wait (cancelling closes the run's `done`), so a stop pressed during the grace woke the waiting dispatch, found the run gone, and STARTED it — a stop button that relaunches the turn it just stopped, holding the checkout for a full run. The tombstone is now re-read inside the post-wait critical section and resolves to a 410, decided late instead of only at the door. Covered at both tiers, verified red at both. `release` now does all of its work under `reg.mu`, `close(done)` included. It used to `markFinished()` (which clears `detached`) before taking the lock, so a dispatch could observe a run that had finished but was still listed in `activeRuns`, read it as "attached", take it over, and discard a terminal that had already been produced. Also from the review: - The takeover log reported the grace CONSTANT, not the wait. Measured now, in ms — a sub-second grace truncated to `0s` told you nothing. - `TestRunFinishingDuringTheGraceIsCollectedNotRestarted` passed via the reattach branch and never checked the successor got a terminal, which is the only thing separating collection from adopting a drained corpse. It asserts the terminal now. - `TestReplacementIncumbentThatIsDetachedIsAdoptedNotKilled` swapped the replacement into the registry before detaching it, so a poll landing between the two saw a live second writer. Detached first. - `in_flight` overclaimed. PENDING is not proof of a live executor: a SIGKILLed pod's rows stay PENDING with `queue_name` intact until DBOS recovery re-enqueues them, and one recovery never reaches would hold a replica up forever. Bounded by a staleness window (30 min, well above the 10-min run liveness ceiling), and the comments now say "a floor while recovery is healthy" rather than "never a source of scale-up". - Both queue-depth queries pass `loadInput: false`. They count rows; a hosted-harness input is the whole run request, deserialized twice per poll to call `.length`.
decocms Bot
pushed a commit
that referenced
this pull request
Sep 4, 2026
PR: #7012 fix(sandbox): let a reconnecting worker adopt its run, not restart it Bump type: patch - decocms (apps/api/package.json): 4.330.0 -> 4.330.1 - @decocms/native (apps/native/package.json): 4.330.0 -> 4.330.1 - @decocms/e2e (packages/e2e/package.json): 1.63.2 -> 1.63.3 - @decocms/sandbox (packages/sandbox/package.json): 1.60.10 -> 1.60.11 - deploy/helm/sandbox-env (chart 0.16.40) (deploy/helm/sandbox-env/values.yaml deploy/helm/sandbox-env/Chart.yaml): image.tag/appVersion -> 1.60.11 Deploy-Scope: server
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.
The bug
Runs keep restarting mid-job and losing work. Root cause is a protocol gap.
The daemon exposes exactly two run verbs:
There is no attach verb. So a replacement worker has no way to say "I am the new reader for run X, stream me from seq 199" — the only thing it can do is
POST /dispatchagain, and the daemon has to guess whether that means reconnect or a competing turn. It guessed from whether the incumbent's connection was still open, which describes a socket, not a worker.What actually happens
KEDA scales in the worker holding a run (then immediately back up, because 1 is below
minReplicas). DBOS recovers the workflow and re-dispatches within a second or two — faster than the dying pod's TCP close is observed by the daemon. The daemon sees a live client, takes the takeover branch, SIGKILLs a healthy harness and re-runs the turn:There was never a second writer. Only a slow-closing one.
#7006 made the daemon able to reattach, but only once the run is already marked detached — so it does not fire when the reconnect wins the race against the socket close, which is the common case.
The change
A dispatch for a run that still looks live now waits
supersedeGrace(5s) for the incumbent to hang up, and reattaches if it does. Waiting costs a reconnect nothing — it is about to stream anyway.Preserved:
Tests
Four, one per branch: reconnect-during-grace reattaches and does not cancel the harness; a genuinely-attached incumbent is still superseded; a run finishing inside the grace is collected; plus the existing takeover suite, with the grace shortened so the suite stays fast.
go test ./...andgo test -race ./internal/dispatch/pass;go vetclean.This is a mitigation, not the fix
The protocol should carry an explicit attach —
GET /_sandbox/runs/{runId}/stream?fromSeq=N— so a reconnect can never be read as a competing turn andPOST /dispatchgoes back to meaning "start this turn" only. Two things to settle first: the frame buffer currently spans only from the moment of detach, so an attach arriving later would still gap, andingestRun/JetStream may be the better replay source since it already holds every chunk by seq.The trigger, fixed too
keda-hpa-deco-studio-workerreported0/5 (avg)while a run was in flight: the metrics-api triggers count ENQUEUED work only, and a hosted run is dequeued the instant it starts, then streams for minutes with nothing behind it. KEDA saw an idle worker and scaled in the pod holding a live DBOS workflow./dbos-queue-depth/:queueNamenow reportsin_flight(PENDING) alongsidequeue_length(ENQUEUED). The two numbers mean different things for scaling and are wired to separate triggers: backlog scales up, in-flight sets a replica floor (ceil(in_flight / workerConcurrency)) so running work can never be scaled out from under itself.in_flightis a floor while DBOS recovery is healthy — per-pod PENDING is capped atworkerConcurrencyby the dequeue, so it can never ask for more replicas than are already busy. It is not unconditional: a PENDING row survives its executor's death until recovery re-enqueues it, so a rollout briefly counts dead pods' rows too. That is bounded by a 30-minutedequeuedAfterwindow (above the 10-minute run-liveness ceiling, so it never clips a real run) rather than left to pin a replica forever. Both queries passloadInput: false— they count rows, and a hosted-harness input is the whole run request.The chart half is decocms/deco-apps-cd#479, and it must land only after this is fully rolled out: KEDA resolves
valueLocationwithgjson, an absent path errors rather than reading 0, and the HPA then freezes at its current replica count.An e2e (
packages/e2e/tests/dbos-queue-depth.spec.ts) pins both field names: they are a wire contract with a ScaledObject in another repo, where a rename would be silent.Follow-up commit: the two edge paths
The grace's wait is lock-free, so its result described a pointer that could already be stale by the time it returned. Two ways that went wrong:
releaseretains nothing (no buffer to hand on). The dispatch behind it adopted that drained entry — zero frames, no terminal — and the thread never settled.Both are now impossible by construction.
waitForDetachreports nothing; the decision is read from the registry under the lock afterwards. InfinishedRuns→ collect. Gone from the map → its terminal was delivered, so this dispatch is a new turn. Still there → detached ? reattach : supersede. Two tests, each verified red against the previous commit.Third commit: the holes the grace itself opened
A review pass on the two commits above, and this is the one that matters:
A stop pressed during the grace restarted the run.
DELETEis exactly what ends the wait — cancelling closes the run'sdone— so the waiting dispatch woke, found the run gone, and started it. A stop button that relaunches the turn it just stopped, with that harness holding the checkout for a full run. The handler's tombstone check ran once at the door; the grace put five seconds between it and the decision. The tombstone is now re-read inside the post-wait critical section and resolves to a 410. Covered at both tiers — a Go unit test and a black-boxdaemon-e2eone that gets200(restarted) without the fix and410with it.releasewas not atomic. It calledmarkFinished()(which clearsdetached) before takingreg.mu, so a dispatch could observe a run that had finished but was still listed inactiveRuns, read it as "attached", take it over, and discard a terminal that had already been produced. All ofreleasenow runs under one lock,close(done)included.Plus: the takeover log reported the grace constant instead of the measured wait; one test passed via the wrong branch and never checked the successor got a terminal; another swapped a replacement into the registry before detaching it, so a poll landing between the two would see a live second writer.
Summary by cubic
Fixes runs restarting mid-job and losing work when KEDA scales in the worker holding them. The daemon has no attach verb, so a reconnect and a competing turn arrive as the same
POST /dispatch; a dispatch for a live run now waits up to 5s for the incumbent to hang up and reattaches instead of taking over. The queue-depth endpoint also now reports in-flight work, so KEDA stops scaling in a worker that is merely busy rather than backed up.Daemon reattach
KEDA trigger
/dbos-queue-depth/:queuenow returnsqueue_lengthandin_flight(PENDING count).in_flightcounts only runs dequeued within the last 30 minutes, so a stuck PENDING row ages out of the replica floor instead of pinning capacity forever.decocms/deco-apps-cdScaledObject needs the newin_flighttrigger — deploy the chart side first, or the scaler errors on the missing field.This is a mitigation: the protocol should gain an explicit attach verb so a reconnect can never look like a competing turn.
Written for commit d22a425. Summary will update on new commits.