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.
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.