Skip to content
echogeco
The harness, in code · Build it

A tiny DAG scheduler

Implement the scheduler that runs a harness DAG in the same topological order every time, retries each step to its own limit, and names the resume point.

codehardExercise · 3 min · 72 of 77
On this page

A resume point is only worth recording if the next run means the same thing by it. The scheduler that produces one has to order the steps identically every time, find a cycle before it spends anything, and stop where a person can pick up.

The situation

A content pipeline runs nightly as a seven-step harness DAG: fetch (code), then extract, outline and draft (model calls), check (code, links and figures against the source), images (a model call from the outline) and publish (code). The first scheduler had three faults, all found in the week step 5 started failing. It sorted the DAG with a recursive depth-first search, which hit Python's default limit of 1,000 frames on a 1,200-step backfill that chains one step per archived article. It walked the ready steps as a set of names, and because string hashing changes between processes, two runs could order draft and images differently, so "resume at step 5" named a different step after a restart. And it retried nothing, so one timeout in extract reran the night.

The team wants the scheduler behind resume after step 5: one order, per-step retries, and the step where the run stopped. Whether the cause lies upstream of that step stays the harness's call.

The question

Implement the scheduler so that every run of the same DAG takes the same order, a cycle is reported before anything runs, and the resume point is the step that used up its attempts.

def solve(steps, failures):
    """steps: [(name, deps, retries), ...]; deps are the names this step waits
    for, each declared somewhere in steps; retries is the attempts allowed
    after the first. failures: name -> how many attempts of that step fail
    before one succeeds; a name not in failures succeeds first time.
    Runs one step at a time in topological order, ties broken by the order
    the steps are declared, and stops at the first step that uses up its
    attempts. Returns (status, trace, resume):
      status  "done", "failed", or "cycle" (no order exists, nothing runs)
      trace   [(name, attempts), ...] for every step that ran, in run order
      resume  for "failed", the step a resumed run starts from, skipping
              every step before it in trace; otherwise None"""
PIPELINE = [
    ("fetch", [], 2),
    ("extract", ["fetch"], 1),
    ("outline", ["extract"], 1),
    ("draft", ["outline"], 2),
    ("check", ["draft"], 1),
    ("images", ["outline"], 1),
    ("publish", ["check", "images"], 0),
]

solve(PIPELINE, {})
    -> ("done", [("fetch", 1), ("extract", 1), ("outline", 1), ("draft", 1),
                 ("check", 1), ("images", 1), ("publish", 1)], None)
solve(PIPELINE, {"draft": 2})
    -> ("done", [("fetch", 1), ("extract", 1), ("outline", 1), ("draft", 3),
                 ("check", 1), ("images", 1), ("publish", 1)], None)
solve(PIPELINE, {"check": 2})
    -> ("failed", [("fetch", 1), ("extract", 1), ("outline", 1), ("draft", 1), ("check", 2)], "check")
solve([("a", ["b"], 0), ("b", ["a"], 0)], {})
    -> ("cycle", [], None)

A strong answer · Rubric · Where answers go wrong · Solution

Write your answer first, then open