Reclaim workflow concurrency slots from abandoned executions - #218
Conversation
There was a problem hiding this comment.
The PR is well-reasoned and the implementation is careful, but there are two real issues and one question worth raising.
-
packages/worker/src/workflows/stale-execution-sweep.ts:200—isInstanceNotFoundcontains a message-text fallback that can fire on unrelated errors, undermining the "fail closed" guarantee the whole sweep is built around.return typeof message === 'string' && /not found|does not exist|no instance/i.test(message);
- The regex matches generic HTTP/fetch errors ("resource not found", "socket does not exist"), a D1 error about a missing row, or any third-party middleware message that happens to include those words. Any of those would be misread as "instance is gone" and trigger the destructive finalization the function was designed to avoid.
- The comment says the message match is "a hedge in case the code is absent." But if the code is absent you don't know it's a Cloudflare not-found error — the hedge undermines the very invariant it's hedging for. The right answer is: if
code !== 1001, throw (leave for next tick). Drop the regex branch entirely, or narrow it to a more specific string Cloudflare actually emits and document the exact source.
-
packages/worker/src/workflows/stale-execution-sweep.ts:176–179—instance.status()result is destructured as{ status, error }, but theerrorfield (used to enrich the error message at line 189) is assumed to have shape{ message?: string }. There is no type guard or null check before accessingerror?.message.- If the platform ever returns
erroras a string or another shape,error?.messagesilently resolves toundefinedand the context is lost with no warning. Given that this is untrusted external API data, at minimum addtypeof error === 'object' && error !== null &&beforeerror.message, or just stringify it.
- If the platform ever returns
-
packages/worker/src/workflows/execution-status.ts:129–140— The CAS uses acompletedAttimestamp as the write receipt, then reads it back to check if the write landed. This is elegant but has a subtle race: two concurrent callers (e.g., the interpreter catch and the sweep arriving in the same minute) generate different timestamps; the second write's CAS will no-op on theinArraycheck because the row is already terminal after the first, soafter?.completedAtwon't match. That means the second caller correctly returnsfalse. However, if two callers race and both find the row in an active status simultaneously (before either write commits), both writes pass theinArrayWHERE clause, the second overwrites the first, and only one caller seestrue. This is acceptable for a best-effort finalization (both write the samestatus: 'failed'), but worth confirming: does the caller that seesfalsein this race do anything harmful, like logging a spurious warning? The interpreter catch discards the return value, so it's fine — but it's worth a glance at any future caller.
Created on behalf of Xiangan He xiangan@turnkey.io
|
Preview deployment: https://pr-218.dev-valet-turnkey-client.pages.dev |
An execution row holds one of the user's ten concurrency slots while its status is active, and only the live Workflow instance or the cancel path ever writes a terminal status. When neither runs, the slot is gone for good and the user's effective limit ratchets down until they cannot start any work at all. - interpreter finalizes the row when a throw escapes the wave loop, then rethrows the original error - the terminal write accepts any active prior status, not just running; a run that ended while parked in waiting_* was silently no-op'ing it - new stale_executions sweep reclaims rows whose instance is gone or terminal, asking the platform rather than inferring from elapsed time - approval status step keys carry the foreach iteration suffix, so an approval inside a loop stops stranding the row in waiting_approval
ca5e357 to
f4ad30b
Compare
|
Addressed in 1. Message-text fallback in 2. Unguarded 3. Concurrent-finalization race — checked; nothing harmful downstream. The interpreter discards the return value, and the sweep uses it only to increment its Full worker suite and |
What
A
workflow_executionsrow occupies one of the user's ten concurrency slots for as long as its status is inACTIVE_EXECUTION_STATUSES. Before this change, only two things ever wrote a terminal status: the runtime, from inside the live Cloudflare Workflow instance, and the cancel pipeline. When neither ran, the row stayed active permanently. Four changes close that:interpreter.tswrappedrunWorkflowDagin atry/catchthat finalizes the row asfailedbefore rethrowing. The original error is always rethrown unchanged, and a failed finalize is logged rather than raised, so a broken D1 cannot mask the real error. Ordinary node failures are unaffected — those return a result and persist their own terminal status.runtime.tsterminal write now accepts any active status as its prior, not justrunning. A run that reached the end while its row was parked inwaiting_approvalorwaiting_timewas silently no-op'ing its own terminal write and leaking the slot — a leak on the success path, not only on crash.cancelling/cancelledstay excluded so a competing cancel still wins.stale-execution-sweep.ts(new, registered on the minutely cron) is the backstop for instances that never run code again. It selects active rows past a 10-minute floor oldest-first and asks the Workflows binding for each instance's state. Elapsed time only bounds the query; the instance decides, so a legitimately long-running node is never touched.It fails closed. Finalization requires either a terminal instance state (
complete→completed,terminated→cancelled,errored→failed) or an error that positively identifies the instance as missing. Any other probe failure leaves the row for the next tick and logs the message verbatim. A wrong verdict here is unrecoverable — the row would read terminal while the instance kept running, its own terminal write would no-op against the CAS, and the spawned-session sweep would tear down its sandboxes.nodes/approval.tsstatus step keys now carry the:i:Nforeach iteration suffix the invocation id already used. Without it every iteration reused iteration 0's memoizedstep.doresult, so an approval inside aforeachstopped trackingwaiting_approvaland could strand the row.No schema change. The sweep is registered in
EXPECTED_INTERVAL_MSand reports its count throughcron_heartbeats, and it logs every tick rather than only when it reclaims — an inert sweep and a healthy one must not look identical.This also reclaims executions that are unreachable through the cancel API:
workflows.idisON DELETE SET NULL, and the cancel endpoint resolves throughworkflow_id, so rows whose workflow was deleted 404 for users and operators alike.Why
Reported from prod: a user was rejected with
Too many concurrent workflow executionswhile only four runs of one workflow were in flight. The cap is 10 and counts every active execution across all of that user's workflows, so the remaining slots were held by rows that had leaked — nothing reclaims an execution whose instance died, and each occurrence lowers the user's effective limit by one until they cannot start work at all.Existing leaked rows are drained without a migration or backfill: every pre-existing leak is past the age floor and the whole candidate set fits in one tick, so the first sweep after deploy is a full pass. Rows whose instance is still genuinely parked recover when the node hits its own bound, which now lands because of the widened CAS.
Two classes are deliberately not reclaimed: an instance paused from the Cloudflare dashboard, and one reporting
unknown. Both are indistinguishable from live work, and the cost of guessing wrong on a live run is the run.Test plan
Full worker suite and
tsc --buildpass locally; the new tests cover each of the four defects, including that a degraded Workflows binding reclaims nothing.Checklist
docs/specs/updated in this PR