NEW: ML Mock & Coaching with a 20+ year experience coach. Save $20 on sessions for Premium.ML Mock & Coaching now available
NEW: ML Mock & Coaching with a 20+ year experience coach. Save $20 on sessions for Premium.ML Mock & Coaching now available
Design a GPU Scheduling Platform - 1,000 nodes, 8,000 GPUs, 10,000 queued jobs, four priority tiers, and per-team quotas. The staff-level signal is not drawing a queue and a scheduler — it is making GPU capacity a transaction, treating a lease expiry as a question rather than an answer, and keeping priority, quota, fairness, and preemption as four separate policies.
Written by a Staff+ Engineer and an Engineering Manager
12+ years of experience in distributed systems and real-time messaging. Tech lead for large-scale chat applications serving billions of users. Over 12 years of industry experience with 6 years in engineering leadership, building and scaling tech infrastructure teams that deliver end-to-end large-scale distributed systems.
Finished this lesson?
Sign in to save your progress across devices.
Tip: use ← and → to move between lessons.
Was this lesson helpful?
Rehearse this exact question with the author
45-minute mock with a Staff+ engineer who interviews at your target company. Premium members save $20.
What to Design?
Could you answer these today? Tick what you already know.
The question as written contains traps — a running-job cap that contradicts the utilization target, a 5-second start objective next to a 30-second preemption grace period. Clear them before drawing anything. The answers below are the ones an OpenAI-style interviewer gives when asked:
| Scope Decisions | ||
|---|---|---|
| Question | Agreed answer | Why it matters |
| Cluster scale? | 1,000 nodes, ~8 GPUs per node (8,000 GPUs); ~10,000 jobs queued at peak | Sizes every table; the queue is small, so durability and ordering matter more than throughput |
| Can a job span nodes? | No — each job fits on one node; multi-node training is out of scope | Placement becomes per-node bin packing; fragmentation becomes the utilization problem (DD7) |
| How long do jobs run? | Minutes to hours, potentially more than 24 hours | The API must be asynchronous; checkpoints decide what a retry costs |
Out of scope (say this out loud): gang scheduling across nodes, the training framework inside the container, billing, and autoscaling the physical fleet. Each is a good extension question; none should eat the first ten minutes.
Four FRs, each mapping to one flow in the High Level Design.
FR1 — Submit, cancel, and track GPU jobs
A client sends POST /v1/jobs with an image, GPU requirements, a priority, and an idempotency key, and gets back a durable job_id in well under 200 ms. Status, logs, and results are read asynchronously; cancellation is accepted asynchronously too.
FR2 — Schedule with priority and resource awareness, reserving GPUs atomically
The scheduler picks the next eligible job across teams and tiers, finds a node that satisfies GPU type, count, and memory, and claims the GPUs in one conditional transaction. Two schedulers can never both succeed for the same capacity.
FR3 — Execute jobs on node agents and expose logs and results
A node agent on every worker starts the container exactly once per attempt, confirms the start, heartbeats, streams logs and checkpoints to object storage, and reports completion through a protocol that tolerates a lost acknowledgment.
FR4 — Preempt lower-priority jobs and retry failed attempts
When a higher-priority job cannot be placed, the platform gracefully preempts strictly lower-priority work and resumes it later from its checkpoints. Failed or lost attempts are retried as new attempts with new identities.
The key principle behind FR2, FR3, and FR4
at-least-once attempts != exactly-once execution
single ACCEPTED result != single running container
lease expired != worker stopped
critical priority != another team's guaranteed quota
Most of the design exists to keep those four distinctions honest.
NFR1 — Scale. 1,000 nodes, ~8,000 GPUs, ~10,000 queued jobs at peak, no fixed cap on concurrent jobs — concurrency is bounded by GPU capacity, and the target is more than 80% GPU utilization.
NFR2 — Latency. Submission p95 under 200 ms. A high-priority job starts within 5 seconds when sufficient capacity is immediately available; a normal-priority job within 2 minutes. The clause matters: waiting for a preemption grace period is not a start-latency violation.
NFR3 — Scheduling correctness. No double allocation of a GPU, ever. Durable job state. Retries that cannot create two accepted results for one job.
NFR4 — Availability and recovery. 99.9% for the control plane. Scheduler crashes, node crashes, and partitions are recovered automatically; node failure is detected within about 30 seconds; nothing stays stuck or permanently reserved.
NFR5 — Fairness and isolation. Per-team guaranteed quotas, no starvation of lower tiers, and noisy-neighbor isolation. Isolation (one team's job cannot hurt another's) and fairness (who gets the next free GPUs) are different requirements.
| Capacity Thought Experiment | |
|---|---|
| Parameter | Value |
| GPUs | 1,000 nodes × 8 = 8,000 GPUs |
| Queue state | 10,000 jobs × ~1 KB ≈ 10 MB — trivially fits in memory; durability, not size, is the problem |
| Heartbeats | 1,000 nodes every 10 s = 100 heartbeats / s — cheap for a registry, wasteful as PostgreSQL row updates |
| Concurrent jobs (assumption) | average 2 GPUs per job → ~4,000 running jobs at 100% allocation |
| Steady-state scheduling rate | average job ~1 h → ~1 completion / s → ~1 placement / s |
The number that identifies the real system is not 8,000 — it is how little scheduling throughput the cluster needs:
cluster frees capacity at ~1 job / s (steady state)
one leader can place ~100 jobs / s (one transaction each)
The scheduler is not the bottleneck, so "add scheduler replicas" is almost never the answer. The constraints that actually decide utilization are feasibility — an 8-GPU job needs eight free GPUs on the same node, and a cluster that is 35% free can fit zero of them (DD7) — and correctness: one conditional transaction per node row is what prevents double allocation (DD1). Every number above is an illustrative assumption to be replaced by measured queue age and free-GPUs-per-node histograms.
Interviewer signal: candidates who multiply 1,000 by 8 and stop have done arithmetic. Candidates who notice that a single leader out-paces the cluster by two orders of magnitude, and then name fragmentation and the per-node transaction as the real limits, have found the system.
These nine questions give the rest of the design its purpose. Raise them before drawing the final architecture — every component below exists because one of them forces it.
Before drawing boxes, orient around three tensions that shape everything:
A Job is what the user asked for. A JobRun is one attempt to do it — on one node, with one reservation, under one fencing token. Retries, preemptions, and failovers create new attempts with new identities; they never resurrect an old one. This is what lets the platform promise at-least-once attempts and one accepted result at the same time, without pretending a user's training container is idempotent.
A cached free-GPU count tells you where to look; it must never decide who gets the GPUs. The allocator is one conditional transaction in the source of truth — decrement available capacity only if enough remains, insert the reservation, commit — and the per-node invariant reserved + available = total is checked, not assumed. Redis orders work; PostgreSQL decides capacity.
Missed heartbeats and expired leases tell you the control plane lost contact, not that a container stopped. The working design treats expiry as "reconcile now": ask the agent, read its last heartbeat, look for a completion manifest, and only then release or retry. Because reconciliation can still be wrong under a partition, every commit point — reservation, assignment, heartbeat, completion, and leadership itself — is fenced by a monotonic token that rejects stale actors.
The core recognition
This is a control plane that hands an expensive, indivisible, node-local resource to long-running, non-idempotent containers. Everything interesting lives where ownership changes hands: the transaction that claims GPUs, the confirmation that a container really started, the expiry that might or might not mean it stopped, and the commit that decides which attempt's result the user sees.
| Design Decisions | |
|---|---|
| Design choice | Reason |
| Job (logical) + JobRun (attempt) + Reservation (ownership) | Retries and failovers are new attempts; results are accepted once per job |
| PostgreSQL as source of truth; Redis as priority queue and cache only | |
Five tables carry the whole design. Spend your explanation on
Reservation,NodeCapacity, andJobRun— their consistency is the architectural problem.NodeCapacitycould be folded intoWorkerNode; keeping it separate makes the allocation transaction easier to explain.
The logical job: what the user asked for and where it stands.
| Job | ||
|---|---|---|
| Field | Type | Comment |
job_id | UUID | Primary key; the only identity that survives retries |
team_id / user_id | STRING | Team comes from authentication — it drives quota and fairness |
One execution attempt. A job may have several; at most one is authoritative.
| JobRun | ||
|---|---|---|
| Field | Type | Comment |
run_id | UUID | Primary key; new for every attempt |
job_id, attempt_no | UUID, INT | Unique together — enforce it with a constraint |
Ownership of specific GPUs by one attempt, for a bounded time.
| Reservation | ||
|---|---|---|
| Field | Type | Comment |
reservation_id | UUID | Primary key |
run_id, node_id | Which attempt holds which node | |
gpu_count, gpu_ids | ||
| NodeCapacity / WorkerNode | ||
|---|---|---|
| Field | Type | Comment |
node_id | STRING | Primary key of both |
total_gpus, available_gpus, reserved_gpus, version | INT | |
Three state machines — deliberately small:
Job PENDING -> SCHEDULED -> RUNNING -> SUCCEEDED | FAILED | CANCELLED
FAILED (retries left) / PREEMPTED -> PENDING (new attempt)
JobRun ASSIGNED -> RUNNING -> SUCCEEDED | FAILED
RUNNING -> PREEMPTING -> PREEMPTED
Reservation RESERVED -> CONFIRMED -> RELEASED
RESERVED/CONFIRMED -> EXPIRED only after reconciliation says the GPUs are free
There is no transition that frees capacity on a timer. Every path to
RELEASEDorEXPIREDpasses through a confirmation from the node agent or an explicit fence (DD2, DD3, DD5).
| Storage | ||
|---|---|---|
| Store | Data | Consistency |
| PostgreSQL | Job, JobRun, Reservation, NodeCapacity, WorkerNode, TeamQuota, leader epoch | Strong, transactional — the only allocator |
| Redis | Priority queues per tier and team, cached free GPUs, hot heartbeat data | Ephemeral; rebuildable from PostgreSQL |
| Object storage | Logs, checkpoints, artifacts, completion manifests — under jobs/{job_id}/attempts/{run_id}/ | Durable; does not check tokens, so publication goes through a commit |
POST /v1/jobs
Authorization: Bearer <token> # team comes from here
Idempotency-Key: 7f3c-... # required for safe client retries
{
"image": "registry/train:1.4",
"gpu_count": 4, "gpu_type": "H100", "memory_mb": 65536,
"priority": "HIGH",
"command": ["python", "train.py"],
"max_retries": 2
}
Response 201: { "job_id": "J1", "status": "PENDING" }
Response 409: { "error": "idempotency_key_reused_with_different_payload" }
GET /v1/jobs/{id} -> status, current attempt, timestamps, result_uri
GET /v1/jobs/{id}/logs -> log stream (running) or log_uri (finished)
DELETE /v1/jobs/{id} -> 202 Accepted; cancellation is asynchronous
Submission is asynchronous by nature: the call persists and enqueues, then answers. Nothing on this path touches a GPU node, which is how the 200 ms objective survives a 10,000-job backlog.
ReserveGPU (run_id, node_id, gpu_count, gpu_type, leader_epoch)
-> reservation_id, gpu_ids, lease_expires_at, fencing_token # a DB transaction, not an RPC to the node
AssignJob (run_id, reservation_id, fencing_token, image, command) # idempotent per run_id
ConfirmAssignment (reservation_id, fencing_token) -> CONFIRMED / REJECTED # agent -> control plane
Heartbeat (node_id, agent_epoch, running: [{run_id, fencing_token}], free_gpus, health)
Preempt (run_id, fencing_token, grace_seconds)
ReportCompletion (run_id, fencing_token, manifest_uri) -> SUCCEEDED / STALE # accepted once
ReleaseGPU (reservation_id, fencing_token) -> RELEASED / ALREADY_RELEASED
ReserveGPUis a transaction against PostgreSQL, not a message to the node. Sending "reserve" over RPC provides no atomicity; the database does. Internal calls go over gRPC; every one of them carries the fencing token and is rejected when it is stale.
The best way to present this design is not to draw the final architecture immediately. Start with the simplest control plane that could work, add one component only when a real failure forces it, and keep every box's position stable. Narrate it in four phases, each in its own colour on the whiteboard: Phase 1 submit (black), Phase 2 schedule (blue), Phase 3 execute with results (green), Phase 4 fetch status (purple). Every phase changes a few rows, so the schema blocks next to the database carry example values and show exactly what each phase writes. Each phase below is an interactive walkthrough: it plays on its own as it comes into view, and you can pause, step with the arrows or keyboard, click any component for its role, switch scenarios, and expand it.
Walk the interviewer through the picture phase by phase:
Phase 1 submit User A -> API-GW -> job submission service -> PostgreSQL (Jobs: J1, PENDING)
-> initial queue entry in Redis by priority -> 201 {job_id}
Phase 2 schedule scheduler pulls by priority -> evaluates candidate workers (GPU count, memory, fairness / quota)
-> ONE transaction: JobRun r1 + Reservation R1 (lease, token 7) + NodeCapacity W1 8 -> 4
-> assign to W1 (r1, R1, token 7), idempotent per run_id
Phase 3 execute W1 confirms the start -> RUNNING; heartbeat every 10 s to the Node Registry;
logs / checkpoints / artifacts to Blob Storage; report completion -> SUCCEEDED,
R1 RELEASED, NodeCapacity W1 4 -> 8
Phase 4 track User A -> API-GW -> job status service -> PostgreSQL (status), Blob Storage (logs)
Preemption and retry (FR4) do not add a fifth phase: a retry is Phase 2 and Phase 3 again with a new attempt. They are covered at the end of this section and in DD3 and DD5.
Four components take part: the API gateway, the job submission service, PostgreSQL as the source of truth, and the Redis priority queues. The walkthrough plays on its own; pause, step with the arrows, click any component for its role, or switch to the retry scenario.
POST /v1/jobs -> validate -> INSERT Jobs(PENDING) [unique (team_id, idempotency_key)]
-> after commit: enqueue (job_id, priority, enqueued_at) into Redis
-> 201 { job_id, status: PENDING } # p95 well under 200 ms
DELETE /v1/jobs/{id} -> 202; cancel_requested = true
PENDING -> skipped by the scheduler -> CANCELLED
RUNNING -> agent SIGTERMs the container (same path as preemption)
Two design points worth narrating here:
First, PostgreSQL first, Redis second. The Jobs row is the source of truth; the queue entry is a hint written after the commit. If the enqueue fails, a sweeper re-enqueues PENDING rows; if Redis is lost, the queues are rebuilt from the table (DD6). The reverse order — enqueue, then persist — creates jobs that exist only in a cache.
Second, idempotent submission is not optional. A client retry after a timeout must return the same job_id, not a second job that burns 8 GPUs for 10 hours. Scope the key to the team and bind it to a payload hash; the same key with a different payload is a 409, never a silent reuse.
The leader scheduler turns a PENDING job into a reservation on one node. Redis orders, PostgreSQL decides: the only write that allocates GPUs is a conditional UPDATE inside one transaction, and it fails safely when a hint was stale.
loop (leader only):
1 quota check team under its guaranteed share? else within its borrow limit?
2 pick team DRF: the most under-served dominant share (GPU or memory)
3 pick job highest priority in that team, plus aging credits for long waits
4 pick node filter: gpu_type, free >= gpu_count, memory, health = HEALTHY
best fit: the node with the LEAST free GPUs that still fits
5 reserve BEGIN
UPDATE node_capacity SET available_gpus = available_gpus - 4, version = version + 1
WHERE node_id = 'W1' AND available_gpus >= 4; -- 0 rows -> abort, back to step 4
INSERT job_runs (r1, J1, attempt 1, W1, ASSIGNED)
INSERT reservations (R1, r1, W1, gpu_ids [4..7], RESERVED, lease now()+30s, token 7, epoch e)
UPDATE jobs SET status = 'SCHEDULED' WHERE job_id = 'J1' AND status = 'PENDING'
COMMIT
Redis orders, PostgreSQL decides. Popping by priority and age from a sorted set is cheap and good enough to choose what to try next. It must never decide who gets the GPUs: two schedulers reading the same cached counter would both "see four free". The conditional UPDATE with its row lock is the allocator; a stale cache only costs a retry (DD1).
A reservation is an ownership record, not a counter. It says which attempt holds which GPUs, until when, under which token. Its expiry does not free capacity by itself — a recovery step must first learn whether the node agent is still using those GPUs (DD2, DD3). Reserve gpu_ids, not just a count, when nodes mix GPU types or jobs pin devices.
Best fit, not emptiest node. Spreading small jobs across empty nodes feels balanced and quietly destroys the cluster's ability to place 8-GPU jobs. Placement is a bin-packing decision from the first day (DD7).
The node agent owns the container; the control plane owns the state transitions. Every call from the node carries token 7, and no row moves to RUNNING or SUCCEEDED without it.
1 Scheduler -> Agent(W1): AssignJob(r1, R1, token 7, image, command)
2 Agent: append to durable assignment log -> start container ONCE per run_id -> pin GPUs 4..7
3 Agent -> Control plane: ConfirmAssignment(R1, token 7)
Reservation RESERVED -> CONFIRMED, JobRun ASSIGNED -> RUNNING, Job -> RUNNING
4 Container -> Object Storage: logs, checkpoint every N minutes, artifacts under attempts/r1/
5 Agent -> Node Registry: heartbeat every 10 s { health, free_gpus, running: [(r1, token 7)] }
6 exit 0 -> Agent: ReportCompletion(r1, token 7, manifest_uri)
7 Completion Service: verify manifest + token -> one transaction: r1 SUCCEEDED, J1 SUCCEEDED,
R1 RELEASED, available_gpus += 4, result_uri = manifest
Accept is not start, and start is not confirm. The agent records the assignment durably before starting anything, so an agent restart replays it instead of forgetting it. Starting is idempotent per run_id — a second AssignJob(r1) answers "already running". Confirming is a third, separate step, and it is the one that can be lost without the work being lost (DD2).
The heartbeat carries the running attempts. Node health and free GPUs are not enough. Each heartbeat lists the run_ids and tokens actually executing on the node — that list is what makes reconciliation after a scheduler crash cheap and what lets the failure detector distinguish a dead node from a dead container.
Where the token is enforced. AssignJob, Heartbeat, Preempt, and ReportCompletion all carry the fencing token. The agent refuses an older token; the database accepts a transition only from the current attempt. A token stored in a row protects nothing by itself — it protects at the points where it is checked (DD3).
The read path is its own service and never talks to the scheduler. Status is a row read from PostgreSQL; logs and artifacts are served by URI from Blob Storage. The same four hops serve a job that is still running and one that finished an hour ago.
GET /v1/jobs/{id} -> job status service -> PostgreSQL: status, attempt, timestamps, result_uri
GET /v1/jobs/{id}/logs -> log_uri -> stream from the agent (running) or object in Blob Storage (finished)
Status and logs are different access patterns. Status is a cheap row read. Logs are a stream from the agent while running and an object afterwards — keep them out of the database, and serve them by URI.
The read path never touches the scheduler. The job status service reads the same Jobs and JobRuns rows the control plane writes, so a status call is one indexed read even while the scheduler is busy, and a burst of polling clients cannot slow down placement.
A. node failure 3 missed heartbeats (30 s) -> W1 SUSPECT (no new placements, no releases)
reconcile: agent reachable? r1 still in its running list? completion manifest?
r1 RUNNING -> repair the DB state; no new attempt
stopped / never ran -> release R1; retry if retries remain
unreachable -> quarantine W1's GPUs until fenced (DD3)
retry = JobRun attempt 2 (r2), Reservation R2 on W2, token 8, resume from last checkpoint
B. priority preemption critical J9 needs 8 GPUs on one node; W1 runs four LOW 2-GPU jobs
PREEMPTING (durable, GPUs stay reserved) -> SIGTERM + 30 s grace -> confirmed exit
-> release 0 -> 8 (one transaction, only now) -> reserve + assign J9 -> requeue victims
A retry is a new identity. New run_id, new reservation, higher fencing token; only job_id is shared. Reusing r1 would let a late report from the old node overwrite the state of the real attempt. The job-level fencing_epoch advances in the same transaction that creates the new attempt, so there is never a moment with two authoritative attempts.
Preemption is a protocol with one invariant: capacity is offered to a new reservation only after the previous containers have verifiably stopped. The full sequence, and what to do when a job ignores SIGTERM, is DD5.
Say what the platform does and does not promise. It guarantees graceful termination and a resume URI; it does not guarantee zero lost work. A SIGKILL after the grace period loses everything since the last checkpoint. Checkpoints belong to the workload — the platform's job is to make the loss bounded and the resume reliable.
The deep dives are the nine challenge questions from the requirements section. Each starts from the failure, shows why the naive design breaks with a concrete worked example, and derives the mechanism that fixes it — rather than jumping to a list of technologies.
| Deep Dive Map | ||
|---|---|---|
| Deep Dive | Question | NFR |
| DD1 | Two schedulers, four free GPUs — atomic reservation | NFR3 — Correctness |
| DD2 | Lost acknowledgment after the container started — reconcile, don't guess | |
Node W1 has eight GPUs, four in use. Scheduler S1 wants four for job J1; scheduler S2 wants four for J2. Both read the same free-GPU counter — in Redis, in a cache, even in a plain SELECT — and both see 4. Both decide 4 is enough. Both write. Eight GPUs have been promised on four physical devices, and the failure shows up hours later as two containers fighting over memory on the same cards. This is a check-then-act race, and no amount of "check more carefully" fixes it: the check and the claim must be the same operation.
BEGIN;
UPDATE node_capacity
SET available_gpus = available_gpus - 4, version = version + 1
WHERE node_id = 'W1' AND available_gpus >= 4; -- row lock, condition re-checked at commit
-- 0 rows updated -> ROLLBACK; pick another node
INSERT INTO reservations (reservation_id, run_id, node_id, gpu_count, gpu_ids, status, lease_expires_at, fencing_token)
VALUES ('R1', 'r1', 'W1', 4, '{4,5,6,7}', 'RESERVED', now() + interval '30 s', 7);
COMMIT;
One conditional update decides. PostgreSQL locks the row for S1's UPDATE; S2's UPDATE blocks; when S1 commits, S2 re-evaluates the WHERE clause against the new value, finds 0 >= 4 false, updates zero rows, and rolls back. A version-based compare-and-swap (WHERE version = 7) is the same property in different clothes. Either way, the reservation insert and the capacity change are in one transaction or neither happens.
Redis is a hint; PostgreSQL is the allocator. Cached free-GPU counts make candidate selection cheap and are allowed to be stale — a stale read costs a retry. Used as the allocator, they cost double allocation, and an 8-GPU job landing on four live devices fails in a way that looks like a hardware bug.
Reserve identities when they matter. A count is enough when every GPU on a node is interchangeable. With mixed GPU types or device pinning, reserve gpu_ids and keep the invariant per node: reserved + available = total. Any violation is a bug — surface it as an alert, not as a quietly corrected number.
what the transaction solves two HEALTHY schedulers cannot claim the same GPUs
what it does NOT solve an expired RESERVED row does not free the GPUs (DD2)
two leaders should not both be scheduling in the first place (DD6)
"The reservation and the capacity decrement happen in one transaction with a conditional update — available_gpus >= n — so the database, not the scheduler, decides who wins. Redis holds the priority queue and a cached view of capacity; it never allocates. Expiry of a reservation is handled by a separate reconciliation path, because the database cannot know whether the node is still using the GPUs."
Follow-up: "Why not a distributed lock per node?" — A lock serializes the schedulers but still relies on them to read, compute, and write correctly, and it needs its own lease and fencing. The conditional update puts the correctness check where the data is and costs one statement.
Scheduler A reserves four GPUs on W1 and commits. It sends the assignment. W1's node agent starts the container. Then A crashes — before the RUNNING acknowledgment is recorded. The database says RESERVED / ASSIGNED; the lease will expire in thirty seconds; a new leader takes over and sees an unconfirmed reservation. Release it and reschedule, and J1 runs twice. Leave it forever, and four GPUs are stuck. The acknowledgment was lost; the work was not.
Scheduler A: 1 reserve (txn) -> 2 AssignJob(r1, R1, token 7) -> [crash]
Agent W1: 3 append to assignment log -> start container r1 -> 4 confirm RUNNING ... lost
Scheduler B (new leader):
5 scan reservations in RESERVED past the confirm timeout
6 ask: last heartbeat from W1 lists r1? or query the agent directly
'r1 is RUNNING' -> write RUNNING + CONFIRMED (lost ACK != lost work)
'never saw r1' -> release R1, capacity += 4, retry (safe: start is keyed by run_id)
unreachable -> W1 SUSPECT, GPUs stay quarantined (lease expiry != stopped, DD3)
Reconcile, do not guess. The new leader's rule is ask before you release. The agent's heartbeat already carries its list of running attempts, so in the common case reconciliation is a read of the registry, not a round-trip to the node. Only the "unreachable" outcome is genuinely uncertain, and it resolves into quarantine rather than into a release.
Accept, start, and confirm are three different facts. The agent appends the assignment to a local durable log before starting, so an agent restart replays the assignment instead of forgetting it. Starting is idempotent per run_id: a replayed or duplicated AssignJob(r1) answers "already running". Confirming is the step that was lost — and because the other two facts are durable, it can be reconstructed.
Why "expire and reschedule" is the wrong default. If the new leader releases R1 at lease expiry and places J1 on W2, W1 is still training J1: two containers, double GPU spend, and two attempts racing to publish. Expiry is the trigger for reconciliation, never the conclusion.
"Durable reservations, idempotent assignment, heartbeat reconciliation, and fencing. The agent records the assignment durably and starts once per run ID. After a scheduler crash the new leader reads active reservations and reconciles each one with the agent's reported running set: confirmed means fix the state, absent means release and retry, unreachable means quarantine. The fencing token guarantees that whatever the old attempt does later cannot be accepted."
Follow-up: "Could you avoid this by confirming before starting?" — You can make the agent acknowledge the assignment before it starts, but that only moves the race: the acknowledgment of the start can still be lost. Make every step replayable instead of trying to make one of them atomic across two machines.
W1 stops heartbeating. Nothing crashed — a switch rebooted, and W1 is partitioned from the control plane while its GPUs keep training J1. After three missed heartbeats the failure detector marks W1 SUSPECT. If the scheduler now places J1 on W2, two containers run the same job. If it waits for W1 forever, J1 never finishes. And if it does reschedule, W1 will eventually reconnect and report "J1 succeeded" — with results computed by an attempt the platform has already given up on.
T+30 s failure detector: W1 SUSPECT -> no new placements on W1, no releases of W1's GPUs
scheduler (txn): job epoch 1 -> 2, JobRun r2, Reservation R2 on W2, token 8
AssignJob(r2, R2, token 8) -> W2
commit rule (control plane): accept a transition only if token == current token AND run not terminal
W2: heartbeat / complete with token 8 -> accepted
W1: heartbeat / complete with token 7 -> rejected, even if it arrives first
W1's GPUs: reserved 4 / available 4 until the agent reports or an operator fences the node
Fence the attempt; quarantine the GPUs. Advancing the job's fencing epoch in the same transaction that creates the new attempt revokes the old attempt's authority everywhere the token is checked: confirm, heartbeat, preempt, complete. W1 can keep computing and can even write temporary files under its attempt path, but it cannot change the job's state or publish a result. Meanwhile W1's capacity row is untouched — releasing those four GPUs would make DD1's invariant lie.
Object storage does not check tokens. Fencing protects controlled writes. A bucket will happily accept W1's files, which is why publication goes through a commit service that verifies the token and why artifacts live under attempt-scoped paths so r1 and r2 cannot overwrite each other (DD8).
Name the guarantee you are giving. Fencing provides a single accepted result. It does not provide a single active execution. If the workload is not safe to run twice — external side effects, a licensed resource — then before starting r2 you must confirm W1 stopped or fence it at the infrastructure level: power it off, isolate it on the network, or rely on an agent that kills any container whose epoch is stale the moment it reconnects. That costs availability; say so.
lease expired -> the control plane lost contact (paused VM, GC pause, partition all look identical)
treat as "reconcile now", never as "free now"
"After three missed heartbeats I mark W1 suspect and stop placing on it, but I do not assume its job stopped. I revoke the old attempt by advancing the job's fencing epoch transactionally and start a new attempt with a new run ID and reservation. The commit path accepts only the current epoch, so the stale attempt can write temporary artifacts but cannot publish. If the product needs strict single execution, I must fence W1 at the infrastructure level before starting the new attempt — otherwise I can guarantee one accepted result, not that two containers never overlap."
Follow-up: "Why a new run ID instead of reusing r1 on W2?" — Because W1 may still be alive and reporting as r1. Two live actors sharing an identity cannot be told apart by any check; a new identity plus a higher token can.
Team A has a 4,000-GPU quota and is using all 4,000. Team B has 2,000 and is using 500. The rest of the cluster is busy. A submits 100 critical jobs; B submits 100 normal jobs. The tempting answer — "critical wins, and B is not using its share anyway" — lets one team monopolize the cluster by labelling everything critical, and quietly converts B's guarantee into a loan B cannot call back. A second version of the same mistake shows up as starvation: critical jobs consume 90% of the cluster, normal jobs have waited six hours, low-priority jobs twenty.
guaranteed shares first: B is under its guarantee -> the next free GPUs go to B's NORMAL jobs
A is at its guarantee -> A's CRITICAL jobs use BORROWED idle capacity only,
reclaimable when the owner needs it back
hierarchy: 1 guaranteed shares -> 2 DRF across teams -> 3 priority + aging within a team
-> 4 best-fit placement -> 5 atomic reserve
Priority lives inside a share; fairness lives between shares. A critical label says "schedule this before my team's other work". It does not say "take another team's guaranteed GPUs". Unused guaranteed capacity should be lent out — leaving 1,500 GPUs idle to protect a quota is the wrong kind of fairness — but a loan is reclaimable, by natural completion or by preemption, when the owner returns.
Pick the team with Dominant Resource Fairness, then the job by priority. DRF compares each team's dominant share (GPUs for most jobs, memory for some) against its allocation and serves the most under-served team next. Within that team, the highest-priority job goes first. Weighted random selection is not a fairness guarantee; weighted fair queuing or deficit round robin is.
Starvation needs three fixes, and preemption is not one of them. Reserve a critical share sized from its latency SLO, guarantee minimum shares for normal and low so they always make progress, and add aging: scheduling credits that grow with wait time, within each tier's rules. Charge in GPU-hours, not job count, so an 8-GPU job consumes eight times the credit of a 1-GPU job for the same slot. Then tune from wait-time p95/p99 per tier — never from a fixed 70/20/10 split. And never preempt a running critical job to run a low one: policy only allows higher to preempt strictly lower.
quota how much is each team GUARANTEED?
fairness who gets the NEXT free GPUs? (DRF / weighted fair queuing)
priority which of MY jobs runs first? (+ aging)
preemption may RUNNING work be displaced? (DD5 — a separate policy)
"I would prioritize guaranteed capacity before global priority. Team B is below its quota, so B's normal jobs receive the next GPUs; A's critical jobs can use borrowed capacity if any exists, but cannot displace B's guaranteed share unless the policy defines an explicit emergency override. Against starvation I combine SLO-sized shares, aging credits, and deficit scheduling measured in GPU resources, and I monitor queue wait p95 and p99 by tier. Fairness decides who receives newly freed resources; preemption decides whether running work can be displaced. They are separate policies."
Follow-up: "Does fairness cost utilization?" — A little, when it holds capacity for a team that is about to return. Borrowing with reclaim, and dynamic rather than static headroom, keep that cost small; measure both per-team wait time and cluster allocation.
The cluster is full. Critical job J9 needs eight GPUs on one node. W1 runs four low-priority 2-GPU jobs, each hours into training and checkpointing periodically. The naive protocol — mark the four jobs paused, set W1's available GPUs from 0 to 8, assign J9 — has a correctness bug in the middle: the GPUs are offered to J9 while four containers are still using them. Worse, one of the four may simply ignore the termination signal.
1 select victims four LOWER-priority, preemptible jobs on W1 freeing exactly 8 GPUs
(weigh checkpoint age, progress, team quota — the cheapest set, not the first four)
2 PREEMPTING JobRun RUNNING -> PREEMPTING, durable; reservations stay CONFIRMED; capacity untouched
3 SIGTERM + grace agent signals each container; the workloads checkpoint to object storage (30 s)
4 confirm exit agent verifies the container exited AND its GPU processes are gone
job 4 ignores SIGTERM -> SIGKILL via the container runtime -> verify again
5 release one transaction: reservations RELEASED, JobRuns PREEMPTED, available 0 -> 8 (only now)
6 reserve + assign J9 new reservation, new token, AssignJob, agent confirms -> RUNNING
7 requeue victims PREEMPTED -> PENDING as new attempts, resuming from the last durable checkpoint
The one invariant: release after stop. Capacity becomes available to a new reservation only when the previous containers have verifiably stopped. Releasing at step 2 — "they will stop in thirty seconds" — is the double-allocation bug in a different costume. Confirmation is physical: the cgroup is empty and no process holds the devices, not merely "the container runtime returned".
PREEMPTING is a real state, not a comment. It tells the failure detector that a missed heartbeat from these runs is expected, tells the status API the job did not fail, and tells a scheduler that fails over mid-preemption (DD6) which reservations are in the middle of a release.
When termination cannot be confirmed, do not place. If SIGKILL does not produce a verified exit — a wedged driver, an unreachable agent — W1 is quarantined and J9 goes elsewhere. The critical job waits a little longer; the alternative is sharing GPUs with a zombie.
Count the cost. Four jobs lose up to one checkpoint interval each and requeue behind other work; job 4, killed without a final checkpoint, loses more. The critical job waits the grace period — which is why the 5-second start SLO is defined when capacity is immediately available and excludes preemption waits.
"Select the minimum-cost set of preemptible lower-priority jobs that frees a whole node. Mark them PREEMPTING durably while their GPUs stay reserved. Send SIGTERM and allow the grace period for checkpointing; if a container does not exit, SIGKILL through the runtime and verify the processes are gone. Only then release the reservations and make the GPUs available in one transaction, reserve for the critical job, and requeue the victims as new attempts from their last checkpoint. Never make capacity available while the previous containers still hold it."
Follow-up: "Why not pause the containers instead of killing them?" — Pausing keeps GPU memory allocated, so it frees nothing. Checkpoint-and-stop is the only way to actually hand the devices to another job.
Three schedulers share PostgreSQL and Redis. The active one crashes mid-assignment; another must take over, pending jobs must survive, in-flight reservations must be recovered, and two schedulers must never make conflicting decisions. Then the harder case: leader S1 loses connectivity to the election service but can still reach PostgreSQL and the node agents. Its lease expires; S2 becomes leader; S1 does not know. Both believe they may schedule. A round-robin pick or "the healthiest replica wins" does not produce a single leader, and checking the lease before a transaction does not stop a leader that is paused or partitioned from acting after it.
election PostgreSQL advisory lock or etcd lease; the coordinator issues a monotonic leader_epoch
S1 = 101 -> lease expires -> S2 acquires, epoch 102
every scheduling write carries the epoch and is validated INSIDE the transaction:
INSERT INTO reservations (..., leader_epoch = :e) WHERE :e = (SELECT epoch FROM leader FOR SHARE);
S1 (101): 0 rows -> rejected S2 (102): commits
node agents also reject AssignJob with an epoch below the highest they have seen
takeover 1 acquire lease + epoch 2 rebuild Redis queues from PENDING rows
3 scan RESERVED / PREEMPTING reservations 4 reconcile with agent heartbeats (DD2)
5 retry incomplete assignments idempotently (expire reservations only after reconciliation)
A single leader, fenced by an epoch at the commit point. Election gives you one leader most of the time; the epoch check inside the transaction is what makes the exception harmless. S1 may be paused, partitioned, or just slow, and may keep executing code written before it noticed — the only thing that stops its writes is a check that happens inside the write, against authoritative leadership state. A cached "am I leader?" boolean read before the transaction still permits the race.
Agents validate too, but the database is the backstop. Node agents reject assignments with an older epoch, which stops most stale commands early. But an agent that has not yet heard of epoch 102 could accept a command from 101; that is why the reservation itself must fail in the database, and why the agent's epoch knowledge is a fast path, not the guarantee.
Recovery is reconciliation, not replay. Pending jobs are not lost because they live in PostgreSQL; the Redis queues are rebuilt from PENDING rows. In-flight reservations are not blindly reassigned; each is reconciled with the agent's reported running set (DD2). An outbox owned by one scheduler is the wrong shape — shared durable state is what the next leader reads.
Two leases, two purposes. The leadership lease says who may make scheduling decisions. The reservation lease says which attempt holds which GPUs. Different lifetimes, different owners, different recovery paths; reusing one as the other conflates a scheduling failure with an execution failure.
"A leader is elected through a lease with a monotonically increasing epoch. Every scheduling transaction carries its epoch, and PostgreSQL verifies the epoch is current inside the same transaction, so a stale leader cannot commit a reservation even though it can still reach the database. Node agents reject stale epochs as a fast path. On takeover the new leader rebuilds the queues from durable job state, scans active reservations, reconciles each with agent heartbeats, and retries incomplete assignments idempotently. Lease expiration alone is not enough because the old leader may act after its lease has expired."
Follow-up: "Can the followers do useful work?" — They can serve reads, run the failure detector, and pre-compute placement candidates, but only the epoch holder commits reservations. Sharding the leader by GPU type or rack is the scaling path, each shard with its own epoch.
A thousand healthy nodes, 35% of GPUs free, scheduler CPU at 20%, and three thousand jobs waiting. "Add scheduler replicas" is the reflex, and it changes nothing: most queued jobs want eight GPUs on one node, and almost every node has one to four free. Eight free GPUs spread over three nodes fit zero 8-GPU jobs. The cluster is not short of capacity or of scheduling throughput — it is fragmented, and the metric that would show it (free GPUs per node against queued job sizes) is not on the dashboard.
Node A 5 used / 3 free
Node B 5 used / 3 free free total = 8 8-GPU jobs that fit = 0 utilization "looks" 67%
Node C 6 used / 2 free
best fit: place a small job on the node with the LEAST free GPUs that still fits -> 8 / 8 / 0 used
same 16 GPUs in use, one whole node free, the 8-GPU job starts
Separate the causes before changing anything:
| Low Utilization With a Backlog | ||
|---|---|---|
| Cause | Metrics to check | Remedy |
| Unhealthy nodes | healthy count, GPU inventory, heartbeat failures, drained nodes | repair or quarantine; drained nodes are not capacity |
| Fragmentation | free-GPUs-per-node histogram against queued job sizes | best-fit packing; demand-driven whole-node headroom; controlled defragmentation |
| Scheduler throughput | queue age p95/p99, placements per second, reservation conflicts, CPU | only then optimize or add replicas |
Prevention: best fit, not spreading. Placing each small job on the node with the least free GPUs that still fits keeps whole nodes free for large jobs. Spreading — the emptiest node first — feels balanced and is exactly how a cluster ends up with a thousand nodes that each have two free GPUs. Add demand-driven headroom for large jobs when the queue shows them coming, rather than twenty nodes kept permanently idle (160 GPUs of utilization paid every hour).
Recovery: defragment with controlled preemption. With 100 nodes — 80 running 4-GPU jobs, 20 full, 50 critical 8-GPU jobs waiting — 320 GPUs are free and nothing can start. Freeing a whole node means preempting or migrating the small jobs on it (DD5): choose the minimum-cost set by priority, checkpoint age, progress, and team quota; consolidate first where two half-nodes can become one full node; and quantify the displacement — fifty half-full nodes freed means 200 GPUs of running work requeued.
Prefer tail metrics. Average queue wait hides the eight-GPU jobs that have waited a day behind thousands of one-GPU jobs that started instantly. Track queue age p95/p99 per job size and per tier, and the free-GPU histogram, before anything else.
"35% free does not mean 35% usable. I would split the backlog into capacity shortage, fragmentation, scheduler throughput, and unschedulable requests using free-GPUs-per-node histograms and p95/p99 queue age by job size and tier. Here most nodes have one to four free GPUs and the queue is 8-GPU jobs, so it is fragmentation: pack small jobs with best fit, defragment with controlled preemption chosen by cost, and keep demand-driven whole-node headroom. Scaling the scheduler would do nothing."
Follow-up: "How do you balance packing against fairness when one team runs 8-GPU jobs and another runs 1-GPU jobs?" — Packing decides where a job goes; fairness decides whose job goes next. Keep them separate: the fair scheduler picks the job, the placer picks the node, and headroom for large jobs is charged to the share of the team that needs it.
A training job runs for three hours, finishes, uploads its outputs to object storage, and the node crashes a moment before ReportCompletion goes out. PostgreSQL still says RUNNING. The failure detector fires; the lease expires; the scheduler considers a retry. Re-run it and three GPU-hours are wasted. Trust "the artifacts are there" and you may bless a half-uploaded result — a crash during upload leaves real files behind too. The RUNNING flag is consistent with success, partial output, and a crash at 10%; it cannot decide.
1 upload artifacts jobs/J1/attempts/r1/part-000.bin, part-001.bin, ...
2 write MANIFEST last jobs/J1/attempts/r1/MANIFEST.json { uris, sha256, token 7, completed_at }
3 ReportCompletion (r1, token 7, manifest_uri) <- lost: the node crashed here
4 commit once Completion Service verifies every URI exists and every checksum matches,
checks token 7 == current, then ONE transaction:
r1 SUCCEEDED, J1 SUCCEEDED, R1 RELEASED, capacity += 4, result_uri = manifest
reconciler (after lease expiry on W1):
already committed? -> nothing to do; a late duplicate report is ignored
valid MANIFEST for r1? -> finalize r1 as SUCCEEDED through the same commit path — no re-execution
parts but no manifest? -> retry as attempt r2 (token 8); r1's partial output is garbage-collected
Attempt-scoped artifacts plus an idempotent completion protocol. The manifest is written last, after every part has landed, and carries checksums — so its existence is the proof of completeness. The protocol makes the proof; a directory listing does not. Attempt-scoped paths keep r1 and r2 from overwriting each other when a DD3 overlap happens, and the job-level result_uri points only at the accepted manifest.
Commit is conditional and fenced. The completion service accepts a report only from the currently authorized attempt and only if the job is not already terminal. If r2 is already running under token 8 when r1's late manifest appears, the commit rejects it — or, if the policy prefers, adopts r1's verified result and cancels r2. Either is defensible; choose explicitly.
Say what is exactly-once. Accepted completion is exactly-once: one terminal state per job, written by one conditional transaction. Execution is not, and cannot be promised for arbitrary user code. Attempts are at-least-once; results are accepted once.
"Attempt-scoped artifacts and an idempotent completion protocol. The run uploads under its own attempt path, writes a manifest with URIs and checksums only after every upload succeeds, and reports completion with its fencing token. The completion service verifies the manifest and the token and atomically marks the run and job succeeded. On a crash, recovery looks for a committed result or a valid manifest before retrying; if either exists it finalizes instead of re-executing. That gives exactly-once accepted completion, not exactly-once execution."
Follow-up: "What if the manifest itself is corrupted or partially written?" — Write it atomically — a single small object, put after the parts — and validate its own checksum. A manifest that fails validation is treated as absent: retry.
Production traces for a high-priority start read: API 100 ms, queue wait 500 ms, placement 200 ms, reservation 100 ms, image pull 8,000 ms, container initialization 2,000 ms — 10.9 seconds against a 5-second objective. Everything the scheduler does adds up to 800 ms. The SLO is being lost in the data plane, after the scheduling decision, and no amount of scheduler tuning will find it.
measured 100 + 500 + 200 + 100 + 8,000 + 2,000 = 10,900 ms SLO: 5,000 ms
redesigned 100 + 50 + 200 + 100 + 300 + 1,500 ≈ 2,250 ms
1 image-aware placement prefer nodes that already hold the image layers (a term in the best-fit score)
2 node-local cache + pre-pull top-N images per GPU type pre-pulled on every node; a per-rack registry mirror
3 warm pool for critical a few pre-initialized sandboxes per node type; idle capacity bought for latency
4 event-driven scheduling wake on enqueue and on release instead of a 500 ms poll
5 lazy / streaming layers start before the full image is local; keep base images small
Fix the two big terms. Image pull and container initialization are ten of the eleven seconds. Image-aware placement and a node-local cache turn the pull into a few hundred milliseconds for the common images; a warm pool for the critical tier removes most of the initialization. The 500 ms queue wait is a polling interval and disappears when the scheduler is woken by events.
Warm capacity is a utilization trade. Every prewarmed slot is a GPU not running work. Size it per tier from measured arrival rates, reclaim it under pressure, and treat it with the same headroom reasoning as DD7 — applied to latency instead of to large-job placement.
Say what the SLO excludes. "Five seconds when capacity is immediately available" does not cover waiting for a preemption grace period or a cold image on a brand-new node type. Tag start-latency measurements with those conditions so the p95 you report is the one the SLO describes.
"Decompose the start path first. Scheduling is 800 ms; the miss is image pull and initialization. I would add image locality to placement, pre-pull hot images per GPU type with a rack-local mirror, keep a small warm pool for the critical tier sized from demand, and make the scheduler event-driven. The trade-off is idle capacity for warm slots, which I would bound and measure like any other headroom."
Follow-up: "Why not just keep every image on every node?" — Hundreds of multi-gigabyte images times a thousand nodes is a storage and churn problem of its own. Cache by measured popularity per node type and let placement prefer locality.
Each of these reveals a different misunderstanding of the problem. Avoiding them is not about memorizing the "correct architecture" — it is about showing where distributed-system guarantees stop and explicit protocols must begin.
A cached free-GPU count tells the scheduler where to look. The moment it decides who gets the GPUs, two schedulers can both see four free and both win. Allocation is one conditional transaction in the source of truth; the cache is a hint that may be stale.
A paused VM, a GC pause, and a partition look identical to a crash from the control plane. Expiry means "reconcile now": ask the agent, read its running set, look for a manifest. Releasing capacity on a timer is how two containers end up on the same devices.
If W1 is alive and reporting as r1, and W2 is also r1, no check can tell them apart. A retry is a new run ID, a new reservation, and a higher fencing token; only the job ID is shared.
"Mark them paused and set available to eight" offers capacity that four containers still hold. Release only after the agent confirms the exit — cgroup empty, no GPU processes — and after SIGKILL when SIGTERM is ignored.
Priority lives inside a team's share; fairness lives between shares. Critical jobs over quota use borrowed, reclaimable capacity; they do not take a guarantee that belongs to someone else.
A 70/20/10 lottery has no long-run guarantee and no answer for GPU-hours versus job counts. Use DRF across teams, deficit or weighted fair queuing within tiers, and aging credits for long waits — tuned from wait-time p95/p99.
Fairness decides who receives newly freed capacity; preemption decides whether running work may be displaced, and policy allows only higher to preempt strictly lower. Cure starvation with minimum shares and aging, never by killing the top tier.
An expired leadership lease does not stop an old leader that is paused or partitioned. Validate a monotonic epoch inside every scheduling transaction; a cached "am I leader?" check before the write still races.
A crash during upload leaves real files behind. Completion is a manifest written last with checksums, verified and committed once by the current attempt. The RUNNING flag is consistent with success, partial output, and a crash at 10%.
A single leader places jobs a hundred times faster than the cluster frees them. Low utilization with a backlog is usually fragmentation, unhealthy nodes, or unschedulable requests — prove which with a free-GPUs-per-node histogram before touching the scheduler.
The platform runs arbitrary containers; it cannot make them idempotent. It promises at-least-once attempts, one accepted result per job, bounded loss through checkpoints, and graceful termination. Say that precisely, and the interviewer hears a Staff engineer.
The shape of a strong answer: clear the contradictions in the prompt, separate the logical job from its attempts, size the cluster and notice that scheduling throughput is not the constraint, then start from the simplest control plane and let each failure force exactly one mechanism into the design — a conditional transaction for capacity, a node agent that starts once and confirms separately, reconciliation before release, fencing tokens on every commit point, a hierarchical scheduler that keeps quota, fairness, priority, and preemption apart, a release-after-stop preemption protocol, a leader epoch validated inside the transaction, best-fit placement, and a manifest-based completion. The FR→flow mapping keeps the HLD legible; the nine deep dives are the same nine questions you raised at the start, now answered.
Capacity is a transaction; a lease is a question
Reserve GPUs with one conditional update in the source of truth and keep the per-node invariant honest. When a lease expires, reconcile — ask the agent, read the heartbeat, look for a manifest — and fence the old attempt with a higher token before you start a new one. Release capacity only when the previous container has verifiably stopped.
Four policies, not one knob
Quota says what each team is guaranteed; fairness says who gets the next free GPUs; priority says which of a team's jobs runs first; preemption says whether running work may be displaced. Keep them separate, let idle guarantees be borrowed and reclaimed, add aging against starvation, and never let a critical label cross a share.
Say the guarantee precisely
At-least-once attempts, one accepted result per job, exactly-once accepted completion through a verified manifest and a conditional commit — never exactly-once execution of a user container. Single accepted result is what fencing gives you; single active execution needs an infrastructure fence, and that is a product decision to state out loud.
Common failure modes to avoid: letting a counter allocate, treating expiry as death, reusing identities across attempts, releasing before stop, and collapsing four scheduling policies into "critical wins".
These aren't toy problems — they reflect real infrastructure challenges at companies running shared GPU fleets for many teams. If you want to walk through this design in a mock interview or get feedback on your own approach, book a session with us. We're here to help you sharpen your thinking, build your confidence, and land your next offer.
| Execution semantics? |
| At-least-once attempts; duplicate execution must be handled safely |
| Job vs JobRun, fencing tokens, idempotent completion (DD3, DD8) |
| Priority and preemption? | Four tiers: critical, high, normal, low; higher may preempt strictly lower | Preemption is a policy with a protocol (DD5), never a reflex |
| Who checkpoints? | The workload; the platform provides graceful termination | The platform promises SIGTERM plus a grace period, not zero lost work |
| Reliability? | Scheduler recovers after crashes; node failure detected within ~30 s | Leader failover with fencing (DD6); reconciliation before release (DD2) |
| Fairness? | Per-team GPU quotas and fairness across teams are required | Quota, fairness, priority, and preemption are four separate policies (DD4) |
| Backlog drain | 10,000 queued jobs at 50–100 placements / s → a few minutes |
| Cost of one placement | one conditional DB transaction, ~5–10 ms → a single leader does 100+ / s serially |
| Capacity and state transitions need transactions; queues are rebuildable |
| Conditional UPDATE of per-node capacity in the same transaction as the reservation | Exactly one scheduler wins the same GPUs; the invariant never lies |
| Node agent: durable assignment log, start once per run_id, confirm, heartbeat | A lost acknowledgment is recoverable by asking, not by guessing |
| Reconcile before release; fencing token on reserve, assign, heartbeat, complete | Lease expiry becomes a question; stale attempts cannot publish |
| Hierarchical scheduling: guaranteed shares → DRF across teams → priority + aging within team → best fit | Quota, fairness, priority, and placement stay separate policies |
| Preemption protocol: PREEMPTING → SIGTERM + grace → confirmed exit → release → reserve | Capacity is never offered while a previous container still holds it |
| Single leader with a monotonic epoch validated inside every scheduling transaction | An old leader that keeps running cannot commit anything |
| Attempt-scoped artifact paths + completion manifest + conditional commit | Exactly-once accepted completion without re-running three hours of training |
image_urigpu_countgpu_typememory_mb| Resource request; also the compatibility filter for placement |
priority | ENUM | CRITICAL / HIGH / NORMAL / LOW — within a team's share, not across it |
idempotency_key | STRING | Unique per team; a client retry returns the same job_id |
status | ENUM | PENDING → SCHEDULED → RUNNING → SUCCEEDED / FAILED / PREEMPTED / CANCELLED |
max_retries, attempt_count, fencing_epoch | INT | Retry budget and the current authoritative attempt |
result_uri | STRING | Points at the accepted completion manifest (DD8) |
node_id, reservation_id |
| Where it runs and which GPUs it owns |
fencing_token | INT | Monotonic per job; carried on every agent call (DD3) |
status | ENUM | ASSIGNED → RUNNING → SUCCEEDED / FAILED / PREEMPTING → PREEMPTED |
started_at, finished_at, error_code | Timing and outcome |
log_uri, artifact_uri | STRING | Attempt-scoped paths in object storage |
INT, INT[] |
| Identities matter when types or device pinning matter |
status | ENUM | RESERVED → CONFIRMED → RELEASED; EXPIRED only after reconciliation |
lease_expires_at | TIMESTAMP | Extended by heartbeats; expiry means reconcile, not free |
fencing_token, leader_epoch | INT | Which attempt, placed by which leader (DD6) |
reserved + available = totalgpu_type, total_memory | Placement filters |
health_status | ENUM | HEALTHY / SUSPECT / DRAINING / DEAD — SUSPECT means no new placements and no releases |
last_heartbeat_at, agent_epoch | Failure detection; agent restarts bump the epoch |
| NFR3, NFR4 — Correctness, Recovery |
| DD3 | Silent but alive — partitions, fencing, and what a lease proves | NFR3 — Correctness |
| DD4 | Quota before priority; fairness and aging against starvation | NFR5 — Fairness |
| DD5 | Preempting four jobs for one — the release-after-stop protocol | NFR3, NFR5 — Correctness, Priority |
| DD6 | Scheduler failover and split brain — leader epochs | NFR4 — Availability |
| DD7 | 65% utilization with 3,000 jobs waiting — fragmentation and bin packing | NFR1 — Utilization |
| DD8 | Finished, uploaded, then the node died — idempotent completion | NFR3 — Correctness |
| DD9 | A 5-second start SLO against an 8-second image pull | NFR2 — Latency |
| GPU type or memory mismatch, exhausted quotas, blocked tiers |
| quota borrowing, right-sized requests, better error surfacing |
| Allocated but idle | reserved GPUs versus actual compute and memory utilization | workload efficiency, not scheduling |