Distributed Compute

Who Runs What, and What Happens When a Worker Goes Quiet

A scheduler places tasks, tracks capacity, and re-runs work it believes was lost. That last word is the whole problem: "believes". A scheduler cannot know a worker died — it only knows the worker stopped talking, and re-running a task that is still executing is how a batch job charges a customer twice.

▶ Run the lab

The question this answers

The question

The worker stopped responding. Do I re-run its task?

The guarantee — the property claimed, and its scope

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.

What a node knows — observation versus inference

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.

What guarantee?What does a node know?How does it work?What can fail?How does it fail?Where is coordination?What holds under failure?How does it recover?How would you know?What is the simpler thing?
schedulingplacementretriesduplicate executioncapacity

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 scheduler’s loop, and the decision that can go wrong
assignheartbeat or silencepresumed deadrequeuefinishedthe duplicate also finishes — one must losePending tasksPlacement resources, locality, constraintsWorkers execute, heartbeatLiveness last heartbeat > threshold?Re-execute (may duplicate a live task)Commit atomic, exclusive, first-wins
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

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.

The scheduler is wrong, and both attempts runprotocol
SchedulerWorker 1 (alive, paused) is down over this spanWorker 1 (alive, paused)Worker 2 (re-execution)heartbeat: deliveredheartbeatheartbeat (never sent — paused): sent, never arrives — dropped in flightheartbeat (never sent — paused)dropped — never arrivesassign T: deliveredassign Tstart task T (write) at t=1start task T8s GC pause — no heartbeats sent (crash) at t=38s GC pause — no heartbeats sentheartbeat threshold exceeded → presume dead (decide) at t=6heartbeat threshold exceeded → presume deadstart task T (duplicate) (write) at t=8start task T (duplicate)resumes, finishes T, writes output (write) at t=9resumes, finishes T, writes outputfinishes T, writes output (write) at t=12finishes T, writes outputt=1time →t=12
delivereddelayed (dashed, long)duplicated (×2)dropped — stops short, never arriveswritecrashdecide
Worker 1 never failed. The scheduler observed silence and inferred death, which is the only inference available to it. Both attempts complete. If T’s output goes through an exclusive commit, one wins and the other is discarded. If T called a payment API, the customer was charged twice and nothing in the system reports an error.

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.

KnobRaise itLower it
Heartbeat / liveness thresholdprotocolFewer false deaths, slower recovery from real onesFaster recovery, more duplicate execution
Max retries per tasktypicalSurvives flaky workers, hides a persistently broken oneFails fast, gives up on transient problems
Locality waittypicalMore local reads, more idle capacityBetter utilisation, more network traffic
Task size / partition counttypicalMore tasks: finer balancing, more scheduling and shuffle overheadFewer tasks: less overhead, coarser balancing, worse stragglers
Concurrency limit per jobassumptionFaster job, more contention with neighboursPredictable neighbours, longer job
Scheduling knobs and what each one actually trades

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.

How it works
  • 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.
What can fail at the boundary
  • 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.
How it fails — what an operator sees
  • 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.
Where coordination is required
  • 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.
What still holds under failure
  • 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.
How it recovers
  • 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.
How you would know
  • 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.
When it helps
  • 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.
When it hurts
  • 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.
Simpler alternatives
  • 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?

The worker stopped responding. Do I re-run its task?
A scheduler cannot know a worker died — it only knows the worker stopped talking. Placement and capacity are optimisation; liveness and re-execution are correctness.
where the output goes
duplicate launched?
yes
time to recover a real death
7s
result
one commit
error reported anywhere
none
The scheduler is wrong, and both attempts run.protocol
SchedulerWorker 1 (alive, paused) is down over this spanWorker 1 (alive, paused)Worker 2 (re-execution)heartbeat: deliveredheartbeatheartbeat (never sent — paused): sent, never arrives — dropped in flightheartbeat (never sent — paused)dropped — never arrivesassign T: deliveredassign Tstart task T (write) at t=1start task T8s GC pause — no heartbeats sent (crash) at t=38s GC pause — no heartbeats sentheartbeat threshold exceeded → presume dead (decide) at t=8heartbeat threshold exceeded → presume deadstart task T (duplicate) (write) at t=10start task T (duplicate)resumes, finishes T, writes output (write) at t=12resumes, finishes T, writes outputfinishes T, writes output (write) at t=14finishes T, writes outputt=1time →t=14
delivereddelayed (dashed, long)duplicated (×2)dropped — stops short, never arriveswritecrashdecide
Worker 1 never failed. The scheduler observed silence and inferred death, which is the only inference available to it. Both attempts complete. If T’s output goes through an exclusive commit, one wins and the other is discarded. If T called a payment API, the customer was charged twice and nothing in the system reports an error.
There is no winning threshold. At 5s against a 8s pause you get a duplicate execution of a task that never failed. Raise it for fewer false deaths and slower recovery; lower it for faster recovery and more duplicates. Safety does not come from getting this right — it comes from making the duplicate harmless: an exclusive atomic commit means the first output to land wins and the second is discarded, so the task is executed at least once and committed at most once. Without a fencing token, a scheduler that presumes death is arming a second writer against a first one that is still alive.
regime
stable
capacity
100/s
peak pending
100
refused
1,260
Arrivals (90/s) sit at 90% of capacity (100/s), so the queue drains as fast as it fills. The burst still shed 1260 requests — a bound is only quiet while nothing spikes. At this utilization there is little headroom left for a spike. And the shape operators misread: a cluster at 70% utilisation with tasks queued reads as a scheduler bug and is usually a packing consequence — the free capacity is in fragments too small for the task that is waiting. Placement runs on a stale view of capacity, which is why optimistic placement with worker-side rejection is the standard answer. Watch retry attribution too: a nightly job that drifted from 40 to 85 minutes with an unchanged task count, and 90% of retries originating on one worker with a failing disk, is a single bad machine hiding inside a job that succeeds.
Raise itLower it
Heartbeat / liveness thresholdprotocolFewer false deaths, slower recovery from real onesFaster recovery, more duplicate execution
Max retries per tasktypicalSurvives flaky workers, hides a persistently broken oneFails fast, gives up on transient problems
Locality waittypicalMore local reads, more idle capacityBetter utilisation, more network traffic
Task size / partition counttypicalMore tasks: finer balancing, more scheduling and shuffle overheadFewer tasks: less overhead, coarser balancing, worse stragglers
Concurrency limit per jobassumptionFaster job, more contention with neighboursPredictable neighbours, longer job
Scheduling knobs and what each one actually trades. None of them has a winning setting.
protocolNo liveness threshold distinguishes a slow worker from a dead one. This is the failure-detector impossibility, not a tuning deficiency, and tuning only moves which error you make more often. Heartbeat intervals of seconds and thresholds of tens of seconds are common defaults, chosen against garbage-collection pauses rather than against any principle.

What people believe, and what is true

Claim

The scheduler knows when a worker dies.

Reality

It knows a worker stopped talking. Death, a pause, and a partition are indistinguishable from where it stands.

Claim

Tuning the heartbeat threshold eliminates duplicate execution.

Reality

It trades duplicate execution against recovery latency. Neither end of the range removes the ambiguity.

Claim

Retries are free because they usually succeed.

Reality

They cost real capacity, and unattributed retries hide the machine causing them behind a job that still reports success.

Claim

My cluster is at 70% so there is room.

Reality

Placement needs contiguous resources on one worker. Fragmentation and over-requesting both produce a cluster that is simultaneously full and idle.

Claim

The framework guarantees exactly-once, so side effects are safe.

Reality

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

Interview questions
  • 💬 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?