Skip to main content

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.

CallerTaskExecutorPriorityQueueRetryPolicyschedule(task, runAt, priority)1offer(scheduledTask)2poll()3nextDelay(attempt)4offer(rescheduled)5
  1. 1The caller registers a task with when it should run and how urgent it is.
  2. 2The task is wrapped and inserted, ordered by run time first, then priority.
  3. 3On each tick the executor pulls only the one task that is both due and highest priority.
  4. 4If the task throws, the executor asks the policy how long to wait before retrying - it never computes backoff itself.
  5. 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

«interface»Task+ run(): void+ recurrenceInterval(): Optional<Duration>«interface»RetryPolicy+ nextDelay(attempt): Duration+ maxAttempts(): intTaskExecutor- queue: PriorityQueue<ScheduledTask>- retryPolicy: RetryPolicy+ schedule(task, runAt, priority): void+ runDueTasks(now): void+ cancel(taskId): voidScheduledTask- task: Task- runAt: datetime- priority: int- attempts: int+ compareTo(other): intExponentialBackoffPolicy+ nextDelay(attempt): Duration+ maxAttempts(): int
implementsuses
TaskExecutor pulls the next-due ScheduledTask from a priority queue, runs its Task, and consults RetryPolicy on failure before re-queuing.

Code

import java.time.Duration;
import java.time.Instant;
import java.util.*;
 
interface Task {
void run() throws Exception;
Optional<Duration> recurrenceInterval();
}
 
interface RetryPolicy {
Duration nextDelay(int attempt);
int maxAttempts();
}
 
class ExponentialBackoffPolicy implements RetryPolicy {
private final Duration base;
private final int maxAttempts;
 
ExponentialBackoffPolicy(Duration base, int maxAttempts) {
this.base = base;
this.maxAttempts = maxAttempts;
}
 
public Duration nextDelay(int attempt) {
return base.multipliedBy((long) Math.pow(2, attempt - 1));
}
 
public int maxAttempts() {
return maxAttempts;
}
}
 
class ScheduledTask implements Comparable<ScheduledTask> {
final String id;
final Task task;
Instant runAt;
final int priority;
int attempts = 0;
 
ScheduledTask(String id, Task task, Instant runAt, int priority) {
this.id = id;
this.task = task;
this.runAt = runAt;
this.priority = priority;
}
 
public int compareTo(ScheduledTask other) {
int byTime = this.runAt.compareTo(other.runAt);
return byTime != 0 ? byTime : Integer.compare(other.priority, this.priority);
}
}
 
class TaskExecutor {
private final PriorityQueue<ScheduledTask> queue = new PriorityQueue<>();
private final RetryPolicy retryPolicy;
private int nextId = 1;
 
TaskExecutor(RetryPolicy retryPolicy) {
this.retryPolicy = retryPolicy;
}
 
String schedule(Task task, Instant runAt, int priority) {
String id = "task-" + (nextId++);
queue.offer(new ScheduledTask(id, task, runAt, priority));
return id;
}
 
void cancel(String taskId) {
queue.removeIf(t -> t.id.equals(taskId));
}
 
void runDueTasks(Instant now) {
while (!queue.isEmpty() && !queue.peek().runAt.isAfter(now)) {
ScheduledTask scheduled = queue.poll();
try {
scheduled.task.run();
scheduled.task.recurrenceInterval().ifPresent(interval -> {
scheduled.runAt = scheduled.runAt.plus(interval);
queue.offer(scheduled);
});
} catch (Exception e) {
scheduled.attempts++;
if (scheduled.attempts < retryPolicy.maxAttempts()) {
scheduled.runAt = now.plus(retryPolicy.nextDelay(scheduled.attempts));
queue.offer(scheduled);
} else {
System.out.println("Task " + scheduled.id + " exhausted retries: " + e.getMessage());
}
}
}
}
}

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.
  • RetryPolicy is 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 ScheduledTask for "every 5 minutes" stores its own runAt and, after running, sets the next runAt to runAt + interval - not now + 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() and heapq.heappop are 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? cancel as written only removes a ScheduledTask still sitting in the queue - once Task.run() has started, the executor has no handle back into it. A real design needs Task.run() to periodically check an isCancelled flag the executor can set, or run tasks on interruptible threads/futures the executor can signal.
  • What happens if nextDelay for ExponentialBackoffPolicy overflows 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 production RetryPolicy caps the delay at a maximum (say, one hour) regardless of attempt count - a one-line change to nextDelay that never touches TaskExecutor.
  • How would you support a task that must never run concurrently with another task of the same type? Add a key() method to Task and have TaskExecutor track which keys are currently executing; runDueTasks skips (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 every Task implementation has to coordinate itself.

Check yourself

Question 1 of 3

Why does a recurring task compute its next `runAt` from the scheduled time plus the interval, rather than from when it just finished?