The question this answers
The worker stopped responding. Do I re-run its task?
Every task is executed at least once and its result committed at most once, *provided* the commit is atomic and exclusive. Without such a commit, the guarantee is at-least-once execution with at-least-once side effects — the scheduler cannot offer better, because it cannot distinguish a dead worker from a slow one.
Everything below is bought to hold this sentence. "Strongly consistent" with no scope attached is a slogan, not a guarantee — read what it actually covers, and what it explicitly does not.
A scheduler knows the last time each worker reported, and the last state each task reported. It does not know whether a silent worker is dead, partitioned, garbage-collecting, or simply slow — and the task it stopped reporting on may be finished, half-finished, or about to write its output. Every re-execution decision is made on this incomplete picture, and there is no way to complete it.
A node knows its own state and the messages that arrived. Everything else is inference from evidence that was already stale. "B has not replied in five seconds" is knowledge; "B is down" is a decision — and usually the bug.
Four jobs, and only one of them is placement
A scheduler does four separable things, and conflating them makes both design and debugging harder. Placement: which worker runs this task, subject to resource requirements, locality preferences and constraints. Admission and capacity: how much work is allowed in at once, and what happens to the rest — which is Decide at the Door Whether the Capacity Exists and Backpressure Is a Signal That Has to Travel — and Reach Someone Who Can Slow Down applied to jobs rather than requests. Liveness tracking: deciding which workers and tasks are still alive. Re-execution: acting on that decision.
The first two are optimisation problems, and getting them wrong costs efficiency. The second two are correctness problems, and getting them wrong costs data. That asymmetry deserves to be visible in how you think about a scheduler: a mediocre packing algorithm wastes money, while a mistaken liveness decision duplicates a payment.
Note also that a scheduler is a coordination point in the sense this domain means. Its availability bounds the job’s ability to start new work; its view of the world is the only global view; and if it holds job state only in memory, its restart is a job-wide event. Whether it holds that state durably is one of the more consequential facts about any scheduler you depend on.
The duplicate-execution problem, which is not optional
A worker stops heartbeating. The scheduler waits its threshold and then re-runs the task elsewhere. But the worker may be entirely fine — a long garbage-collection pause, a saturated network interface, a partition between it and the scheduler while its connection to the database is perfectly healthy. The original task keeps running. Now two attempts are executing the same work at the same time, and neither knows about the other.
This is Crashed or Just Slow: The Distinction You Cannot Make with a compute framework’s consequences, and it is not avoidable by tuning. Raise the threshold and genuinely dead workers stall the job for longer. Lower it and you duplicate more often. There is no setting that distinguishes the two cases, because no such setting can exist: the information required is not available to the scheduler. The correct response is not to fix the detector but to make the duplicate harmless, exactly as A Timeout Tells You Nothing About Whether It Happened concluded.
Frameworks make it harmless with an exclusive commit: each attempt writes to a private location, and the first to finish atomically claims the destination. The loser discards its work. This is why a task must not have external side effects — the commit convention controls the framework’s own output and nothing else. A task that inserts rows, calls an API, or sends a message has performed that effect regardless of who wins the commit race.
The second thing you need is fencing. If the task holds a lease or a lock, the re-executed attempt must be able to invalidate the original rather than merely coexist with it. Fencing Tokens: Making the Stale Actor Safe, Not Just Unlikely is the mechanism: the new attempt carries a higher token, and the downstream system rejects writes carrying an older one. Without fencing, a scheduler that presumes death is arming a second writer against a first one that is still alive.
Placement is a constraint problem with a stale view
Placement takes a task’s requirements — cores, memory, an accelerator, a locality preference, an anti-affinity rule that keeps replicas apart — and finds a worker that satisfies them. It is a bin-packing problem, it is NP-hard in general, and every real scheduler uses heuristics: first fit, best fit, scoring functions, a queue per priority class.
What makes it a *distributed* problem rather than an algorithms exercise is that the scheduler’s view of capacity is stale by construction. Reported free memory was true when it was reported. A worker may have started something since. Two schedulers, or two scheduling loops, may both place a task into the same free slot. Systems handle this with optimistic placement plus rejection: the worker refuses what it cannot run, and the task returns to the queue. That is optimistic placement in the ordinary sense — assume it will fit, detect when it does not, rather than trying to hold an accurate global view.
The failure to watch for is resource fragmentation: enough total capacity exists but no single worker has enough contiguous free resources, so a large task waits indefinitely while the cluster reports plenty of headroom. Operators see a cluster at 70% utilisation with tasks queued, which reads as a scheduler bug and is actually a packing consequence. The related trap is over-requesting: tasks that reserve far more than they use make the cluster look full while real utilisation is low, which the Cloud domain covers as requests versus limits.
| Knob | Raise it | Lower it |
|---|---|---|
| Heartbeat / liveness thresholdprotocol | Fewer false deaths, slower recovery from real ones | Faster recovery, more duplicate execution |
| Max retries per tasktypical | Survives flaky workers, hides a persistently broken one | Fails fast, gives up on transient problems |
| Locality waittypical | More local reads, more idle capacity | Better utilisation, more network traffic |
| Task size / partition counttypical | More tasks: finer balancing, more scheduling and shuffle overhead | Fewer tasks: less overhead, coarser balancing, worse stragglers |
| Concurrency limit per jobassumption | Faster job, more contention with neighbours | Predictable neighbours, longer job |
Retries, blacklists and the failure that hides
Retry policy is where a scheduler either contains a failure or amplifies it. A task that fails is re-run; if it fails again it is re-run again, up to a limit. Two things must be true for this to be safe. The retries must be bounded, or a deterministically failing task consumes the cluster forever. And the retries must be attributed, or a single bad machine silently absorbs the whole job.
That second point is the one teams miss. A worker with a failing disk, a corrupt local cache or a misconfigured mount fails every task it is given. The scheduler dutifully re-runs each one elsewhere, where they succeed. The job completes. Nothing is reported as broken — and the job took twice as long, every night, for a month. The fix is per-worker failure attribution and blacklisting: count failures by worker, and stop assigning to one that stands out. Without it, the scheduler’s helpfulness is precisely what hides the fault.
The mirror-image mistake is retrying what cannot succeed. A task that fails because its input is malformed will fail identically on every worker. Retrying it three times costs three times the work and produces the same outcome. Distinguishing *retryable* from *terminal* failures — usually by exception class, not by heuristic — is what keeps a retry policy from becoming a work amplifier, the same reasoning Cap Retries as a Fraction of Traffic, Not as a Count per Request applies to request traffic.
- Bound retries per task, or one poison task consumes the cluster.
- Attribute failures per worker and blacklist outliers, or one bad machine hides inside successful retries.
- Separate retryable failures from terminal ones; retrying a malformed input just costs more.
- Track total retry work as a fraction of useful work — it is the metric that surfaces a silent, expensive problem.
- A scheduler that holds job state only in memory turns its own restart into a job-wide failure.
Key points
- A scheduler does four things: placement, capacity, liveness tracking, and re-execution. The last two are correctness, not efficiency.
- It cannot distinguish a dead worker from a slow one, so duplicate execution is a permanent possibility, not a bug to fix.
- Safety comes from an exclusive commit plus fencing — making the duplicate harmless rather than preventing it.
- A task with external side effects escapes the commit convention entirely and becomes at-least-once in the real world.
- Placement operates on a stale view of capacity; optimistic placement with worker-side rejection is the standard answer.
- Unattributed retries hide a single bad machine inside a job that succeeds and merely takes twice as long.
The chain, answered
Every field here is required, which is why no lesson in this domain can recommend a design without naming what an operator sees when it fails, what survives the partition, what repairs it afterwards, and the simpler thing to consider first.
- • Tasks enter a queue with resource requirements, constraints and locality preferences.
- • The scheduler matches tasks to workers using a heuristic over its most recent view of capacity.
- • A worker accepts or rejects the assignment; a rejection returns the task to the queue.
- • The worker executes the task and heartbeats progress to the scheduler.
- • If heartbeats stop for longer than the liveness threshold, the scheduler presumes failure and requeues the task.
- • Any attempt that completes tries to commit atomically and exclusively; the first to succeed wins and the others discard their work.
- • Failures are counted per task and per worker, feeding retry limits and blacklisting.
- • A worker pauses long enough to miss its heartbeats and is presumed dead while still running.
- • The scheduler’s capacity view is stale and it places a task that the worker cannot accept.
- • A worker fails every task assigned to it and the failures are absorbed by successful retries elsewhere.
- • A task fails deterministically and consumes its retry budget on every attempt.
- • The scheduler restarts and loses its in-memory record of assignments.
- • Enough total capacity exists but no single worker can host a large task, and it queues indefinitely.
- • Charged twice: a task calling an external API is re-executed after a false liveness verdict. The operator sees two charges, both jobs green, and no error anywhere.
- • The job that quietly doubled: nightly runtime went from 40 to 85 minutes over a month. Total task count is unchanged; retry count grew, and 90% of retries originate on one worker with a failing disk.
- • Queued at 70% utilisation: tasks wait while dashboards show substantial free capacity. No single worker has enough contiguous free memory for the task at the head of the queue.
- • Retry consumption: one malformed input fails on every worker, each failure is retried, and a meaningful share of cluster capacity is spent producing the same error.
- • Scheduler restart takes the job: the coordinator is redeployed and every running job fails, because assignment state lived only in its memory.
- • Thundering re-execution: a rack loses connectivity briefly, every worker on it is presumed dead at once, and the scheduler requeues thousands of tasks simultaneously into a cluster that has not actually lost capacity.
- • The scheduler is a single global view, and everything it knows arrived as a message that was true when sent.
- • Liveness is a No Heartbeat Does Not Mean Dead problem, and inherits the impossibility: no threshold separates slow from dead.
- • The exclusive commit is the only place real agreement happens, and it is usually delegated to a file-system rename or a conditional write rather than to a protocol.
- • Fencing tokens are needed wherever a re-executed attempt might act on shared state the original attempt still holds.
- • Two-level scheduling — a resource manager offering capacity to per-framework schedulers — exists to keep the global coordination point small while letting each framework make its own placement decisions.
- • Tasks are executed at least once; the job completes as long as the scheduler lives and capacity exists.
- • Framework-managed output stays correct through the exclusive commit; external side effects do not.
- • A scheduler outage stops new assignment. Whether running tasks survive depends on whether workers can proceed without it.
- • A false liveness verdict costs duplicated work and, where side effects exist, duplicated effects.
- • Detect: alert on retry work as a fraction of total work, and on retry counts grouped by worker. Both catch problems that no error rate shows.
- • Contain: bound retries per task, blacklist workers that fail disproportionately, and cap simultaneous re-executions so a network blip does not requeue the cluster.
- • Recover: re-run lost tasks; hold job assignment state durably so the scheduler’s own restart is survivable.
- • Reconcile: for tasks with external effects, make the effect idempotent with a key derived from the task identity — not from the attempt — so a duplicate collapses into the original.
- • Verify: check output counts against expectation, since duplicate execution and lost execution both produce a job that reports success.
- • Task state distribution over time — pending, running, failed, retried — which shows a scheduling problem long before wall time does.
- • Retry count grouped by worker, the single most valuable scheduler metric and the one most often missing.
- • Time tasks spend pending versus running, which separates a capacity problem from a compute problem.
- • Count of tasks presumed dead that later reported in — direct evidence of a liveness threshold set too aggressively.
- • Achieved locality distribution, so a placement regression is visible as a cause rather than inferred from runtime.
- • Requested versus actually used resources per task, which is what explains a cluster that is full and idle at once.
- • Any workload with more tasks than workers, where placement and re-execution buy real fault tolerance for free.
- • Heterogeneous clusters, where matching task requirements to worker capabilities is genuine value.
- • Pre-emptible or spot capacity, where workers disappear routinely and re-execution is the entire reason the job completes at all.
- • Tasks with non-idempotent external side effects, where the re-execution model actively causes damage.
- • Very short tasks, where scheduling overhead exceeds the work — batch them instead.
- • Long-running tasks with no checkpointing, where any re-execution repeats hours of work and the retry policy is a bad bet.
- • Workloads needing hard latency guarantees, where queueing and re-execution make the tail unpredictable by design.
- • A plain work queue with at-least-once delivery and idempotent consumers — simpler, and the failure model is explicit rather than hidden in a scheduler.
- • Static assignment, when the worker set is fixed and small: partition the work up front and skip the scheduler entirely.
- • Let the platform schedule for you — a container orchestrator already solves placement, liveness and restart, and this lesson describes what it is doing.
- • Checkpoint long tasks so a re-execution resumes rather than restarts, which is Recovered State Is a Checkpoint Plus the Log After It applied to compute.
The worker went quiet. Re-run its task, or wait?
| Raise it | Lower it | |
|---|---|---|
| Heartbeat / liveness thresholdprotocol | Fewer false deaths, slower recovery from real ones | Faster recovery, more duplicate execution |
| Max retries per tasktypical | Survives flaky workers, hides a persistently broken one | Fails fast, gives up on transient problems |
| Locality waittypical | More local reads, more idle capacity | Better utilisation, more network traffic |
| Task size / partition counttypical | More tasks: finer balancing, more scheduling and shuffle overhead | Fewer tasks: less overhead, coarser balancing, worse stragglers |
| Concurrency limit per jobassumption | Faster job, more contention with neighbours | Predictable neighbours, longer job |
What people believe, and what is true
The scheduler knows when a worker dies.
It knows a worker stopped talking. Death, a pause, and a partition are indistinguishable from where it stands.
Tuning the heartbeat threshold eliminates duplicate execution.
It trades duplicate execution against recovery latency. Neither end of the range removes the ambiguity.
Retries are free because they usually succeed.
They cost real capacity, and unattributed retries hide the machine causing them behind a job that still reports success.
My cluster is at 70% so there is room.
Placement needs contiguous resources on one worker. Fragmentation and over-requesting both produce a cluster that is simultaneously full and idle.
The framework guarantees exactly-once, so side effects are safe.
It guarantees exactly-once for output it commits itself. Anything your task does to the outside world is at-least-once.
Go deeper
Only the levels this lesson can honestly fill — a missing level is a claim nobody had.
Overview
A scheduler decides who runs what, notices when a worker goes quiet, and re-runs the work. Because "quiet" is not "dead", the same task can run twice — so tasks must be safe to run twice.
Practical
Bound retries, attribute failures per worker and blacklist outliers, and alert on retry work as a share of total work. Keep external side effects out of tasks; where they are unavoidable, key them by task identity rather than attempt so a duplicate collapses. Track how many workers presumed dead later reported in — that number tells you whether your threshold is wrong.
Advanced
The scheduler is a failure detector with the authority to act on its own guesses, which is a genuinely uncomfortable design and the reason exclusive commits and fencing exist. Everything else follows from where you put the authority: a single global scheduler gives good packing and one availability bottleneck; two-level scheduling keeps the shared component small at the cost of globally worse decisions; fully decentralised scheduling removes the bottleneck and makes it very hard to reason about who might be running what. All three are defensible, and the choice is essentially about how much you value a single global view against how much you fear a single global view.
Apply it
- 💬 A worker stops heartbeating during a task that charges a credit card. What do you do, and what does the framework guarantee?
- 💬 Your cluster is at 70% utilisation with tasks queued. Give two explanations.
- 💬 A nightly job doubled in runtime with no code change and no failures. Where do you look?
- 💬 Why does raising the heartbeat threshold not solve duplicate execution?