What L5 looks like
- Catches the cycle closed through a task submitted earlier with an unknown dependency, and explains why checking only from the new task is enough.
- Blocks every downstream task when a task dies (no silent hang) and records a root-cause reason in the DLQ.
- Redrive that respects diamonds and other dead dependencies; a randomized DAG test.
Approach
Three parts, one state machine. Write it down before coding; every rule in the prompt is one row of it.
| From | Event | To |
|---|---|---|
| (new) |
submit, some dependency not yet succeeded |
PENDING |
| (new) or PENDING | every dependency succeeded | READY |
| (new) or PENDING | a dependency is DEAD or BLOCKED | BLOCKED |
| READY | next_ready |
RUNNING |
| RUNNING | complete |
SUCCEEDED |
| RUNNING |
fail, attempts left |
READY (back of the line) |
| RUNNING |
fail, no attempts left |
DEAD |
| DEAD | redrive |
READY |
| BLOCKED | the dead task upstream is redriven, nothing else dead upstream | PENDING |
Part 1: dependencies (Kahn's algorithm, online). For each task keep waiting[t], the number of its dependencies that have not succeeded. A task with waiting == 0 is READY and joins a FIFO deque. When a task completes, walk its dependents and decrement; any that reach 0 become READY. A dependency that has not been submitted yet simply counts as not succeeded, so tasks can arrive in any order. Each edge is touched once on submit and once on completion: O(V + E) for the whole run, the same as an offline topological sort.
Retries. attempts counts how many times a task was handed out. fail puts the task at the back of the ready line while attempts < max_attempts, and kills it otherwise. Back of the line, not the front: a task that fails fast must not starve everything behind it.
Part 2: cycle detection on submit. A cycle can only appear through the task being added, because the graph was acyclic before. So the check is one question: can the new task reach itself? That is, starting from its dependencies and following dependency edges, do we arrive back at the new task? One DFS, O(V + E) worst case, and usually tiny because it only explores the new task's ancestors. Reject the submit with an error and change nothing, so the caller can fix the graph and resubmit.
The tricky case is a cycle closed through a task submitted earlier with a not-yet-known dependency: a waits on b, b waits on c, then c arrives waiting on a. The DFS from c's dependencies goes a → b → c and finds it, because a and b stored their dependency lists when they were submitted. A check that only looked at known tasks' states would miss this and leave three tasks PENDING forever, which is the silent failure the interviewer is probing for.
Part 3: dead letters.
- A task out of attempts becomes DEAD and enters the dead-letter queue (DLQ) with its last error.
- Everything downstream of it can never run. Leaving those tasks PENDING is a silent hang: the queue looks idle and nobody is told. Mark every transitive dependent BLOCKED and put it in the DLQ too, with the reason naming the dead task at the root (an operator wants "blocked by fetch", not "blocked by parse").
- A task submitted later onto a dead or blocked dependency is blocked at once.
-
redrive(task)gives a DEAD task fresh attempts and puts it back in line. Then walk its dependents: a BLOCKED task becomes PENDING again only if none of its other dependencies is still DEAD or BLOCKED. If one is, it stays blocked and its reason is updated to name the other cause. Re-queue a task's dependents every time it is unblocked, so a diamond resolves no matter which branch the walk reaches first.
Code
from __future__ import annotations
from collections import defaultdict, deque
PENDING, READY, RUNNING = "PENDING", "READY", "RUNNING"
SUCCEEDED, DEAD, BLOCKED = "SUCCEEDED", "DEAD", "BLOCKED"
class CycleError(ValueError):
pass
class WorkQueue:
def __init__(self, max_attempts: int) -> None:
if max_attempts < 1:
raise ValueError("max_attempts must be >= 1")
self.max_attempts = max_attempts
self.state: dict = {}
self.deps: dict = {} # task -> its dependencies
self.dependents = defaultdict(list) # task -> tasks that wait on it
self.waiting: dict = {} # task -> deps not yet SUCCEEDED
self.attempts: dict = {}
self.ready: deque = deque() # READY tasks, FIFO
self.dlq: dict = {} # task -> reason, in arrival order
# -- level 1 -------------------------------------------------------------
def submit(self, task: str, deps=()) -> bool:
if task in self.state:
return False
deps = list(dict.fromkeys(deps)) # drop repeats, keep order
if task in deps or self._reaches(deps, task):
raise CycleError(f"{task} would depend on itself")
self.state[task] = PENDING
self.deps[task] = deps
self.attempts[task] = 0
self.waiting[task] = sum(1 for d in deps if self.state.get(d) != SUCCEEDED)
for d in deps:
self.dependents[d].append(task)
bad = next((d for d in deps if self.state.get(d) in (DEAD, BLOCKED)), None)
if bad is not None:
self._block(task, bad)
elif self.waiting[task] == 0:
self._make_ready(task)
return True
def next_ready(self) -> str | None:
if not self.ready:
return None
task = self.ready.popleft()
self.state[task] = RUNNING
self.attempts[task] += 1
return task
def complete(self, task: str) -> bool:
if self.state.get(task) != RUNNING:
return False
self.state[task] = SUCCEEDED
for t in self.dependents[task]:
self.waiting[t] -= 1
if self.waiting[t] == 0 and self.state[t] == PENDING:
self._make_ready(t)
return True
def fail(self, task: str, error: str = "") -> str | None:
if self.state.get(task) != RUNNING:
return None
if self.attempts[task] < self.max_attempts:
self._make_ready(task) # retry at the back of the line
return READY
self.state[task] = DEAD
self.dlq[task] = f"failed after {self.attempts[task]} attempts: {error}"
for t in self._downstream(task):
if self.state[t] == PENDING: # downstream of a task that never succeeded
self._block(t, task)
return DEAD
def status(self, task: str) -> str | None:
return self.state.get(task)
# -- level 3: dead letters -------------------------------------------------
def dead_letters(self) -> list:
return list(self.dlq.items())
def redrive(self, task: str) -> bool:
"""Give a DEAD task a fresh set of attempts and unblock what waited on it."""
if self.state.get(task) != DEAD:
return False
del self.dlq[task]
self.attempts[task] = 0
self._make_ready(task)
todo = deque(self.dependents[task])
while todo:
t = todo.popleft()
if self.state.get(t) != BLOCKED:
continue
bad = next((d for d in self.deps[t] if self.state.get(d) in (DEAD, BLOCKED)), None)
if bad is not None:
self._block(t, bad) # still blocked: name the other cause
continue
del self.dlq[t]
self.state[t] = PENDING
todo.extend(self.dependents[t])
return True
# -- helpers -----------------------------------------------------------------
def _make_ready(self, task: str) -> None:
self.state[task] = READY
self.ready.append(task)
def _block(self, task: str, dep: str) -> None:
# Name the dead task at the root, even through a chain of blocked ones.
self.state[task] = BLOCKED
self.dlq[task] = self.dlq[dep] if self.state[dep] == BLOCKED else f"blocked by {dep}"
def _downstream(self, task: str) -> list:
"""Every task that depends on `task`, directly or not, nearest first."""
seen, order, todo = {task}, [], deque([task])
while todo:
for t in self.dependents[todo.popleft()]:
if t not in seen:
seen.add(t)
order.append(t)
todo.append(t)
return order
def _reaches(self, starts, target: str) -> bool:
"""Is `target` reachable from `starts` along dependency edges?"""
seen, stack = set(), list(starts)
while stack:
t = stack.pop()
if t == target:
return True
if t in seen:
continue
seen.add(t)
stack.extend(self.deps.get(t, ()))
return False
Complexity
V tasks, E dependency edges.
| Operation | Cost |
|---|---|
submit |
O(d) for the task's d deps, plus the cycle DFS: O(ancestors + their edges), O(V + E) worst case |
next_ready, status
|
O(1) |
complete |
O(number of dependents) |
fail (dead) |
O(downstream tasks + their edges) |
redrive |
O(downstream tasks + their edges) |
Worked numbers. The cycle DFS on each submit visits only the new task's ancestors. For a wide, shallow DAG (1,000 shards that each wait on one download and feed one reducer) that is one or two tasks per submit, so the whole graph costs about 2,000 steps. For a chain of 10^5 tasks, submit i visits i ancestors: 10^5 × 10^5 / 2 = 5 × 10^9 steps, which is minutes in Python. Say which shape you expect; if graphs can be deep, use the incremental approach in the follow-ups.
Tests
from workqueue import WorkQueue
def drain(q):
"""Run every ready task to success; return the order they ran in."""
order = []
while True:
t = q.next_ready()
if t is None:
return order
order.append(t)
q.complete(t)
def test_fifo_without_deps():
q = WorkQueue(max_attempts=3)
for t in ["a", "b", "c"]:
assert q.submit(t) is True
assert q.submit("a") is False
assert drain(q) == ["a", "b", "c"]
def test_dependencies_run_first():
q = WorkQueue(max_attempts=3)
q.submit("train", deps=["tokenize", "download"])
q.submit("tokenize", deps=["download"])
q.submit("download")
assert q.status("train") == "PENDING"
assert drain(q) == ["download", "tokenize", "train"]
def test_retry_goes_to_the_back():
q = WorkQueue(max_attempts=2)
q.submit("a")
q.submit("b")
assert q.next_ready() == "a"
assert q.fail("a", "timeout") == "READY"
assert q.next_ready() == "b"
assert q.next_ready() == "a"
assert q.fail("a", "timeout") == "DEAD"
assert q.fail("a") is None
assert q.complete("zzz") is False
from workqueue import WorkQueue
def drain(q):
"""Run every ready task to success; return the order they ran in."""
order = []
while True:
t = q.next_ready()
if t is None:
return order
order.append(t)
q.complete(t)
def test_self_dependency_is_a_cycle():
q = WorkQueue(max_attempts=1)
try:
q.submit("a", deps=["a"])
except ValueError:
assert q.status("a") is None
return
assert False
def test_cycle_through_a_task_not_yet_submitted():
q = WorkQueue(max_attempts=1)
q.submit("a", deps=["b"])
q.submit("b", deps=["c"])
try:
q.submit("c", deps=["a"])
except ValueError:
assert q.status("c") is None # rejected, nothing changed
q.submit("c") # the fixed version is accepted
assert drain(q) == ["c", "b", "a"]
return
assert False, "a -> b -> c -> a is a cycle"
def test_diamond_is_not_a_cycle():
q = WorkQueue(max_attempts=1)
q.submit("root")
q.submit("left", deps=["root"])
q.submit("right", deps=["root"])
q.submit("join", deps=["left", "right", "left"])
assert drain(q) == ["root", "left", "right", "join"]
def test_random_dags_run_in_dependency_order():
import random
rng = random.Random(11)
for _ in range(50):
n = 30
deps = {i: rng.sample(range(i), min(i, rng.randint(0, 3))) for i in range(n)}
q = WorkQueue(max_attempts=1)
for i in rng.sample(range(n), n): # submit in random order
q.submit(str(i), deps=[str(d) for d in deps[i]])
pos = {t: k for k, t in enumerate(drain(q))}
assert len(pos) == n
assert all(pos[str(d)] < pos[str(i)] for i in deps for d in deps[i])
from workqueue import WorkQueue
def drain(q):
"""Run every ready task to success; return the order they ran in."""
order = []
while True:
t = q.next_ready()
if t is None:
return order
order.append(t)
q.complete(t)
def test_dead_task_blocks_everything_downstream():
q = WorkQueue(max_attempts=1)
q.submit("fetch")
q.submit("parse", deps=["fetch"])
q.submit("index", deps=["parse"])
q.submit("other")
assert q.next_ready() == "fetch"
assert q.fail("fetch", "HTTP 503") == "DEAD"
assert q.status("parse") == "BLOCKED" and q.status("index") == "BLOCKED"
assert q.dead_letters() == [
("fetch", "failed after 1 attempts: HTTP 503"),
("parse", "blocked by fetch"),
("index", "blocked by fetch"),
]
assert drain(q) == ["other"]
def test_submitting_onto_a_dead_task_blocks_at_once():
q = WorkQueue(max_attempts=1)
q.submit("fetch")
q.next_ready()
q.fail("fetch", "boom")
q.submit("late", deps=["fetch"])
assert q.status("late") == "BLOCKED"
def test_redrive_unblocks_the_chain():
q = WorkQueue(max_attempts=1)
q.submit("fetch")
q.submit("parse", deps=["fetch"])
q.submit("index", deps=["parse"])
q.next_ready()
q.fail("fetch", "HTTP 503")
assert q.redrive("fetch") is True
assert q.redrive("fetch") is False
assert q.dead_letters() == []
assert drain(q) == ["fetch", "parse", "index"]
def test_redrive_keeps_tasks_blocked_by_another_dead_dep():
q = WorkQueue(max_attempts=1)
q.submit("a")
q.submit("b")
q.submit("join", deps=["a", "b"])
for _ in range(2):
q.fail(q.next_ready(), "x")
q.redrive("a")
assert q.status("join") == "BLOCKED"
assert q.dead_letters() == [("join", "blocked by b"), ("b", "failed after 1 attempts: x")]
q.redrive("b")
assert drain(q) == ["a", "b", "join"]
Follow-ups
-
"Make the cycle check cheap for deep graphs." Keep a topological order index per task (a number) and maintain it incrementally (the Pearce–Kelly algorithm): adding edge
u → vis only a problem iford[v] < ord[u], and then you search only the tasks whose order lies between them. Most inserts are O(1). Or batch: accept a whole DAG per submit call and run one Kahn's pass over it, rejecting the batch if anything is left with a nonzero in-degree. -
"Workers crash." Add leases:
next_readyreturns a lease that expires after T seconds unless heartbeated, and an expired lease counts as a failure (see the leases-and-retries version of this question). Thencompletemust carry the lease id, or a slow worker can complete a task that was already handed to someone else. -
"Retry with backoff." Failed tasks wait
base × 2^attempt(with jitter) before they are READY again: a delay heap in front of the ready deque. Distinguish retryable errors (timeouts, 503) from permanent ones (bad input): a permanent error goes straight to the DLQ without burning attempts. -
"Operate the DLQ." Alert on DLQ growth, not on each entry. Redrive in bulk by reason (
redrive where reason startswith "HTTP 503") after the dependency recovers. Keep a poison-pill cap: a task redriven three times goes to a human queue instead. -
"Make it durable and distributed." Store tasks and edges in Postgres;
next_readyisSELECT ... WHERE state = 'READY' ORDER BY ready_at LIMIT 1 FOR UPDATE SKIP LOCKED;completeand the dependent decrements happen in one transaction so a crash cannot leave a counter half-updated. At very high volume, shard by DAG id so a DAG's edges stay in one shard.
Interviewer expectations
Mid-level (L4)
- Correct dependency ordering with in-degree counts, FIFO among ready tasks, and retries to the back of the line.
- Detects a direct cycle (and self-dependency) and rejects the submit without changing state.
- Tests for ordering, retry and dead.
Senior (L5)
- Catches the cycle closed through a task submitted earlier with an unknown dependency, and explains why checking only from the new task is enough.
- Blocks every downstream task when a task dies (no silent hang) and records a root-cause reason in the DLQ.
- Redrive that respects diamonds and other dead dependencies; a randomized DAG test.
Staff (L6+)
- States the cost of the cycle check on deep graphs and offers incremental topological ordering or batch submits.
- Brings leases, backoff with retryable versus permanent errors, and DLQ operations (alerting, bulk redrive, poison pills).
- Sketches the durable version: transactional state changes,
SKIP LOCKEDdispatch, sharding by DAG.
Make it yours
Remix it: write each part yourself, grade it against this answer, then let the coach ask you follow-ups. Practice it with the 3-level tests, run in your browser.
