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.
0%0 of 122 pages studied