Why this matters: the whole scheduler reduces to one sentence — keep the future in a heap, and have one thread sleep until the earliest entry is due. Everything else is a consequence. Candidates who don't find this shape end up with a thread per task or a poll loop ticking every second; both work in a demo and both are the anti-patterns this round exists to catch.
The model in words
Say it before drawing: "All pending fires live in one min-heap ordered by next-fire time. A single dispatcher thread looks at the top of the heap and sleeps exactly until that moment — or until something new is scheduled, because a new task might be due sooner than anything already waiting. When a fire comes due, the dispatcher pops it and submits the task to the provided pool. The dispatcher never runs task code; it only decides readiness."
That last clause is the load-bearing one. The dispatcher must stay fast and unblockable — the moment it executes a task inline, one slow task freezes every schedule behind it.
The structures
The heap. Entries of (next-fire-time, task). Peek is the next deadline; pop is the next dispatch. Ten thousand tasks is nothing to a binary heap — insert and pop are logarithmic, and the dispatcher touches only the top.
The dispatcher's wait. The subtle mechanism is how the dispatcher sleeps. It cannot simply sleep until the top entry's fire time, because a schedule() call might insert an earlier deadline mid-sleep. The shape is a lock plus a condition variable with a deadline: sleep until the earliest-known fire time, but let any insert wake the sleeper so it can re-inspect the top. And on every wakeup, the dispatcher re-checks the heap top against the clock rather than assuming why it woke — condition waits can wake spuriously, and re-checking the predicate is what makes that harmless. This is the condvar discipline from the thread-pool chapter, wearing a deadline.
The clock. Injected, as promised in lesson 01. The dispatcher asks the clock for "now" and computes sleep durations from it; a fake clock in tests advances time instantly and deterministically.
The task record. Each task carries a state — pending, ready, running, succeeded, failed, skipped, cancelled — and its scheduling rule. The state machine is small but must be explicit, because dependencies (below) and cancellation both key off it.
Recurrence: reinsertion, and the drift decision
A recurring task is not a special heap entry — it is an ordinary entry that, on dispatch, computes its next fire and reinserts itself. The design question hiding inside is drift: is the next fire scheduled_time + interval, or now + interval? Fixed-schedule (scheduled + interval) keeps fires aligned to the original cadence — a 10:00 five-minute task fires at 10:05, 10:10 — even if dispatch runs late; now + interval lets the schedule slide by each run's lateness. Both are legitimate; alignment is the usual default for "every five minutes" semantics, sliding suits "at least five minutes apart." The graded move is choosing knowingly — drift is the kind of bug that ships silently and pages someone at 3 a.m. six weeks later. Per lesson 01's requirement set, a fire that comes due while the previous run is still going is skipped — which the reinsert-on-dispatch shape makes natural: the task isn't in the heap while it runs.
Dependencies: readiness by counting
run-after-tasks tasks don't enter the time heap at all — they aren't waiting on time. Each holds an indegree counter: the number of parents that haven't succeeded yet. When a parent succeeds, the scheduler decrements each child's counter; a counter reaching zero makes the child ready, and it's submitted (or, if it also has a time rule, enters the heap). Parent failure walks descendants and marks them skipped — visible, terminal, never silently dropped. Cycles are rejected at submission with a standard graph check, because the counter scheme would simply deadlock on one: a cycle's counters can never reach zero.
This is topological ordering without ever sorting — readiness propagates one edge at a time, which is all a scheduler needs.
The invariants
- Every scheduled-but-not-running task is in exactly one place: the time heap, or a dependency-wait set — never both, never neither.
- The dispatcher never executes task code; it only submits.
- A recurring task has at most one heap entry at any moment.
- A dependency counter equals the count of its unfinished parents, always.
- Cancelled and skipped are terminal states, and they are observable.
What we rejected, and why
A thread per scheduled task, each sleeping until its own fire time: ten thousand sleeping threads to represent a data structure. The heap is those threads, collapsed into an ordered list and one sleeper.
Polling every second: simple, and wrong twice — it burns wakeups when nothing is due and adds up to a second of latency to every fire. The condvar-with-deadline dispatcher wakes exactly when there's a reason to.
Key takeaway
One min-heap holds the future; one dispatcher sleeps until the top is due, wakes on earlier inserts, re-checks its predicate, and submits — never executes. Recurrence is reinsertion with the drift decision made on purpose; dependencies are indegree counters that make readiness propagate without sorting; and the invariants keep every task in exactly one place. Find this shape and the scheduler is small; miss it and it's a thread farm.