# jobd preemption protocol (path B) `jobd` can preempt a running job to free its slot for a higher-priority queued job. By default the broker SIGTERMs the workload and reports the final state as `preempted` — the workload dies and the project reruns it later. Long-running training jobs can opt into a **checkpoint protocol** that lets them save state durably during the SIGTERM grace window before exiting. ## When does a job get preempted? The sweeper auto-preempts a running job when: 1. Some other queued job has been queued for more than 5 minutes, 2. The running job is `preemptible: true` (per `--preemptible` or per- project default in `projects.yaml`), 3. Its priority is strictly less than the queued job's, and 4. It has been running for more than 5 minutes (the `AUTO_PREEMPT_MIN_RUNTIME_SECONDS` floor). An operator can also force preemption manually: ```bash job preempt # request: this specific running job job preempt-blockers # find a blocker for this queued job ``` ## What the worker does 1. Broker sets `signal=preempt` on the running job. 2. Worker poll picks it up and SIGTERMs the workload (via the systemd user-scope cgroup). 3. Worker waits up to `checkpoint_grace_s` (default 60s, max 300s) for the workload to exit cleanly. After that the worker SIGKILLs. 4. If the worker observes the literal string `jobd-checkpoint-complete` in the job's stdout during that window, it POSTs `/jobs/{id}/checkpoint-complete` to the broker, which appends a `checkpoint_complete` event to `events.jsonl`. This is pure observability — the actual final state still flows through `/complete` once the workload exits. 5. Final state is `preempted`. Children with `depends_on` cascade-cancel (the input never materialized) unless `depends_on_any_exit=True`. ## What the workload does The convention is: install a SIGTERM handler that durably checkpoints, then prints `jobd-checkpoint-complete` and exits 0. `jobd.client` provides a helper: ```python from jobd.client import install_preemption_handler def checkpoint(time_remaining: float) -> None: """Save model + optimizer state somewhere durable. `time_remaining` is seconds until SIGKILL — you can use it to self-cap a slow checkpoint.""" save_checkpoint(model, optimizer, path=ckpt_path) install_preemption_handler(checkpoint) # ... start training ... ``` The helper: - Reads `JOBD_CHECKPOINT_GRACE_S` from env (the worker sets this). - Installs a SIGTERM handler that calls your function with the remaining seconds. - On success: prints the `jobd-checkpoint-complete` sentinel and exits 0. - On exception: prints the error to stderr and exits 1 — the sentinel is **not** printed, so the broker won't fire the `checkpoint_complete` event. `jobd.client.time_remaining()` returns the live remaining seconds and is also callable from anywhere inside the handler chain if you need it. ## Configuring the grace window Per-job (CLI): ```bash job submit --project p --cwd $(pwd) --preemptible \ --checkpoint-grace 300 -- python train.py ``` Per-project default in `projects.yaml`: ```yaml projects: long-training: priority: 60 defaults: preemptible: true checkpoint_grace_s: 300 ``` Resolution order: CLI flag > project default > worker default (60s). Server caps at 300s (the sweeper interval) so a stuck checkpoint can't pin a slot indefinitely. ## Observability Each fire writes a JSON line to `/events.jsonl`: - `auto_preempt` — broker signaled SIGTERM (sweeper or manual). - `checkpoint_complete` — worker saw the sentinel before the grace expired. ```bash grep '"checkpoint_complete"' ~/jobd/logs/events.jsonl | jq ``` A successful path-B preemption looks like one `auto_preempt` followed by a matching `checkpoint_complete` event for the same `job` / `candidate_job` ID. ## Checkpoint directory contract The worker creates a per-job directory and exposes its path to the workload via the `JOBD_CHECKPOINT_DIR` env var: ``` JOBD_CHECKPOINT_DIR=// ``` `` resolves as: 1. `$JOBD_WORKER_CHECKPOINT_ROOT` if set 2. `$XDG_DATA_HOME/jobd/checkpoints` if `XDG_DATA_HOME` is set and non-empty 3. `~/.local/share/jobd/checkpoints` The override is treated as the literal checkpoint root — the worker appends `/` directly under it, NOT `jobd/checkpoints//`. Tilde paths are expanded via `os.path.expanduser` (so `~/ckpts` works), but the override is otherwise used verbatim; supply an absolute path in systemd `Environment=` lines to avoid surprises. The directory is `mkdir -p`-ed with mode `0700` before the workload starts. Convention is for the workload to write its durable checkpoint files inside that directory during the SIGTERM grace window. The resuming run reads from `$RESUME_FROM` (which the operator points at the prior run's `latest.` — see closing paragraph), NOT from `$JOBD_CHECKPOINT_DIR`, since the resuming job gets a fresh empty checkpoint dir of its own. Mode `0700` and worker-user ownership mean cross-user resume on a shared filesystem doesn't work without re-permissioning. If `JOBD_WORKER_CHECKPOINT_ROOT` points at network storage so a _different_ user-or-host can resume, the operator is responsible for chmod/chown after the producing run finishes. The broker does **not** sweep, rotate, or otherwise manage the contents. Cleanup of stale checkpoint directories is currently a manual / out-of-band concern. (Filed as a future backlog entry.) Example workload (full path-B helper + checkpoint dir): ```python import os from pathlib import Path import torch from jobd.client import install_preemption_handler ckpt_dir = Path(os.environ["JOBD_CHECKPOINT_DIR"]) def checkpoint(time_remaining: float) -> None: torch.save( {"model": model.state_dict(), "optimizer": opt.state_dict(), "step": step}, ckpt_dir / "latest.pt", ) install_preemption_handler(checkpoint) ``` A preempted job's terminal state is `preempted` — it is NOT automatically re-queued. To resume from a prior run, the operator resubmits a new job (which gets a fresh `` and a fresh empty `JOBD_CHECKPOINT_DIR`) and points it at the old checkpoint via a separate convention env var like `RESUME_FROM=$OLD_CHECKPOINT_DIR/latest.`. The new run writes its own checkpoints into its own `JOBD_CHECKPOINT_DIR`; the prior run's directory remains on disk until manually cleaned up.