Decision 0020 — Reload first, restart after drain, recover interrupted jobs
Reload first, restart after drain, and recover interrupted jobs, with schedules reloading without a restart.
Status: accepted · 2026-10-09
Context
A running daemon reads some state on every use and some state once into
memory. Schedules are the read-once case: SIGHUP and --reload-interval
rebuild the registrations from the database, but there is no reload verb and
the unit carries no ExecReload, so an operator who changed a schedule reaches
for a restart — which stops the scheduler, spends the drain grace, and retires
whatever is still owned to interrupted. A restart is also the only path when
only the binary or a serve flag changed, and an interrupted job today is
listed (drain interrupted) but never re-driven by anything.
Decision
Prefer reload over restart, and restart only after drain, in three phases:
- Reload first (landed). A first-class
reload:POST /api/v1/maintenance/reload(operator scope, bearer-only, likecordon), served by the drain seam calling the existingScheduler.Reloadand rebuilding the daemon's other reloadable read-once set, the push transports, from their sealed secrets; and anexocomp reloadCLI over--remotereporting the schedule count. It adds no second reload mechanism: a reload that cannot read the database leaves the running set alone, exactly asSIGHUPdoes today. - Restart after drain (this change).
exocomp restartandPOST /api/v1/maintenance/restart(operator scope, bearer-only) cordon, return at once, and drain in the daemon's background: when the in-flight count reaches zero the daemon runs the ordinary clean shutdown and re-execs the on-disk binary in place — same pid, no systemd restart, no unit edit. The requesting job counts as in flight, so a job that asks for its own restart finishes before the daemon goes down instead of deadlocking against it;--waitis the operator's form and the CLI refuses it inside a job for exactly that reason. A drain that does not finish within its bound aborts and lifts the cordon (never cancelling work implicitly);--forcecancels the remaining jobs and restarts at once. An unchanged on-disk binary is refused as a no-op, the replacement is prechecked withversion, the exec follows only a clean shutdown, the run-secret staging session directory is closed before the exec (an in-place exec keeps the pid, so the next start's liveness sweep would otherwise leave it), and a second request while one is pending is a no-op — the pending reservation is taken atomically, so two simultaneous requests cannot both run the precheck. The unit's reload staysExecReload=~/.local/bin/exocomp --remote self reload; the unit file itself is not edited in this repo. - Job recovery (landed). The interrupted listing became something an
operator re-drives from:
exocomp jobs redriveselects the cut-off rows (default--state interrupted, repeatable withcancelled) and re-enqueues each as a fresh queued job carrying the same spec — rebuilt from the row's stored args, labelled with theredrivetrigger — through the oneservice.Reenqueuepath the web "Run again" uses. The idempotency and dedup keys are dropped, so the enqueue lands a new row rather than collapsing onto the original; each redrive instead lands under a deterministic key derived from the cut-off row's id, so a repeated redrive is a no-op that reports the original as already re-driven. That key is the durable marker: job rows are never pruned (the store has no job retention, and nothing deletes a job row), so no schema column has to remember a redrive. A workflow-step row is excluded: the sequencer re-arms it from the ledger (Decision 0008), so re-driving it would run the step twice and drop any approval gate it carried. A real run needs confirmation (--yes, or an answered prompt;--dry-runenqueues nothing); a clean restart-after-drain interrupts nothing, so there is nothing to recover; and auto-redrive is deliberately off — a cut-off destructive run is the operator's call, never auto-rerun. No new reactive event: a restart is control-plane state, not a reactive trigger.
The read-once audit (landed): every piece of state the daemon reads once at boot was classified, and the sets that rebuild safely and cheaply moved behind the one reload seam. What needs which action:
| Change | Action |
|---|---|
| Schedules, push transports (the ntfy URL/topic settings + sealed token, the ntfy Detail setting, sealed GoAlert keys) — read once into memory | exocomp reload (or SIGHUP) |
| Secrets/redaction, approvals, reactive rules, policies, runners, check config, dead-man threshold and heartbeat, backup retention, workflow ledger — read from the DB per use | live, no action |
Backup quiesce file (.exocomp/backup-quiesce.yml) — read once at boot |
restart-only: a local config file, not store state; a live refresh would add a swap seam through the driver router |
| Binary change | exocomp restart (drains, then re-execs in place) |
serve flag change, and the sandbox tier, secret-staging, lease, and envelope seal decided at boot |
the unit's own restart (a re-exec keeps the same argv) |
Mutes are read per use by the store-backed checker, so they need no reload; the daemon's notifier does not yet attach that checker, a separate gap from this audit. The operational matrix lives in docs/operations/serve.md.
Consequences
- Schedule edits and sealed push-transport changes reach a running daemon without cancelling or retiring work.
- A restart stays available for what only a restart can change, and when it happens it happens on a drained daemon.
ExecReloadgives the unit a reload that is not a restart; the unit file stays Ansible-managed and out of this repo.- A restart re-execs in place rather than through the unit, so it carries the
same argv: a
serveflag change is still the unit's own restart. - Phase 3 (re-driving interrupted jobs) is landed as
exocomp jobs redrive; interrupted jobs keep today's listing alongside it.