Job/Schedule/JobExecutor/JobQueue classes, a single-node priority-queue-by-next-run-time implementation, and the extension path toward a distributed scheduler.
Published September 23, 2026
interface Job { void execute(); }
class Schedule {
Instant nextRunTime;
Duration recurrenceInterval; // null for a one-time job
}
class ScheduledJob {
Job job;
Schedule schedule;
String id;
}
class JobQueue {
private final PriorityQueue<ScheduledJob> queue = new PriorityQueue<>(
Comparator.comparing(sj -> sj.schedule.nextRunTime) // always exposes the SOONEST job at the top
);
synchronized void schedule(ScheduledJob job) { queue.offer(job); }
synchronized ScheduledJob peekNext() { return queue.peek(); }
synchronized ScheduledJob pollNext() { return queue.poll(); }
}
class JobExecutor {
private final JobQueue jobQueue;
private volatile boolean running = true;
void run() {
while (running) {
ScheduledJob next = jobQueue.peekNext();
if (next == null) { sleepBriefly(); continue; }
long waitMs = Duration.between(Instant.now(), next.schedule.nextRunTime).toMillis();
if (waitMs > 0) { sleepFor(waitMs); continue; } // not due yet — wait and re-check
jobQueue.pollNext().job.execute();
if (next.schedule.recurrenceInterval != null) {
next.schedule.nextRunTime = Instant.now().plus(next.schedule.recurrenceInterval);
jobQueue.schedule(next); // re-insert — the priority queue naturally re-sorts it to its new position
}
}
}
}
A priority queue ordered by nextRunTime means "what's the next job to run" is always an O(log n) peek/poll away, regardless of how many jobs are scheduled — no need to scan the full job list on every tick. Recurring jobs re-insert themselves with an updated nextRunTime after each execution, and the priority queue automatically places them correctly relative to every other pending job — no manual re-sorting logic needed.
This single-node design has an implicit, easy-to-miss assumption: exactly one JobExecutor process exists. Design: Distributed Task Scheduler's actual hard problems — finding "jobs due now" efficiently across a much larger job set (a time-bucketed structure instead of one in-memory priority queue), distributing execution across multiple worker nodes, and specifically avoiding double-execution of the same job when multiple workers could pick it up — are the direct extension points from this LLD foundation. The core data model (Job, Schedule, next-run-time ordering) transfers directly; what changes at distributed scale is where that priority ordering lives (a shared, coordinated store instead of one process's in-memory heap) and how workers coordinate to ensure only one of them actually claims and runs a given due job.
The priority-queue design is right. The difficult part is the waiting loop: sleeping until the next job is due, but waking early if a new job is scheduled sooner. Busy-polling wastes CPU, and a plain sleep misses newly added urgent jobs. A DelayQueue (or a lock and condition) handles both:
final class ScheduledJob implements Delayed {
final String id; final Job job; final Schedule schedule;
final Instant runAt; final int attempt;
ScheduledJob(String id, Job job, Schedule schedule, Instant runAt, int attempt) {
this.id = id; this.job = job; this.schedule = schedule; this.runAt = runAt; this.attempt = attempt;
}
public long getDelay(TimeUnit unit) {
return unit.convert(Duration.between(Instant.now(), runAt));
}
public int compareTo(Delayed o) {
return runAt.compareTo(((ScheduledJob) o).runAt);
}
}
final class Scheduler {
private final DelayQueue<ScheduledJob> queue = new DelayQueue<>(); // take() blocks until the earliest job is due
private final ExecutorService workers;
private final Map<String, Boolean> cancelled = new ConcurrentHashMap<>();
Scheduler(int threads) { this.workers = Executors.newFixedThreadPool(threads); }
void schedule(ScheduledJob j) { queue.put(j); } // wakes the dispatcher if j is earlier
void cancel(String jobId) { cancelled.put(jobId, true); } // lazy cancellation
void runDispatcher() throws InterruptedException {
while (!Thread.currentThread().isInterrupted()) {
ScheduledJob due = queue.take();
if (cancelled.remove(due.id) != null) continue; // skip cancelled jobs
workers.submit(() -> runOnce(due)); // never run jobs on the dispatcher thread
}
}
private void runOnce(ScheduledJob j) {
try {
j.job.execute();
scheduleNext(j, 1); // success: next occurrence, reset attempts
} catch (Exception e) {
if (j.attempt < 3) { // transient failure: retry with backoff
Duration backoff = Duration.ofSeconds((long) Math.pow(2, j.attempt) * 5);
schedule(new ScheduledJob(j.id, j.job, j.schedule, Instant.now().plus(backoff), j.attempt + 1));
} else {
alert(j, e); // give up this run…
scheduleNext(j, 1); // …but keep the recurrence alive
}
}
}
private void scheduleNext(ScheduledJob j, int attempt) {
j.schedule.nextAfter(j.runAt).ifPresent(next -> // from the PLANNED time: no drift
schedule(new ScheduledJob(j.id, j.job, j.schedule, next, attempt)));
}
}
Design choices to explain:
DelayQueue.take() blocks until the head's delay reaches zero, and inserting an earlier job makes it re-evaluate. There's no polling and no missed wake-ups.Two policies every scheduler must define:
lastRunAt, so the decision is possible after a restart.@Scheduled(fixedDelay=…) avoids overlap by design, and fixedRate can also queue runs back to back.Schedule.nextAfter(instant) is the only thing the scheduler needs from a schedule, which makes schedules pluggable (Strategy):
runAt + interval;"0 0 2 * * MON-FRI" and compute the next matching time, in an explicit time zone;Time zones and daylight-saving changes matter. A job at 02:30 local time doesn't exist on the night clocks jump forward, and happens twice when they fall back. Scheduling in UTC avoids this. When business rules demand local time, the cron library must define the behaviour.
Q: Why does peekNext() exist as a separate method rather than always calling pollNext() and re-inserting if not yet due? A: Peeking avoids unnecessary remove-then-reinsert churn on the priority queue for a job that isn't due yet — pollNext() should only be called once a job is actually confirmed ready to execute, keeping the queue's O(log n) operations reserved for genuine state changes rather than repeated no-op poll/re-insert cycles on every check.
Q: What happens if execute() throws an exception for a recurring job — does it still get rescheduled? A: Worth deciding explicitly and stating the choice: rescheduling regardless of failure keeps the job running on its normal cadence despite one bad execution (appropriate for most cases), while NOT rescheduling on failure requires manual intervention to resume — the code above reschedules unconditionally, which is a specific, statable design decision, not an oversight.
Q: How would you avoid double-execution even on a SINGLE node with multiple executor threads? A: The synchronized JobQueue methods already prevent two threads from both polling the same ScheduledJob instance — pollNext() atomically removes it from the queue, so a second thread's pollNext() call simply won't see it, which is the single-node analog of the distributed double-execution problem, solved here by ordinary mutual exclusion rather than a distributed lock.
Q: Could missed jobs (the process was down when a job was due) be handled by this design? A: Not by the structure shown — a job whose nextRunTime already passed while the process was down would need explicit 'catch-up' handling (run immediately on restart, or skip to the next scheduled occurrence) rather than assuming JobExecutor.run() is always live at the exact moment a job comes due, which is a real gap worth naming when discussing production readiness.
Q: Why compute the next run from the planned time instead of the completion time? A: Computing from completion adds each run's duration to the schedule, so an "every hour" job drifts later and later. Computing from the planned time keeps it aligned. Use completion-based delays only when you deliberately want a gap between runs.
Q: How should a scheduler handle jobs missed while it was down? A: Persist each job's last run time, and apply an explicit misfire policy on startup: run once now, run all missed occurrences, or skip to the next one. The right choice depends on the job. A daily report usually needs one catch-up run, while a cache refresh can simply skip.
Q: How do you prevent a long-running job from overlapping with its next run? A: Track in-progress runs per job ID (in memory for one node, or as a distributed lock or database row for many), and skip or postpone a run while the previous one is still active.