Skip to content

Give boot-time restore a real cancellation path instead of an abandoning timeout - #499

Merged
TheGreatAxios merged 3 commits into
mainfrom
cl-7215-restore-timeout-cancel
Aug 30, 2026
Merged

TheGreatAxios merged 3 commits into
mainfrom
cl-7215-restore-timeout-cancel

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Summary

Fixes CL-7215: withRestoreTimeout raced a boot-time deployment restore against a timer and, on timeout, abandoned the still-running restore instead of cancelling it. If the restore later succeeded anyway, spawnWorkflowDeployment registered a live supervisor for an address the boot loop had already durably recorded as a restore FAILURE — a deployment recorded as failed that is actually live.

  • restoreDeploymentFromRecord now takes an AbortSignal, threaded from withRestoreTimeout's timer. Two cooperative cancellation checkpoints (throwIfRestoreAborted) sit before the closure fetch and before the spawn — the only two places real cancellation is possible, since neither @intx/tool-packaging's applyAtomic nor @intx/workflow-host's Supervisor.spawn accept a signal of their own.
  • Since a restore already past both checkpoints cannot actually be stopped, a late-settle correction runs after spawnWorkflowDeployment resolves: if the timeout already fired, it reconciles the durable record against whatever's actually true on disk instead of leaving a lie in place.
  • A new per-deployment lock (withDeploymentRecordLock) serializes every writer that can touch a given deployment's deployment.json around this race: the boot loop's own failure-recording catch, the late-settle correction, and teardownDeployment's reclaim/park writes.
  • Closing the write-ordering race surfaced a second, worse bug: a reclaiming teardown racing a dangling restore could delete the record while the boot loop's catch resurrected it with a false failure, or — if the dangling restore's spawn succeeded after the teardown — leave an orphaned live supervisor with no durable record at all, invisible to any future boot scan. recordWorkflowDeploymentRestoreFailure now re-reads from disk and no-ops on a missing record; the late-settle correction now recognizes "record is gone" as its own outcome and unwinds the orphaned supervisor via the ordinary teardownDeployment(reclaimDirs: true) path.
  • RESTORE_ATTEMPT_TIMEOUT_MS is overridable per router (restoreAttemptTimeoutMs) so tests exercise the timeout path in milliseconds.

CL-7219 verdict

CL-7219 (sources-rotation rollback correctness) lives in the same file but is a separate, unrelated code path — the single-step warm-keep sources-rotation handler's currentSources respawn-hint rollback, not the boot-restore lock. It does not fall out of this change; left as its own ticket.

Test plan

  • cd apps/sidecar && bun run typecheck — clean
  • cd apps/sidecar && bun test — 223 pass, 0 fail (run twice)
  • New regression tests: a restore that outlasts its timeout corrects its record once its late spawn finishes; a reclaiming teardown racing a dangling restore is not resurrected with a false failure, and does not leave an orphaned live supervisor behind
  • bunx prettier --check on all touched files
  • bun run check:structural from repo root — all checks pass
  • Race-condition test run 16+ times back-to-back with no flakiness

DO NOT MERGE.

withRestoreTimeout races a boot-restore attempt against a timer and, on
timeout, abandons the still-running attempt outright. If
restoreDeploymentFromRecord later succeeds anyway, spawnWorkflowDeployment
registers a live supervisor for an address the boot loop already recorded
as a restore failure -- a genuinely live deployment whose durable record
says it failed to restore.

This asserts the fix ahead of the implementation: a restore that outlasts
its timeout must still finish (never abandoned), and once it does, its
record must be corrected rather than left claiming a live deployment
failed. createHandshakeSpawner is extracted out of makeLifecycleFixture so
this test can drive a real spawn/ready handshake on its own timing
instead of one that throws or resolves synchronously; the fixture also
grows optional materializeDeploymentClosure and restoreAttemptTimeoutMs
overrides so a boot-restore test can use a multi-step closure and a
millisecond-scale timeout instead of the real 30s default.
…ing timeout

withRestoreTimeout raced restoreDeploymentFromRecord against a timer and
walked away on timeout, with no way to tell the loser it had lost. The
underlying restore kept running regardless -- including
spawnWorkflowDeployment, which registers a live supervisor and spawns a
real workflow-process child -- so a restore that finished after the
timeout already fired left a genuinely live deployment whose durable
record claimed it had failed to restore (CL-7215).

withRestoreTimeout now hands the restore an AbortSignal that fires the
instant the deadline wins. restoreDeploymentFromRecord checks it at two
points where real cancellation is still possible: before the closure
fetch (applyAtomic has no signal of its own, so bailing here is the only
way to skip it) and before the spawn (Supervisor.spawn has none either).
Past that second checkpoint the restore cannot be cancelled -- so once
spawnWorkflowDeployment resolves, it checks whether the caller's own
timeout had already fired and, if so, corrects the durable record instead
of leaving that lie in place.

The correction and the boot loop's own failure-recording catch both touch
the same on-disk record, so they're serialized through a per-deployment
in-memory lock (withDeploymentRecordLock): without it, the correction's
disk read could land before the catch's disk write, observe nothing to
clear, and no-op -- leaving the catch's write to permanently mismark a
live deployment. The catch also consults activeSupervisors (in-memory,
always set synchronously the instant a spawn actually succeeds) while
holding that same lock, so a restore that raced ahead of its own timeout
never gets a boot failure recorded against it in the first place. Together
the two make the final on-disk state correct regardless of which side
finishes first.

RESTORE_ATTEMPT_TIMEOUT_MS is now overridable per router
(restoreAttemptTimeoutMs) so a test can exercise this in milliseconds
rather than the real 30s ceiling.
teardownDeployment's own two on-disk deployment-record writes
(deleteWorkflowDeploymentRecord for a reclaiming undeploy,
markWorkflowDeploymentRecordParked for a hibernate) were a third,
unlocked writer touching deployment.json for a given deploymentId,
alongside the two the CL-7215 boot-restore fix already serializes
through withDeploymentRecordLock: the boot loop's restoreFailure write
and a dangling (timed-out but still-running) restore's late-settle
correction. Routing both teardown writes through the same lock closes
the write-ordering half of that race.

Closing the write ordering alone was not enough: a reclaiming teardown
racing a dangling restore can still delete the record between when the
boot loop scans it and when either writer runs, and two blind writers
mishandled that:

- recordWorkflowDeploymentRestoreFailure wrote the caller's stale
  in-memory record unconditionally, so a teardown that reclaimed the
  deployment first got its delete silently undone -- the boot loop's
  catch would resurrect deployment.json with a false restoreFailure
  for a deployment that no longer exists. It now re-reads from disk
  under the lock and no-ops when the record is already gone, matching
  markWorkflowDeploymentRecordParked's sibling shape.

- restoreDeploymentFromRecord's late-settle correction only handled
  "record still exists but wrongly marked failed"; it had no branch
  for "record is gone entirely". If the reclaimed restore's own spawn
  later succeeded anyway, spawnWorkflowDeployment would register a
  live supervisor and re-register its routers with nothing durable
  backing them -- an orphaned live deployment invisible to any future
  boot scan, arguably worse than the original false-failure bug. It
  now distinguishes that outcome and unwinds the orphaned supervisor
  through the ordinary teardownDeployment(reclaimDirs: true) path.

Two tests exercise the fixed race: a reclaiming teardown ahead of the
boot loop's own failure write must not have its delete undone, and a
teardown racing a restore whose spawn resolves afterward must not
leave a live, record-less deployment behind.
@TheGreatAxios
TheGreatAxios merged commit b8a5708 into main Aug 30, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant