Task Scheduler
Run tasks at their scheduled time, in priority order, and don't let one failing task disappear silently - retry it with backoff, then give up loudly. The whole design turns on one data structure: a priority queue ordered by "when should this run next."
Requirements
Functional
- A task has a priority and a scheduled run time; the scheduler runs the highest-priority due task first among those ready to run.
- A task can be one-off (runs once) or recurring (reschedules itself after each run).
- A failed task is retried with a backoff delay, up to a maximum number of attempts.
- A caller can cancel a task that hasn't run yet.
Non-functional
- Picking the next task to run must not require scanning every pending task - it should be a queue operation, not a linear search.
- A recurring task's next run must be computed from when it was scheduled to run, not from when it finished, so execution jitter doesn't drift the schedule over time.
Design
TaskExecutor holds one PriorityQueue<ScheduledTask> ordered by run time, then priority.
It pops the earliest-due task, hands it to a Task's own run(), and on failure asks a
RetryPolicy for the next delay rather than hardcoding backoff math into the executor
itself.
- 1The caller registers a task with when it should run and how urgent it is.
- 2The task is wrapped and inserted, ordered by run time first, then priority.
- 3On each tick the executor pulls only the one task that is both due and highest priority.
- 4If the task throws, the executor asks the policy how long to wait before retrying - it never computes backoff itself.
- 5A recurring task or a retry both end the same way: a new ScheduledTask goes back into the same queue.
A recurring task doesn't get special-cased in the executor - after a successful run, the
executor just asks the ScheduledTask for its next occurrence and re-inserts it into the
same queue, exactly like a first-time schedule.
Class diagram
Code
Design decisions
- One priority queue ordered by (runAt, priority), not a plain list checked on a timer. Picking "what's due next" out of a plain list means scanning everything on every tick; a priority queue makes it a peek-and-pop, so the executor's per-tick cost doesn't grow with the number of pending tasks.
RetryPolicyis pulled out of the executor, not an if-else on attempt count inline. Backoff strategy (fixed delay vs. exponential) is a decision independent of "how do I run a task" - keeping it behind an interface means changing backoff behavior never touches the scheduling loop.- Recurrence is computed from the scheduled time, not the completion time. A
ScheduledTaskfor "every 5 minutes" stores its ownrunAtand, after running, sets the nextrunAttorunAt + interval- notnow + interval. That's what keeps a consistently-slightly-late task from drifting later and later over hundreds of runs. - What's missing for a real system: this executor runs one task at a time on a single thread; a production scheduler needs a worker pool pulling from the same queue (with the queue access made thread-safe), and needs the queue itself to be durable - a crash should lose no more than the task currently executing, not everything still pending.
Common follow-ups
- Two workers pull from the same queue at once - what breaks?
PriorityQueue.poll()andheapq.heappopare not thread-safe, so two threads can both pop the same head element or corrupt the heap's internal array under concurrent access. The fix is a lock around push/pop (or a genuinely concurrent priority structure), not a bigger queue. - How do you stop a task that's already running, not just one still queued?
cancelas written only removes aScheduledTaskstill sitting in the queue - onceTask.run()has started, the executor has no handle back into it. A real design needsTask.run()to periodically check anisCancelledflag the executor can set, or run tasks on interruptible threads/futures the executor can signal. - What happens if
nextDelayforExponentialBackoffPolicyoverflows for a task with a huge number of attempts?base.multipliedBy(2^(attempt-1))grows without bound, so a task stuck retrying for hours eventually schedules itself decades out. A productionRetryPolicycaps the delay at a maximum (say, one hour) regardless of attempt count - a one-line change tonextDelaythat never touchesTaskExecutor. - How would you support a task that must never run concurrently with another task of the
same type? Add a
key()method toTaskand haveTaskExecutortrack which keys are currently executing;runDueTasksskips (re-queues without popping) any due task whose key is already in flight. This keeps mutual exclusion a property of the executor's dispatch loop rather than something everyTaskimplementation has to coordinate itself.
Check yourself
Why does a recurring task compute its next `runAt` from the scheduled time plus the interval, rather than from when it just finished?