fix(spurctld): report wall-time expiry as TIMEOUT - #554
Conversation
The time-limit watchdog tracked which jobs it had signalled in two in-memory maps owned by the scheduler task, so the SIGTERM decision was invisible to the completion path. A job that exited on that SIGTERM was finalized from its agent's report, where derived_completion sees only "signaled" and returns Failed — the well-behaved job was the one misreported, while a job ignoring SIGTERM survived to the kill phase that sets Timeout explicitly. Record the expiry on the job itself instead. The marker has to be replicated: completions are finalized inside the Raft apply, which must reach the same verdict on every replica. Deriving the grace period from it also drops both in-memory maps and makes the window survive a leadership change, which previously restarted it from scratch. Timeout now carries Slurm's TimeLimit reason (FAIL_TIMEOUT) rather than RaisedSignal, and the agent's real exit status survives the verdict, so a timed-out job stays distinguishable from one the watchdog had to SIGKILL. An OOM kill still outranks the time limit, being direct kernel evidence of a failure the user has to act on. Correct attribution also routes these jobs into the Timeout requeue path, which --requeue jobs dying on SIGTERM previously skipped. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Hi @maybeharshit , you marked this PR as draft, are you still working on more changes ? |
The marker promotion lived only in the per-node completion path, so two completions that route through complete_job bypassed it: the completing- timeout force-finish (a multi-node run where a node never reports sits in Completing, which the watchdog skips) and the srun path (state derived from exit code alone). A run the watchdog had already signalled was finalized as Failed there and skipped the Timeout requeue. Promote Failed/Completed to Timeout in complete_job when the marker is set, matching Job::completion_verdict; Cancelled and an already-correct Timeout pass through. The corrected state is baked into the JobComplete WAL entry so every replica applies it directly. Also add the JobTimeLimitSignaled WAL round-trip test the other variants have. Co-authored-by: Cursor <cursoragent@cursor.com>
|
@yansun1996 Please review and merge. |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #554 +/- ##
==========================================
+ Coverage 74.73% 76.10% +1.37%
==========================================
Files 165 166 +1
Lines 60486 63172 +2686
==========================================
+ Hits 45203 48074 +2871
+ Misses 15283 15098 -185 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR fixes Spur’s wall-time expiry attribution so jobs that exceed their time limit reliably finalize as TIMEOUT (with Reason=TimeLimit) even when they exit promptly on the watchdog’s initial SIGTERM, by persisting a replicated “time-limit signaled” marker that the completion path can consult during Raft apply.
Changes:
- Add a replicated
time_limit_signaled_atmarker onJob, written via a newWalOperation::JobTimeLimitSignaledbefore SIGTERM is sent, and cleared on requeue. - Centralize final completion attribution in
Job::completion_verdictso finalJobStateandPendingReasonare derived together (includingCompleting -> Timeout). - Add unit coverage (WAL + apply-path tests) and a new native-host E2E test suite for both “dies on SIGTERM” and “ignores SIGTERM” timeout paths.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/native_host/e2e/test_time_limit.py | Adds E2E coverage ensuring both SIGTERM and SIGKILL timeout paths report TIMEOUT and that in-limit failures remain FAILED. |
| crates/spurctld/src/scheduler_loop.rs | Removes in-memory timeout tracking and keys watchdog behavior off replicated time_limit_signaled_at. |
| crates/spurctld/src/cluster.rs | Introduces signal_time_limit() (WAL-backed), applies the new WAL entry, and ensures timeout reason attribution on forced completion. |
| crates/spur-core/src/wal.rs | Adds JobTimeLimitSignaled WAL operation and a round-trip serialization test. |
| crates/spur-core/src/job.rs | Adds PendingReason::TimeLimit, the time_limit_signaled_at field, and completion_verdict() plus state-machine transition coverage. |
| crates/spur-tests/src/t55_format.rs | Extends formatting tests to include PendingReason::TimeLimit. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Motivation
A job killed by its wall-time limit landed in
FAILEDwithExitCode=0:15, and whether it ever reachedTIMEOUTdepended on how it reacted to SIGTERM — the well-behaved job was the one misreported.The time-limit watchdog tracked which jobs it had signalled in two in-memory maps (
warned_jobs,warn_times) owned by the scheduler task, so the SIGTERM decision was invisible to the completion path. A job that exits promptly on that SIGTERM is finalized from its agent's completion report, whereJob::derived_completionsees only "signaled" and returnsFailed. Only a job that ignored SIGTERM survived to the kill phase, which setsTimeoutexplicitly.Fixes #546.
Technical Details
The expiry is now recorded on the job itself as
time_limit_signaled_at, written through a newJobTimeLimitSignaledWAL entry before the SIGTERM goes out.Replicating it is not optional: completions are finalized inside the Raft apply, so a non-replicated flag could not be consulted there without replicas disagreeing on the outcome. Deriving the grace period from the same field then falls out for free — it removes both in-memory maps and makes the window survive a leadership change, which previously restarted it from scratch.
Two smaller pieces follow from that:
Job::completion_verdictdecides the final state and reason together, so they cannot drift.Completing -> Timeoutwas missing from the state machine, since every completion routes throughCompleting(Slurm'sJOB_TIMEOUT | JOB_COMPLETING).PendingReason::TimeLimitmirrors Slurm'sFAIL_TIMEOUT, replacing theRaisedSignal:15(Terminated)that would otherwise sit incongruously next toJobState=TIMEOUT.Design choices worth flagging:
[1] The agent's real exit status survives the verdict rather than being normalized, so a job that ended on SIGTERM stays distinguishable from one the watchdog had to SIGKILL. The forced kill-phase path keeps its synthetic
-1, because it is the backstop that guarantees finalization when no agent report is coming.[2] An OOM kill still outranks the time limit. It is direct kernel evidence of a distinct failure the user has to act on, whereas the timeout is the controller's own reason for signalling.
[3] Correct attribution routes these jobs into the existing
Timeoutrequeue path, which--requeuejobs dying on SIGTERM previously skipped. The issue names this as affected behaviour.time_limit_signaled_atis cleared inclear_run_state_for_requeue, so a requeued run starts with a fresh budget instead of being marked timed out the moment it ends.Test Plan
spur-corefor the verdict itself: SIGTERM death, a trapped-SIGTERM clean exit, an unrelated signal death, OOM precedence, and theCompleting -> Timeouttransition.spurctlddriving the real WAL apply: completion after a signal reportsTimeout/TimeLimit, a signal arriving after the run ended is a no-op, the forced kill path carries the reason, and requeue clears the marker.tests/native_host/e2e/test_time_limit.pycovering the issue's two scripts plus a control job that fails inside its limit.--time=00:00:20.Test Result
Clippy clean; full workspace suite passes.
On the LXD cluster:
tl-plainsleep 300JobState=TIMEOUT Reason=TimeLimit ExitCode=143:0tl-traptrap "" TERM; sleep 300JobState=TIMEOUT Reason=TimeLimit ExitCode=-1:0tl-fastfailexit 3JobState=FAILED Reason=NonZeroExitCode ExitCode=3:0The controller log shows
tl-plainreachingTIMEOUTwith nograce period expiredline, confirming it was finalized from the agent's completion report — the exact path that used to yieldFAILED.tl-trapwas force-killed 30s after its SIGTERM, as before.ExitCodefortl-plainis143:0rather than the issue's0:15because this environment's bash exits128+15instead of dying from the signal. That is a property of the shell, not of the fix, so the e2e test only rules out the synthetic-1rather than asserting an exact status.A
--requeuejob at the same limit was requeued repeatedly withfrom=TIMEOUT, each run logging a fullelapsed_secs=27againstlimit_secs=20, which confirms both the new requeue routing and that the marker is cleared between runs.Submission Checklist
Made with Cursor