2026-08-05
Scheduling in Rust: one heap, not a timer per task
- rust
- tokio
- scheduling
- performance
TL;DR. Thousands of monitors, each on its own interval, all scheduled from one
BinaryHeap<Reverse<Due>>owned by a single task. Cancelling a job never touches the heap, it bumps a generation counter and lets the stale entry die on pop. Start times are a hash of the id, so a thousand monitors on a 60-second interval do not all fire on the same second. Falling behind skips the backlog instead of replaying it. And the one slow thing, reading config from Postgres, runs somewhere else entirely.
Every monitor has an interval. Some check every 20 seconds, some every 5 minutes, some once a day. They get added, edited and deleted while the process runs. Nothing may drift, and nothing may fire twice.
The obvious way to write that is a task per monitor:
// The version I did not ship.
for target in targets
I did not ship it. Not because spawning is expensive: a Tokio task is cheap, and thousands of them are fine. The problem is everything around that loop.
Replacing it with one heap taught me five things.
1. The cost of a task per job is not the task
Count what you own in that first version. Memory grows with the fleet, which is fine. But so does the number of independent clocks, and that is not.
Editing a monitor means finding its task and stopping it. So now you keep a map of ids to JoinHandles, and a lock around the map, and you think about what happens when a probe is in flight while the edit lands. Deleting is the same problem again. Each task also holds its own copy of the config, so "what is this monitor set to right now" has as many answers as you have tasks.
None of that is the scheduling problem. It is bookkeeping you inherit by spreading one decision, what runs next, across a thousand places.
One heap puts the decision back in one place:
let mut heap: = new;
let mut live: = new;
BinaryHeap is a max-heap, so entries go in wrapped in Reverse to pop the earliest first. The driver is one loop that sleeps until the top of the heap is due:
loop
Because heap and live never leave this task, neither one needs a lock or an Arc. They are plain local variables in a loop. Every question about what runs next has exactly one answer, held by one owner.
One small thing that is easy to get wrong: an empty heap has no next time, and a select! branch that returns instantly turns the loop into a spin.
async
pending() is a future that never resolves. With no work scheduled, that branch never wins, and the loop waits on the others.
2. You cannot remove from a heap, so do not try
A BinaryHeap gives you push and pop. There is no "delete this entry in the middle". When a monitor is edited or deleted, its pending entry is already inside, and you cannot reach in and pull it out.
The usual workaround is to rebuild the heap on every change. Do not. The fix is to stop treating the heap as the truth.
Each entry carries a generation number, seq. A separate map holds the current generation for each live id. Rescheduling means writing a new number:
The old entry is still in the heap. It is just no longer the current generation, and the pop side is where that gets decided:
let Reverse = heap.pop.expect;
if live.get != Some
A superseded entry fails that check and is dropped. A deleted monitor is removed from live, so get returns None and every entry it left behind dies the same way. Nothing scans the heap, nothing rebuilds it, nothing locks.
The cost is that a stale entry lingers until its time comes around. That is bounded by the interval it was scheduled at, and it is a few dozen bytes. Cheap. Watch one edit and one delete work their way out:
Upper rail: entries still on the heap, each parked at the second it is due. Lower rail: what the pop decided once the clock reached it. Watch B get edited at t+8s and C deleted at t+32s, and the entries they leave behind.
This pattern is worth stealing for any queue you cannot delete from: mark the truth somewhere else and let the queue lie until you pop it.
3. A hashed offset, or everything fires on the same second
A thousand monitors on a 60-second interval, all scheduled at boot, are a thousand probes on the same second, then 59 seconds of nothing, forever. Your outbound connections spike, your database write batches spike, and your own latency numbers get worse at exactly the moment you are measuring someone else.
So every target gets a fixed offset inside its own interval, derived from its id:
Two things matter here. The first is that the id goes through a hash instead of being used directly. My ids come from gen_random_uuid(), so they are already random, and id % interval would spread them perfectly well. That is luck, not design. Reach for the top bytes instead of the whole value, or move to a time-ordered v7 id, and every monitor created in the same millisecond lands in the same slot. Hashing all sixteen bytes makes the offset independent of the id version and of which bits you happened to grab. DefaultHasher is SipHash, which is far more than this needs: the job is spreading load, not resisting an attacker.
The tempting alternative is to skip hashing and just count: hand the first monitor slot 0, the next one interval / n, and so on around the circle. It spreads perfectly, and it breaks the moment the fleet changes. Adding one monitor changes n, which moves every other monitor to a new slot. It also needs one place that knows the whole fleet at once, which is exactly the coordination the single-hash version does not need.
The difference only shows up once the fleet moves, so it is worth watching rather than reading:
Each bar is one second of the interval, and its height is how many monitors are due in it. The fleet grows, shrinks and reloads in a different order underneath the rule, so read the running count of slot changes rather than the shape of any one frame.
And hash the id, not the URL. It is tempting, because a URL carries more variety than a batch of ids. It is also wrong: two monitors watching the same URL would then get the same offset and fire together, which is the one collision you most want to avoid.
The offset also has to be stable. Restart the process and the same monitor lands in the same slot, because the input is only the id and the interval. Nothing is stored and no region has to coordinate with another, because each one computes the same answer on its own.
That is a claim worth checking, so the test bins a thousand ids and fails if any tenth of the interval is starved or crowded:
The first probe is a special case. Spreading a brand new monitor across its full interval is correct for load and terrible for the person who just created it and is staring at an empty chart. So the first one uses a much smaller window:
const INITIAL_PROBE_SPREAD: Duration = from_secs;
let cap = INITIAL_PROBE_SPREAD.min;
Five seconds at most, and never more than half the interval, so a fast monitor cannot have its first probe pushed past its second one. After that first fire the entry switches to the full offset and settles onto its own grid.
4. Falling behind must not turn into a stampede
Something will eventually stall the loop. A slow batch, a paused container, a laptop lid. When it comes back, a naive scheduler sees a hundred missed slots and fires a hundred probes at once, which is the worst possible response to a machine that is already struggling.
Tokio's Interval has a name for the fix, MissedTickBehavior::Skip. The heap needs its own version:
/// First phase-aligned grid point strictly after `now` — the heap form of
/// `MissedTickBehavior::Skip`, dropping any backlog while preserving phase.
The next time is computed from the scheduled time, not from now. That is the whole trick. Adding the interval to now after each run makes every run drift by however long the work took, and the drift accumulates forever. Adding to the previous scheduled point keeps the phase, so a check on a 20-second interval stays on the same 20-second grid all week.
When it is behind, it jumps forward whole intervals until it is in the future, and the missed ones are gone. For a monitor that is the right call. Nobody wants sixty stale probes replayed; they want the current state now.
async
5. Keep the slow thing off the driver
The config lives in Postgres. Something has to read it, notice new monitors and edits, and get them into the heap. The tempting place to put that is the driver loop, since that is where the heap is.
That would mean every scheduling decision waits behind a database round trip. A slow query, or a failover, and no check fires anywhere until it returns.
So the refresh runs on its own task and the driver only ever hears about the result:
// Refresh runs off the driver so a slow PG round-trip can never stall
// probe dispatch — the driver only ever does in-memory work.
let = ;
let refresher = spawn;
The channel is bounded at 8. If the driver ever falls behind, the refresher blocks on send instead of piling diffs up in memory, which turns a backlog into back pressure.
When the database is unreachable, the refresh backs off and the checks keep running on the last config it saw:
From a 30-second base the delay walks 30, 60, 120, 240, and then stops at 300 seconds. The cap matters as much as the doubling: uncapped backoff means that after a long outage the process waits hours to notice the database came back. Five minutes is the worst case here, and it is a number I picked on purpose rather than one that fell out of the formula.
The result is the property I was after. Postgres going away degrades the scheduler into "keeps doing what it was doing", not "stops".
What carries over
Almost none of this is about uptime monitoring. Any system with recurring jobs on per-job intervals hits the same five walls:
- One owner for "what runs next", so the state needs no locks.
- Cancel by generation, not by removing from the queue.
- Deterministic hashed offsets, so a batch of jobs never lands on one instant.
- Compute the next time from the last scheduled time, and skip the backlog instead of replaying it.
- Keep I/O off the loop that makes the decisions, with a bounded channel and a capped backoff.
The whole scheduler is one file, about 460 lines including the tests, in src/scheduler/runner.rs.
Uptimepage is open source, AGPL-3.0: github.com/uptimepage/uptimepage. The wider build story, one binary and two databases, is here; the HTTP client that runs on the other end of this dispatch is here; and where the check results go once they come back is here. To see where the scheduler sits in the whole system, there is a live map of the architecture.