feat(spurd): survive agent restart without interrupting running jobs - #492
feat(spurd): survive agent restart without interrupting running jobs#492yansun1996 wants to merge 1 commit into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #492 +/- ##
==========================================
+ Coverage 76.03% 76.41% +0.38%
==========================================
Files 166 166
Lines 63002 64336 +1334
==========================================
+ Hits 47898 49159 +1261
- Misses 15104 15177 +73 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR updates spurd to support transparent graceful restarts (e.g., systemctl restart spurd) without force-evicting currently running jobs, by persisting per-job launch state to disk and reconciling/resuming those jobs on startup while relying on the existing heartbeat timeout as the safety net for overly slow restarts.
Changes:
- Update
spurdshutdown/startup flow: avoid SIGTERM deregistration when jobs are running, and reconcile manifests on startup to re-adopt surviving jobs and restore their allocations. - Add manifest + resume plumbing in the executor (exit-status sentinel, cgroup-based completion detection, run-attempt-scoped cgroups) and allocation restoration support in the scheduler.
- Add/extend unit and e2e coverage for restart survival, manifest scanning/reconciliation, and restore-allocation behavior; add a controller startup warning for low heartbeat timeouts.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/native_host/e2e/test_deregistration.py | Adds an e2e acceptance test asserting agent restart mid-job does not interrupt the job and does not strand/double-book CPU allocation. |
| crates/spurd/src/main.rs | Reconciles running jobs before serving; changes SIGTERM handling to deregister only when no jobs are running. |
| crates/spurd/src/executor.rs | Implements job manifests, exit-status sentinel wrapping, resumed-job completion detection via cgroup/proc liveness, and run-attempt-scoped cgroups; adjusts spool-dir behavior. |
| crates/spurd/src/container.rs | Makes RootfsMode serializable for inclusion in job manifests. |
| crates/spurd/src/agent_server.rs | Adds startup reconciliation to restore allocations/track resumed jobs; writes manifests immediately after launch. |
| crates/spurctld/src/main.rs | Warns at startup when heartbeat_timeout_secs is configured below 30s. |
| crates/spur-sched/src/cons_tres.rs | Adds restore_committed() to pin/restore exact resource IDs after agent restart. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
fa44b48 to
968f6a7
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
crates/spurd/src/executor.rs:483
- write_job_manifest() uses std::fs::write(), which creates manifest.json with default permissions (typically 0644). Because per-job spool dirs are 0o711, any local user who can guess the path (job IDs are enumerable) can read another job’s manifest and learn paths/UID/GID/cgroup/resource details. The manifest should be root-only readable (0600) and ideally written via an OpenOptions path that refuses symlinks.
pub fn write_job_manifest(spool_dir: &Path, manifest: &JobManifest) {
let path = manifest_path(spool_dir);
let tmp = path.with_extension("json.tmp");
let bytes = match serde_json::to_vec(manifest) {
Ok(b) => b,
2293453 to
1cbb947
Compare
biluriuday
left a comment
There was a problem hiding this comment.
Couple of comment on the design decisions:
-
The cost is paid on the universal path for a benefit on the rare path. Surviving a restart is a rare event; wrapping every job's script in a subshell, writing a manifest for every launch, and re-keying every spool and cgroup path are universal changes. That trade is only acceptable if the universal change is provably neutral, and it isn't. I reproduced this locally with the exact generated wrapper shape: bash executes a script file incrementally, but a subshell is one compound command that must parse completely before any of it runs. An unwrapped script with a syntax error on line 2 still runs line 1; the wrapped version runs nothing. Every job on the cluster now has different failure semantics so that restarts can recover an exit code.
-
The manifest captures process identity but not the job's lifecycle obligations. The author is candid that reconstructing state after the fact is the trade-off versus a slurmstepd-style supervisor, and that's a reasonable call for this PR. But three of the blocking bugs below are the same failure repeated: the manifest records what a job is (pid, cgroup, resources) and not what the agent still owes it (run the epilog, report the completion, hold the srun reservation). If the manifest is going to be the durable record, its schema should be driven by "what must still happen to this job," not "what does this process look like."
Few issues identified. Please check
-
A missing cgroup.events is read as "job finished." executor.rs:520-527 returns false when the file can't be read, and Resumed::try_wait turns that into completion. setup_cgroup returns Ok(Some(path)) after a bare create_dir_all with no cgroup-v2 detection, and every attribute write below it only warns. On a v1 or hybrid host that path is an ordinary directory that never gets a cgroup.events, so on the first monitor tick after a restart every adopted job is reported complete with exit -1 and its GPUs are released to another job while the real workload keeps running. move_to_cgroup failing silently (executor.rs:1657 discards the error outright) produces the same outcome on a v2 host. Make cgroup_populated tri-state and fall back to proc_alive, and record cgroup_path: None when the process was never actually moved in.
-
The exit sentinel is a denial-of-service on spurd startup. read_exit_status opens with O_NOFOLLOW, which refuses a symlink but says nothing about a FIFO, and opening a FIFO read-only blocks until a writer appears — I confirmed this blocks indefinitely. For container jobs the sentinel path is inside the job-writable rootfs, so mkfifo /tmp/spur_exit_status in a batch script is enough. Because reconcile_running_jobs() is awaited at main.rs:294 before the server future is ever polled, the agent never starts again. Open with O_NONBLOCK, fstat the fd, and refuse anything that isn't a regular file with st_nlink == 1.
-
restore_committed double-books the exact GPU the PR exists to protect. cons_tres.rs:221-237 validates nothing before mutating. Two manifests claiming the same device both land in owners mapped to one bit; releasing the first clears the bit while the second job still holds the device, and the next allocate_for_job hands it out. This was verified with a passing test on PR head. allocate_for_job already guards this at lines 178-180 — restore_committed needs the same all-or-nothing validation and a Result return so adopt_manifests can refuse to adopt.
-
The manifest is deleted before the completion is reported. agent_server.rs:334 calls cleanup_job_spool inside adopt_manifests; the report only happens afterwards at lines 252-266. If the 30-second budget fires, or the report exhausts its retries, or it comes back InvalidArgument (which completion_report_retryable excludes), the completion is gone and there is no backstop — the node is registered and heartbeating, so the controller will never time it out and the job sits in Running forever. The code comment at line 250 asserts the opposite of what the code does. Delete the spool directory only after a successful report.
-
srun/salloc reservations are a new double-booking regression. register_job_allocation inserts RunningJob::AllocationOnly into running at agent_server.rs:1618, so has_no_active_jobs now returns false for a node holding only a reservation and SIGTERM skips deregistration. But the manifest write is gated on result.job.pid() and AllocationOnly has none, so nothing persists. After restart the agent's table is empty while the controller still holds those CPUs and GPUs, and the agent first-fits the same device ids to the next dispatch. The PR description lists this as "the allocation is simply absent rather than double-counted" — that is exactly backwards, and it's a regression from the old unconditional-deregister behavior.
-
Adopted jobs skip the epilog and SPANK hooks. The monitor path runs hooks.epilog, SpankHook::TaskExit, and SpankHook::JobEpilog, and drains the node if the epilog fails. The Dead branch at agent_server.rs:321-341 does none of it. Epilog is where sites do GPU reset and scratch purge, so the next job onto those GPUs inherits dirty state, and the drain safety valve is bypassed for precisely the jobs most likely to need it. The manifest already carries every field HookContext needs. Factor the completion teardown into a shared function called from both paths — this is the refactor AGENTS.md asks for rather than a band-aid.
-
The subshell wrapper's parse-model change, described above. A brace group plus an outer trap ... EXIT gives you the "internal exit still writes the sentinel" property without changing when bash parses.
spurd's SIGTERM handler unconditionally deregistered the node, force-evicting every running job — so any graceful restart (e.g. a binary upgrade) failed in-flight jobs even though the job process could survive it. This makes a graceful restart transparent to running jobs, with the controller's heartbeat timeout as the backstop if the restart takes too long. - SIGTERM deregisters only when the agent has no active jobs (none running and none mid-launch); a reservation-only node still deregisters so the controller reclaims it. - Per-job manifest split into identity (pid, start time, cgroup, exit sentinel) and an obligation ledger (epilog, completion report), each cleared as it is discharged. Reconcile drives the ledger to empty, persisting progress so a lost completion report retried on the next restart resends only the report, never re-running a non-idempotent epilog; the resolved exit is recorded so it survives the sentinel/rootfs being cleaned up. - Completion detection without waitpid: tri-state cgroup liveness with a /proc fallback when cgroup.events is unreadable (non-v2 host, or a process never moved into the cgroup), so an adopted job is not misreported as complete. - Resource restore is all-or-nothing, so a restarted agent refuses to adopt a job whose manifest names a GPU another adopted job already holds. - Exit sentinel opened O_NONBLOCK|O_NOFOLLOW with a regular-file/nlink check and a bounded read, so a job-planted FIFO or oversized file can't block or bloat startup; the wrapper uses a top-level trap on EXIT (not a subshell) so a later syntax error still runs the lines above it. - cgroups and spool dirs are keyed by (job_id, run_attempt); the spool dir stays root-owned with a sticky bit so a co-located user can't replace the manifest. - spurctld warns at startup if heartbeat_timeout_secs is below 30s.
b265bcb to
4ed288a
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (5)
crates/spurd/src/agent_server.rs:554
- In the live monitor path, the manifest is only updated when report_completion fails. If spurd crashes after run_teardown_hooks() succeeds but before cleanup_job_spool() runs, the stale on-disk manifest still says epilog is owed, so the next startup's reconcile will run the epilog/SPANK hooks a second time (the exact non-idempotency the obligation ledger is meant to prevent). Consider marking epilog discharged in the manifest unconditionally after running teardown hooks, regardless of whether the completion report succeeds.
// Keep the spool (and its manifest) until the controller has
// the completion: if the report was lost, a restarted spurd
// re-adopts the manifest and retries rather than stranding the
// job in Running forever. Record that the epilog is already
// done so the restart retries only the report, not the
// (possibly non-idempotent) epilog.
if reported {
crate::executor::cleanup_job_spool(c.job_id, c.run_attempt);
} else {
crate::executor::mark_manifest_epilog_discharged(
c.job_id,
c.run_attempt,
(c.exit_code, c.signal),
drain,
);
}
crates/spurd/src/agent_server.rs:1596
- Job manifests are written with a hard-coded schema_version of 1 here (and in several tests below). Since scan_job_manifests() filters by executor::MANIFEST_SCHEMA_VERSION, bumping the schema in executor.rs without updating all of these literals will silently stop reconcile from seeing newly-written manifests. Consider exporting MANIFEST_SCHEMA_VERSION (pub(crate)) from executor and using it everywhere manifests are constructed (runtime + tests) to avoid drift.
&executor::JobManifest {
schema_version: 1,
job_id,
crates/spurd/src/agent_server.rs:1600
- start_time is used to disambiguate PID reuse during reconcile; defaulting it to 0 when /proc//stat can't be read will make proc_alive() fail and can misclassify a still-running job as dead on restart (releasing resources / reporting completion incorrectly). If proc_start_time() fails, it's safer to skip writing the manifest (best-effort: the job just won't survive a spurd restart) than to write one that guarantees a false negative.
pid: pid as i32,
start_time: executor::proc_start_time(pid as i32).unwrap_or(0),
cgroup_path: result.job.cgroup_path().map(|p| p.to_path_buf()),
crates/spurd/src/agent_server.rs:344
- discharge_obligations() clears manifest.pending.report_completion after a successful report but never persists that change. If spurd crashes (or cleanup_job_spool fails) before the spool dir is removed, the next restart will re-run the completion report even though it already succeeded. Persist the updated manifest before proceeding to cleanup so retries resume from the correct ledger state.
This issue also appears on line 539 of the same file.
if !reported {
// Report lost; leave the manifest (epilog already marked done, so
// the retry won't re-run it) for the next startup to resend.
return;
}
manifest.pending.report_completion = false;
}
crates/spurd/src/agent_server.rs:4696
- This test writes the manifest under $TMPDIR/spur, but scan_job_manifests() intentionally does not scan the temp-dir base when running as root (to avoid trusting user-writable manifests). If the test suite runs as root, reconcile_running_jobs() won't see this manifest and the test will fail. Make the spool_dir base conditional on euid so it matches executor::spool_bases() behavior.
let spool_dir = std::env::temp_dir()
.join("spur")
.join(format!("job{job_id}_1"));
std::fs::create_dir_all(&spool_dir).unwrap();
|
Thanks for the review — this was the kind of scrutiny the PR needed, and objection B in particular changed the shape of the fix rather than just adding patches. On the 7 issues — all addressed:
On objection B (the important one) — you're right, and I reworked the schema around it. The manifest is now split into identity (how to find/verify the process) and an obligation ledger ( On the slurmstepd point — agreed that a persistent per-step supervisor is the structurally stronger answer, and I want to be explicit about where this design stops rather than imply the manifest closes the gap. What it can't do, and a supervisor could: give Everything above has unit tests, and I verified restart survival, the epilog-once-on-retry path, and reservation deregistration end-to-end on a 2-node setup. |
|
Thanks @yansun1996 for addressing the comments. Few more comments. Please check:
graceful_cancel sends SIGTERM, and for a Resumed job that goes through cgroup_signal_all to every pid including the batch shell. The monitor then reads 0 from the sentinel and report_completion derives Completed from exit_code == 0. scancel on any job that survived a restart now records it as a successful completion. The previous revision at least reported -1 here. This is silent corruption of job state on the feature's primary path. The fix is to stop inferring a signal from $?. Have the wrapper write a two-field sentinel (exited N versus signaled N) and install explicit signal traps that record 128+N; that also resolves the long-standing asymmetry where decode_shell_exit is applied only on the resumed path. Two related gaps in the same wrapper: a user script installing its own trap ... EXIT silently replaces the wrapper's trap, and a trailing exec replaces the shell — both verified, both produce no sentinel and a -1. Scratch-cleanup traps are common enough that this is the dominant real-world case, and the doc comment currently claims the trap "fires on any exit."
The right fix is the shared helper: extract
|
Summary
spurd's SIGTERM handler unconditionally deregistered the node from the controller, which force-evicts every running job on that node immediately — so any graceful restart (e.g. a binary upgrade viasystemctl restart spurd) failed in-flight jobs, even though the underlying process could survive it (it already runs in its own process group and a cgroup outside spurd's own systemd cgroup).This PR makes a graceful restart transparent to running jobs, as long as the restart completes within
heartbeat_timeout_secs(the existing heartbeat-timeout remains the backstop for a restart that takes too long — the same bound Slurm documents for aslurmdupgrade).Approach
/proc/<pid>/statstart time to detect PID reuse, cgroup path, exit-status sentinel path). Obligations — what the agent still owes the job once its process ends (release resources, run epilog + SPANK, report completion), each recorded as a pending flag alongside the resolved exit and any drain intent. Structuring the record around "what must still happen" rather than only "what the process looks like" keeps restart teardown honest: every obligation has an explicit home in the schema instead of being re-derived by the reconcile code (which is where obligations were being silently dropped). Written via a temp file + rename so a crash mid-write can't leave a torn manifest that startup would silently skip.waitpid: a restarted agent is no longer the parent of a still-running job, so it can'twait()it (a kernel restriction, not a design choice). Liveness is a tri-state read of the cgroup (cgroup.events): populated → alive, empty → done, and unreadable → fall back to a direct/proc/<pid>liveness check rather than assume the job finished (a non-cgroup-v2 host, or a process that was never moved into the cgroup, would otherwise be misread as complete). The exit code is recovered from a sentinel file the job's own wrapper script writes — with a re-exit so the normal (non-restarted) path's exit-status reporting is unchanged.-1.(job_id, run_attempt), notjob_idalone — otherwise a same-node redispatch while an old run is still finishing could collide on the cgroup (a cgroup-wide kill would take the new run down too) or read a stale exit-status sentinel from the prior run.0o711); the job's exit-status sentinel is pre-created and chowned to it, so the job never needs to create files there and a co-located user can't plant a symlink over the root-authored manifest.spurctldwarns at startup ifheartbeat_timeout_secsis below 30s, since it's a more load-bearing safety net now than before.Known limitations (not solved here)
heartbeat_timeout_secsstill falls back to today's eviction path.wait()a job it no longer parents, the exit code comes from the sentinel file (or, failing that,/proc/cgroup inference). Two consequences: (a) on a non-cgroup-v2 host, or if the sentinel was never written (e.g. the process wasSIGKILLed before its wrapper'straparmed), a re-adopted job can only report an approximate outcome; (b) the container sentinel lives in the job-writable rootfs, so a container job could write an arbitrary integer there and have the agent report it as that job's own exit code after a restart. The read is hardened against a planted symlink/FIFO/oversized file, but the value is the job's to state — bounded impact (a job can only lie about its own status), noted for completeness. Authoritative exit truth would require a live per-job supervisor (see below)./proc/<pid>existence plus start-time. Start-time has clock-tick granularity, so a PID recycled within the same tick could in principle be mistaken for the original — realistically only across a reboot, which this feature doesn't cover (a rebooted node's jobs are already gone). The cgroup path, used whenever a cgroup is present, is not affected.srunallocation (no batch process) is not written to a manifest, so it isn't restored on restart; interactive sessions don't survive a restart in any case (the client connection drops). A node holding only such a reservation now deregisters on SIGTERM so the controller reclaims it, rather than stranding the allocation.spurdpanic or OOM is survived by the job process itself (it re-parents to init), but the agent's authoritativewait()-based exit truth for that job is lost at that instant, degrading it to the inference path above.Structurally, the exit-truth gap, the mid-launch PMI loss, and crash isolation all stem from the same root: reconstructing state from a manifest after the fact, rather than querying a live per-job supervisor (as Slurm does with
slurmstepd). That trade-off is deliberate and adequate for the graceful-upgrade goal this PR targets; a supervisor process is out of scope here. The obligation-ledger schema above is the piece that carries forward under either model, and the natural next step if authoritative exit codes or crash isolation become requirements is a minimal per-job exit-recorder (a supervisor's embryo), not a rewrite.Testing
executor.rs,agent_server.rs, andcons_tres.rs, several exercising real spawned processes rather than simulating behavior — including: a resumed container job reading its real exit code from the sentinel; a mixed reconcile pass that re-adopts a surviving job and discharges only the finished one; the obligation ledger running the epilog exactly once across repeated report-retries (not once per retry); tri-state cgroup liveness falling back to a/proccheck whencgroup.eventsis unreadable; all-or-nothing GPU restore rejecting a double-book; the exit sentinel refusing a planted FIFO/symlink and capping the read; run-attempt-scoped spool isolation; and atomic manifest write (no leftover temp file).test_deregistration.py): restart mid-job and assert the job survives, completes, and its allocation is neither stranded nor double-booked; exit code preserved across a restart for the full matrix of plain and container jobs × success and failure; and a job that finishes entirely while the agent is down still reports its true exit code on restart.cargo clippy --workspace --exclude spur-ffi --all-targetsandcargo fmtclean; targetedcargo testpasses.