Description
Where did this happen?
Web app (app.superset.sh)
Version
Source build, main at 19db630
What happened?
Two races are left after the provisioning ABA fix:
- Duplicate delivery of one provisioning job (for example a QStash retry): if one delivery throws, its catch deletes the sandbox and marks the row failed, even when the other delivery has just made the same sandbox ready. This contradicts the "Safe to run twice on the same row" note in
provision.ts. - Undo between the job's "deleted" read and its
deleteSandboxcall: the undo sees the sandbox still exists and marks the row ready, then the job deletes the sandbox. The next wake throwsSandboxUnavailableErrorand the workspace becomes failed.
Expected: a job tears down a sandbox only when no row still needs it.
Steps to reproduce
Case 1: deliver the same provisioning job twice and make one delivery fail late.
Case 2:
- Delete a workspace while its sandbox is being created.
- Click undo just as the job finds the row deleted.
- See a ready workspace with no sandbox.
Logs, screenshots, or recordings
Found by model checking every order of events. Case 2 was reproduced as a failing test against the real code; case 1 was seen only in the model. Not fixed yet.
Implementation notes
Files
packages/trpc/src/router/cloud-workspace/provision.ts: the!readybranch and the catch block.packages/trpc/src/router/cloud-workspace/cloud-workspace.tsunarchive.
Approach
Model checking showed that the simple fixes (stop instead of delete, catch transitions first) each leave or create another bad state. A correct fix probably needs the job to claim the row (a guarded write that renames or marks the sandbox) before it tears the sandbox down, and the resumable unarchive transition guarded on the sandbox it checked.
Activity
github-actions commentedon Nov 7, 2026
Case 2 is clearly real from the code. Case 1 is plausible as described. Both come from the same flaw: provisioning a workspace and undoing its delete each decide to delete or revive the sandbox based on a status they read earlier, and they never check that status again when they act. Two deliveries of the same job, or a job and an undo, use the same sandbox name. So when one side tears the sandbox down, it can destroy a sandbox the other side has just marked
ready. The row then saysready(orfailed) while the sandbox is gone, and the next wake turns the workspacefailed.Affected code
packages/trpc/src/router/cloud-workspace/provision.ts:124-149: the!readybranch. It does a separate, unguarded read (:139-142), then callsdeleteSandbox(providerSandboxId)(:144).packages/trpc/src/router/cloud-workspace/provision.ts:180-209: the catch block. It tears the sandbox down by name first (:196-203), then moves the row tofailedwithfrom: ["provisioning", "ready"](:204-209).packages/trpc/src/router/cloud-workspace/cloud-workspace.ts:779-820:unarchive. It checkssandboxExists(row.providerSandboxId)(:786), then movesdeleted → readyguarded only on status (:790-797).packages/trpc/src/router/cloud-workspace/transition.ts:13-30: the only guard available isstatus IN (from). It cannot check which sandbox the row points at.packages/trpc/src/router/cloud-workspace/cloud-workspace.ts:146-160: a wake that getsSandboxUnavailableErrorcallsmarkSandboxUnavailable, which is how areadyrow with no sandbox ends upfailed.Suspected root cause
Case 1 (duplicate delivery): Both deliveries read
status = provisioningatprovision.ts:92and use the sameproviderSandboxId.provisionSandboxreuses the sandbox if it already exists (lib/sandbox/vercel.ts:214-215), so they share one sandbox.ready.authorizeBoot,provisionSandboxhitting a sandbox that is still being created, orsettleSandbox.:196). Its transition then succeeds, because"ready"is infrom(:206). This overwrites A'sreadywithfailed.The comment "Safe to run twice on the same row" (
:64-65) only holds for the success path. The catch block has no idea whether this delivery is the one that marked the rowready.Case 2 (undo during the job's delete): Order of events:
readytransition loses todelete.status = deleted(:139-143).unarchiverunssandboxExists→ true, so it takes the resumable path and moves the row toready(cloud-workspace.ts:783-797).deleteSandbox(provision.ts:144), still acting on thedeletedit read in step 2.The row is now
readywith no sandbox. The next wake throwsSandboxUnavailableErrorand the workspace is markedfailed.Reproduction
bun:test): Seed aprovisioningrow and runprovision()with a fake sandbox layer. MakeprovisionSandboxresolve only afterdeletehas run, so thereadytransition fails. Pause between the job's status read anddeleteSandbox(for example a deferred promise in the fakegetSandboxordelete), callunarchivethere, then let the job continue. The row should come outreadywith the fake sandbox deleted.provision()twice at once on the same row. Let A resolve normally, and make B'sauthorizeBootorprovisionSandboxreject after A'sreadytransition. The row should end upfailedand the sandbox deleted.mock.modulelib/sandbox, because other test files import it for real.Fix direction
I agree with the issue that the fix has to be a guarded claim on the row, not a change of order:
!readybranch and the catch, replace "read, then delete" with a conditional update that claims the sandbox, for exampleWHERE id = ? AND status = 'deleted' AND providerSandboxId = ?settingproviderSandboxIdto a tombstone or the next name. Delete the sandbox only if that update changed a row.transitionCloudWorkspaceneeds an optional extra condition (such asproviderSandboxId) to support this.providerSandboxId = row.providerSandboxIdto thedeleted → readytransition. If the job claimed the sandbox in between, the transition fails, andunarchiveshould fall back to theprovisioningpath withnextSandboxNameForinstead of returningunarchived: false."ready"infromonly when this delivery's ownreadytransition succeeded. Otherwise use["provisioning"]and tear down only after winning that transition. A real per-delivery lease (a token column checked in each transition) would also stop a slower duplicate failing a row whose first delivery is still settling.provision.ts:64-65to match whatever guarantee the fix gives.I found no existing test that covers either order;
cloud-workspace.test.tsandreap.test.tsare the closest suites.