Low Level DesignWorked problems
Design a task scheduler
Design an in-process task scheduler step by step for a low level design interview. Grow from a loop that scans every job each second to a timetable kept in a min-heap, fixed-rate and fixed-delay repeating jobs, retries with backoff and a status per job, and a watcher thread with a pool of workers, with code in Java, Python, C#, Go and TypeScript.
- Read first
- Object modelingThread safety basics
- Patterns
- Command
- Related
- Pub-sub systemRate limiter
“Design a task scheduler. Code can ask for a task to run after a delay, or every few minutes.” Then, sooner or later: “What happens when a job takes longer than its period?”
A task scheduler holds work that should happen later. One part of a program says “send this reminder email in 24 hours”, or “refresh this cache every five minutes”. The scheduler makes sure it happens at the right time. In this problem, the scheduler lives inside one program, in memory.
The first version is a list of jobs, and a loop that checks it every second. It works for a demo. But it reads every job on every check. It runs jobs up to a second late. And one slow job makes all the others late. Each of those becomes a design decision, and the interviewer wants to hear each one made.
The answer in one minute
Section titled “The answer in one minute”If you only have a minute, this is the design. The rest of the page explains how we get here.
- Jobs wait in a timetable sorted by run time. It’s a min-heap, so the next job due is always on top. The scheduler never looks at jobs that aren’t due, and it knows exactly how long to sleep.
- Cancel just marks the job. A cancelled job is thrown away when it reaches the top. That keeps cancel cheap, because a heap is slow at removing from the middle.
- “Every five minutes” means one of two things. Fixed rate keeps to the clock, and skips a run instead of overlapping. Fixed delay waits five minutes after each run finishes.
- A failed one-off job is retried, with growing waits. The retry is just the job back on the timetable, at a later time. Every job has a status that callers can ask for.
- One thread waits, and a pool of workers runs. The waiting thread sleeps until the next run time. It’s woken whenever a sooner job is added. Workers run jobs without holding any lock.
We’ll build this in four stages, then run it with real threads.
Scope it
Section titled “Scope it”Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.
Design a task scheduler.
Whether you pin down one process or many before drawing anything. In one process, the hard parts are the data structure and the threads. Across machines, the hard part is that two machines might run the same job, which is a system design question.
Here are the questions that matter:
- One process, or many machines? One process, in memory. Many machines need a shared store, so that only one machine runs each job.
- Must jobs survive a restart? No. Surviving a restart would need a jobs table in a database.
- Which kinds of job? Once after a delay, and repeating, at a fixed rate or with a fixed delay.
- How exact must timing be? Within milliseconds, and never early.
- What if a job fails? One-off jobs retry with growing waits. Repeating jobs just carry on.
These answers give us one promise that matters more than the others: no job runs early, and none runs much late. A job runs as soon as it’s due, unless every worker is busy. Three smaller promises come with it. A slow or failing job never delays or stops the others. The work per job doesn’t grow with the number of jobs waiting. And two runs of the same repeating job never overlap.
We leave out saving to disk, running across machines, and cron expressions (like “9:00 every Monday”). They come up at the end.
Go deeperThree more questions, and what each changes1 min
| Question | We assume | If the answer were different |
|---|---|---|
| Priorities between jobs? | No, only run time | The heap sorts by (run time, priority, sequence number) |
| Can a job be changed once scheduled? | Cancel and schedule again | Rescheduling is a cancel plus a schedule, the same cost |
| A limit on jobs running at once? | Yes, the worker count | Unlimited means one thread per run, which the first busy minute breaks |
Build it
Section titled “Build it”We’ll build the design in four stages. Each stage adds only the classes that a new requirement needs. In each diagram, the new parts are outlined, and the colors show what each class does (the key is under every diagram). Time is a number of milliseconds from a clock the scheduler is given, so the tests can move time by hand.
Stage 1: A list and a loop
Section titled “Stage 1: A list and a loop”Start simple: an order service wants an email sent 24 hours after an order. What are your classes?
Whether you get a working shape in two minutes, and then say what’s wrong with it yourself. The scan on every check and the one-second lateness are the two things they’re waiting for you to notice.
Start with the smallest thing that works. A Job is a task plus the time it should run. The
Scheduler keeps a list of jobs. A loop calls tick every second, and tick takes out every job
whose time has come, and runs it.
The task is a function with no arguments. This is the Command pattern: the scheduler holds work to be done later, without knowing what the work is.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// A task and when it should run, in milliseconds on the scheduler's clock.record Job(String id, long runAt, Runnable task) {}
class Scheduler { private final List<Job> jobs = new ArrayList<>(); private final LongSupplier clock; private final Supplier<String> ids;
Scheduler(LongSupplier clock, Supplier<String> ids) { this.clock = clock; this.ids = ids; }
String schedule(Runnable task, long delayMillis) { Job j = new Job(ids.get(), clock.getAsLong() + delayMillis, task); jobs.add(j); return j.id(); }
// Called by a loop that wakes up every second. // flaw 1: every tick scans every job, due or not. A million jobs scheduled for next week are // read every second. // flaw 2: a job due at 10:00:00.1 runs at 10:00:01, because nothing happens between ticks. // flaw 3: jobs run one after another on this thread, so a slow one makes every other job // late, and one that throws stops the rest of this tick. // flaw 4: no repeating jobs, and no way to cancel one. void tick() { long now = clock.getAsLong(); List<Job> due = jobs.stream().filter(j -> j.runAt() <= now).toList(); jobs.removeAll(due); for (Job j : due) j.task().run(); }}# A task and when it should run, in milliseconds on the scheduler's clock.@dataclassclass Job: id: str run_at: int task: Callable[[], None]
class Scheduler: def __init__(self, clock: Callable[[], int], ids: Callable[[], str]): self._jobs: list[Job] = [] self._clock = clock self._ids = ids
def schedule(self, task: Callable[[], None], delay_ms: int) -> str: job = Job(self._ids(), self._clock() + delay_ms, task) self._jobs.append(job) return job.id
# Called by a loop that wakes up every second. # # flaw 1: every tick scans every job, due or not. A million jobs scheduled for next week # are read every second. # flaw 2: a job due at 10:00:00.1 runs at 10:00:01, because nothing happens between ticks. # flaw 3: jobs run one after another on this thread, so a slow one makes every other job # late, and one that raises stops the rest of this tick. # flaw 4: no repeating jobs, and no way to cancel one. def tick(self) -> None: now = self._clock() due = [j for j in self._jobs if j.run_at <= now] self._jobs = [j for j in self._jobs if j.run_at > now] for j in due: j.task()// A task and when it should run, in milliseconds on the scheduler's clock.public sealed record Job(string Id, long RunAt, Action Task);
public sealed class Scheduler(Func<long> clock, Func<string> ids){ private List<Job> _jobs = [];
public string Schedule(Action task, long delayMs) { var job = new Job(ids(), clock() + delayMs, task); _jobs.Add(job); return job.Id; }
// Called by a timer that fires every second. // flaw 1: every tick scans every job, due or not. A million jobs scheduled for next week are // read every second. // flaw 2: a job due at 10:00:00.1 runs at 10:00:01, because nothing happens between ticks. // flaw 3: jobs run one after another on this thread, so a slow one makes every other job // late, and one that throws stops the rest of this tick. // flaw 4: no repeating jobs, and no way to cancel one. public void Tick() { var now = clock(); var due = _jobs.Where(j => j.RunAt <= now).ToList(); _jobs = _jobs.Where(j => j.RunAt > now).ToList(); foreach (var j in due) j.Task(); }}// Job is a task and when it should run, in milliseconds on the scheduler's clock.type Job struct { ID string RunAt int64 Task func()}
type Scheduler struct { jobs []Job clock func() int64 ids func() string}
func NewScheduler(clock func() int64, ids func() string) *Scheduler { return &Scheduler{clock: clock, ids: ids}}
func (s *Scheduler) Schedule(task func(), delay int64) string { j := Job{s.ids(), s.clock() + delay, task} s.jobs = append(s.jobs, j) return j.ID}
// Tick is called by a loop that wakes up every second.// flaw 1: every tick scans every job, due or not. A million jobs scheduled for next week are// read every second.// flaw 2: a job due at 10:00:00.1 runs at 10:00:01, because nothing happens between ticks.// flaw 3: jobs run one after another on this goroutine, so a slow one makes every other job// late, and one that panics stops the rest of this tick.// flaw 4: no repeating jobs, and no way to cancel one.func (s *Scheduler) Tick() { now := s.clock() var due, later []Job for _, j := range s.jobs { if j.RunAt <= now { due = append(due, j) } else { later = append(later, j) } } s.jobs = later for _, j := range due { j.Task() }}// A task may be async: sending an email awaits the network.type Task = () => void | Promise<void>;
// A task and when it should run, in milliseconds on the scheduler's clock.interface Job { id: string; runAt: number; task: Task;}
class Scheduler { private jobs: Job[] = [];
constructor( private readonly clock: () => number, private readonly ids: () => string, ) {}
schedule(task: Task, delayMs: number): string { const job = { id: this.ids(), runAt: this.clock() + delayMs, task }; this.jobs.push(job); return job.id; }
// Called by setInterval every second. // flaw 1: every tick scans every job, due or not. A million jobs scheduled for next week are // read every second. // flaw 2: a job due at 10:00:00.1 runs at 10:00:01, because nothing happens between ticks. // flaw 3: jobs run one after another, each awaited, so a slow one makes every other job late, // and one that throws stops the rest of this tick. // flaw 4: no repeating jobs, and no way to cancel one. async tick(): Promise<void> { const now = this.clock(); const due = this.jobs.filter((j) => j.runAt <= now); this.jobs = this.jobs.filter((j) => j.runAt > now); for (const j of due) await j.task(); }}It works, but it has four problems, and we leave them in on purpose. First, every check reads every job. So a million reminders for next week are read every second. Second, a job due just after a check waits almost a full second. Third, jobs run on the loop’s thread, one after another. One slow job makes every job after it late, and one that throws an error stops the rest. And fourth, nothing repeats, and nothing can be cancelled.
Stage 2: A timetable in a min-heap
Section titled “Stage 2: A timetable in a min-heap”There are a million jobs waiting, most of them days away. How does the scheduler find the ones that are due, without reading them all?
Whether you reach for a priority queue sorted by run time, and know what each operation costs. Senior candidates also ask how cancel works, because removing from the middle of a heap is what a heap is bad at.
The scheduler always asks the same question: “which job is earliest?” A min-heap answers it by looking at the top, without searching. A min-heap is a tree where every item is smaller than the items below it, so the smallest is always on top. Adding a job costs O(log n), and taking the earliest costs O(log n). Nothing else is touched.
There’s a bonus. The scheduler now knows exactly when the next job is due, so the loop can sleep until then, instead of checking every second. That fixes both the scan and the lateness.
Two jobs can be due at the same millisecond. So the heap sorts by run time, then by a sequence number taken from a counter when each job is scheduled. Jobs due together run in the order they were scheduled. Without it, their order would depend on how the heap happens to be arranged.
- Color key
- The earliest job, read in O(1)
- Cache
Cancel is the catch. To remove a job from the middle of a heap, we’d first have to find it, and that’s a search through everything. So cancel only marks the job, using a map from id to job. A cancelled job is thrown away when it reaches the top. Cancel costs O(1). The price is that cancelled jobs use memory until their time comes. This trick is called lazy deletion.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// A job is mutable now: cancelling marks it instead of searching the timetable for it.class Job { final String id; final Runnable task; final long seq; // breaks ties: two jobs due at the same moment run in the order scheduled long runAt; boolean cancelled;
Job(String id, Runnable task, long seq, long runAt) { this.id = id; this.task = task; this.seq = seq; this.runAt = runAt; }}
// The timetable is a min-heap ordered by run time, so the next job due is always on top. Adding// a job and taking the next one are O(log n), and asking "when is the next one?" is a peek.// Nothing scans the jobs that aren't due.class Scheduler { private final PriorityQueue<Job> timetable = new PriorityQueue<>(Comparator.comparingLong((Job j) -> j.runAt).thenComparingLong(j -> j.seq)); private final Map<String, Job> jobs = new HashMap<>(); private final LongSupplier clock; private final Supplier<String> ids; private long nextSeq;
Scheduler(LongSupplier clock, Supplier<String> ids) { this.clock = clock; this.ids = ids; }
String schedule(Runnable task, long delayMillis) { Job j = new Job(ids.get(), task, nextSeq++, clock.getAsLong() + delayMillis); jobs.put(j.id, j); timetable.add(j); return j.id; }
// Removing from the middle of a heap means finding the job first, which is a scan. So a // cancelled job is only marked, and it is thrown away when it reaches the top. boolean cancel(String id) { Job j = jobs.remove(id); if (j == null) return false; j.cancelled = true; return true; }
// When the next live job is due, so the loop can sleep exactly until then. OptionalLong nextRunAt() { dropCancelled(); return timetable.isEmpty() ? OptionalLong.empty() : OptionalLong.of(timetable.peek().runAt); }
// Takes every job that is due, earliest first. Deciding what is due is separate from running // it, so stage 5 can hand the jobs to worker threads. List<Job> takeDue() { long now = clock.getAsLong(); List<Job> due = new ArrayList<>(); for (dropCancelled(); !timetable.isEmpty() && timetable.peek().runAt <= now; dropCancelled()) { Job j = timetable.poll(); jobs.remove(j.id); due.add(j); } return due; }
// Runs due jobs on the caller's thread. One that throws doesn't stop the others. // flaw: a slow job still makes the next one late. Stage 5 runs them on workers. int runDue() { List<Job> due = takeDue(); for (Job j : due) { try { j.task.run(); } catch (RuntimeException e) { // stage 4 decides what a failure means } } return due.size(); }
private void dropCancelled() { while (!timetable.isEmpty() && timetable.peek().cancelled) timetable.poll(); }}# Mutable now: cancelling marks a job instead of searching the timetable for it.@dataclass(eq=False)class Job: id: str task: Callable[[], None] seq: int # breaks ties: two jobs due at the same moment run in the order scheduled run_at: int cancelled: bool = False
def __lt__(self, other: "Job") -> bool: return (self.run_at, self.seq) < (other.run_at, other.seq)
# The timetable is a min-heap ordered by run time, so the next job due is always at# index 0. Adding a job and taking the next one are O(log n), and asking "when is the next# one?" is a peek. Nothing scans the jobs that aren't due.class Scheduler: def __init__(self, clock: Callable[[], int], ids: Callable[[], str]): self._timetable: list[Job] = [] self._jobs: dict[str, Job] = {} self._clock = clock self._ids = ids self._next_seq = 0
def schedule(self, task: Callable[[], None], delay_ms: int) -> str: job = Job(self._ids(), task, self._next_seq, self._clock() + delay_ms) self._next_seq += 1 self._jobs[job.id] = job heapq.heappush(self._timetable, job) return job.id
def cancel(self, job_id: str) -> bool: # Removing from the middle of a heap means finding the job first, which is a scan. So a # cancelled job is only marked, and it is thrown away when it reaches the top. job = self._jobs.pop(job_id, None) if job is None: return False job.cancelled = True return True
# When the next live job is due, so the loop can sleep exactly until then. def next_run_at(self) -> int | None: self._drop_cancelled() return self._timetable[0].run_at if self._timetable else None
# Every job that is due, earliest first. Deciding what is due is separate from running # it, so stage 5 can hand the jobs to worker threads. def take_due(self) -> list[Job]: now = self._clock() due = [] self._drop_cancelled() while self._timetable and self._timetable[0].run_at <= now: job = heapq.heappop(self._timetable) self._jobs.pop(job.id, None) due.append(job) self._drop_cancelled() return due
# Runs due jobs on the caller's thread. One that raises doesn't stop the others. # # flaw: a slow job still makes the next one late. Stage 5 runs them on workers. def run_due(self) -> int: due = self.take_due() for job in due: try: job.task() except Exception: pass # stage 4 decides what a failure means return len(due)
def _drop_cancelled(self) -> None: while self._timetable and self._timetable[0].cancelled: heapq.heappop(self._timetable)// A class now: cancelling marks a job instead of searching the timetable for it.public sealed class Job(string id, Action task, long seq, long runAt){ public string Id { get; } = id; public Action Task { get; } = task; public long Seq { get; } = seq; // breaks ties: two jobs due at the same moment run in the order scheduled public long RunAt { get; } = runAt; public bool Cancelled { get; set; }}
// The timetable is a PriorityQueue keyed by (run time, seq), so the next job due is always on// top. Adding a job and taking the next one are O(log n), and asking "when is the next one?"// is a peek. Nothing scans the jobs that aren't due.public sealed class Scheduler(Func<long> clock, Func<string> ids){ private readonly PriorityQueue<Job, (long RunAt, long Seq)> _timetable = new(); private readonly Dictionary<string, Job> _jobs = []; private long _nextSeq;
public string Schedule(Action task, long delayMs) { var job = new Job(ids(), task, _nextSeq++, clock() + delayMs); _jobs[job.Id] = job; _timetable.Enqueue(job, (job.RunAt, job.Seq)); return job.Id; }
// Removing from the middle of a heap means finding the job first, which is a scan. So a // cancelled job is only marked, and it is thrown away when it reaches the top. public bool Cancel(string id) { if (!_jobs.Remove(id, out var job)) return false; job.Cancelled = true; return true; }
// When the next live job is due, so the loop can sleep exactly until then. public long? NextRunAt() { DropCancelled(); return _timetable.TryPeek(out var job, out _) ? job.RunAt : null; }
// Takes every job that is due, earliest first. Deciding what is due is separate from running // it, so stage 5 can hand the jobs to worker threads. public List<Job> TakeDue() { var now = clock(); var due = new List<Job>(); for (DropCancelled(); _timetable.TryPeek(out var top, out _) && top.RunAt <= now; DropCancelled()) { _timetable.Dequeue(); _jobs.Remove(top.Id); due.Add(top); } return due; }
// Runs due jobs on the caller's thread. One that throws doesn't stop the others. // flaw: a slow job still makes the next one late. Stage 5 runs them on workers. public int RunDue() { var due = TakeDue(); foreach (var job in due) { try { job.Task(); } catch (Exception) { /* stage 4 decides what a failure means */ } } return due.Count; }
private void DropCancelled() { while (_timetable.TryPeek(out var top, out _) && top.Cancelled) _timetable.Dequeue(); }}// Job is shared by pointer now: cancelling marks it instead of searching the timetable for it.type Job struct { ID string Task func() seq int64 // breaks ties: two jobs due at the same moment run in the order scheduled RunAt int64 cancelled bool}
// timetable is a min-heap ordered by run time, for container/heap, so the next job due is// always at index 0. Adding a job and taking the next one are O(log n), and asking "when is// the next one?" is a peek. Nothing scans the jobs that aren't due.type timetable []*Job
func (t timetable) Len() int { return len(t) }func (t timetable) Less(a, b int) bool { if t[a].RunAt != t[b].RunAt { return t[a].RunAt < t[b].RunAt } return t[a].seq < t[b].seq}func (t timetable) Swap(a, b int) { t[a], t[b] = t[b], t[a] }func (t *timetable) Push(x any) { *t = append(*t, x.(*Job)) }func (t *timetable) Pop() any { old := *t j := old[len(old)-1] *t = old[:len(old)-1] return j}
type Scheduler struct { timetable timetable jobs map[string]*Job clock func() int64 ids func() string nextSeq int64}
func NewScheduler(clock func() int64, ids func() string) *Scheduler { return &Scheduler{jobs: map[string]*Job{}, clock: clock, ids: ids}}
func (s *Scheduler) Schedule(task func(), delay int64) string { j := &Job{ID: s.ids(), Task: task, seq: s.nextSeq, RunAt: s.clock() + delay} s.nextSeq++ s.jobs[j.ID] = j heap.Push(&s.timetable, j) return j.ID}
// Cancel only marks the job. Removing from the middle of a heap means finding the job first,// which is a scan, so a cancelled job is thrown away when it reaches the top.func (s *Scheduler) Cancel(id string) bool { j, ok := s.jobs[id] if !ok { return false } delete(s.jobs, id) j.cancelled = true return true}
// NextRunAt says when the next live job is due, so the loop can sleep exactly until then.func (s *Scheduler) NextRunAt() (int64, bool) { s.dropCancelled() if len(s.timetable) == 0 { return 0, false } return s.timetable[0].RunAt, true}
// TakeDue takes every job that is due, earliest first. Deciding what is due is separate from// running it, so stage 5 can hand the jobs to worker goroutines.func (s *Scheduler) TakeDue() []*Job { now := s.clock() var due []*Job for s.dropCancelled(); len(s.timetable) > 0 && s.timetable[0].RunAt <= now; s.dropCancelled() { j := heap.Pop(&s.timetable).(*Job) delete(s.jobs, j.ID) due = append(due, j) } return due}
// RunDue runs due jobs on the caller's goroutine. One that panics doesn't stop the others.// flaw: a slow job still makes the next one late. Stage 5 runs them on workers.func (s *Scheduler) RunDue() int { due := s.TakeDue() for _, j := range due { runSafely(j.Task) // stage 4 decides what a failure means } return len(due)}
// runSafely turns a panic into an error, so one bad task can't take the scheduler down.func runSafely(task func()) (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("%v", r) } }() task() return nil}
func (s *Scheduler) dropCancelled() { for len(s.timetable) > 0 && s.timetable[0].cancelled { heap.Pop(&s.timetable) }}// Mutable now: cancelling marks a job instead of searching the timetable for it.interface Job { id: string; task: Task; seq: number; // breaks ties: two jobs due at the same moment run in the order scheduled runAt: number; cancelled: boolean;}
// JavaScript has no built-in priority queue, so here is a small binary min-heap. The smallest// item is always at index 0; push and pop move one item up or down a path of log n parents.class MinHeap<T> { private items: T[] = [];
constructor(private readonly before: (a: T, b: T) => boolean) {}
get size(): number { return this.items.length; }
peek(): T | undefined { return this.items[0]; }
push(item: T): void { const a = this.items; a.push(item); for (let i = a.length - 1; i > 0; ) { const parent = (i - 1) >> 1; if (!this.before(a[i], a[parent])) break; [a[i], a[parent]] = [a[parent], a[i]]; i = parent; } }
pop(): T | undefined { const a = this.items; const top = a[0]; const last = a.pop(); if (a.length > 0 && last !== undefined) { a[0] = last; for (let i = 0; ; ) { const l = 2 * i + 1; const r = l + 1; let m = i; if (l < a.length && this.before(a[l], a[m])) m = l; if (r < a.length && this.before(a[r], a[m])) m = r; if (m === i) break; [a[i], a[m]] = [a[m], a[i]]; i = m; } } return top; }}
const byRunTime = (a: Job, b: Job) => a.runAt < b.runAt || (a.runAt === b.runAt && a.seq < b.seq);
// The timetable is a min-heap ordered by run time, so the next job due is always on top. Adding// a job and taking the next one are O(log n), and asking "when is the next one?" is a peek.// Nothing scans the jobs that aren't due.class Scheduler { private readonly timetable = new MinHeap<Job>(byRunTime); private readonly jobs = new Map<string, Job>(); private nextSeq = 0;
constructor( private readonly clock: () => number, private readonly ids: () => string, ) {}
schedule(task: Task, delayMs: number): string { const job = { id: this.ids(), task, seq: this.nextSeq++, runAt: this.clock() + delayMs, cancelled: false }; this.jobs.set(job.id, job); this.timetable.push(job); return job.id; }
// Removing from the middle of a heap means finding the job first, which is a scan. So a // cancelled job is only marked, and it is thrown away when it reaches the top. cancel(id: string): boolean { const job = this.jobs.get(id); if (!job) return false; this.jobs.delete(id); job.cancelled = true; return true; }
// When the next live job is due, so the loop can sleep exactly until then. nextRunAt(): number | undefined { this.dropCancelled(); return this.timetable.peek()?.runAt; }
// Takes every job that is due, earliest first. Deciding what is due is separate from running // it, so stage 5 can run several at once. takeDue(): Job[] { const now = this.clock(); const due: Job[] = []; for (this.dropCancelled(); (this.timetable.peek()?.runAt ?? Infinity) <= now; this.dropCancelled()) { const job = this.timetable.pop()!; this.jobs.delete(job.id); due.push(job); } return due; }
// Runs due jobs one after another. One that throws doesn't stop the others. // flaw: a slow job still makes the next one late. Stage 5 runs several at once. async runDue(): Promise<number> { const due = this.takeDue(); for (const job of due) { try { await job.task(); } catch { // stage 4 decides what a failure means } } return due.length; }
private dropCancelled(): void { while (this.timetable.peek()?.cancelled) this.timetable.pop(); }}Choosing what’s due (takeDue) is kept separate from running it (runDue). That separation is
where we’ll later hand jobs to workers instead.
Go deeperWhen marking is the wrong choice1 min
| Mark, and skip it at the top | Remove from the heap now | |
|---|---|---|
| Cost of cancel | O(1) | O(n) to find it, or O(log n) if every job tracks its spot |
| Memory | Cancelled jobs stay until due | Freed at once |
| Code | One flag, and a loop at the top | The heap must track every job’s position |
| Best when | Cancels are rare, or jobs are short-lived | Many long-lived jobs are cancelled |
We mark. The timetable can fill up with cancelled jobs, for example reminders cancelled weeks ahead. So once more than half are cancelled, the scheduler rebuilds the heap without them.
Go deeperWhy a heap, and not a sorted list or a tree2 min
Three structures can keep jobs in time order. Here is what each costs with a million jobs.
| Structure | Add a job | Take the earliest | Cancel by id |
|---|---|---|---|
| Sorted list (array) | O(n) | O(1) | O(n) |
| Min-heap | O(log n) | O(log n) | O(1) by marking |
| Balanced tree | O(log n) | O(log n) | O(log n), really removed |
A sorted list is fast to read but slow to add to: every new job shifts, on average, half a million entries to make room. Reminders arrive all day, so that’s the wrong trade.
A balanced tree, such as Java’s TreeMap or a skip list, does everything in O(log n), and it
can really remove a cancelled job. It’s a fine answer, and the better one when most jobs are
cancelled long before they’re due. The price is more memory per job, and more code to get right.
A heap is the simplest structure that answers the only question the scheduler asks often, “what’s earliest?”, cheaply. It’s a plain array, so it’s small and fast in practice. With marking, its one weak spot, cancel, costs O(1). That’s why most schedulers in standard libraries use one.
Stage 3: Repeating jobs
Section titled “Stage 3: Repeating jobs”The cache refresh should run every five minutes. Sometimes it takes six. What happens?
Whether you know there are two meanings of “every five minutes”, and say which one you’re building. One red flag is a slow run letting the next one start while it’s still going. Another is a paused scheduler that fires the job ten times in a row when it resumes.
“Every five minutes” means one of two things:
- Fixed rate: start at 10:00, 10:05, 10:10, on the clock, however long each run takes. A report that must arrive on the hour wants this.
- Fixed delay: wait five minutes after each run finishes. A run that takes two minutes pushes the next one to 10:07. A job that checks a mailbox wants this, because there’s no point checking again before the last check is done.
Both kinds go back on the timetable after each run, but at different moments. A fixed-rate job is put back as soon as it’s taken, at its next slot. So its next run time doesn’t depend on how long the run takes. A fixed-delay job is put back only when the run finishes, at the finish time plus the delay.
Two cases decide whether fixed rate is correct. First, if a run is still going when the next slot comes, that slot is skipped and counted, so runs never overlap. Second, suppose the scheduler was paused for three periods, by a long garbage collection pause or a closed laptop lid. The job then runs once to catch up, and goes back to its slots. Firing three times in a row would hit whatever the job talks to, all at once, for no benefit.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
A job’s running flag tells the scheduler that a fixed-rate run is still going, and
finished(job) is the one place that clears it. A repeating job keeps its id across runs, so cancel
works on the whole series: the current run finishes, and no more start.
// How a job repeats, if at all.enum Repeat { ONCE, FIXED_RATE, // starts every period on the clock: 10:00, 10:05, 10:10, however long each run takes FIXED_DELAY // waits a delay after each run finishes: a run that takes 2 minutes pushes the next one back}
class Job { final String id; final Runnable task; final Repeat repeat; final long every; // the period or the delay; unused for ONCE long seq; long runAt; boolean running; boolean cancelled;
Job(String id, Runnable task, Repeat repeat, long every, long runAt) { this.id = id; this.task = task; this.repeat = repeat; this.every = every; this.runAt = runAt; }}
class Scheduler { private final PriorityQueue<Job> timetable = new PriorityQueue<>(Comparator.comparingLong((Job j) -> j.runAt).thenComparingLong(j -> j.seq)); private final Map<String, Job> jobs = new HashMap<>(); private final LongSupplier clock; private final Supplier<String> ids; private long nextSeq; long skipped; // fixed-rate runs skipped because the previous one was still going
Scheduler(LongSupplier clock, Supplier<String> ids) { this.clock = clock; this.ids = ids; }
String schedule(Runnable task, long delayMillis) { return add(task, Repeat.ONCE, 0, delayMillis); }
String scheduleAtFixedRate(Runnable task, long initialDelay, long period) { return add(task, Repeat.FIXED_RATE, period, initialDelay); }
String scheduleWithFixedDelay(Runnable task, long initialDelay, long delay) { return add(task, Repeat.FIXED_DELAY, delay, initialDelay); }
// A running job finishes its current run; cancelling stops the ones after it. boolean cancel(String id) { Job j = jobs.remove(id); if (j == null) return false; j.cancelled = true; return true; }
OptionalLong nextRunAt() { dropCancelled(); return timetable.isEmpty() ? OptionalLong.empty() : OptionalLong.of(timetable.peek().runAt); }
List<Job> takeDue() { long now = clock.getAsLong(); List<Job> due = new ArrayList<>(); for (dropCancelled(); !timetable.isEmpty() && timetable.peek().runAt <= now; dropCancelled()) { Job j = timetable.poll(); if (j.repeat == Repeat.FIXED_RATE) { // The next run goes on the timetable now, at the next slot after now. A scheduler // that was paused for three periods runs the job once, not three times in a row. long next = j.runAt + j.every; if (next <= now) next += (now - next) / j.every * j.every + j.every; requeue(j, next); if (j.running) { // the last run is still going: skip this one rather than overlap skipped++; continue; } } j.running = true; due.add(j); } return due; }
// Called when a run ends. A fixed-delay job is only now put back, a delay after it finished. void finished(Job j) { j.running = false; if (j.cancelled) return; if (j.repeat == Repeat.ONCE) jobs.remove(j.id); if (j.repeat == Repeat.FIXED_DELAY) requeue(j, clock.getAsLong() + j.every); }
int runDue() { List<Job> due = takeDue(); for (Job j : due) { try { j.task.run(); } catch (RuntimeException e) { // stage 4 } finished(j); } return due.size(); }
private String add(Runnable task, Repeat repeat, long every, long delay) { Job j = new Job(ids.get(), task, repeat, every, 0); jobs.put(j.id, j); requeue(j, clock.getAsLong() + delay); return j.id; }
private void requeue(Job j, long runAt) { j.runAt = runAt; j.seq = nextSeq++; timetable.add(j); }
private void dropCancelled() { while (!timetable.isEmpty() && timetable.peek().cancelled) timetable.poll(); }}# How a job repeats, if at all.class Repeat(Enum): ONCE = "once" FIXED_RATE = "fixed_rate" # starts every period on the clock: 10:00, 10:05, 10:10, however long each run takes FIXED_DELAY = "fixed_delay" # waits a delay after each run finishes: a 2-minute run pushes the next one back
@dataclass(eq=False)class Job: id: str task: Callable[[], None] repeat: Repeat every: int # the period or the delay; unused for ONCE seq: int = 0 run_at: int = 0 running: bool = False cancelled: bool = False
def __lt__(self, other: "Job") -> bool: return (self.run_at, self.seq) < (other.run_at, other.seq)
class Scheduler: def __init__(self, clock: Callable[[], int], ids: Callable[[], str]): self._timetable: list[Job] = [] self._jobs: dict[str, Job] = {} self._clock = clock self._ids = ids self._next_seq = 0 self.skipped = 0 # fixed-rate runs skipped because the previous one was still going
def schedule(self, task: Callable[[], None], delay_ms: int) -> str: return self._add(task, Repeat.ONCE, 0, delay_ms)
def schedule_at_fixed_rate(self, task: Callable[[], None], initial_delay: int, period: int) -> str: return self._add(task, Repeat.FIXED_RATE, period, initial_delay)
def schedule_with_fixed_delay(self, task: Callable[[], None], initial_delay: int, delay: int) -> str: return self._add(task, Repeat.FIXED_DELAY, delay, initial_delay)
def cancel(self, job_id: str) -> bool: # A running job finishes its current run; cancelling stops the ones after it. job = self._jobs.pop(job_id, None) if job is None: return False job.cancelled = True return True
def next_run_at(self) -> int | None: self._drop_cancelled() return self._timetable[0].run_at if self._timetable else None
def take_due(self) -> list[Job]: now = self._clock() due = [] self._drop_cancelled() while self._timetable and self._timetable[0].run_at <= now: job = heapq.heappop(self._timetable) if job.repeat is Repeat.FIXED_RATE: # The next run goes on the timetable now, at the next slot after now. A scheduler # that was paused for three periods runs the job once, not three times in a row. nxt = job.run_at + job.every if nxt <= now: nxt += (now - nxt) // job.every * job.every + job.every self._requeue(job, nxt) if job.running: # the last run is still going: skip this one rather than overlap self.skipped += 1 self._drop_cancelled() continue job.running = True due.append(job) self._drop_cancelled() return due
# Called when a run ends. A fixed-delay job is only now put back, a delay after it finished. def finished(self, job: Job) -> None: job.running = False if job.cancelled: return if job.repeat is Repeat.ONCE: self._jobs.pop(job.id, None) if job.repeat is Repeat.FIXED_DELAY: self._requeue(job, self._clock() + job.every)
def run_due(self) -> int: due = self.take_due() for job in due: try: job.task() except Exception: pass # stage 4 self.finished(job) return len(due)
def _add(self, task: Callable[[], None], repeat: Repeat, every: int, delay: int) -> str: job = Job(self._ids(), task, repeat, every) self._jobs[job.id] = job self._requeue(job, self._clock() + delay) return job.id
def _requeue(self, job: Job, run_at: int) -> None: job.run_at = run_at job.seq = self._next_seq self._next_seq += 1 heapq.heappush(self._timetable, job)
def _drop_cancelled(self) -> None: while self._timetable and self._timetable[0].cancelled: heapq.heappop(self._timetable)// How a job repeats, if at all.public enum Repeat{ Once, FixedRate, // starts every period on the clock: 10:00, 10:05, 10:10, however long each run takes FixedDelay, // waits a delay after each run finishes: a 2-minute run pushes the next one back}
public sealed class Job(string id, Action task, Repeat repeat, long every){ public string Id { get; } = id; public Action Task { get; } = task; public Repeat Repeat { get; } = repeat; public long Every { get; } = every; // the period or the delay; unused for Once public long Seq { get; set; } public long RunAt { get; set; } public bool Running { get; set; } public bool Cancelled { get; set; }}
public sealed class Scheduler(Func<long> clock, Func<string> ids){ private readonly PriorityQueue<Job, (long RunAt, long Seq)> _timetable = new(); private readonly Dictionary<string, Job> _jobs = []; private long _nextSeq;
public long Skipped { get; private set; } // fixed-rate runs skipped because the previous one was still going
public string Schedule(Action task, long delayMs) => Add(task, Repeat.Once, 0, delayMs);
public string ScheduleAtFixedRate(Action task, long initialDelay, long period) => Add(task, Repeat.FixedRate, period, initialDelay);
public string ScheduleWithFixedDelay(Action task, long initialDelay, long delay) => Add(task, Repeat.FixedDelay, delay, initialDelay);
// A running job finishes its current run; cancelling stops the ones after it. public bool Cancel(string id) { if (!_jobs.Remove(id, out var job)) return false; job.Cancelled = true; return true; }
public long? NextRunAt() { DropCancelled(); return _timetable.TryPeek(out var job, out _) ? job.RunAt : null; }
public List<Job> TakeDue() { var now = clock(); var due = new List<Job>(); for (DropCancelled(); _timetable.TryPeek(out var job, out _) && job.RunAt <= now; DropCancelled()) { _timetable.Dequeue(); if (job.Repeat == Repeat.FixedRate) { // The next run goes on the timetable now, at the next slot after now. A scheduler // that was paused for three periods runs the job once, not three times in a row. var next = job.RunAt + job.Every; if (next <= now) next += (now - next) / job.Every * job.Every + job.Every; Requeue(job, next); if (job.Running) // the last run is still going: skip this one rather than overlap { Skipped++; continue; } } job.Running = true; due.Add(job); } return due; }
// Called when a run ends. A fixed-delay job is only now put back, a delay after it finished. public void Finished(Job job) { job.Running = false; if (job.Cancelled) return; if (job.Repeat == Repeat.Once) _jobs.Remove(job.Id); if (job.Repeat == Repeat.FixedDelay) Requeue(job, clock() + job.Every); }
public int RunDue() { var due = TakeDue(); foreach (var job in due) { try { job.Task(); } catch (Exception) { /* stage 4 */ } Finished(job); } return due.Count; }
private string Add(Action task, Repeat repeat, long every, long delay) { var job = new Job(ids(), task, repeat, every); _jobs[job.Id] = job; Requeue(job, clock() + delay); return job.Id; }
private void Requeue(Job job, long runAt) { job.RunAt = runAt; job.Seq = _nextSeq++; _timetable.Enqueue(job, (job.RunAt, job.Seq)); }
private void DropCancelled() { while (_timetable.TryPeek(out var top, out _) && top.Cancelled) _timetable.Dequeue(); }}// Repeat says how a job repeats, if at all.type Repeat int
const ( Once Repeat = iota FixedRate // starts every period on the clock: 10:00, 10:05, 10:10, however long each run takes FixedDelay // waits a delay after each run finishes: a 2-minute run pushes the next one back)
type Job struct { ID string Task func() Repeat Repeat Every int64 // the period or the delay; unused for Once seq int64 RunAt int64 running bool cancelled bool}
type Scheduler struct { timetable timetable jobs map[string]*Job clock func() int64 ids func() string nextSeq int64 Skipped int // fixed-rate runs skipped because the previous one was still going}
func NewScheduler(clock func() int64, ids func() string) *Scheduler { return &Scheduler{jobs: map[string]*Job{}, clock: clock, ids: ids}}
func (s *Scheduler) Schedule(task func(), delay int64) string { return s.add(task, Once, 0, delay) }
func (s *Scheduler) ScheduleAtFixedRate(task func(), initialDelay, period int64) string { return s.add(task, FixedRate, period, initialDelay)}
func (s *Scheduler) ScheduleWithFixedDelay(task func(), initialDelay, delay int64) string { return s.add(task, FixedDelay, delay, initialDelay)}
// Cancel lets a running job finish its current run, and stops the ones after it.func (s *Scheduler) Cancel(id string) bool { j, ok := s.jobs[id] if !ok { return false } delete(s.jobs, id) j.cancelled = true return true}
func (s *Scheduler) NextRunAt() (int64, bool) { s.dropCancelled() if len(s.timetable) == 0 { return 0, false } return s.timetable[0].RunAt, true}
func (s *Scheduler) TakeDue() []*Job { now := s.clock() var due []*Job for s.dropCancelled(); len(s.timetable) > 0 && s.timetable[0].RunAt <= now; s.dropCancelled() { j := heap.Pop(&s.timetable).(*Job) if j.Repeat == FixedRate { // The next run goes on the timetable now, at the next slot after now. A scheduler // that was paused for three periods runs the job once, not three times in a row. next := j.RunAt + j.Every if next <= now { next += (now-next)/j.Every*j.Every + j.Every } s.requeue(j, next) if j.running { // the last run is still going: skip this one rather than overlap s.Skipped++ continue } } j.running = true due = append(due, j) } return due}
// Finished is called when a run ends. A fixed-delay job is only now put back, a delay after// it finished.func (s *Scheduler) Finished(j *Job) { j.running = false if j.cancelled { return } switch j.Repeat { case Once: delete(s.jobs, j.ID) case FixedDelay: s.requeue(j, s.clock()+j.Every) }}
func (s *Scheduler) RunDue() int { due := s.TakeDue() for _, j := range due { runSafely(j.Task) // stage 4 s.Finished(j) } return len(due)}
func (s *Scheduler) add(task func(), repeat Repeat, every, delay int64) string { j := &Job{ID: s.ids(), Task: task, Repeat: repeat, Every: every} s.jobs[j.ID] = j s.requeue(j, s.clock()+delay) return j.ID}
func (s *Scheduler) requeue(j *Job, runAt int64) { j.RunAt = runAt j.seq = s.nextSeq s.nextSeq++ heap.Push(&s.timetable, j)}
func (s *Scheduler) dropCancelled() { for len(s.timetable) > 0 && s.timetable[0].cancelled { heap.Pop(&s.timetable) }}// once; fixed-rate starts every period on the clock (10:00, 10:05, 10:10) however long each run// takes; fixed-delay waits a delay after each run finishes, so a 2-minute run pushes the next back.type Repeat = 'once' | 'fixed-rate' | 'fixed-delay';
interface Job { id: string; task: Task; repeat: Repeat; every: number; // the period or the delay; unused for 'once' seq: number; runAt: number; running: boolean; cancelled: boolean;}
class Scheduler { private readonly timetable = new MinHeap<Job>(byRunTime); private readonly jobs = new Map<string, Job>(); private nextSeq = 0; skipped = 0; // fixed-rate runs skipped because the previous one was still going
constructor( private readonly clock: () => number, private readonly ids: () => string, ) {}
schedule(task: Task, delayMs: number): string { return this.add(task, 'once', 0, delayMs); }
scheduleAtFixedRate(task: Task, initialDelay: number, period: number): string { return this.add(task, 'fixed-rate', period, initialDelay); }
scheduleWithFixedDelay(task: Task, initialDelay: number, delay: number): string { return this.add(task, 'fixed-delay', delay, initialDelay); }
// A running job finishes its current run; cancelling stops the ones after it. cancel(id: string): boolean { const job = this.jobs.get(id); if (!job) return false; this.jobs.delete(id); job.cancelled = true; return true; }
nextRunAt(): number | undefined { this.dropCancelled(); return this.timetable.peek()?.runAt; }
takeDue(): Job[] { const now = this.clock(); const due: Job[] = []; for (this.dropCancelled(); (this.timetable.peek()?.runAt ?? Infinity) <= now; this.dropCancelled()) { const job = this.timetable.pop()!; if (job.repeat === 'fixed-rate') { // The next run goes on the timetable now, at the next slot after now. A scheduler that // was paused for three periods runs the job once, not three times in a row. let next = job.runAt + job.every; if (next <= now) next += Math.floor((now - next) / job.every) * job.every + job.every; this.requeue(job, next); if (job.running) { // the last run is still going: skip this one rather than overlap this.skipped++; continue; } } job.running = true; due.push(job); } return due; }
// Called when a run ends. A fixed-delay job is only now put back, a delay after it finished. finished(job: Job): void { job.running = false; if (job.cancelled) return; if (job.repeat === 'once') this.jobs.delete(job.id); if (job.repeat === 'fixed-delay') this.requeue(job, this.clock() + job.every); }
async runDue(): Promise<number> { const due = this.takeDue(); for (const job of due) { try { await job.task(); } catch { // stage 4 } this.finished(job); } return due.length; }
private add(task: Task, repeat: Repeat, every: number, delay: number): string { const job: Job = { id: this.ids(), task, repeat, every, seq: 0, runAt: 0, running: false, cancelled: false }; this.jobs.set(job.id, job); this.requeue(job, this.clock() + delay); return job.id; }
private requeue(job: Job, runAt: number): void { job.runAt = runAt; job.seq = this.nextSeq++; this.timetable.push(job); }
private dropCancelled(): void { while (this.timetable.peek()?.cancelled) this.timetable.pop(); }}Go deeperA six-minute run, with a five-minute period2 min
Let’s follow the cache refresh with both kinds, when one run takes six minutes.
With fixed rate, the slots are 10:00, 10:05 and 10:10. The 10:00 run starts, and goes on until 10:06. At 10:05, the scheduler sees that the job is still running. So it skips the 10:05 slot, counts the skip, and puts the job back for 10:10. At 10:10, the run starts on time. The clock stays the same, and one run was lost. The skip count shows it.
With fixed delay, the 10:00 run finishes at 10:06, and the next run is put on the timetable for 10:11. Nothing is skipped, but the whole series slides later.
- Color key
- Background worker
- Facade
Why not let fixed-rate runs overlap, if the job is slow? Because most jobs aren’t written to run twice at once. Two cache refreshes at the same time double the load on the database. And they do it when the database is already slow, which is often why the run was slow in the first place. Skipping and counting the skip makes the problem visible, without making it worse.
Stage 4: Failures, retries and status
Section titled “Stage 4: Failures, retries and status”The reminder email fails because the mail server is down. What happens to it? And how does the order service find out?
Whether you retry with growing waits instead of right away, and treat a repeating job differently from a one-off. They also check that a failure is visible: a status someone can ask for, and the error that caused it.
A one-off job that fails is retried, with growing waits: up to three attempts in all, waiting one second, then two. Retrying right away would hit a mail server that’s down three times in a millisecond. After the last attempt, the job is failed, and its last error is kept.
A repeating job is not retried. Its next run is its retry, and one failed refresh shouldn’t end a series that runs again in five minutes. The error is still recorded.
Where does a retry wait? It goes back on the timetable, at a later time. So the worker is free for other jobs while it waits, cancel still works during the wait, and the status honestly says “scheduled”. A retry is just a job with a later run time, and the timetable already knows how to wait for those.
Every job now has a status, and the scheduler keeps finished jobs in its map, so callers can ask for it. A job that has ended can’t be cancelled. A running job can be: it finishes its current run, but never runs again.
- Color key
- State
- Ends well
- Fails or stops
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// Where a job is in its life. SUCCEEDED, FAILED and CANCELLED are final.enum Status { SCHEDULED, RUNNING, SUCCEEDED, FAILED, CANCELLED }
// How a one-off job retries: up to maxAttempts runs in all, waiting backoff, then twice that,// then four times, so a struggling service isn't hammered.record Retry(int maxAttempts, long backoffMillis) {}
// One scheduled job and its bookkeeping. runAt is when it runs next. seq breaks ties: jobs due// at the same moment run in the order they were queued. running is true while a run is in// progress, and cancelled is only a mark, because taking a job out of the middle of the// timetable is slow.class Job { final String id; final Runnable task; final Repeat repeat; final long every; long seq; long runAt; boolean running; boolean cancelled; Status status = Status.SCHEDULED; int attempts; // runs started so far String lastError;
Job(String id, Runnable task, Repeat repeat, long every, long runAt) { this.id = id; this.task = task; this.repeat = repeat; this.every = every; this.runAt = runAt; }}
// The timetable. A priority queue keeps the job due soonest at the front, so finding what is// due never scans every job. It has no threads of its own: tests drive it with a fake clock,// and a separate runner drives it for real.class Scheduler { private final PriorityQueue<Job> timetable = new PriorityQueue<>(Comparator.comparingLong((Job j) -> j.runAt).thenComparingLong(j -> j.seq)); private final Map<String, Job> jobs = new HashMap<>(); // kept after they end, so status can be asked private final LongSupplier clock; private final Supplier<String> ids; private final Retry retry; private long nextSeq; // Fixed-rate runs skipped because the previous run of the same job was still going. long skipped;
Scheduler(LongSupplier clock, Supplier<String> ids, Retry retry) { this.clock = clock; this.ids = ids; this.retry = retry; }
String schedule(Runnable task, long delayMillis) { return add(task, Repeat.ONCE, 0, delayMillis); }
String scheduleAtFixedRate(Runnable task, long initialDelay, long period) { return add(task, Repeat.FIXED_RATE, period, initialDelay); }
String scheduleWithFixedDelay(Runnable task, long initialDelay, long delay) { return add(task, Repeat.FIXED_DELAY, delay, initialDelay); }
// Only a job that hasn't ended can be cancelled. A running job finishes its current run, but // it is reported as cancelled and never runs again. boolean cancel(String id) { Job j = jobs.get(id); if (j == null || j.cancelled || j.status == Status.SUCCEEDED || j.status == Status.FAILED) return false; j.cancelled = true; j.status = Status.CANCELLED; return true; }
Status status(String id) { return jobs.get(id).status; }
String lastError(String id) { return jobs.get(id).lastError; }
// When the next job is due, or nothing if the timetable is empty. Cancelled jobs at the // front are dropped first, so they can't cause a pointless wake-up. OptionalLong nextRunAt() { dropCancelled(); return timetable.isEmpty() ? OptionalLong.empty() : OptionalLong.of(timetable.peek().runAt); }
// Every job due by now, marked as running. A fixed-rate job is put back for its next slot // at once, counted from its schedule, not from now. Slots missed while the machine was busy // are skipped, not run in a burst. If its last run is still going, this run is skipped too, // so two runs of one job never overlap. List<Job> takeDue() { long now = clock.getAsLong(); List<Job> due = new ArrayList<>(); for (dropCancelled(); !timetable.isEmpty() && timetable.peek().runAt <= now; dropCancelled()) { Job j = timetable.poll(); if (j.repeat == Repeat.FIXED_RATE) { long next = j.runAt + j.every; if (next <= now) next += (now - next) / j.every * j.every + j.every; requeue(j, next); if (j.running) { skipped++; continue; } } j.running = true; j.status = Status.RUNNING; j.attempts++; due.add(j); } return due; }
// error is null when the run succeeded. A one-off job that fails is retried with growing // waits, then marked FAILED. A repeating job isn't retried: its next run is the retry, and // one bad run doesn't end the series. void finished(Job j, Throwable error) { j.running = false; if (error != null) j.lastError = String.valueOf(error.getMessage()); if (j.cancelled) return; long now = clock.getAsLong(); switch (j.repeat) { case ONCE -> { if (error == null) { j.status = Status.SUCCEEDED; } else if (j.attempts < retry.maxAttempts()) { j.status = Status.SCHEDULED; requeue(j, now + (retry.backoffMillis() << (j.attempts - 1))); } else { j.status = Status.FAILED; } } case FIXED_RATE -> j.status = Status.SCHEDULED; case FIXED_DELAY -> { j.status = Status.SCHEDULED; requeue(j, now + j.every); } } }
// Runs every due job on the calling thread, one after another. Tests use this with a fake // clock. int runDue() { List<Job> due = takeDue(); for (Job j : due) { Throwable error = null; try { j.task.run(); } catch (RuntimeException e) { error = e; } finished(j, error); } return due.size(); }
// Every schedule call ends here. The job is remembered by id even after it ends, so its // status can still be asked. private String add(Runnable task, Repeat repeat, long every, long delay) { Job j = new Job(ids.get(), task, repeat, every, 0); jobs.put(j.id, j); requeue(j, clock.getAsLong() + delay); return j.id; }
// Each requeue takes the next seq, so jobs due at the same moment run in the order they // were queued. private void requeue(Job j, long runAt) { j.runAt = runAt; j.seq = nextSeq++; timetable.add(j); }
// Cancel only marks a job. Marked jobs are thrown away here, once they reach the front. private void dropCancelled() { while (!timetable.isEmpty() && timetable.peek().cancelled) timetable.poll(); }}# Where a job is in its life. SUCCEEDED, FAILED and CANCELLED are final.class Status(Enum): SCHEDULED = "scheduled" RUNNING = "running" SUCCEEDED = "succeeded" FAILED = "failed" CANCELLED = "cancelled"
# How a one-off job retries: up to max_attempts runs in all, waiting backoff, then twice# that, then four times, so a struggling service isn't hammered.@dataclass(frozen=True)class Retry: max_attempts: int backoff_ms: int
# One scheduled job and its bookkeeping. run_at is when it runs next. seq breaks ties: jobs due# at the same moment run in the order they were queued. running is true while a run is in# progress, and cancelled is only a mark, because taking a job out of the middle of the# timetable is slow.@dataclass(eq=False)class Job: id: str task: Callable[[], None] repeat: Repeat every: int seq: int = 0 run_at: int = 0 running: bool = False cancelled: bool = False status: Status = Status.SCHEDULED attempts: int = 0 # runs started so far last_error: str | None = None
def __lt__(self, other: "Job") -> bool: return (self.run_at, self.seq) < (other.run_at, other.seq)
# The timetable. A priority queue keeps the job due soonest at the front, so finding what is due# never scans every job. It has no threads of its own: tests drive it with a fake clock, and a# separate runner drives it for real.class Scheduler: def __init__(self, clock: Callable[[], int], ids: Callable[[], str], retry: Retry): self._timetable: list[Job] = [] self._jobs: dict[str, Job] = {} # kept after they end, so status can be asked self._clock = clock self._ids = ids self._retry = retry self._next_seq = 0 # Fixed-rate runs skipped because the previous run of the same job was still going. self.skipped = 0
def schedule(self, task: Callable[[], None], delay_ms: int) -> str: return self._add(task, Repeat.ONCE, 0, delay_ms)
def schedule_at_fixed_rate(self, task: Callable[[], None], initial_delay: int, period: int) -> str: return self._add(task, Repeat.FIXED_RATE, period, initial_delay)
def schedule_with_fixed_delay(self, task: Callable[[], None], initial_delay: int, delay: int) -> str: return self._add(task, Repeat.FIXED_DELAY, delay, initial_delay)
def cancel(self, job_id: str) -> bool: # Only a job that hasn't ended can be cancelled. A running job finishes its current run, # but it is reported as cancelled and never runs again. job = self._jobs.get(job_id) if job is None or job.cancelled or job.status in (Status.SUCCEEDED, Status.FAILED): return False job.cancelled = True job.status = Status.CANCELLED return True
def status(self, job_id: str) -> Status: return self._jobs[job_id].status
def last_error(self, job_id: str) -> str | None: return self._jobs[job_id].last_error
# When the next job is due, or nothing if the timetable is empty. Cancelled jobs at the # front are dropped first, so they can't cause a pointless wake-up. def next_run_at(self) -> int | None: self._drop_cancelled() return self._timetable[0].run_at if self._timetable else None
# Every job due by now, marked as running. A fixed-rate job is put back for its next slot at # once, counted from its schedule, not from now. Slots missed while the machine was busy are # skipped, not run in a burst. If its last run is still going, this run is skipped too, so # two runs of one job never overlap. def take_due(self) -> list[Job]: now = self._clock() due = [] self._drop_cancelled() while self._timetable and self._timetable[0].run_at <= now: job = heapq.heappop(self._timetable) if job.repeat is Repeat.FIXED_RATE: nxt = job.run_at + job.every if nxt <= now: nxt += (now - nxt) // job.every * job.every + job.every self._requeue(job, nxt) if job.running: self.skipped += 1 self._drop_cancelled() continue job.running = True job.status = Status.RUNNING job.attempts += 1 due.append(job) self._drop_cancelled() return due
# error is None when the run succeeded. A one-off job that fails is retried with # growing waits, then marked FAILED. A repeating job isn't retried: its next run is the # retry, and one bad run doesn't end the series. def finished(self, job: Job, error: Exception | None) -> None: job.running = False if error is not None: job.last_error = str(error) if job.cancelled: return now = self._clock() if job.repeat is Repeat.ONCE: if error is None: job.status = Status.SUCCEEDED elif job.attempts < self._retry.max_attempts: job.status = Status.SCHEDULED self._requeue(job, now + self._retry.backoff_ms * 2 ** (job.attempts - 1)) else: job.status = Status.FAILED elif job.repeat is Repeat.FIXED_RATE: job.status = Status.SCHEDULED else: job.status = Status.SCHEDULED self._requeue(job, now + job.every)
# Runs every due job on the calling thread, one after another. Tests use this with a fake # clock. def run_due(self) -> int: due = self.take_due() for job in due: error = None try: job.task() except Exception as e: error = e self.finished(job, error) return len(due)
# Every schedule call ends here. The job is remembered by id even after it ends, so its # status can still be asked. def _add(self, task: Callable[[], None], repeat: Repeat, every: int, delay: int) -> str: job = Job(self._ids(), task, repeat, every) self._jobs[job.id] = job self._requeue(job, self._clock() + delay) return job.id
# Each requeue takes the next seq, so jobs due at the same moment run in the order they were # queued. def _requeue(self, job: Job, run_at: int) -> None: job.run_at = run_at job.seq = self._next_seq self._next_seq += 1 heapq.heappush(self._timetable, job)
# Cancel only marks a job. Marked jobs are thrown away here, once they reach the front. def _drop_cancelled(self) -> None: while self._timetable and self._timetable[0].cancelled: heapq.heappop(self._timetable)// Where a job is in its life. SUCCEEDED, FAILED and CANCELLED are final.public enum Status { Scheduled, Running, Succeeded, Failed, Cancelled }
// How a one-off job retries: up to MaxAttempts runs in all, waiting BackoffMs, then twice that,// then four times, so a struggling service isn't hammered.public sealed record Retry(int MaxAttempts, long BackoffMs);
// One scheduled job and its bookkeeping. RunAt is when it runs next. Seq breaks ties: jobs due// at the same moment run in the order they were queued. Running is true while a run is in// progress, and Cancelled is only a mark, because taking a job out of the middle of the// timetable is slow.public sealed class Job(string id, Action task, Repeat repeat, long every){ public string Id { get; } = id; public Action Task { get; } = task; public Repeat Repeat { get; } = repeat; public long Every { get; } = every; public long Seq { get; set; } public long RunAt { get; set; } public bool Running { get; set; } public bool Cancelled { get; set; } public Status Status { get; set; } = Status.Scheduled; public int Attempts { get; set; } // runs started so far public string? LastError { get; set; }}
// The timetable. A priority queue keeps the job due soonest at the front, so finding what is// due never scans every job. It has no threads of its own: tests drive it with a fake clock,// and a separate runner drives it for real.public sealed class Scheduler(Func<long> clock, Func<string> ids, Retry retry){ private readonly PriorityQueue<Job, (long RunAt, long Seq)> _timetable = new(); private readonly Dictionary<string, Job> _jobs = []; // kept after they end, so status can be asked private long _nextSeq;
// Fixed-rate runs skipped because the previous run of the same job was still going. public long Skipped { get; private set; }
public string Schedule(Action task, long delayMs) => Add(task, Repeat.Once, 0, delayMs);
public string ScheduleAtFixedRate(Action task, long initialDelay, long period) => Add(task, Repeat.FixedRate, period, initialDelay);
public string ScheduleWithFixedDelay(Action task, long initialDelay, long delay) => Add(task, Repeat.FixedDelay, delay, initialDelay);
// Only a job that hasn't ended can be cancelled. A running job finishes its current run, but // it is reported as cancelled and never runs again. public bool Cancel(string id) { if (!_jobs.TryGetValue(id, out var job) || job.Cancelled) return false; if (job.Status is Status.Succeeded or Status.Failed) return false; job.Cancelled = true; job.Status = Status.Cancelled; return true; }
public Status StatusOf(string id) => _jobs[id].Status;
public string? LastError(string id) => _jobs[id].LastError;
// When the next job is due, or nothing if the timetable is empty. Cancelled jobs at the // front are dropped first, so they can't cause a pointless wake-up. public long? NextRunAt() { DropCancelled(); return _timetable.TryPeek(out var job, out _) ? job.RunAt : null; }
// Every job due by now, marked as running. A fixed-rate job is put back for its next slot // at once, counted from its schedule, not from now. Slots missed while the machine was busy // are skipped, not run in a burst. If its last run is still going, this run is skipped too, // so two runs of one job never overlap. public List<Job> TakeDue() { var now = clock(); var due = new List<Job>(); for (DropCancelled(); _timetable.TryPeek(out var job, out _) && job.RunAt <= now; DropCancelled()) { _timetable.Dequeue(); if (job.Repeat == Repeat.FixedRate) { var next = job.RunAt + job.Every; if (next <= now) next += (now - next) / job.Every * job.Every + job.Every; Requeue(job, next); if (job.Running) { Skipped++; continue; } } job.Running = true; job.Status = Status.Running; job.Attempts++; due.Add(job); } return due; }
// error is null when the run succeeded. A one-off job that fails is retried with growing // waits, then marked Failed. A repeating job isn't retried: its next run is the retry, and // one bad run doesn't end the series. public void Finished(Job job, Exception? error) { job.Running = false; if (error is not null) job.LastError = error.Message; if (job.Cancelled) return; var now = clock(); switch (job.Repeat) { case Repeat.Once when error is null: job.Status = Status.Succeeded; break; case Repeat.Once when job.Attempts < retry.MaxAttempts: job.Status = Status.Scheduled; Requeue(job, now + (retry.BackoffMs << (job.Attempts - 1))); break; case Repeat.Once: job.Status = Status.Failed; break; case Repeat.FixedRate: job.Status = Status.Scheduled; break; case Repeat.FixedDelay: job.Status = Status.Scheduled; Requeue(job, now + job.Every); break; } }
// Runs every due job on the calling thread, one after another. Tests use this with a fake // clock. public int RunDue() { var due = TakeDue(); foreach (var job in due) { Exception? error = null; try { job.Task(); } catch (Exception e) { error = e; } Finished(job, error); } return due.Count; }
// Every schedule call ends here. The job is remembered by id even after it ends, so its // status can still be asked. private string Add(Action task, Repeat repeat, long every, long delay) { var job = new Job(ids(), task, repeat, every); _jobs[job.Id] = job; Requeue(job, clock() + delay); return job.Id; }
// Each requeue takes the next seq, so jobs due at the same moment run in the order they // were queued. private void Requeue(Job job, long runAt) { job.RunAt = runAt; job.Seq = _nextSeq++; _timetable.Enqueue(job, (job.RunAt, job.Seq)); }
// Cancel only marks a job. Marked jobs are thrown away here, once they reach the front. private void DropCancelled() { while (_timetable.TryPeek(out var top, out _) && top.Cancelled) _timetable.Dequeue(); }}// Status is where a job is in its life. Succeeded, Failed and Cancelled are final.type Status int
const ( Scheduled Status = iota Running Succeeded Failed Cancelled)
// Retry says how a one-off job retries: up to MaxAttempts runs in all, waiting Backoff, then// twice that, then four times, so a struggling service isn't hammered.type Retry struct { MaxAttempts int Backoff int64}
// Job is one scheduled job and its bookkeeping. RunAt is when it runs next. seq breaks ties:// jobs due at the same moment run in the order they were queued. running is true while a run is// in progress, and cancelled is only a mark, because taking a job out of the middle of the// timetable is slow.type Job struct { ID string Task func() Repeat Repeat Every int64 seq int64 RunAt int64 running bool cancelled bool Status Status Attempts int // runs started so far LastError string}
// Scheduler is the timetable. A heap keeps the job due soonest at the front, so finding what is// due never scans every job. It has no goroutines of its own: tests drive it with a fake clock,// and a separate runner drives it for real.type Scheduler struct { timetable timetable jobs map[string]*Job // kept after they end, so status can be asked clock func() int64 ids func() string retry Retry nextSeq int64 // Fixed-rate runs skipped because the previous run of the same job was still going. Skipped int}
func NewScheduler(clock func() int64, ids func() string, retry Retry) *Scheduler { return &Scheduler{jobs: map[string]*Job{}, clock: clock, ids: ids, retry: retry}}
func (s *Scheduler) Schedule(task func(), delay int64) string { return s.add(task, Once, 0, delay) }
func (s *Scheduler) ScheduleAtFixedRate(task func(), initialDelay, period int64) string { return s.add(task, FixedRate, period, initialDelay)}
func (s *Scheduler) ScheduleWithFixedDelay(task func(), initialDelay, delay int64) string { return s.add(task, FixedDelay, delay, initialDelay)}
// Cancel works only on a job that hasn't ended. A running job finishes its current run, but// it is reported as cancelled and never runs again.func (s *Scheduler) Cancel(id string) bool { j, ok := s.jobs[id] if !ok || j.cancelled || j.Status == Succeeded || j.Status == Failed { return false } j.cancelled = true j.Status = Cancelled return true}
func (s *Scheduler) StatusOf(id string) Status { return s.jobs[id].Status }
func (s *Scheduler) LastError(id string) string { return s.jobs[id].LastError }
// NextRunAt says when the next job is due, or false if the timetable is empty. Cancelled jobs// at the front are dropped first, so they can't cause a pointless wake-up.func (s *Scheduler) NextRunAt() (int64, bool) { s.dropCancelled() if len(s.timetable) == 0 { return 0, false } return s.timetable[0].RunAt, true}
// TakeDue returns every job due by now, marked as running. A fixed-rate job is put back for its// next slot at once, counted from its schedule, not from now. Slots missed while the machine// was busy are skipped, not run in a burst. If its last run is still going, this run is skipped// too, so two runs of one job never overlap.func (s *Scheduler) TakeDue() []*Job { now := s.clock() var due []*Job for s.dropCancelled(); len(s.timetable) > 0 && s.timetable[0].RunAt <= now; s.dropCancelled() { j := heap.Pop(&s.timetable).(*Job) if j.Repeat == FixedRate { next := j.RunAt + j.Every if next <= now { next += (now-next)/j.Every*j.Every + j.Every } s.requeue(j, next) if j.running { s.Skipped++ continue } } j.running = true j.Status = Running j.Attempts++ due = append(due, j) } return due}
// Finished takes a nil err when the run succeeded. A one-off job that fails is retried with// growing waits, then marked Failed. A repeating job isn't retried: its next run is the retry,// and one bad run doesn't end the series.func (s *Scheduler) Finished(j *Job, err error) { j.running = false if err != nil { j.LastError = err.Error() } if j.cancelled { return } now := s.clock() switch j.Repeat { case Once: switch { case err == nil: j.Status = Succeeded case j.Attempts < s.retry.MaxAttempts: j.Status = Scheduled s.requeue(j, now+s.retry.Backoff<<(j.Attempts-1)) default: j.Status = Failed } case FixedRate: j.Status = Scheduled case FixedDelay: j.Status = Scheduled s.requeue(j, now+j.Every) }}
// RunDue runs every due job on the calling goroutine, one after another. Tests use this with a// fake clock.func (s *Scheduler) RunDue() int { due := s.TakeDue() for _, j := range due { s.Finished(j, runSafely(j.Task)) } return len(due)}
// add is where every schedule call ends. The job is remembered by id even after it ends, so its// status can still be asked.func (s *Scheduler) add(task func(), repeat Repeat, every, delay int64) string { j := &Job{ID: s.ids(), Task: task, Repeat: repeat, Every: every} s.jobs[j.ID] = j s.requeue(j, s.clock()+delay) return j.ID}
// requeue gives the job the next seq, so jobs due at the same moment run in the order they were// queued.func (s *Scheduler) requeue(j *Job, runAt int64) { j.RunAt = runAt j.seq = s.nextSeq s.nextSeq++ heap.Push(&s.timetable, j)}
// dropCancelled throws away marked jobs once they reach the front. Cancel only marks them.func (s *Scheduler) dropCancelled() { for len(s.timetable) > 0 && s.timetable[0].cancelled { heap.Pop(&s.timetable) }}// Where a job is in its life. SUCCEEDED, FAILED and CANCELLED are final.type Status = 'scheduled' | 'running' | 'succeeded' | 'failed' | 'cancelled';
// How a one-off job retries: up to maxAttempts runs in all, waiting backoffMs, then twice that,// then four times, so a struggling service isn't hammered.interface Retry { maxAttempts: number; backoffMs: number;}
// One scheduled job and its bookkeeping. runAt is when it runs next. seq breaks ties: jobs due// at the same moment run in the order they were queued. running is true while a run is in// progress, and cancelled is only a mark, because taking a job out of the middle of the// timetable is slow.interface Job { id: string; task: Task; repeat: Repeat; every: number; seq: number; runAt: number; running: boolean; cancelled: boolean; status: Status; attempts: number; // runs started so far lastError?: string;}
// The timetable. A priority queue keeps the job due soonest at the front, so finding what is// due never scans every job. It has no threads of its own: tests drive it with a fake clock,// and a separate runner drives it for real.class Scheduler { private readonly timetable = new MinHeap<Job>(byRunTime); private readonly jobs = new Map<string, Job>(); // kept after they end, so status can be asked private nextSeq = 0; // Fixed-rate runs skipped because the previous run of the same job was still going. skipped = 0;
constructor( private readonly clock: () => number, private readonly ids: () => string, private readonly retry: Retry, ) {}
schedule(task: Task, delayMs: number): string { return this.add(task, 'once', 0, delayMs); }
scheduleAtFixedRate(task: Task, initialDelay: number, period: number): string { return this.add(task, 'fixed-rate', period, initialDelay); }
scheduleWithFixedDelay(task: Task, initialDelay: number, delay: number): string { return this.add(task, 'fixed-delay', delay, initialDelay); }
// Only a job that hasn't ended can be cancelled. A running job finishes its current run, but // it is reported as cancelled and never runs again. cancel(id: string): boolean { const job = this.jobs.get(id); if (!job || job.cancelled || job.status === 'succeeded' || job.status === 'failed') return false; job.cancelled = true; job.status = 'cancelled'; return true; }
status(id: string): Status { return this.jobs.get(id)!.status; }
lastError(id: string): string | undefined { return this.jobs.get(id)!.lastError; }
// When the next job is due, or nothing if the timetable is empty. Cancelled jobs at the front // are dropped first, so they can't cause a pointless wake-up. nextRunAt(): number | undefined { this.dropCancelled(); return this.timetable.peek()?.runAt; }
// Every job due by now, marked as running. A fixed-rate job is put back for its next slot at // once, counted from its schedule, not from now. Slots missed while the machine was busy are // skipped, not run in a burst. If its last run is still going, this run is skipped too, so // two runs of one job never overlap. takeDue(): Job[] { const now = this.clock(); const due: Job[] = []; for (this.dropCancelled(); (this.timetable.peek()?.runAt ?? Infinity) <= now; this.dropCancelled()) { const job = this.timetable.pop()!; if (job.repeat === 'fixed-rate') { let next = job.runAt + job.every; if (next <= now) next += Math.floor((now - next) / job.every) * job.every + job.every; this.requeue(job, next); if (job.running) { this.skipped++; continue; } } job.running = true; job.status = 'running'; job.attempts++; due.push(job); } return due; }
// error is undefined when the run succeeded. A one-off job that fails is retried with growing // waits, then marked failed. A repeating job isn't retried: its next run is the retry, and one // bad run doesn't end the series. finished(job: Job, error?: unknown): void { job.running = false; if (error !== undefined) job.lastError = error instanceof Error ? error.message : String(error); if (job.cancelled) return; const now = this.clock(); if (job.repeat === 'once') { if (error === undefined) { job.status = 'succeeded'; } else if (job.attempts < this.retry.maxAttempts) { job.status = 'scheduled'; this.requeue(job, now + this.retry.backoffMs * 2 ** (job.attempts - 1)); } else { job.status = 'failed'; } } else { job.status = 'scheduled'; if (job.repeat === 'fixed-delay') this.requeue(job, now + job.every); } }
// Runs every due job, one after another. Tests use this with a fake clock. async runDue(): Promise<number> { const due = this.takeDue(); for (const job of due) this.finished(job, await runSafely(job.task)); return due.length; }
// Every schedule call ends here. The job is remembered by id even after it ends, so its // status can still be asked. private add(task: Task, repeat: Repeat, every: number, delay: number): string { const job: Job = { id: this.ids(), task, repeat, every, seq: 0, runAt: 0, running: false, cancelled: false, status: 'scheduled', attempts: 0, }; this.jobs.set(job.id, job); this.requeue(job, this.clock() + delay); return job.id; }
// Each requeue takes the next seq, so jobs due at the same moment run in the order they were // queued. private requeue(job: Job, runAt: number): void { job.runAt = runAt; job.seq = this.nextSeq++; this.timetable.push(job); }
// Cancel only marks a job. Marked jobs are thrown away here, once they reach the front. private dropCancelled(): void { while (this.timetable.peek()?.cancelled) this.timetable.pop(); }}
// Runs a task and hands back what it threw, or undefined if it succeeded.async function runSafely(task: Task): Promise<unknown> { try { await task(); return undefined; } catch (e) { return e ?? new Error('task failed'); }}This is enough for a mid-level answer. A heap sorted by run time, repeating jobs, and errors caught so one job can’t stop the others. The rest of the page is what makes it a senior answer.
The full design
Section titled “The full design”Here is everything together. RunningScheduler is what the application calls, and it owns the
threads. Scheduler is the timetable and every rule about what runs next. It has no threads of its
own, which is why the tests can drive it with a clock moved by hand.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
// The complete design, built from every stage on this page.
import java.util.*;import java.util.concurrent.*;import java.util.concurrent.locks.*;import java.util.function.*;
// One scheduled job and its bookkeeping. runAt is when it runs next. seq breaks ties: jobs due// at the same moment run in the order they were queued. running is true while a run is in// progress, and cancelled is only a mark, because taking a job out of the middle of the// timetable is slow.class Job { final String id; final Runnable task; final Repeat repeat; final long every; long seq; long runAt; boolean running; boolean cancelled; Status status = Status.SCHEDULED; int attempts; // runs started so far String lastError;
Job(String id, Runnable task, Repeat repeat, long every, long runAt) { this.id = id; this.task = task; this.repeat = repeat; this.every = every; this.runAt = runAt; }}
// The timetable. A priority queue keeps the job due soonest at the front, so finding what is// due never scans every job. It has no threads of its own: tests drive it with a fake clock,// and a separate runner drives it for real.class Scheduler { private final PriorityQueue<Job> timetable = new PriorityQueue<>(Comparator.comparingLong((Job j) -> j.runAt).thenComparingLong(j -> j.seq)); private final Map<String, Job> jobs = new HashMap<>(); // kept after they end, so status can be asked private final LongSupplier clock; private final Supplier<String> ids; private final Retry retry; private long nextSeq; // Fixed-rate runs skipped because the previous run of the same job was still going. long skipped;
Scheduler(LongSupplier clock, Supplier<String> ids, Retry retry) { this.clock = clock; this.ids = ids; this.retry = retry; }
String schedule(Runnable task, long delayMillis) { return add(task, Repeat.ONCE, 0, delayMillis); }
String scheduleAtFixedRate(Runnable task, long initialDelay, long period) { return add(task, Repeat.FIXED_RATE, period, initialDelay); }
String scheduleWithFixedDelay(Runnable task, long initialDelay, long delay) { return add(task, Repeat.FIXED_DELAY, delay, initialDelay); }
// Only a job that hasn't ended can be cancelled. A running job finishes its current run, but // it is reported as cancelled and never runs again. boolean cancel(String id) { Job j = jobs.get(id); if (j == null || j.cancelled || j.status == Status.SUCCEEDED || j.status == Status.FAILED) return false; j.cancelled = true; j.status = Status.CANCELLED; return true; }
Status status(String id) { return jobs.get(id).status; }
String lastError(String id) { return jobs.get(id).lastError; }
// When the next job is due, or nothing if the timetable is empty. Cancelled jobs at the // front are dropped first, so they can't cause a pointless wake-up. OptionalLong nextRunAt() { dropCancelled(); return timetable.isEmpty() ? OptionalLong.empty() : OptionalLong.of(timetable.peek().runAt); }
// Every job due by now, marked as running. A fixed-rate job is put back for its next slot // at once, counted from its schedule, not from now. Slots missed while the machine was busy // are skipped, not run in a burst. If its last run is still going, this run is skipped too, // so two runs of one job never overlap. List<Job> takeDue() { long now = clock.getAsLong(); List<Job> due = new ArrayList<>(); for (dropCancelled(); !timetable.isEmpty() && timetable.peek().runAt <= now; dropCancelled()) { Job j = timetable.poll(); if (j.repeat == Repeat.FIXED_RATE) { long next = j.runAt + j.every; if (next <= now) next += (now - next) / j.every * j.every + j.every; requeue(j, next); if (j.running) { skipped++; continue; } } j.running = true; j.status = Status.RUNNING; j.attempts++; due.add(j); } return due; }
// error is null when the run succeeded. A one-off job that fails is retried with growing // waits, then marked FAILED. A repeating job isn't retried: its next run is the retry, and // one bad run doesn't end the series. void finished(Job j, Throwable error) { j.running = false; if (error != null) j.lastError = String.valueOf(error.getMessage()); if (j.cancelled) return; long now = clock.getAsLong(); switch (j.repeat) { case ONCE -> { if (error == null) { j.status = Status.SUCCEEDED; } else if (j.attempts < retry.maxAttempts()) { j.status = Status.SCHEDULED; requeue(j, now + (retry.backoffMillis() << (j.attempts - 1))); } else { j.status = Status.FAILED; } } case FIXED_RATE -> j.status = Status.SCHEDULED; case FIXED_DELAY -> { j.status = Status.SCHEDULED; requeue(j, now + j.every); } } }
// Runs every due job on the calling thread, one after another. Tests use this with a fake // clock. int runDue() { List<Job> due = takeDue(); for (Job j : due) { Throwable error = null; try { j.task.run(); } catch (RuntimeException e) { error = e; } finished(j, error); } return due.size(); }
// Every schedule call ends here. The job is remembered by id even after it ends, so its // status can still be asked. private String add(Runnable task, Repeat repeat, long every, long delay) { Job j = new Job(ids.get(), task, repeat, every, 0); jobs.put(j.id, j); requeue(j, clock.getAsLong() + delay); return j.id; }
// Each requeue takes the next seq, so jobs due at the same moment run in the order they // were queued. private void requeue(Job j, long runAt) { j.runAt = runAt; j.seq = nextSeq++; timetable.add(j); }
// Cancel only marks a job. Marked jobs are thrown away here, once they reach the front. private void dropCancelled() { while (!timetable.isEmpty() && timetable.peek().cancelled) timetable.poll(); }}
// How a job repeats, if at all.enum Repeat { ONCE, FIXED_RATE, // starts every period on the clock: 10:00, 10:05, 10:10, however long each run takes FIXED_DELAY // waits a delay after each run finishes: a run that takes 2 minutes pushes the next one back}
// Where a job is in its life. SUCCEEDED, FAILED and CANCELLED are final.enum Status { SCHEDULED, RUNNING, SUCCEEDED, FAILED, CANCELLED }
// How a one-off job retries: up to maxAttempts runs in all, waiting backoff, then twice that,// then four times, so a struggling service isn't hammered.record Retry(int maxAttempts, long backoffMillis) {}
// The scheduler running for real: one thread watches the timetable, and a pool of workers runs// the jobs, so a slow job only occupies its own worker. Everything that touches the timetable// (schedule, cancel, taking due jobs, a worker reporting a finished run) holds one lock, so a// cancel can't slip in between "this job is due" and "this job is running".//// The watcher sleeps until the next job is due, but a job scheduled for sooner must wake it.// It waits on a condition under the same lock that schedule() takes to add the job and signal,// so the signal can't arrive in the gap between checking the timetable and starting to wait.class RunningScheduler { private final Scheduler timetable; private final LongSupplier clock; private final ExecutorService workers; private final ReentrantLock lock = new ReentrantLock(); private final Condition changed = lock.newCondition(); private final Thread watcher = new Thread(this::watch, "scheduler"); private boolean stopped;
RunningScheduler(Scheduler timetable, LongSupplier clock, int workerCount) { this.timetable = timetable; this.clock = clock; this.workers = Executors.newFixedThreadPool(workerCount); watcher.start(); }
// Every change goes through changing(), which wakes the watcher, because the new job may be // due sooner than what it is waiting for. String schedule(Runnable task, long delayMillis) { return changing(() -> timetable.schedule(task, delayMillis)); }
String scheduleAtFixedRate(Runnable task, long initialDelay, long period) { return changing(() -> timetable.scheduleAtFixedRate(task, initialDelay, period)); }
String scheduleWithFixedDelay(Runnable task, long initialDelay, long delay) { return changing(() -> timetable.scheduleWithFixedDelay(task, initialDelay, delay)); }
boolean cancel(String id) { return changing(() -> timetable.cancel(id)); }
// Read under the lock too, because workers change a job's status from their own threads. Status status(String id) { return changing(() -> timetable.status(id)); }
// Stops taking new runs; runs already started finish. void shutdown() throws InterruptedException { changing(() -> stopped = true); watcher.join(); workers.shutdown(); workers.awaitTermination(1, TimeUnit.MINUTES); }
// The watcher thread. It hands every due job to a worker, then sleeps until the next job is // due or a change wakes it. Waiting lets go of the lock, so schedule and cancel can get in // while it sleeps. private void watch() { lock.lock(); try { while (!stopped) { for (Job j : timetable.takeDue()) workers.execute(() -> run(j)); OptionalLong next = timetable.nextRunAt(); if (next.isEmpty()) changed.await(); else changed.await(Math.max(1, next.getAsLong() - clock.getAsLong()), TimeUnit.MILLISECONDS); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } }
// On a worker, with no lock held while the task runs. private void run(Job j) { Throwable error = null; try { j.task.run(); } catch (RuntimeException e) { error = e; } Throwable result = error; changing(() -> { timetable.finished(j, result); return null; }); }
// Runs a change under the lock and wakes the watcher, whose next wake-up may now be sooner. private <T> T changing(Supplier<T> change) { lock.lock(); try { T result = change.get(); changed.signal(); return result; } finally { lock.unlock(); } }}# The complete design, built from every stage on this page.
from __future__ import annotations
import heapqimport threadingfrom concurrent.futures import ThreadPoolExecutorfrom dataclasses import dataclassfrom enum import Enumfrom typing import Callable, TypeVar
T = TypeVar("T")
# Where a job is in its life. SUCCEEDED, FAILED and CANCELLED are final.class Status(Enum): SCHEDULED = "scheduled" RUNNING = "running" SUCCEEDED = "succeeded" FAILED = "failed" CANCELLED = "cancelled"
# One scheduled job and its bookkeeping. run_at is when it runs next. seq breaks ties: jobs due# at the same moment run in the order they were queued. running is true while a run is in# progress, and cancelled is only a mark, because taking a job out of the middle of the# timetable is slow.@dataclass(eq=False)class Job: id: str task: Callable[[], None] repeat: Repeat every: int seq: int = 0 run_at: int = 0 running: bool = False cancelled: bool = False status: Status = Status.SCHEDULED attempts: int = 0 # runs started so far last_error: str | None = None
def __lt__(self, other: "Job") -> bool: return (self.run_at, self.seq) < (other.run_at, other.seq)
# The timetable. A priority queue keeps the job due soonest at the front, so finding what is due# never scans every job. It has no threads of its own: tests drive it with a fake clock, and a# separate runner drives it for real.class Scheduler: def __init__(self, clock: Callable[[], int], ids: Callable[[], str], retry: Retry): self._timetable: list[Job] = [] self._jobs: dict[str, Job] = {} # kept after they end, so status can be asked self._clock = clock self._ids = ids self._retry = retry self._next_seq = 0 # Fixed-rate runs skipped because the previous run of the same job was still going. self.skipped = 0
def schedule(self, task: Callable[[], None], delay_ms: int) -> str: return self._add(task, Repeat.ONCE, 0, delay_ms)
def schedule_at_fixed_rate(self, task: Callable[[], None], initial_delay: int, period: int) -> str: return self._add(task, Repeat.FIXED_RATE, period, initial_delay)
def schedule_with_fixed_delay(self, task: Callable[[], None], initial_delay: int, delay: int) -> str: return self._add(task, Repeat.FIXED_DELAY, delay, initial_delay)
def cancel(self, job_id: str) -> bool: # Only a job that hasn't ended can be cancelled. A running job finishes its current run, # but it is reported as cancelled and never runs again. job = self._jobs.get(job_id) if job is None or job.cancelled or job.status in (Status.SUCCEEDED, Status.FAILED): return False job.cancelled = True job.status = Status.CANCELLED return True
def status(self, job_id: str) -> Status: return self._jobs[job_id].status
def last_error(self, job_id: str) -> str | None: return self._jobs[job_id].last_error
# When the next job is due, or nothing if the timetable is empty. Cancelled jobs at the # front are dropped first, so they can't cause a pointless wake-up. def next_run_at(self) -> int | None: self._drop_cancelled() return self._timetable[0].run_at if self._timetable else None
# Every job due by now, marked as running. A fixed-rate job is put back for its next slot at # once, counted from its schedule, not from now. Slots missed while the machine was busy are # skipped, not run in a burst. If its last run is still going, this run is skipped too, so # two runs of one job never overlap. def take_due(self) -> list[Job]: now = self._clock() due = [] self._drop_cancelled() while self._timetable and self._timetable[0].run_at <= now: job = heapq.heappop(self._timetable) if job.repeat is Repeat.FIXED_RATE: nxt = job.run_at + job.every if nxt <= now: nxt += (now - nxt) // job.every * job.every + job.every self._requeue(job, nxt) if job.running: self.skipped += 1 self._drop_cancelled() continue job.running = True job.status = Status.RUNNING job.attempts += 1 due.append(job) self._drop_cancelled() return due
# error is None when the run succeeded. A one-off job that fails is retried with # growing waits, then marked FAILED. A repeating job isn't retried: its next run is the # retry, and one bad run doesn't end the series. def finished(self, job: Job, error: Exception | None) -> None: job.running = False if error is not None: job.last_error = str(error) if job.cancelled: return now = self._clock() if job.repeat is Repeat.ONCE: if error is None: job.status = Status.SUCCEEDED elif job.attempts < self._retry.max_attempts: job.status = Status.SCHEDULED self._requeue(job, now + self._retry.backoff_ms * 2 ** (job.attempts - 1)) else: job.status = Status.FAILED elif job.repeat is Repeat.FIXED_RATE: job.status = Status.SCHEDULED else: job.status = Status.SCHEDULED self._requeue(job, now + job.every)
# Runs every due job on the calling thread, one after another. Tests use this with a fake # clock. def run_due(self) -> int: due = self.take_due() for job in due: error = None try: job.task() except Exception as e: error = e self.finished(job, error) return len(due)
# Every schedule call ends here. The job is remembered by id even after it ends, so its # status can still be asked. def _add(self, task: Callable[[], None], repeat: Repeat, every: int, delay: int) -> str: job = Job(self._ids(), task, repeat, every) self._jobs[job.id] = job self._requeue(job, self._clock() + delay) return job.id
# Each requeue takes the next seq, so jobs due at the same moment run in the order they were # queued. def _requeue(self, job: Job, run_at: int) -> None: job.run_at = run_at job.seq = self._next_seq self._next_seq += 1 heapq.heappush(self._timetable, job)
# Cancel only marks a job. Marked jobs are thrown away here, once they reach the front. def _drop_cancelled(self) -> None: while self._timetable and self._timetable[0].cancelled: heapq.heappop(self._timetable)
# How a job repeats, if at all.class Repeat(Enum): ONCE = "once" FIXED_RATE = "fixed_rate" # starts every period on the clock: 10:00, 10:05, 10:10, however long each run takes FIXED_DELAY = "fixed_delay" # waits a delay after each run finishes: a 2-minute run pushes the next one back
# How a one-off job retries: up to max_attempts runs in all, waiting backoff, then twice# that, then four times, so a struggling service isn't hammered.@dataclass(frozen=True)class Retry: max_attempts: int backoff_ms: int
# The scheduler running for real: one thread watches the timetable, and a pool of workers# runs the jobs, so a slow job only occupies its own worker. Everything that touches the# timetable (schedule, cancel, taking due jobs, a worker reporting a finished run) holds one# lock, so a cancel can't slip in between "this job is due" and "this job is running".## The watcher sleeps until the next job is due, but a job scheduled for sooner must wake it.# It waits on a condition under the same lock that schedule() takes to add the job and# notify, so the notify can't arrive in the gap between checking the timetable and waiting.class RunningScheduler: def __init__(self, timetable: Scheduler, clock: Callable[[], int], worker_count: int): self._timetable = timetable self._clock = clock self._workers = ThreadPoolExecutor(max_workers=worker_count) self._changed = threading.Condition() self._stopped = False self._watcher = threading.Thread(target=self._watch, name="scheduler") self._watcher.start()
# Every change goes through _changing(), which wakes the watcher, because the new job may be # due sooner than what it is waiting for. def schedule(self, task: Callable[[], None], delay_ms: int) -> str: return self._changing(lambda: self._timetable.schedule(task, delay_ms))
def schedule_at_fixed_rate(self, task: Callable[[], None], initial_delay: int, period: int) -> str: return self._changing(lambda: self._timetable.schedule_at_fixed_rate(task, initial_delay, period))
def schedule_with_fixed_delay(self, task: Callable[[], None], initial_delay: int, delay: int) -> str: return self._changing(lambda: self._timetable.schedule_with_fixed_delay(task, initial_delay, delay))
def cancel(self, job_id: str) -> bool: return self._changing(lambda: self._timetable.cancel(job_id))
# Read under the lock too, because workers change a job's status from their own threads. def status(self, job_id: str) -> Status: return self._changing(lambda: self._timetable.status(job_id))
# Stops taking new runs; runs already started finish. def shutdown(self) -> None: def stop() -> None: self._stopped = True self._changing(stop) self._watcher.join() self._workers.shutdown(wait=True)
# The watcher thread. It hands every due job to a worker, then sleeps until the next job is # due or a change wakes it. Waiting lets go of the lock, so schedule and cancel can get in # while it sleeps. def _watch(self) -> None: with self._changed: while not self._stopped: for job in self._timetable.take_due(): self._workers.submit(self._run, job) nxt = self._timetable.next_run_at() timeout = None if nxt is None else max(1, nxt - self._clock()) / 1000 self._changed.wait(timeout)
# On a worker, with no lock held while the task runs. def _run(self, job: Job) -> None: error = None try: job.task() except Exception as e: error = e self._changing(lambda: self._timetable.finished(job, error))
# Runs a change under the lock and wakes the watcher, whose next wake-up may now be sooner. def _changing(self, change: Callable[[], T]) -> T: with self._changed: result = change() self._changed.notify() return result// The complete design, built from every stage on this page.
using System.Collections.Concurrent;
// One scheduled job and its bookkeeping. RunAt is when it runs next. Seq breaks ties: jobs due// at the same moment run in the order they were queued. Running is true while a run is in// progress, and Cancelled is only a mark, because taking a job out of the middle of the// timetable is slow.public sealed class Job(string id, Action task, Repeat repeat, long every){ public string Id { get; } = id; public Action Task { get; } = task; public Repeat Repeat { get; } = repeat; public long Every { get; } = every; public long Seq { get; set; } public long RunAt { get; set; } public bool Running { get; set; } public bool Cancelled { get; set; } public Status Status { get; set; } = Status.Scheduled; public int Attempts { get; set; } // runs started so far public string? LastError { get; set; }}
// The timetable. A priority queue keeps the job due soonest at the front, so finding what is// due never scans every job. It has no threads of its own: tests drive it with a fake clock,// and a separate runner drives it for real.public sealed class Scheduler(Func<long> clock, Func<string> ids, Retry retry){ private readonly PriorityQueue<Job, (long RunAt, long Seq)> _timetable = new(); private readonly Dictionary<string, Job> _jobs = []; // kept after they end, so status can be asked private long _nextSeq;
// Fixed-rate runs skipped because the previous run of the same job was still going. public long Skipped { get; private set; }
public string Schedule(Action task, long delayMs) => Add(task, Repeat.Once, 0, delayMs);
public string ScheduleAtFixedRate(Action task, long initialDelay, long period) => Add(task, Repeat.FixedRate, period, initialDelay);
public string ScheduleWithFixedDelay(Action task, long initialDelay, long delay) => Add(task, Repeat.FixedDelay, delay, initialDelay);
// Only a job that hasn't ended can be cancelled. A running job finishes its current run, but // it is reported as cancelled and never runs again. public bool Cancel(string id) { if (!_jobs.TryGetValue(id, out var job) || job.Cancelled) return false; if (job.Status is Status.Succeeded or Status.Failed) return false; job.Cancelled = true; job.Status = Status.Cancelled; return true; }
public Status StatusOf(string id) => _jobs[id].Status;
public string? LastError(string id) => _jobs[id].LastError;
// When the next job is due, or nothing if the timetable is empty. Cancelled jobs at the // front are dropped first, so they can't cause a pointless wake-up. public long? NextRunAt() { DropCancelled(); return _timetable.TryPeek(out var job, out _) ? job.RunAt : null; }
// Every job due by now, marked as running. A fixed-rate job is put back for its next slot // at once, counted from its schedule, not from now. Slots missed while the machine was busy // are skipped, not run in a burst. If its last run is still going, this run is skipped too, // so two runs of one job never overlap. public List<Job> TakeDue() { var now = clock(); var due = new List<Job>(); for (DropCancelled(); _timetable.TryPeek(out var job, out _) && job.RunAt <= now; DropCancelled()) { _timetable.Dequeue(); if (job.Repeat == Repeat.FixedRate) { var next = job.RunAt + job.Every; if (next <= now) next += (now - next) / job.Every * job.Every + job.Every; Requeue(job, next); if (job.Running) { Skipped++; continue; } } job.Running = true; job.Status = Status.Running; job.Attempts++; due.Add(job); } return due; }
// error is null when the run succeeded. A one-off job that fails is retried with growing // waits, then marked Failed. A repeating job isn't retried: its next run is the retry, and // one bad run doesn't end the series. public void Finished(Job job, Exception? error) { job.Running = false; if (error is not null) job.LastError = error.Message; if (job.Cancelled) return; var now = clock(); switch (job.Repeat) { case Repeat.Once when error is null: job.Status = Status.Succeeded; break; case Repeat.Once when job.Attempts < retry.MaxAttempts: job.Status = Status.Scheduled; Requeue(job, now + (retry.BackoffMs << (job.Attempts - 1))); break; case Repeat.Once: job.Status = Status.Failed; break; case Repeat.FixedRate: job.Status = Status.Scheduled; break; case Repeat.FixedDelay: job.Status = Status.Scheduled; Requeue(job, now + job.Every); break; } }
// Runs every due job on the calling thread, one after another. Tests use this with a fake // clock. public int RunDue() { var due = TakeDue(); foreach (var job in due) { Exception? error = null; try { job.Task(); } catch (Exception e) { error = e; } Finished(job, error); } return due.Count; }
// Every schedule call ends here. The job is remembered by id even after it ends, so its // status can still be asked. private string Add(Action task, Repeat repeat, long every, long delay) { var job = new Job(ids(), task, repeat, every); _jobs[job.Id] = job; Requeue(job, clock() + delay); return job.Id; }
// Each requeue takes the next seq, so jobs due at the same moment run in the order they // were queued. private void Requeue(Job job, long runAt) { job.RunAt = runAt; job.Seq = _nextSeq++; _timetable.Enqueue(job, (job.RunAt, job.Seq)); }
// Cancel only marks a job. Marked jobs are thrown away here, once they reach the front. private void DropCancelled() { while (_timetable.TryPeek(out var top, out _) && top.Cancelled) _timetable.Dequeue(); }}
// How a job repeats, if at all.public enum Repeat{ Once, FixedRate, // starts every period on the clock: 10:00, 10:05, 10:10, however long each run takes FixedDelay, // waits a delay after each run finishes: a 2-minute run pushes the next one back}
// Where a job is in its life. SUCCEEDED, FAILED and CANCELLED are final.public enum Status { Scheduled, Running, Succeeded, Failed, Cancelled }
// How a one-off job retries: up to MaxAttempts runs in all, waiting BackoffMs, then twice that,// then four times, so a struggling service isn't hammered.public sealed record Retry(int MaxAttempts, long BackoffMs);
// The scheduler running for real: one thread watches the timetable, and a fixed set of worker// threads runs the jobs, so a slow job only occupies its own worker. Everything that touches the// timetable (schedule, cancel, taking due jobs, a worker reporting a finished run) holds one// lock, so a cancel can't slip in between "this job is due" and "this job is running".//// The watcher sleeps until the next job is due, but a job scheduled for sooner must wake it.// Monitor.Wait releases the lock and starts waiting as one step, and schedule pulses under the// same lock, so the pulse can't arrive in the gap between checking the timetable and waiting.public sealed class RunningScheduler{ private readonly Scheduler _timetable; private readonly Func<long> _clock; private readonly object _lock = new(); // a plain object: Monitor.Wait doesn't work on the Lock type private readonly BlockingCollection<Job> _work = new(); private readonly Thread _watcher; private readonly Thread[] _workers; private bool _stopped;
public RunningScheduler(Scheduler timetable, Func<long> clock, int workerCount) { _timetable = timetable; _clock = clock; _workers = Enumerable.Range(0, workerCount).Select(_ => new Thread(Work)).ToArray(); foreach (var w in _workers) w.Start(); _watcher = new Thread(Watch) { Name = "scheduler" }; _watcher.Start(); }
// Every change goes through Changing(), which wakes the watcher, because the new job may be // due sooner than what it is waiting for. public string Schedule(Action task, long delayMs) => Changing(() => _timetable.Schedule(task, delayMs));
public string ScheduleAtFixedRate(Action task, long initialDelay, long period) => Changing(() => _timetable.ScheduleAtFixedRate(task, initialDelay, period));
public string ScheduleWithFixedDelay(Action task, long initialDelay, long delay) => Changing(() => _timetable.ScheduleWithFixedDelay(task, initialDelay, delay));
public bool Cancel(string id) => Changing(() => _timetable.Cancel(id));
// Read under the lock too, because workers change a job's status from their own threads. public Status StatusOf(string id) => Changing(() => _timetable.StatusOf(id));
// Stops taking new runs; runs already started finish. public void Shutdown() { Changing(() => _stopped = true); _watcher.Join(); _work.CompleteAdding(); // the workers drain what was handed over, then exit foreach (var w in _workers) w.Join(); }
// The watcher thread. It hands every due job to a worker, then sleeps until the next job is // due or a change wakes it. Waiting lets go of the lock, so schedule and cancel can get in // while it sleeps. private void Watch() { lock (_lock) { while (!_stopped) { foreach (var job in _timetable.TakeDue()) _work.Add(job); var next = _timetable.NextRunAt(); if (next is null) Monitor.Wait(_lock); else Monitor.Wait(_lock, TimeSpan.FromMilliseconds(Math.Max(1, next.Value - _clock()))); } } }
// On a worker, with no lock held while the task runs. private void Work() { foreach (var job in _work.GetConsumingEnumerable()) { Exception? error = null; try { job.Task(); } catch (Exception e) { error = e; } Changing(() => { _timetable.Finished(job, error); return 0; }); } }
// Runs a change under the lock and wakes the watcher, whose next wake-up may now be sooner. private T Changing<T>(Func<T> change) { lock (_lock) { var result = change(); Monitor.Pulse(_lock); return result; } }}// The complete design, built from every stage on this page.
package taskscheduler
import ( "container/heap" "fmt" "sync" "time")
// timetable is a min-heap ordered by run time, for container/heap, so the next job due is// always at index 0. Adding a job and taking the next one are O(log n), and asking "when is// the next one?" is a peek. Nothing scans the jobs that aren't due.type timetable []*Job
func (t timetable) Len() int { return len(t) }
func (t timetable) Less(a, b int) bool { if t[a].RunAt != t[b].RunAt { return t[a].RunAt < t[b].RunAt } return t[a].seq < t[b].seq}
func (t timetable) Swap(a, b int) { t[a], t[b] = t[b], t[a] }
func (t *timetable) Push(x any) { *t = append(*t, x.(*Job)) }
func (t *timetable) Pop() any { old := *t j := old[len(old)-1] *t = old[:len(old)-1] return j}
// runSafely turns a panic into an error, so one bad task can't take the scheduler down.func runSafely(task func()) (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("%v", r) } }() task() return nil}
// Repeat says how a job repeats, if at all.type Repeat int
const ( Once Repeat = iota FixedRate // starts every period on the clock: 10:00, 10:05, 10:10, however long each run takes FixedDelay // waits a delay after each run finishes: a 2-minute run pushes the next one back)
// Status is where a job is in its life. Succeeded, Failed and Cancelled are final.type Status int
const ( Scheduled Status = iota Running Succeeded Failed Cancelled)
// Retry says how a one-off job retries: up to MaxAttempts runs in all, waiting Backoff, then// twice that, then four times, so a struggling service isn't hammered.type Retry struct { MaxAttempts int Backoff int64}
// Job is one scheduled job and its bookkeeping. RunAt is when it runs next. seq breaks ties:// jobs due at the same moment run in the order they were queued. running is true while a run is// in progress, and cancelled is only a mark, because taking a job out of the middle of the// timetable is slow.type Job struct { ID string Task func() Repeat Repeat Every int64 seq int64 RunAt int64 running bool cancelled bool Status Status Attempts int // runs started so far LastError string}
// Scheduler is the timetable. A heap keeps the job due soonest at the front, so finding what is// due never scans every job. It has no goroutines of its own: tests drive it with a fake clock,// and a separate runner drives it for real.type Scheduler struct { timetable timetable jobs map[string]*Job // kept after they end, so status can be asked clock func() int64 ids func() string retry Retry nextSeq int64 // Fixed-rate runs skipped because the previous run of the same job was still going. Skipped int}
func NewScheduler(clock func() int64, ids func() string, retry Retry) *Scheduler { return &Scheduler{jobs: map[string]*Job{}, clock: clock, ids: ids, retry: retry}}
func (s *Scheduler) Schedule(task func(), delay int64) string { return s.add(task, Once, 0, delay) }
func (s *Scheduler) ScheduleAtFixedRate(task func(), initialDelay, period int64) string { return s.add(task, FixedRate, period, initialDelay)}
func (s *Scheduler) ScheduleWithFixedDelay(task func(), initialDelay, delay int64) string { return s.add(task, FixedDelay, delay, initialDelay)}
// Cancel works only on a job that hasn't ended. A running job finishes its current run, but// it is reported as cancelled and never runs again.func (s *Scheduler) Cancel(id string) bool { j, ok := s.jobs[id] if !ok || j.cancelled || j.Status == Succeeded || j.Status == Failed { return false } j.cancelled = true j.Status = Cancelled return true}
func (s *Scheduler) StatusOf(id string) Status { return s.jobs[id].Status }
func (s *Scheduler) LastError(id string) string { return s.jobs[id].LastError }
// NextRunAt says when the next job is due, or false if the timetable is empty. Cancelled jobs// at the front are dropped first, so they can't cause a pointless wake-up.func (s *Scheduler) NextRunAt() (int64, bool) { s.dropCancelled() if len(s.timetable) == 0 { return 0, false } return s.timetable[0].RunAt, true}
// TakeDue returns every job due by now, marked as running. A fixed-rate job is put back for its// next slot at once, counted from its schedule, not from now. Slots missed while the machine// was busy are skipped, not run in a burst. If its last run is still going, this run is skipped// too, so two runs of one job never overlap.func (s *Scheduler) TakeDue() []*Job { now := s.clock() var due []*Job for s.dropCancelled(); len(s.timetable) > 0 && s.timetable[0].RunAt <= now; s.dropCancelled() { j := heap.Pop(&s.timetable).(*Job) if j.Repeat == FixedRate { next := j.RunAt + j.Every if next <= now { next += (now-next)/j.Every*j.Every + j.Every } s.requeue(j, next) if j.running { s.Skipped++ continue } } j.running = true j.Status = Running j.Attempts++ due = append(due, j) } return due}
// Finished takes a nil err when the run succeeded. A one-off job that fails is retried with// growing waits, then marked Failed. A repeating job isn't retried: its next run is the retry,// and one bad run doesn't end the series.func (s *Scheduler) Finished(j *Job, err error) { j.running = false if err != nil { j.LastError = err.Error() } if j.cancelled { return } now := s.clock() switch j.Repeat { case Once: switch { case err == nil: j.Status = Succeeded case j.Attempts < s.retry.MaxAttempts: j.Status = Scheduled s.requeue(j, now+s.retry.Backoff<<(j.Attempts-1)) default: j.Status = Failed } case FixedRate: j.Status = Scheduled case FixedDelay: j.Status = Scheduled s.requeue(j, now+j.Every) }}
// RunDue runs every due job on the calling goroutine, one after another. Tests use this with a// fake clock.func (s *Scheduler) RunDue() int { due := s.TakeDue() for _, j := range due { s.Finished(j, runSafely(j.Task)) } return len(due)}
// add is where every schedule call ends. The job is remembered by id even after it ends, so its// status can still be asked.func (s *Scheduler) add(task func(), repeat Repeat, every, delay int64) string { j := &Job{ID: s.ids(), Task: task, Repeat: repeat, Every: every} s.jobs[j.ID] = j s.requeue(j, s.clock()+delay) return j.ID}
// requeue gives the job the next seq, so jobs due at the same moment run in the order they were// queued.func (s *Scheduler) requeue(j *Job, runAt int64) { j.RunAt = runAt j.seq = s.nextSeq s.nextSeq++ heap.Push(&s.timetable, j)}
// dropCancelled throws away marked jobs once they reach the front. Cancel only marks them.func (s *Scheduler) dropCancelled() { for len(s.timetable) > 0 && s.timetable[0].cancelled { heap.Pop(&s.timetable) }}
// RunningScheduler runs the scheduler for real: one goroutine watches the timetable, and a// fixed set of workers runs the jobs, so a slow job only occupies its own worker. Everything// that touches the timetable (schedule, cancel, taking due jobs, a worker reporting a finished// run) holds one mutex, so a cancel can't slip in between "this job is due" and "it's running".//// The watcher sleeps until the next job is due, but a job scheduled for sooner must wake it.// wake has room for one signal, so a change made while the watcher is busy isn't lost: the// watcher finds it waiting the next time it selects.type RunningScheduler struct { mu sync.Mutex table *Scheduler clock func() int64 work chan *Job wake chan struct{} stop chan struct{} done sync.WaitGroup}
func NewRunningScheduler(table *Scheduler, clock func() int64, workers int) *RunningScheduler { r := &RunningScheduler{table: table, clock: clock, work: make(chan *Job), wake: make(chan struct{}, 1), stop: make(chan struct{})} r.done.Add(workers) for range workers { go r.worker() } go r.watch() return r}
// Schedule and the other changes go through changing, which wakes the watcher, because the new// job may be due sooner than what it is waiting for.func (r *RunningScheduler) Schedule(task func(), delay int64) string { return changing(r, func() string { return r.table.Schedule(task, delay) })}
func (r *RunningScheduler) ScheduleAtFixedRate(task func(), initialDelay, period int64) string { return changing(r, func() string { return r.table.ScheduleAtFixedRate(task, initialDelay, period) })}
func (r *RunningScheduler) ScheduleWithFixedDelay(task func(), initialDelay, delay int64) string { return changing(r, func() string { return r.table.ScheduleWithFixedDelay(task, initialDelay, delay) })}
func (r *RunningScheduler) Cancel(id string) bool { return changing(r, func() bool { return r.table.Cancel(id) })}
// StatusOf reads under the mutex too, because workers change a job's status from their own// goroutines.func (r *RunningScheduler) StatusOf(id string) Status { return changing(r, func() Status { return r.table.StatusOf(id) })}
// Shutdown stops taking new runs; runs already started finish.func (r *RunningScheduler) Shutdown() { close(r.stop) r.done.Wait()}
// watch is the watcher goroutine. It takes the due jobs under the mutex, hands them to workers,// then sleeps until the next job is due, a change wakes it, or Shutdown stops it.func (r *RunningScheduler) watch() { defer close(r.work) // after the watcher stops, the workers drain and exit for { r.mu.Lock() due := r.table.TakeDue() next, ok := r.table.NextRunAt() r.mu.Unlock() // Handing a job over blocks until a worker is free, without the mutex held, so // schedule and cancel keep working while every worker is busy. for _, j := range due { select { case r.work <- j: case <-r.stop: return } } var timer <-chan time.Time // nil when nothing is scheduled: waits for a change if ok { timer = time.After(time.Duration(max(1, next-r.clock())) * time.Millisecond) } select { case <-timer: case <-r.wake: case <-r.stop: return } }}
// worker runs jobs with no mutex held while the task runs.func (r *RunningScheduler) worker() { defer r.done.Done() for j := range r.work { err := runSafely(j.Task) changing(r, func() struct{} { r.table.Finished(j, err); return struct{}{} }) }}
// changing runs a change under the mutex and nudges the watcher, whose next wake-up may now// be sooner. A generic function, because Go methods can't take type parameters.func changing[T any](r *RunningScheduler, change func() T) T { r.mu.Lock() result := change() r.mu.Unlock() select { case r.wake <- struct{}{}: default: // a nudge is already waiting } return result}// The complete design, built from every stage on this page.
// A task may be async: sending an email awaits the network.type Task = () => void | Promise<void>;
// One scheduled job and its bookkeeping. runAt is when it runs next. seq breaks ties: jobs due// at the same moment run in the order they were queued. running is true while a run is in// progress, and cancelled is only a mark, because taking a job out of the middle of the// timetable is slow.interface Job { id: string; task: Task; repeat: Repeat; every: number; seq: number; runAt: number; running: boolean; cancelled: boolean; status: Status; attempts: number; // runs started so far lastError?: string;}
// The timetable. A priority queue keeps the job due soonest at the front, so finding what is// due never scans every job. It has no threads of its own: tests drive it with a fake clock,// and a separate runner drives it for real.class Scheduler { private readonly timetable = new MinHeap<Job>(byRunTime); private readonly jobs = new Map<string, Job>(); // kept after they end, so status can be asked private nextSeq = 0; // Fixed-rate runs skipped because the previous run of the same job was still going. skipped = 0;
constructor( private readonly clock: () => number, private readonly ids: () => string, private readonly retry: Retry, ) {}
schedule(task: Task, delayMs: number): string { return this.add(task, 'once', 0, delayMs); }
scheduleAtFixedRate(task: Task, initialDelay: number, period: number): string { return this.add(task, 'fixed-rate', period, initialDelay); }
scheduleWithFixedDelay(task: Task, initialDelay: number, delay: number): string { return this.add(task, 'fixed-delay', delay, initialDelay); }
// Only a job that hasn't ended can be cancelled. A running job finishes its current run, but // it is reported as cancelled and never runs again. cancel(id: string): boolean { const job = this.jobs.get(id); if (!job || job.cancelled || job.status === 'succeeded' || job.status === 'failed') return false; job.cancelled = true; job.status = 'cancelled'; return true; }
status(id: string): Status { return this.jobs.get(id)!.status; }
lastError(id: string): string | undefined { return this.jobs.get(id)!.lastError; }
// When the next job is due, or nothing if the timetable is empty. Cancelled jobs at the front // are dropped first, so they can't cause a pointless wake-up. nextRunAt(): number | undefined { this.dropCancelled(); return this.timetable.peek()?.runAt; }
// Every job due by now, marked as running. A fixed-rate job is put back for its next slot at // once, counted from its schedule, not from now. Slots missed while the machine was busy are // skipped, not run in a burst. If its last run is still going, this run is skipped too, so // two runs of one job never overlap. takeDue(): Job[] { const now = this.clock(); const due: Job[] = []; for (this.dropCancelled(); (this.timetable.peek()?.runAt ?? Infinity) <= now; this.dropCancelled()) { const job = this.timetable.pop()!; if (job.repeat === 'fixed-rate') { let next = job.runAt + job.every; if (next <= now) next += Math.floor((now - next) / job.every) * job.every + job.every; this.requeue(job, next); if (job.running) { this.skipped++; continue; } } job.running = true; job.status = 'running'; job.attempts++; due.push(job); } return due; }
// error is undefined when the run succeeded. A one-off job that fails is retried with growing // waits, then marked failed. A repeating job isn't retried: its next run is the retry, and one // bad run doesn't end the series. finished(job: Job, error?: unknown): void { job.running = false; if (error !== undefined) job.lastError = error instanceof Error ? error.message : String(error); if (job.cancelled) return; const now = this.clock(); if (job.repeat === 'once') { if (error === undefined) { job.status = 'succeeded'; } else if (job.attempts < this.retry.maxAttempts) { job.status = 'scheduled'; this.requeue(job, now + this.retry.backoffMs * 2 ** (job.attempts - 1)); } else { job.status = 'failed'; } } else { job.status = 'scheduled'; if (job.repeat === 'fixed-delay') this.requeue(job, now + job.every); } }
// Runs every due job, one after another. Tests use this with a fake clock. async runDue(): Promise<number> { const due = this.takeDue(); for (const job of due) this.finished(job, await runSafely(job.task)); return due.length; }
// Every schedule call ends here. The job is remembered by id even after it ends, so its // status can still be asked. private add(task: Task, repeat: Repeat, every: number, delay: number): string { const job: Job = { id: this.ids(), task, repeat, every, seq: 0, runAt: 0, running: false, cancelled: false, status: 'scheduled', attempts: 0, }; this.jobs.set(job.id, job); this.requeue(job, this.clock() + delay); return job.id; }
// Each requeue takes the next seq, so jobs due at the same moment run in the order they were // queued. private requeue(job: Job, runAt: number): void { job.runAt = runAt; job.seq = this.nextSeq++; this.timetable.push(job); }
// Cancel only marks a job. Marked jobs are thrown away here, once they reach the front. private dropCancelled(): void { while (this.timetable.peek()?.cancelled) this.timetable.pop(); }}
// JavaScript has no built-in priority queue, so here is a small binary min-heap. The smallest// item is always at index 0; push and pop move one item up or down a path of log n parents.class MinHeap<T> { private items: T[] = [];
constructor(private readonly before: (a: T, b: T) => boolean) {}
get size(): number { return this.items.length; }
peek(): T | undefined { return this.items[0]; }
push(item: T): void { const a = this.items; a.push(item); for (let i = a.length - 1; i > 0; ) { const parent = (i - 1) >> 1; if (!this.before(a[i], a[parent])) break; [a[i], a[parent]] = [a[parent], a[i]]; i = parent; } }
pop(): T | undefined { const a = this.items; const top = a[0]; const last = a.pop(); if (a.length > 0 && last !== undefined) { a[0] = last; for (let i = 0; ; ) { const l = 2 * i + 1; const r = l + 1; let m = i; if (l < a.length && this.before(a[l], a[m])) m = l; if (r < a.length && this.before(a[r], a[m])) m = r; if (m === i) break; [a[i], a[m]] = [a[m], a[i]]; i = m; } } return top; }}
const byRunTime = (a: Job, b: Job) => a.runAt < b.runAt || (a.runAt === b.runAt && a.seq < b.seq);
// once; fixed-rate starts every period on the clock (10:00, 10:05, 10:10) however long each run// takes; fixed-delay waits a delay after each run finishes, so a 2-minute run pushes the next back.type Repeat = 'once' | 'fixed-rate' | 'fixed-delay';
// Where a job is in its life. SUCCEEDED, FAILED and CANCELLED are final.type Status = 'scheduled' | 'running' | 'succeeded' | 'failed' | 'cancelled';
// How a one-off job retries: up to maxAttempts runs in all, waiting backoffMs, then twice that,// then four times, so a struggling service isn't hammered.interface Retry { maxAttempts: number; backoffMs: number;}
// Runs a task and hands back what it threw, or undefined if it succeeded.async function runSafely(task: Task): Promise<unknown> { try { await task(); return undefined; } catch (e) { return e ?? new Error('task failed'); }}
// The scheduler running for real. There is one thread, so no lock: the event loop runs one// callback at a time, and nothing can cut in between "this job is due" and "it's running".// Two things are left to build. A timer has to wake up when the next job is due, and be moved// earlier when a sooner job arrives. And a limit on how many jobs run at once, because "start// every due job" on a busy minute opens a thousand database connections. Jobs over the limit// wait in a ready line, in the order they came due.class RunningScheduler { private timer?: ReturnType<typeof setTimeout>; private timerAt = Infinity; // when the armed timer fires private readonly ready: Job[] = []; private inFlight = 0; private stopped = false; private idle?: () => void; // resolves shutdown() once the last run ends
constructor( private readonly timetable: Scheduler, private readonly clock: () => number, private readonly workers: number, ) {}
// Every change goes through changing(), which re-arms the timer, because the new job may be // due sooner. schedule(task: Task, delayMs: number): string { return this.changing(() => this.timetable.schedule(task, delayMs)); }
scheduleAtFixedRate(task: Task, initialDelay: number, period: number): string { return this.changing(() => this.timetable.scheduleAtFixedRate(task, initialDelay, period)); }
scheduleWithFixedDelay(task: Task, initialDelay: number, delay: number): string { return this.changing(() => this.timetable.scheduleWithFixedDelay(task, initialDelay, delay)); }
cancel(id: string): boolean { return this.changing(() => this.timetable.cancel(id)); }
status(id: string): Status { return this.timetable.status(id); }
// Stops taking new runs; runs already started finish. async shutdown(): Promise<void> { this.stopped = true; clearTimeout(this.timer); this.ready.length = 0; if (this.inFlight > 0) await new Promise<void>((r) => (this.idle = r)); }
// Moves due jobs to the ready line, starts as many as the limit allows, and sets the timer // for the next one. Called whenever the timetable or the number running changes. private pump(): void { if (this.stopped) return; this.ready.push(...this.timetable.takeDue()); while (this.inFlight < this.workers && this.ready.length > 0) void this.run(this.ready.shift()!); const next = this.timetable.nextRunAt() ?? Infinity; if (next !== this.timerAt) { // Only re-arm when the time changed: clearing and setting a timer on every call is churn. clearTimeout(this.timer); this.timerAt = next; if (next !== Infinity) { this.timer = setTimeout(() => { this.timerAt = Infinity; this.pump(); }, Math.max(1, next - this.clock())); } } }
// Runs one job. inFlight counts runs in progress, so shutdown can wait for the last one to // end. private async run(job: Job): Promise<void> { this.inFlight++; const error = await runSafely(job.task); this.inFlight--; if (this.stopped && this.inFlight === 0) this.idle?.(); this.changing(() => this.timetable.finished(job, error)); }
private changing<T>(change: () => T): T { const result = change(); this.pump(); return result; }}Three ideas hold it together:
- The timetable never searches. Every question it answers, “what’s next, and when?”, is a look at the top of the heap (a priority queue). Every change is one add or one removal at the top.
- Every run ends in
finished. Success, failure, retry, the next fixed-delay run, and the end of a cancelled job are all decided in one method. So the rules live in one place. - Deciding is separate from running.
Schedulerdecides what runs and when. The threads only carry jobs to workers and results back (producer-consumer). The task itself is a Command.
Stress it
Section titled “Stress it”So far, our tests call runDue by hand. Now we run the scheduler for real, with threads.
A sooner job, while the scheduler sleeps
Section titled “A sooner job, while the scheduler sleeps”Now run it for real. Jobs come in from many threads, and a job can take a minute. A job scheduled for one second from now must not wait behind one due in an hour. How do the threads work?
Whether the thread that sleeps until the next job is woken when a sooner job arrives, without
losing the wake-up. The red flags are a plain sleep(nextRunAt - now), which can’t be woken,
and running tasks while holding the scheduler’s lock.
The clearly correct design has one watcher thread that owns the waiting. It takes the jobs that are due, hands them to a fixed pool of workers, and sleeps until the next run time. A slow job holds one worker, and the watcher and the other workers carry on.
The watcher’s sleep is where the bugs are. Suppose it’s sleeping until a job an hour away, and a
job for one second from now is scheduled. A plain sleep would run that new job an hour late. So
the watcher waits on a condition with a time limit. A condition is a way for one thread to
wait until another says “something changed”. Every change to the timetable signals it. The
watcher wakes, looks at the top of the heap again, and goes back to sleep for the new, shorter time.
The better fix makes the signal impossible to miss. Picture this timing:
- The watcher checks the timetable: the next job is an hour away.
- Another thread adds a job for one second from now, and signals.
- Only then does the watcher start waiting.
The signal in step 2 went to nobody, and the watcher sleeps an hour. This is called a lost
wake-up. The fix: checking and starting to wait happen under the same lock that schedule
holds while it adds a job and signals. And waiting on the condition releases that lock as one step.
So there’s no gap for the signal to fall into.
- Color key
- Background worker
- Facade
| Option | How | Wakes for a sooner job? | Cost |
|---|---|---|---|
| 1. One watcher, a condition, a worker pool (our choice) | Wait until the next run time, signalled on change | Yes | One lock shared by callers, the watcher and workers |
| 2. Check every second | Stage 1’s loop, with a heap | Within a second | Up to a second late, and wakes when there’s nothing to do |
3. A DelayQueue that workers take from |
Workers wait on the queue until the top job is due | Yes, built in | Repeat, retry and status logic moves into the workers |
4. A plain sleep until the next run |
The watcher sleeps; nothing can wake it | No | A sooner job waits behind the earliest one |
| 5. A thread per job | Each sleeps until its own time | Yes | A thread per waiting job |
// The scheduler running for real: one thread watches the timetable, and a pool of workers runs// the jobs, so a slow job only occupies its own worker. Everything that touches the timetable// (schedule, cancel, taking due jobs, a worker reporting a finished run) holds one lock, so a// cancel can't slip in between "this job is due" and "this job is running".//// The watcher sleeps until the next job is due, but a job scheduled for sooner must wake it.// It waits on a condition under the same lock that schedule() takes to add the job and signal,// so the signal can't arrive in the gap between checking the timetable and starting to wait.class RunningScheduler { private final Scheduler timetable; private final LongSupplier clock; private final ExecutorService workers; private final ReentrantLock lock = new ReentrantLock(); private final Condition changed = lock.newCondition(); private final Thread watcher = new Thread(this::watch, "scheduler"); private boolean stopped;
RunningScheduler(Scheduler timetable, LongSupplier clock, int workerCount) { this.timetable = timetable; this.clock = clock; this.workers = Executors.newFixedThreadPool(workerCount); watcher.start(); }
// Every change goes through changing(), which wakes the watcher, because the new job may be // due sooner than what it is waiting for. String schedule(Runnable task, long delayMillis) { return changing(() -> timetable.schedule(task, delayMillis)); }
String scheduleAtFixedRate(Runnable task, long initialDelay, long period) { return changing(() -> timetable.scheduleAtFixedRate(task, initialDelay, period)); }
String scheduleWithFixedDelay(Runnable task, long initialDelay, long delay) { return changing(() -> timetable.scheduleWithFixedDelay(task, initialDelay, delay)); }
boolean cancel(String id) { return changing(() -> timetable.cancel(id)); }
// Read under the lock too, because workers change a job's status from their own threads. Status status(String id) { return changing(() -> timetable.status(id)); }
// Stops taking new runs; runs already started finish. void shutdown() throws InterruptedException { changing(() -> stopped = true); watcher.join(); workers.shutdown(); workers.awaitTermination(1, TimeUnit.MINUTES); }
// The watcher thread. It hands every due job to a worker, then sleeps until the next job is // due or a change wakes it. Waiting lets go of the lock, so schedule and cancel can get in // while it sleeps. private void watch() { lock.lock(); try { while (!stopped) { for (Job j : timetable.takeDue()) workers.execute(() -> run(j)); OptionalLong next = timetable.nextRunAt(); if (next.isEmpty()) changed.await(); else changed.await(Math.max(1, next.getAsLong() - clock.getAsLong()), TimeUnit.MILLISECONDS); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } }
// On a worker, with no lock held while the task runs. private void run(Job j) { Throwable error = null; try { j.task.run(); } catch (RuntimeException e) { error = e; } Throwable result = error; changing(() -> { timetable.finished(j, result); return null; }); }
// Runs a change under the lock and wakes the watcher, whose next wake-up may now be sooner. private <T> T changing(Supplier<T> change) { lock.lock(); try { T result = change.get(); changed.signal(); return result; } finally { lock.unlock(); } }}# The scheduler running for real: one thread watches the timetable, and a pool of workers# runs the jobs, so a slow job only occupies its own worker. Everything that touches the# timetable (schedule, cancel, taking due jobs, a worker reporting a finished run) holds one# lock, so a cancel can't slip in between "this job is due" and "this job is running".## The watcher sleeps until the next job is due, but a job scheduled for sooner must wake it.# It waits on a condition under the same lock that schedule() takes to add the job and# notify, so the notify can't arrive in the gap between checking the timetable and waiting.class RunningScheduler: def __init__(self, timetable: Scheduler, clock: Callable[[], int], worker_count: int): self._timetable = timetable self._clock = clock self._workers = ThreadPoolExecutor(max_workers=worker_count) self._changed = threading.Condition() self._stopped = False self._watcher = threading.Thread(target=self._watch, name="scheduler") self._watcher.start()
# Every change goes through _changing(), which wakes the watcher, because the new job may be # due sooner than what it is waiting for. def schedule(self, task: Callable[[], None], delay_ms: int) -> str: return self._changing(lambda: self._timetable.schedule(task, delay_ms))
def schedule_at_fixed_rate(self, task: Callable[[], None], initial_delay: int, period: int) -> str: return self._changing(lambda: self._timetable.schedule_at_fixed_rate(task, initial_delay, period))
def schedule_with_fixed_delay(self, task: Callable[[], None], initial_delay: int, delay: int) -> str: return self._changing(lambda: self._timetable.schedule_with_fixed_delay(task, initial_delay, delay))
def cancel(self, job_id: str) -> bool: return self._changing(lambda: self._timetable.cancel(job_id))
# Read under the lock too, because workers change a job's status from their own threads. def status(self, job_id: str) -> Status: return self._changing(lambda: self._timetable.status(job_id))
# Stops taking new runs; runs already started finish. def shutdown(self) -> None: def stop() -> None: self._stopped = True self._changing(stop) self._watcher.join() self._workers.shutdown(wait=True)
# The watcher thread. It hands every due job to a worker, then sleeps until the next job is # due or a change wakes it. Waiting lets go of the lock, so schedule and cancel can get in # while it sleeps. def _watch(self) -> None: with self._changed: while not self._stopped: for job in self._timetable.take_due(): self._workers.submit(self._run, job) nxt = self._timetable.next_run_at() timeout = None if nxt is None else max(1, nxt - self._clock()) / 1000 self._changed.wait(timeout)
# On a worker, with no lock held while the task runs. def _run(self, job: Job) -> None: error = None try: job.task() except Exception as e: error = e self._changing(lambda: self._timetable.finished(job, error))
# Runs a change under the lock and wakes the watcher, whose next wake-up may now be sooner. def _changing(self, change: Callable[[], T]) -> T: with self._changed: result = change() self._changed.notify() return result// The scheduler running for real: one thread watches the timetable, and a fixed set of worker// threads runs the jobs, so a slow job only occupies its own worker. Everything that touches the// timetable (schedule, cancel, taking due jobs, a worker reporting a finished run) holds one// lock, so a cancel can't slip in between "this job is due" and "this job is running".//// The watcher sleeps until the next job is due, but a job scheduled for sooner must wake it.// Monitor.Wait releases the lock and starts waiting as one step, and schedule pulses under the// same lock, so the pulse can't arrive in the gap between checking the timetable and waiting.public sealed class RunningScheduler{ private readonly Scheduler _timetable; private readonly Func<long> _clock; private readonly object _lock = new(); // a plain object: Monitor.Wait doesn't work on the Lock type private readonly BlockingCollection<Job> _work = new(); private readonly Thread _watcher; private readonly Thread[] _workers; private bool _stopped;
public RunningScheduler(Scheduler timetable, Func<long> clock, int workerCount) { _timetable = timetable; _clock = clock; _workers = Enumerable.Range(0, workerCount).Select(_ => new Thread(Work)).ToArray(); foreach (var w in _workers) w.Start(); _watcher = new Thread(Watch) { Name = "scheduler" }; _watcher.Start(); }
// Every change goes through Changing(), which wakes the watcher, because the new job may be // due sooner than what it is waiting for. public string Schedule(Action task, long delayMs) => Changing(() => _timetable.Schedule(task, delayMs));
public string ScheduleAtFixedRate(Action task, long initialDelay, long period) => Changing(() => _timetable.ScheduleAtFixedRate(task, initialDelay, period));
public string ScheduleWithFixedDelay(Action task, long initialDelay, long delay) => Changing(() => _timetable.ScheduleWithFixedDelay(task, initialDelay, delay));
public bool Cancel(string id) => Changing(() => _timetable.Cancel(id));
// Read under the lock too, because workers change a job's status from their own threads. public Status StatusOf(string id) => Changing(() => _timetable.StatusOf(id));
// Stops taking new runs; runs already started finish. public void Shutdown() { Changing(() => _stopped = true); _watcher.Join(); _work.CompleteAdding(); // the workers drain what was handed over, then exit foreach (var w in _workers) w.Join(); }
// The watcher thread. It hands every due job to a worker, then sleeps until the next job is // due or a change wakes it. Waiting lets go of the lock, so schedule and cancel can get in // while it sleeps. private void Watch() { lock (_lock) { while (!_stopped) { foreach (var job in _timetable.TakeDue()) _work.Add(job); var next = _timetable.NextRunAt(); if (next is null) Monitor.Wait(_lock); else Monitor.Wait(_lock, TimeSpan.FromMilliseconds(Math.Max(1, next.Value - _clock()))); } } }
// On a worker, with no lock held while the task runs. private void Work() { foreach (var job in _work.GetConsumingEnumerable()) { Exception? error = null; try { job.Task(); } catch (Exception e) { error = e; } Changing(() => { _timetable.Finished(job, error); return 0; }); } }
// Runs a change under the lock and wakes the watcher, whose next wake-up may now be sooner. private T Changing<T>(Func<T> change) { lock (_lock) { var result = change(); Monitor.Pulse(_lock); return result; } }}// RunningScheduler runs the scheduler for real: one goroutine watches the timetable, and a// fixed set of workers runs the jobs, so a slow job only occupies its own worker. Everything// that touches the timetable (schedule, cancel, taking due jobs, a worker reporting a finished// run) holds one mutex, so a cancel can't slip in between "this job is due" and "it's running".//// The watcher sleeps until the next job is due, but a job scheduled for sooner must wake it.// wake has room for one signal, so a change made while the watcher is busy isn't lost: the// watcher finds it waiting the next time it selects.type RunningScheduler struct { mu sync.Mutex table *Scheduler clock func() int64 work chan *Job wake chan struct{} stop chan struct{} done sync.WaitGroup}
func NewRunningScheduler(table *Scheduler, clock func() int64, workers int) *RunningScheduler { r := &RunningScheduler{table: table, clock: clock, work: make(chan *Job), wake: make(chan struct{}, 1), stop: make(chan struct{})} r.done.Add(workers) for range workers { go r.worker() } go r.watch() return r}
// Schedule and the other changes go through changing, which wakes the watcher, because the new// job may be due sooner than what it is waiting for.func (r *RunningScheduler) Schedule(task func(), delay int64) string { return changing(r, func() string { return r.table.Schedule(task, delay) })}
func (r *RunningScheduler) ScheduleAtFixedRate(task func(), initialDelay, period int64) string { return changing(r, func() string { return r.table.ScheduleAtFixedRate(task, initialDelay, period) })}
func (r *RunningScheduler) ScheduleWithFixedDelay(task func(), initialDelay, delay int64) string { return changing(r, func() string { return r.table.ScheduleWithFixedDelay(task, initialDelay, delay) })}
func (r *RunningScheduler) Cancel(id string) bool { return changing(r, func() bool { return r.table.Cancel(id) })}
// StatusOf reads under the mutex too, because workers change a job's status from their own// goroutines.func (r *RunningScheduler) StatusOf(id string) Status { return changing(r, func() Status { return r.table.StatusOf(id) })}
// Shutdown stops taking new runs; runs already started finish.func (r *RunningScheduler) Shutdown() { close(r.stop) r.done.Wait()}
// watch is the watcher goroutine. It takes the due jobs under the mutex, hands them to workers,// then sleeps until the next job is due, a change wakes it, or Shutdown stops it.func (r *RunningScheduler) watch() { defer close(r.work) // after the watcher stops, the workers drain and exit for { r.mu.Lock() due := r.table.TakeDue() next, ok := r.table.NextRunAt() r.mu.Unlock() // Handing a job over blocks until a worker is free, without the mutex held, so // schedule and cancel keep working while every worker is busy. for _, j := range due { select { case r.work <- j: case <-r.stop: return } } var timer <-chan time.Time // nil when nothing is scheduled: waits for a change if ok { timer = time.After(time.Duration(max(1, next-r.clock())) * time.Millisecond) } select { case <-timer: case <-r.wake: case <-r.stop: return } }}
// worker runs jobs with no mutex held while the task runs.func (r *RunningScheduler) worker() { defer r.done.Done() for j := range r.work { err := runSafely(j.Task) changing(r, func() struct{} { r.table.Finished(j, err); return struct{}{} }) }}
// changing runs a change under the mutex and nudges the watcher, whose next wake-up may now// be sooner. A generic function, because Go methods can't take type parameters.func changing[T any](r *RunningScheduler, change func() T) T { r.mu.Lock() result := change() r.mu.Unlock() select { case r.wake <- struct{}{}: default: // a nudge is already waiting } return result}// The scheduler running for real. There is one thread, so no lock: the event loop runs one// callback at a time, and nothing can cut in between "this job is due" and "it's running".// Two things are left to build. A timer has to wake up when the next job is due, and be moved// earlier when a sooner job arrives. And a limit on how many jobs run at once, because "start// every due job" on a busy minute opens a thousand database connections. Jobs over the limit// wait in a ready line, in the order they came due.class RunningScheduler { private timer?: ReturnType<typeof setTimeout>; private timerAt = Infinity; // when the armed timer fires private readonly ready: Job[] = []; private inFlight = 0; private stopped = false; private idle?: () => void; // resolves shutdown() once the last run ends
constructor( private readonly timetable: Scheduler, private readonly clock: () => number, private readonly workers: number, ) {}
// Every change goes through changing(), which re-arms the timer, because the new job may be // due sooner. schedule(task: Task, delayMs: number): string { return this.changing(() => this.timetable.schedule(task, delayMs)); }
scheduleAtFixedRate(task: Task, initialDelay: number, period: number): string { return this.changing(() => this.timetable.scheduleAtFixedRate(task, initialDelay, period)); }
scheduleWithFixedDelay(task: Task, initialDelay: number, delay: number): string { return this.changing(() => this.timetable.scheduleWithFixedDelay(task, initialDelay, delay)); }
cancel(id: string): boolean { return this.changing(() => this.timetable.cancel(id)); }
status(id: string): Status { return this.timetable.status(id); }
// Stops taking new runs; runs already started finish. async shutdown(): Promise<void> { this.stopped = true; clearTimeout(this.timer); this.ready.length = 0; if (this.inFlight > 0) await new Promise<void>((r) => (this.idle = r)); }
// Moves due jobs to the ready line, starts as many as the limit allows, and sets the timer // for the next one. Called whenever the timetable or the number running changes. private pump(): void { if (this.stopped) return; this.ready.push(...this.timetable.takeDue()); while (this.inFlight < this.workers && this.ready.length > 0) void this.run(this.ready.shift()!); const next = this.timetable.nextRunAt() ?? Infinity; if (next !== this.timerAt) { // Only re-arm when the time changed: clearing and setting a timer on every call is churn. clearTimeout(this.timer); this.timerAt = next; if (next !== Infinity) { this.timer = setTimeout(() => { this.timerAt = Infinity; this.pump(); }, Math.max(1, next - this.clock())); } } }
// Runs one job. inFlight counts runs in progress, so shutdown can wait for the last one to // end. private async run(job: Job): Promise<void> { this.inFlight++; const error = await runSafely(job.task); this.inFlight--; if (this.stopped && this.inFlight === 0) this.idle?.(); this.changing(() => this.timetable.finished(job, error)); }
private changing<T>(change: () => T): T { const result = change(); this.pump(); return result; }}Why is this correct? The lock covers everything that touches the timetable: schedule, cancel,
taking due jobs, and a worker reporting a finished run. So a cancel can’t slip in between “this job
is due” and “this job is running”. And the fixed-rate running flag is always right. The watcher
also checks and waits only while holding that lock. So every signal arrives either before it checks
(and it sees the new job), or while it waits (and it wakes).
What does it cost? Callers, the watcher and the workers share one lock, but each holds it only for a few heap operations. The lock is never held while a task runs, or one slow job would freeze scheduling for everyone.
Go deeperHow each language waits so it can be woken1 min
Java waits on a Condition from the lock, Python on a threading.Condition, and C# on
Monitor.Wait. Each one releases the lock and starts waiting as one step. Go has no condition
variable with a time limit. So the watcher waits on either a timer or a wake channel with room for
one signal. A signal sent while the watcher is busy stays in the channel, which closes the same gap.
TypeScript has one thread, so there is no lock. The watcher is a setTimeout, moved earlier when a
sooner job arrives. The worker count is a limit on how many tasks run at once.
How you’d prove it works
Section titled “How you’d prove it works”The scheduler is given a clock, so the tests control time. A test schedules jobs, sets the clock,
calls runDue, and checks. A test that sleeps five minutes to check a five-minute job is slow and
still flaky. Only one test uses real threads, and it waits on latches (a signal that a thread has
reached a point), not sleeps. The tests that matter:
- Jobs run in time order and never early. Jobs due together run in the order scheduled.
- A cancelled job never runs, and cancelling twice is refused.
- Fixed rate keeps to its slots. After a pause through three slots, it runs once, then carries on at the next slot.
- Fixed rate skips a slot while the previous run is still going.
- Fixed delay’s next run is the delay after the run finished.
- A failing one-off job retries after 1 s, then 2 s, then fails with its last error. One that succeeds on a retry ends as succeeded.
- A failed run doesn’t end a repeating job.
- With real threads, a job scheduled sooner wakes a watcher that was sleeping until a minute from now. It runs while a slow job holds the other worker.
Go deeperThe tests in five languages2 min
long now = 0; // the test's clock, in millisecondsAtomicInteger nextId = new AtomicInteger();Scheduler s;List<String> ran = new ArrayList<>();
@BeforeEachvoid setUp() { s = new Scheduler(() -> now, () -> "j" + nextId.incrementAndGet(), new Retry(3, 1000)); }
Runnable record(String name) { return () -> ran.add(name); }
void advanceTo(long t) { now = t; s.runDue();}
@Testvoid jobsRunInTimeOrderAndNeverEarly() { s.schedule(record("c"), 300); s.schedule(record("a"), 100); s.schedule(record("b"), 200); advanceTo(99); assertEquals(List.of(), ran); advanceTo(250); assertEquals(List.of("a", "b"), ran); assertEquals(300L, s.nextRunAt().getAsLong());}
@Testvoid jobsDueTogetherRunInTheOrderScheduled() { s.schedule(record("first"), 100); s.schedule(record("second"), 100); advanceTo(100); assertEquals(List.of("first", "second"), ran);}
@Testvoid aCancelledJobNeverRuns() { String id = s.schedule(record("x"), 100); assertTrue(s.cancel(id)); assertFalse(s.cancel(id)); advanceTo(1000); assertEquals(List.of(), ran); assertEquals(Status.CANCELLED, s.status(id));}
@Testvoid fixedRateKeepsToTheClockAndSkipsMissedRuns() { s.scheduleAtFixedRate(record("tick"), 100, 100); advanceTo(100); advanceTo(200); assertEquals(2, ran.size()); advanceTo(550); // paused through 300, 400 and 500 assertEquals(3, ran.size()); // runs once to catch up, not three times assertEquals(600L, s.nextRunAt().getAsLong()); // and stays on the 100s}
@Testvoid fixedRateSkipsARunWhileThePreviousOneIsStillGoing() { s.scheduleAtFixedRate(record("tick"), 0, 100); List<Job> first = s.takeDue(); // started at 0, still running now = 100; assertEquals(0, s.takeDue().size()); assertEquals(1L, s.skipped); s.finished(first.get(0), null); now = 200; assertEquals(1, s.takeDue().size());}
@Testvoid fixedDelayWaitsAfterEachRunFinishes() { s.scheduleWithFixedDelay(() -> now += 300, 0, 100); // each run takes 300 ms advanceTo(0); // runs 0 to 300 assertEquals(400L, s.nextRunAt().getAsLong()); // 100 after it finished, not after it started}
@Testvoid aFailingJobIsRetriedWithGrowingWaitsThenFails() { String id = s.schedule(() -> { throw new IllegalStateException("smtp down"); }, 0); advanceTo(0); assertEquals(Status.SCHEDULED, s.status(id)); assertEquals(1000L, s.nextRunAt().getAsLong()); // first retry after 1 s advanceTo(1000); assertEquals(3000L, s.nextRunAt().getAsLong()); // then 2 s advanceTo(3000); assertEquals(Status.FAILED, s.status(id)); assertEquals("smtp down", s.lastError(id));}
@Testvoid aRetryThatSucceedsEndsTheJob() { AtomicInteger calls = new AtomicInteger(); String id = s.schedule(() -> { if (calls.incrementAndGet() == 1) throw new IllegalStateException("timeout"); }, 0); advanceTo(0); advanceTo(1000); assertEquals(Status.SUCCEEDED, s.status(id)); assertTrue(s.nextRunAt().isEmpty());}
@Testvoid aFailedRunDoesNotEndARepeatingJob() { AtomicInteger calls = new AtomicInteger(); String id = s.scheduleAtFixedRate(() -> { if (calls.incrementAndGet() == 1) throw new IllegalStateException("blip"); }, 0, 100); advanceTo(0); advanceTo(100); assertEquals(2, calls.get()); assertEquals(Status.SCHEDULED, s.status(id)); assertTrue(s.cancel(id)); advanceTo(1000); assertEquals(2, calls.get());}
@Testvoid workersKeepASlowJobFromDelayingTheNext() throws Exception { LongSupplier millis = () -> System.nanoTime() / 1_000_000; // monotonic: a clock change can't move it RunningScheduler live = new RunningScheduler( new Scheduler(millis, () -> "j" + nextId.incrementAndGet(), new Retry(1, 0)), millis, 2); CountDownLatch release = new CountDownLatch(1); CountDownLatch quickRan = new CountDownLatch(1); live.schedule(() -> { try { release.await(); } catch (InterruptedException e) { throw new RuntimeException(e); } }, 0); live.schedule(() -> {}, 60_000); // the watcher now sleeps toward a minute away live.schedule(quickRan::countDown, 20); // scheduled sooner, so it must wake the watcher assertTrue(quickRan.await(5, TimeUnit.SECONDS)); // ran while the slow job holds a worker release.countDown(); live.shutdown();}class World: def __init__(self): self.now = 0 # the test's clock, in milliseconds self.ran: list[str] = [] ids = itertools.count(1) self.s = Scheduler(lambda: self.now, lambda: f"j{next(ids)}", Retry(3, 1000))
def record(self, name: str) -> Callable[[], None]: return lambda: self.ran.append(name)
def advance_to(self, t: int) -> None: self.now = t self.s.run_due()
def fail_with(message: str) -> Callable[[], None]: def task() -> None: raise RuntimeError(message) return task
def fail_first(count: list[int], message: str) -> Callable[[], None]: def task() -> None: count[0] += 1 if count[0] == 1: raise RuntimeError(message) return task
def test_jobs_run_in_time_order_and_never_early(): w = World() w.s.schedule(w.record("c"), 300) w.s.schedule(w.record("a"), 100) w.s.schedule(w.record("b"), 200) w.advance_to(99) assert w.ran == [] w.advance_to(250) assert w.ran == ["a", "b"] assert w.s.next_run_at() == 300
def test_jobs_due_together_run_in_the_order_scheduled(): w = World() w.s.schedule(w.record("first"), 100) w.s.schedule(w.record("second"), 100) w.advance_to(100) assert w.ran == ["first", "second"]
def test_a_cancelled_job_never_runs(): w = World() job_id = w.s.schedule(w.record("x"), 100) assert w.s.cancel(job_id) assert not w.s.cancel(job_id) w.advance_to(1000) assert w.ran == [] assert w.s.status(job_id) is Status.CANCELLED
def test_fixed_rate_keeps_to_the_clock_and_skips_missed_runs(): w = World() w.s.schedule_at_fixed_rate(w.record("tick"), 100, 100) w.advance_to(100) w.advance_to(200) assert len(w.ran) == 2 w.advance_to(550) # paused through 300, 400 and 500 assert len(w.ran) == 3 # runs once to catch up, not three times assert w.s.next_run_at() == 600 # and stays on the 100s
def test_fixed_rate_skips_a_run_while_the_previous_one_is_still_going(): w = World() w.s.schedule_at_fixed_rate(w.record("tick"), 0, 100) first = w.s.take_due() # started at 0, still running w.now = 100 assert w.s.take_due() == [] assert w.s.skipped == 1 w.s.finished(first[0], None) w.now = 200 assert len(w.s.take_due()) == 1
def test_fixed_delay_waits_after_each_run_finishes(): w = World() def slow() -> None: w.now += 300 # each run takes 300 ms w.s.schedule_with_fixed_delay(slow, 0, 100) w.advance_to(0) # runs 0 to 300 assert w.s.next_run_at() == 400 # 100 after it finished, not after it started
def test_a_failing_job_is_retried_with_growing_waits_then_fails(): w = World() job_id = w.s.schedule(fail_with("smtp down"), 0) w.advance_to(0) assert w.s.status(job_id) is Status.SCHEDULED assert w.s.next_run_at() == 1000 # first retry after 1 s w.advance_to(1000) assert w.s.next_run_at() == 3000 # then 2 s w.advance_to(3000) assert w.s.status(job_id) is Status.FAILED assert w.s.last_error(job_id) == "smtp down"
def test_a_retry_that_succeeds_ends_the_job(): w = World() job_id = w.s.schedule(fail_first([0], "timeout"), 0) w.advance_to(0) w.advance_to(1000) assert w.s.status(job_id) is Status.SUCCEEDED assert w.s.next_run_at() is None
def test_a_failed_run_does_not_end_a_repeating_job(): w = World() calls = [0] job_id = w.s.schedule_at_fixed_rate(fail_first(calls, "blip"), 0, 100) w.advance_to(0) w.advance_to(100) assert calls[0] == 2 assert w.s.status(job_id) is Status.SCHEDULED assert w.s.cancel(job_id) w.advance_to(1000) assert calls[0] == 2
def test_workers_keep_a_slow_job_from_delaying_the_next(): millis = lambda: int(time.monotonic() * 1000) # monotonic: a clock change can't move it ids = itertools.count(1) live = RunningScheduler(Scheduler(millis, lambda: f"j{next(ids)}", Retry(1, 0)), millis, 2) release = threading.Event() quick_ran = threading.Event() live.schedule(release.wait, 0) live.schedule(lambda: None, 60_000) # the watcher now sleeps toward a minute away live.schedule(quick_ran.set, 20) # scheduled sooner, so it must wake the watcher assert quick_ran.wait(5) # ran while the slow job holds a worker release.set() live.shutdown()private long _now; // the test's clock, in millisecondsprivate int _nextId;private readonly Scheduler _s;private readonly List<string> _ran = [];
public SchedulerTests() => _s = new Scheduler(() => _now, () => $"j{Interlocked.Increment(ref _nextId)}", new Retry(3, 1000));
private Action Record(string name) => () => _ran.Add(name);
private void AdvanceTo(long t){ _now = t; _s.RunDue();}
[Fact]public void JobsRunInTimeOrderAndNeverEarly(){ _s.Schedule(Record("c"), 300); _s.Schedule(Record("a"), 100); _s.Schedule(Record("b"), 200); AdvanceTo(99); Assert.Empty(_ran); AdvanceTo(250); Assert.Equal(new[] { "a", "b" }, _ran); Assert.Equal(300L, _s.NextRunAt());}
[Fact]public void JobsDueTogetherRunInTheOrderScheduled(){ _s.Schedule(Record("first"), 100); _s.Schedule(Record("second"), 100); AdvanceTo(100); Assert.Equal(new[] { "first", "second" }, _ran);}
[Fact]public void ACancelledJobNeverRuns(){ var id = _s.Schedule(Record("x"), 100); Assert.True(_s.Cancel(id)); Assert.False(_s.Cancel(id)); AdvanceTo(1000); Assert.Empty(_ran); Assert.Equal(Status.Cancelled, _s.StatusOf(id));}
[Fact]public void FixedRateKeepsToTheClockAndSkipsMissedRuns(){ _s.ScheduleAtFixedRate(Record("tick"), 100, 100); AdvanceTo(100); AdvanceTo(200); Assert.Equal(2, _ran.Count); AdvanceTo(550); // paused through 300, 400 and 500 Assert.Equal(3, _ran.Count); // runs once to catch up, not three times Assert.Equal(600L, _s.NextRunAt()); // and stays on the 100s}
[Fact]public void FixedRateSkipsARunWhileThePreviousOneIsStillGoing(){ _s.ScheduleAtFixedRate(Record("tick"), 0, 100); var first = _s.TakeDue(); // started at 0, still running _now = 100; Assert.Empty(_s.TakeDue()); Assert.Equal(1, _s.Skipped); _s.Finished(first[0], null); _now = 200; Assert.Single(_s.TakeDue());}
[Fact]public void FixedDelayWaitsAfterEachRunFinishes(){ _s.ScheduleWithFixedDelay(() => _now += 300, 0, 100); // each run takes 300 ms AdvanceTo(0); // runs 0 to 300 Assert.Equal(400L, _s.NextRunAt()); // 100 after it finished, not after it started}
[Fact]public void AFailingJobIsRetriedWithGrowingWaitsThenFails(){ var id = _s.Schedule(() => throw new InvalidOperationException("smtp down"), 0); AdvanceTo(0); Assert.Equal(Status.Scheduled, _s.StatusOf(id)); Assert.Equal(1000L, _s.NextRunAt()); // first retry after 1 s AdvanceTo(1000); Assert.Equal(3000L, _s.NextRunAt()); // then 2 s AdvanceTo(3000); Assert.Equal(Status.Failed, _s.StatusOf(id)); Assert.Equal("smtp down", _s.LastError(id));}
[Fact]public void ARetryThatSucceedsEndsTheJob(){ var calls = 0; var id = _s.Schedule(() => { if (++calls == 1) throw new TimeoutException("timeout"); }, 0); AdvanceTo(0); AdvanceTo(1000); Assert.Equal(Status.Succeeded, _s.StatusOf(id)); Assert.Null(_s.NextRunAt());}
[Fact]public void AFailedRunDoesNotEndARepeatingJob(){ var calls = 0; var id = _s.ScheduleAtFixedRate(() => { if (++calls == 1) throw new InvalidOperationException("blip"); }, 0, 100); AdvanceTo(0); AdvanceTo(100); Assert.Equal(2, calls); Assert.Equal(Status.Scheduled, _s.StatusOf(id)); Assert.True(_s.Cancel(id)); AdvanceTo(1000); Assert.Equal(2, calls);}
[Fact]public void WorkersKeepASlowJobFromDelayingTheNext(){ long Millis() => Environment.TickCount64; // monotonic: a clock change can't move it var live = new RunningScheduler( new Scheduler(Millis, () => $"j{Interlocked.Increment(ref _nextId)}", new Retry(1, 0)), Millis, 2); using var release = new ManualResetEventSlim(); using var quickRan = new ManualResetEventSlim(); live.Schedule(() => release.Wait(), 0); live.Schedule(() => { }, 60_000); // the watcher now sleeps toward a minute away live.Schedule(quickRan.Set, 20); // scheduled sooner, so it must wake the watcher Assert.True(quickRan.Wait(TimeSpan.FromSeconds(5))); // ran while the slow job holds a worker release.Set(); live.Shutdown();}type world struct { now int64 // the test's clock, in milliseconds ran []string s *Scheduler}
func newWorld() *world { w := &world{} var next atomic.Int64 ids := func() string { return fmt.Sprintf("j%d", next.Add(1)) } w.s = NewScheduler(func() int64 { return w.now }, ids, Retry{MaxAttempts: 3, Backoff: 1000}) return w}
func (w *world) record(name string) func() { return func() { w.ran = append(w.ran, name) } }
func (w *world) advanceTo(t int64) { w.now = t w.s.RunDue()}
func (w *world) next(t *testing.T) int64 { t.Helper() at, ok := w.s.NextRunAt() if !ok { t.Fatal("nothing scheduled") } return at}
func expect[T comparable](t *testing.T, want, got T) { t.Helper() if want != got { t.Fatalf("want %v, got %v", want, got) }}
func TestJobsRunInTimeOrderAndNeverEarly(t *testing.T) { w := newWorld() w.s.Schedule(w.record("c"), 300) w.s.Schedule(w.record("a"), 100) w.s.Schedule(w.record("b"), 200) w.advanceTo(99) expect(t, 0, len(w.ran)) w.advanceTo(250) expect(t, "a b", strings.Join(w.ran, " ")) expect(t, int64(300), w.next(t))}
func TestJobsDueTogetherRunInTheOrderScheduled(t *testing.T) { w := newWorld() w.s.Schedule(w.record("first"), 100) w.s.Schedule(w.record("second"), 100) w.advanceTo(100) expect(t, "first second", strings.Join(w.ran, " "))}
func TestACancelledJobNeverRuns(t *testing.T) { w := newWorld() id := w.s.Schedule(w.record("x"), 100) expect(t, true, w.s.Cancel(id)) expect(t, false, w.s.Cancel(id)) w.advanceTo(1000) expect(t, 0, len(w.ran)) expect(t, Cancelled, w.s.StatusOf(id))}
func TestFixedRateKeepsToTheClockAndSkipsMissedRuns(t *testing.T) { w := newWorld() w.s.ScheduleAtFixedRate(w.record("tick"), 100, 100) w.advanceTo(100) w.advanceTo(200) expect(t, 2, len(w.ran)) w.advanceTo(550) // paused through 300, 400 and 500 expect(t, 3, len(w.ran)) // runs once to catch up, not three times expect(t, int64(600), w.next(t)) // and stays on the 100s}
func TestFixedRateSkipsARunWhileThePreviousOneIsStillGoing(t *testing.T) { w := newWorld() w.s.ScheduleAtFixedRate(w.record("tick"), 0, 100) first := w.s.TakeDue() // started at 0, still running w.now = 100 expect(t, 0, len(w.s.TakeDue())) expect(t, 1, w.s.Skipped) w.s.Finished(first[0], nil) w.now = 200 expect(t, 1, len(w.s.TakeDue()))}
func TestFixedDelayWaitsAfterEachRunFinishes(t *testing.T) { w := newWorld() w.s.ScheduleWithFixedDelay(func() { w.now += 300 }, 0, 100) // each run takes 300 ms w.advanceTo(0) // runs 0 to 300 expect(t, int64(400), w.next(t)) // 100 after it finished, not after it started}
func TestAFailingJobIsRetriedWithGrowingWaitsThenFails(t *testing.T) { w := newWorld() id := w.s.Schedule(func() { panic("smtp down") }, 0) w.advanceTo(0) expect(t, Scheduled, w.s.StatusOf(id)) expect(t, int64(1000), w.next(t)) // first retry after 1 s w.advanceTo(1000) expect(t, int64(3000), w.next(t)) // then 2 s w.advanceTo(3000) expect(t, Failed, w.s.StatusOf(id)) expect(t, "smtp down", w.s.LastError(id))}
func TestARetryThatSucceedsEndsTheJob(t *testing.T) { w := newWorld() calls := 0 id := w.s.Schedule(func() { if calls++; calls == 1 { panic("timeout") } }, 0) w.advanceTo(0) w.advanceTo(1000) expect(t, Succeeded, w.s.StatusOf(id)) _, ok := w.s.NextRunAt() expect(t, false, ok)}
func TestAFailedRunDoesNotEndARepeatingJob(t *testing.T) { w := newWorld() calls := 0 id := w.s.ScheduleAtFixedRate(func() { if calls++; calls == 1 { panic("blip") } }, 0, 100) w.advanceTo(0) w.advanceTo(100) expect(t, 2, calls) expect(t, Scheduled, w.s.StatusOf(id)) expect(t, true, w.s.Cancel(id)) w.advanceTo(1000) expect(t, 2, calls)}
func TestWorkersKeepASlowJobFromDelayingTheNext(t *testing.T) { start := time.Now() millis := func() int64 { return time.Since(start).Milliseconds() } // monotonic: a clock change can't move it var next atomic.Int64 ids := func() string { return fmt.Sprintf("j%d", next.Add(1)) } live := NewRunningScheduler(NewScheduler(millis, ids, Retry{MaxAttempts: 1}), millis, 2) release := make(chan struct{}) quickRan := make(chan struct{}) live.Schedule(func() { <-release }, 0) live.Schedule(func() {}, 60_000) // the watcher now sleeps toward a minute away live.Schedule(func() { close(quickRan) }, 20) // scheduled sooner, so it must wake the watcher select { case <-quickRan: // ran while the slow job holds a worker case <-time.After(5 * time.Second): t.Fatal("the quick job never ran") } close(release) live.Shutdown()}import { test } from 'node:test';import assert from 'node:assert/strict';
function world() { const w = { now: 0, ran: [] as string[], s: undefined as unknown as Scheduler }; let next = 0; w.s = new Scheduler(() => w.now, () => `j${++next}`, { maxAttempts: 3, backoffMs: 1000 }); return { w, record: (name: string) => () => void w.ran.push(name), advanceTo: async (t: number) => { w.now = t; await w.s.runDue(); }, };}
test('jobs run in time order and never early', async () => { const { w, record, advanceTo } = world(); w.s.schedule(record('c'), 300); w.s.schedule(record('a'), 100); w.s.schedule(record('b'), 200); await advanceTo(99); assert.deepEqual(w.ran, []); await advanceTo(250); assert.deepEqual(w.ran, ['a', 'b']); assert.equal(w.s.nextRunAt(), 300);});
test('jobs due together run in the order scheduled', async () => { const { w, record, advanceTo } = world(); w.s.schedule(record('first'), 100); w.s.schedule(record('second'), 100); await advanceTo(100); assert.deepEqual(w.ran, ['first', 'second']);});
test('a cancelled job never runs', async () => { const { w, record, advanceTo } = world(); const id = w.s.schedule(record('x'), 100); assert.equal(w.s.cancel(id), true); assert.equal(w.s.cancel(id), false); await advanceTo(1000); assert.deepEqual(w.ran, []); assert.equal(w.s.status(id), 'cancelled');});
test('fixed rate keeps to the clock and skips missed runs', async () => { const { w, record, advanceTo } = world(); w.s.scheduleAtFixedRate(record('tick'), 100, 100); await advanceTo(100); await advanceTo(200); assert.equal(w.ran.length, 2); await advanceTo(550); // paused through 300, 400 and 500 assert.equal(w.ran.length, 3); // runs once to catch up, not three times assert.equal(w.s.nextRunAt(), 600); // and stays on the 100s});
test('fixed rate skips a run while the previous one is still going', () => { const { w, record } = world(); w.s.scheduleAtFixedRate(record('tick'), 0, 100); const first = w.s.takeDue(); // started at 0, still running w.now = 100; assert.equal(w.s.takeDue().length, 0); assert.equal(w.s.skipped, 1); w.s.finished(first[0]); w.now = 200; assert.equal(w.s.takeDue().length, 1);});
test('fixed delay waits after each run finishes', async () => { const { w, advanceTo } = world(); w.s.scheduleWithFixedDelay(() => void (w.now += 300), 0, 100); // each run takes 300 ms await advanceTo(0); // runs 0 to 300 assert.equal(w.s.nextRunAt(), 400); // 100 after it finished, not after it started});
test('a failing job is retried with growing waits then fails', async () => { const { w, advanceTo } = world(); const id = w.s.schedule(() => { throw new Error('smtp down'); }, 0); await advanceTo(0); assert.equal(w.s.status(id), 'scheduled'); assert.equal(w.s.nextRunAt(), 1000); // first retry after 1 s await advanceTo(1000); assert.equal(w.s.nextRunAt(), 3000); // then 2 s await advanceTo(3000); assert.equal(w.s.status(id), 'failed'); assert.equal(w.s.lastError(id), 'smtp down');});
test('a retry that succeeds ends the job', async () => { const { w, advanceTo } = world(); let calls = 0; const id = w.s.schedule(async () => { if (++calls === 1) throw new Error('timeout'); }, 0); await advanceTo(0); await advanceTo(1000); assert.equal(w.s.status(id), 'succeeded'); assert.equal(w.s.nextRunAt(), undefined);});
test('a failed run does not end a repeating job', async () => { const { w, advanceTo } = world(); let calls = 0; const id = w.s.scheduleAtFixedRate(() => { if (++calls === 1) throw new Error('blip'); }, 0, 100); await advanceTo(0); await advanceTo(100); assert.equal(calls, 2); assert.equal(w.s.status(id), 'scheduled'); assert.equal(w.s.cancel(id), true); await advanceTo(1000); assert.equal(calls, 2);});
test('workers keep a slow job from delaying the next', async () => { let next = 0; const millis = () => performance.now(); // monotonic: a clock change can't move it const live = new RunningScheduler(new Scheduler(millis, () => `j${++next}`, { maxAttempts: 1, backoffMs: 0 }), millis, 2); let release!: () => void; const released = new Promise<void>((r) => (release = r)); let quickRan!: () => void; const ran = new Promise<void>((r) => (quickRan = r)); live.schedule(() => released, 0); live.schedule(() => {}, 60_000); // the timer now points a minute away live.schedule(quickRan, 20); // scheduled sooner, so the timer must move const timeout = new Promise((_, no) => setTimeout(() => no(new Error('the quick job never ran')), 5000).unref()); await Promise.race([ran, timeout]); // ran while the slow job holds a worker release(); await live.shutdown();});Go deeperEvery edge case, and where it is handled2 min
| Case | What happens | Where |
|---|---|---|
| Two jobs due at the same moment | Run in the order scheduled | The heap’s seq tie-break |
| A job scheduled sooner than the next one | The watcher wakes and checks again | changing signals |
| A delay of zero or less | Due at once, taken on the next check | takeDue |
| Cancel a running job | The run finishes, no further runs | cancel, then finished |
| Cancel a finished job | Refused | cancel |
| Fixed-rate run still going at the next slot | Skipped and counted | takeDue |
| Scheduler paused for several periods | Fixed rate runs once, then keeps to its slots | takeDue |
| A task throws | One-off: retried with growing waits, then failed; repeating: carries on | finished |
| Every worker busy | Due jobs wait for a free worker, in order | the pool’s queue |
| Shutdown with jobs running | Running jobs finish; waiting jobs don’t start | shutdown |
| Process crashes | Every waiting job is lost: the timetable is in memory | out of scope |
Wrap up
Section titled “Wrap up”The decisions, and what each one costs
Section titled “The decisions, and what each one costs”| Decision | We chose | We gave up |
|---|---|---|
| Finding the next job | A min-heap sorted by run time, then sequence number | O(1) add, which a timing wheel gives |
| Cancel | Mark, drop at the top | Memory until the cancelled job’s time |
| Repeating | Fixed rate (skip on overlap, run once after a pause) and fixed delay | Overlapping runs for jobs that could take them |
| Failures | One-off jobs retry with growing waits on the timetable; repeating jobs carry on | Retries for repeating jobs |
| Waiting | One watcher on a condition, woken on every change | The simplicity of a one-second check |
| Running | A fixed pool of workers, no lock held while a task runs | Unlimited parallel runs |
| Saving to disk | None: in memory, one process | Every waiting job, on a crash |
Go deeperGenuinely different designs, and when to pick them2 min
Use the platform’s scheduler. Java’s ScheduledThreadPoolExecutor is this whole design. It has
a heap, fixed rate and fixed delay with the same overlap rules, and a worker pool. In a real service
you’d use it, or Quartz for cron jobs and saving to disk. The interview asks whether you could build
it, and whether you know what it does when a job is slow.
A hashed timing wheel. Netty and Kafka keep timers in a ring of buckets, one bucket per tick, so adding and cancelling cost O(1). The price is precision: a timer fires on the tick after it’s due. It wins when there are millions of timers and most are cancelled, like request timeouts.
A queue with delayed delivery. SQS delay queues, or a Redis sorted set scored by run time, move the timetable out of the process entirely. Workers become plain consumers, and the queue handles saving and claiming jobs. It suits jobs that already cross machines.
How to spend 45 minutes
Section titled “How to spend 45 minutes”- 0 to 3. One process, nothing saved to disk, the kinds of job, and what failure means.
- 3 to 5. The promises. Name “never early, and a slow job can’t delay the others” as the hard one.
- 5 to 24. A list and a loop, a min-heap with cancel by marking, fixed rate and fixed delay, then
retries and status. Code
takeDueandfinished. - 24 to 26. Walk through the full diagram: the timetable decides, the threads carry.
- 26 to 32. The watcher, waking it for a sooner job, and the workers.
- 32 to 45. Saving to disk and several machines, then your own questions.
If time is short, skip retries and spend it on fixed rate versus fixed delay, and on waking the watcher. Those are the two places this question is decided.
Follow-up questions
Section titled “Follow-up questions”The jobs now have to survive a restart, and the service runs on three machines. What changes?
Whether you see that the timetable moves into a database, and that the new problem is two machines running the same job. They want to hear about claiming a job in one step, and what happens when the machine that claimed it dies.
Most extensions fit into one place:
| Extension | What changes | Touches the threads? |
|---|---|---|
| Survive a restart | A jobs table; startup reloads due and future jobs | No |
| Several machines | Claim with a lease, SKIP LOCKED, jobs safe to repeat |
Yes |
| Cron expressions | A Trigger that computes the next run time; fixed rate is one kind |
No |
| Priorities | The heap sorts by (run time, priority, sequence number) | No |
| Millions of timers, rough timing | A hashed timing wheel instead of a heap: O(1) add and cancel | No |
| Metrics | Lag (start time minus run time), skips, failures per job | No |
Go deeperMoving to a database and three machines, step by step2 min
- The timetable becomes a table. A
jobstable with arun_atcolumn and an index on it. The index does what the heap did: it finds the earliest jobs without reading the rest. - Each machine asks for due jobs, and claims them in one step. In PostgreSQL:
SELECT ... WHERE run_at <= now() ORDER BY run_at LIMIT 10 FOR UPDATE SKIP LOCKED.FOR UPDATElocks the rows it picks, andSKIP LOCKEDmakes other machines pass over locked rows instead of waiting. So three machines take different jobs, at the same time. - A claim is a lease, with an expiry. The machine writes “claimed by me until 10:02”. If it dies in the middle of a run, the lease runs out and another machine takes the job.
- So a job can run twice. Once on the machine that died, partly or fully, and once on its replacement. Jobs must be safe to repeat, for example by recording “reminder 41 sent” and checking it first.
- The in-memory design doesn’t go away. It becomes each machine’s local timetable of the jobs it has claimed, with the same watcher and workers.
The new costs are a database query every few seconds per machine, and jobs that must be safe to repeat. Both are the price of not losing jobs on a crash.
Go deeperFour more follow-up questions2 min
Interviewer asksHow do you shut down cleanly?
Stop the watcher, so no new runs start. Then let the workers finish the runs already handed to
them, with a deadline. Jobs still waiting on the timetable are dropped, which is fine in memory,
and is exactly what a jobs table fixes. That’s what shutdown does, minus the deadline.
Interviewer asksWhat if a task never returns?
It holds its worker forever, and enough of them hold every worker. Give each run a time limit: the worker abandons or interrupts the task after it, records a failure, and the job’s retry rules apply. Interrupting only works if the task cooperates, so the time limit is also a number to alert on.
Interviewer asksHow many workers?
Enough for the jobs that are due at once, and no more than what the jobs talk to can take. Jobs that mostly wait on the network can have many workers. Jobs that use the CPU want about one per core. Lag, the time between a job’s run time and its actual start, is the number that says the pool is too small.
Interviewer asksWhy let the clock be passed in?
So time is an input. The tests set it instead of sleeping. The scheduler can use a monotonic clock for delays. And a simulation can run a week of jobs in a second.
Further reading
Section titled “Further reading”- Design a pub-sub system, another set of workers fed from a queue
- Design a rate limiter, which also lets time be injected for tests
- Command pattern, the shape of a task that runs later
- Java
ScheduledThreadPoolExecutor, the production version of this design - Hashed and Hierarchical Timing Wheels, Varghese and Lauck, the timing wheel paper
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.