myxce/.kiro/specs/scheduler-preemption/brief.md

183 lines
12 KiB
Markdown

# Brief: scheduler-preemption
## Problem
The XCE scheduler has no preemption mechanism. When a high-priority job cannot be scheduled because all resources are consumed by lower-priority running jobs, the scheduler has no way to free those resources. The job is simply pended, and it waits indefinitely — regardless of its priority — until running jobs naturally complete.
Three specific gaps exist:
1. **No preemption decision type** — the `xce_decision_op_t` enum has `DISPATCH` and `PEND` but no `PREEMPT`. The scheduler cannot communicate a preemption intent to the controller.
2. **No victim selection mechanism** — even if a preemption decision type existed, there is no logic to select which running jobs should be preempted. Cross-queue victim selection (a high-priority job in queue A preempting a low-priority job in queue B) is entirely absent.
3. **No SIGSTP pipeline** — the controller and worker have no path to deliver a stop signal to a running job and transition its state to `JOB_SUSPENDED`, freeing its resources for the preempting job.
Operators with mixed-priority workloads need preemption to ensure critical work proceeds without manual intervention — the operator should not have to manually kill low-priority jobs to make room.
## Current State
### What exists
- **Decision framework** (`decision.h`): `xce_decision_op_t` with `DISPATCH`, `PEND`, and `NONE` ops. Core/decision callback flows to driver/controller.
- **Queue structure** (`xce_sched_queue_t`): has `priority` field for inter-queue ordering within the picker, but no preemption-related fields.
- **HOOK_DETAIL_JOB_STOP** (enum value 65 in `cluster_def.h`): existing hook detail enum, reused for preemption stop notifications.
- **Controller→worker messaging**: existing message pipeline for dispatch/pend decisions; the same path can carry preemption actions to workers.
### What's missing
- `XCE_DECISION_PREEMPT` opcode and `xce_preempt_decision_t` payload in `decision.h`
- Victim selection policy: which running jobs get preempted for a given high-priority job
- Per-queue preemption configuration: which queues can preempt from which other queues, which queues are immune to preemption
- SIGSTP delivery pipeline: scheduler → controller → worker → process group
- Job state transition to `JOB_SUSPENDED` triggered by preemption
- Integration of preemption into the scheduling loop: when allocation fails, attempt preemption before pended
## Desired Outcome
A job preemption system with three layers:
**1. Decision type** — `XCE_DECISION_PREEMPT` carries victim jobids, signal type, and preempting job id through the core's decision callback → normal-mode driver → controller → worker pipeline, reusing the same callback infrastructure (`xce_core_decision_cb_t`) used by `DISPATCH` and `PEND`.
**2. Victim selection** — a `preempt_policy` plugin on each queue selects which running jobs to preempt. The default policy `"lowest_priority_first"` implements LSF-style cross-queue victim selection:
- Collect running jobs from the preempting job's `preemptible_queues` list
- Filter: victim priority < preempting job priority AND victim's queue has `preemptible == true`
- Rank by priority ascending (lowest priority first)
- Select the minimum subset whose freed resources satisfy the preempting job's resreq
**3. SIGSTP pipeline** — the controller receives `XCE_DECISION_PREEMPT`, delivers `HOOK_DETAIL_JOB_STOP` to each victim's worker, the worker sends SIGSTP to the job's process group, the controller transitions the job to `JOB_SUSPENDED`, and resources are released. The preempting job is pended and retried in the next scheduling round (async preemption).
### Queue Preemption Configuration
Each queue carries three new fields:
| Field | Type | Purpose |
|-------|------|---------|
| `preemptible_queues` | `char**` | List of queue names this queue CAN preempt from |
| `preemptible` | `bool` | Whether this queue's jobs CAN BE preempted by other queues |
| `preempt_policy` | `char*` | Victim-selection plugin name (NULL = use default) |
## Alternatives Considered
### Approach A: Kill-and-requeue only (rejected)
| Aspect | Detail |
|--------|--------|
| **Mechanism** | SIGKILL victims, requeue from scratch |
| **Pros** | Simple, no suspended state, no resume logic |
| **Cons** | Destroys progress, lose all intermediate computation. Requeued jobs restart from zero. Not viable for long-running HPC workloads where checkpointing is explicit. |
**Rejected because:** HPC jobs can run for hours or days. Killing a job that is 90% complete to make room for a higher-priority job wastes all accumulated work. SIGSTP + resume preserves progress when the preempted job is later resumed.
### Approach B: SIGSTP with suspend/resume (chosen)
| Aspect | Detail |
|--------|--------|
| **Mechanism** | SIGSTP to process group → job → `JOB_SUSPENDED` → resources freed → preempting job runs → preempted job optionally resumed later |
| **Victim selection** | Policy plugin per queue, default `"lowest_priority_first"` |
| **Cross-queue** | Per-queue `preemptible_queues` list enables cross-queue preemption |
| **Scope** | v1 is SIGSTP only; kill-and-requeue escalation deferred |
**Chosen because:** Preserves victim job progress. Enables later resume (outside this spec's scope). Aligns with LSF-style preemption semantics familiar to HPC operators. The async pattern (pend preempting job, retry next round after SIGSTP) keeps the scheduling loop simple.
## Approach
### Preemption Flow
```
scheduler controller worker
─────────── ────────── ──────
alloc fails for high-prio job J
preempt_policy.select_victims(J):
collect running jobs from
Q_aggressor.preemptible_queues
filter: victim.prio < J.prio
filter: victim.queue.preemptible
rank: lowest priority first
select minimum set satisfying J.resreq
→ [victim1, victim2]
XCE_DECISION_PREEMPT ────────────▶ handle_job_preempt() ──────▶ SIGSTP victim1
.victim_jobids = [v1, v2] deliver to each job → JOB_SUSPENDED
.signal = SIGSTP victim's worker release resources
.preempting_jobid = J
J pended → next round → retry alloc
```
### Integration into Scheduling Loop
Preemption fires at Level 3 in the core scheduling loop — after allocation failure, before pending:
```c
while ((job = picker->next_job(picker)) != NULL) {
if (try_allocate(job) > 0) {
emit_decision(DISPATCH, job);
picker->on_dispatch(picker, job->jobid);
} else if (can_preempt(job)) {
victims = select_victims(job);
emit_decision(PREEMPT, job, victims);
picker->on_pend(picker, job->jobid); // retry next round
} else {
emit_decision(PEND, job);
picker->on_pend(picker, job->jobid);
}
}
```
The core loop itself contains no victim selection logic — that lives in the `preempt_policy` plugin. The core only checks `can_preempt(job)` (does the job's queue have a non-NULL `preemptible_queues` list?) and delegates to the policy plugin for victim selection.
### What stays unchanged
- Core scheduling loop — preemption is an additional branch in the alloc-fail path (Level 3), not a restructure
- Decision callback (`xce_core_decision_cb_t`) — `PREEMPT` flows through the same callback as `DISPATCH` and `PEND`
- Existing plugin framework — `preempt_policy` is registered, loaded, and invoked through the existing `plugin_register`/`key_find` mechanism
- `HOOK_DETAIL_JOB_STOP` — existing enum value, reused without change
### What's new
- `XCE_DECISION_PREEMPT` in `xce_decision_op_t` (`decision.h`)
- `xce_preempt_decision_t` payload carrying `victim_jobids[]`, `n_victims`, `signal`, `preempting_jobid`
- `preemptible_queues`, `preemptible`, `preempt_policy` fields on `xce_sched_queue_t`
- Default `"lowest_priority_first"` preemption policy plugin
- Controller handler for `XCE_DECISION_PREEMPT` → SIGSTP delivery to workers
- Worker-side SIGSTP handler → process group signal + `JOB_SUSPENDED` state transition
## Scope
### In
- `XCE_DECISION_PREEMPT` decision type and `xce_preempt_decision_t` payload
- `preempt_policy` plugin interface (victim selection)
- Default `"lowest_priority_first"` preemption policy
- LSF-style cross-queue victim selection via `preemptible_queues` list
- SIGSTP pipeline: scheduler → controller → worker → process group
- Per-queue preemption config: `preemptible_queues`, `preemptible`, `preempt_policy`
- Victim safety guards: never preempt same-or-higher priority, never preempt non-`preemptible` queues
- `JOB_SUSPENDED` state transition on victim jobs
- Reuse of `HOOK_DETAIL_JOB_STOP` for preemption stop notifications
- Unit tests: victim selection policy, safety guard enforcement, edge cases (empty target queues, no eligible victims, insufficient resources from any victim set)
- Integration test: end-to-end preemption flow — high-priority job submits, preemption fires, victims get SIGSTP, resources freed, preempting job dispatched in next round
### Out
- Kill-and-requeue escalation (v1 is SIGSTP only)
- Deadline-based preemption (requires `deadline` field on jobs — deferred)
- Preempted job resume logic (outside this spec's scope — the `JOB_SUSPENDED` state is defined, but the resume mechanism is a separate feature)
- Preemption priority inversion guards beyond the priority comparison already specified
- Fair-share or proportional victim selection beyond the default `"lowest_priority_first"` policy (the plugin interface enables custom policies, but none beyond the default ship here)
## Upstream / Downstream
### Upstream (dependencies)
- **scheduler-core** (sibling spec): provides the decision callback (`xce_core_decision_cb_t`) through which `XCE_DECISION_PREEMPT` flows. The core scheduling loop and `xce_sched_core_t` are defined there. Preemption hooks into the core loop at Level 3 (alloc-fail branch).
- **scheduler-allocation** (sibling spec): provides `try_allocate()` — preemption fires only when allocation fails. The resreq fingerprint cache interacts with preemption (a cached-unsatisfiable resreq skips allocation AND preemption).
- **Controller message handling**: existing handler dispatch path for decisions — extended to handle `XCE_DECISION_PREEMPT`.
- **Worker job control**: existing job lifecycle management — extended to handle SIGSTP delivery and `JOB_SUSPENDED`.
### Downstream (future consumers)
- **Resume mechanism** — a future spec for resuming `JOB_SUSPENDED` jobs, potentially with priority-based ordering
- **Kill-and-requeue escalation** — a future enhancement where repeated preemption failure escalates to SIGKILL
- **Custom preemption policies** — deadline-aware, fair-share, or resource-specific victim selection implemented as new `preempt_policy` plugins
## Constraints
- **C (gnu17)** — no C++, no external libraries beyond existing (pthreads)
- **Async preemption** — the preempting job is pended and retried in the next scheduling round; preemption is not synchronous
- **Priority guard** — victim priority must be strictly less than preempting job priority (never preempt equal or higher)
- **Preemptible guard** — victim queue must have `preemptible == true` (opt-in — queues are not preemptible by default)
- **Minimal victim set** — select the minimum subset of victims whose freed resources satisfy the preempting job's resreq (avoid over-preemption)
- **No self-preemption** — the preempting job's own queue is implicitly excluded from victim selection
- **Cancel-safe** — `preempt_policy.select_victims()` must have `pthread_cancel` cancellation points