Learning pathsPricingChecking sign-in…
← LLD problem library

LLD-017 · LOW LEVEL DESIGN

Task Scheduler / Thread Pool

Own queue admission, worker lifetime and cooperative cancellation with precise state transitions.

Local Java 21 reference verified: 26 checks. Learner drafts are not executed. Author self-review; independent review pending.

Existing introductory lesson · Version 1

Understand the problem

Design a fixed-worker scheduler for immediate and delayed tasks. Bound pending work, run user code outside scheduler ownership, expose results and distinguish graceful shutdown, immediate shutdown, cancellation request and actual local task completion.

Before you start

  • Producer/consumer queues and heaps
  • Thread interruption and cooperative cancellation
  • Futures and task state machines
  • Monotonic clocks and bounded admission

Scope and explicit decisions

  • Local Java worker pool, 1..32 daemon workers and a positive pending-queue capacity. No persistence, distributed leases, automatic retries, recurring schedules or untrusted-code sandbox is implemented. Tasks are trusted Callable<String> bodies.
  • The priority queue orders by due elapsed nanoseconds then submission ID. Delays are nonnegative and deadlines use checked arithmetic. A trusted injected clock must be nonnegative, nondecreasing, fast and nonthrowing.
  • Workers use timed monitor waits and wake when new/earlier work arrives. Tests advance an AtomicLong clock then call wake, so correctness checks do not require arbitrary sleeps. Real termination waits use System.nanoTime, independent of the injected task clock.
  • Queue capacity limits QUEUED tasks, not running workers or retained history. Total executing task count is bounded by worker count; results/records remain retained for process lifetime. There is no user fairness or priority-class policy.
  • Cancel QUEUED removes it and completes its result as cancelled. Cancel RUNNING sets CANCEL_REQUESTED and interrupts the worker; a task that ignores/catches interruption may keep running. It becomes CANCELLED only when its local body returns/throws.
  • The scheduler cannot retract remote effects performed by task code. A cancelled future does not prove an email/charge was undone. User callbacks and future awaits run outside the queue monitor.
  • Graceful shutdown rejects new submissions and continues queued tasks, including delayed ones when due. Immediate shutdown cancels pending tasks and requests interruption of running tasks. Neither forcibly kills uncooperative user code.
  • awaitTermination has a bounded real-time wait (0..60,000ms) and returns false if workers remain alive. Daemon threads do not keep a process alive; they do not make tasks durable. Fatal VM errors may terminate a worker without replacement.
  • A completed task’s interruption flag is cleared before the next task runs. Ordinary task failure is stored in its future. No public raw CompletableFuture is exposed for arbitrary callbacks under scheduler locks.

Worked examples

Input: One worker, queue capacity2, future tasks at1s; cancel one then insert100ms task.

Outcome: Third submission initially rejects; cancelled slot permits earlier task, which runs first when fake time advances.

Admission capacity and deadline order are distinct controls.

Input: Running task catches interruption and waits for explicit release.

Outcome: cancel reports CANCEL_REQUESTED; after body returns the future becomes CANCELLED.

Interruption is a cooperative request, not proof of immediate termination.

Try before revealing

Hint 1

Which resources are queued and which are already executing?

Hint 2

What wakes a worker when an earlier deadline arrives?

Hint 3

How can callers tell cancellation requested from locally stopped?

Requirements and invariants

R1 — Pending queue count never exceeds configured admission capacity. submit protects I1.

R2 — Only an owned worker can transition a queued task to running and execute it outside the queue lock. loop protects I2.

R3 — Cancellation requested is not reported as stopped until local execution ends. cancel protects I3.

R4 — Shutdown stops new admission while preserving the declared queued/running policy. shutdown protects I1.

I1: queue.size<=queueCapacity; admission closes once shutdown starts.

I2: At most workerCount task bodies execute, and a task has one current worker owner.

I3: RUNNING→CANCEL_REQUESTED may precede actual stop; only completion produces terminal CANCELLED for running work.

Derive the design

An executor needs admission and lifecycle beyond a worker loop

A single worker polling a FIFO can run tasks, but it cannot order future deadlines or protect the process against unlimited pending work. The reference adds a deadline heap, bounded admission and owned worker state. It does not expand into a durable scheduler merely because the class is named Scheduler.

Alternative: Java executor services already supply robust production building blocks. This teaching implementation exposes the mechanics; a production choice should consider mature libraries, real clock behavior, thread factories, metrics and shutdown guarantees rather than reimplement them without need.

Name queue and running capacity separately

A worker limit controls concurrency; queue capacity controls accepted backlog. Neither alone caps retained history or total bytes.

Use condition loops around waits

Spurious wakeups, new earlier deadlines and shutdown all require rechecking the actual state under the lock. Never assume one notify means a task is ready.

Execute outside ownership

Claim a queued task and owner thread under the lock, then call arbitrary user code outside it. Result waiting must also be outside the lock so workers can finish.

Make cancellation visible until completion

Preserve CANCEL_REQUESTED while code still runs. This gives callers an honest state and prevents unsupported claims about remote effects.

Responsibilities and ownership

Scheduler

Owns: Queue, records, workers, clock checks and lifecycle flags

Methods: submit, cancel, shutdown, awaitTermination

Trusted local executor, not a sandbox or durable job platform.

Task

Owns: Due time, body, result, owner thread and state

Methods: Private lifecycle state

Worker/result ownership is coordinated under one lock.

PriorityQueue

Owns: Due-order admission plan

Methods: Peek/remove/insert

Heap order is not global fairness or preemption.

View / await

Owns: Immutable status and bounded wait interface

Methods: view, await

A terminal result describes local execution, not external side-effect rollback.

Task ownership

Local trusted callables only; no durable execution or sandbox is implied.

  1. Submission + due time
  2. ↓ Reject closed/full admission

    Bounded deadline heap
  3. ↓ Wait/recheck earliest deadline

    Worker claims RUNNING
  4. ↓ One owner per task

    Body outside lock → terminal result
Cancellation lifecycle

A terminal local cancellation does not undo remote side effects.

  1. QUEUED or RUNNING
  2. ↓ Pending and active cases differ

    Queued: remove / Running: interrupt request
  3. ↓ Never force-stop user code

    CANCEL_REQUESTED while body may run
  4. ↓ Clear reused worker interruption

    CANCELLED after local completion

Data model, schema and relationships

A scheduler owns tasks, due ordering and worker claims. A result is not the same thing as a cancellation request: a running body may ignore interruption until it exits.

Implementation boundary: Local daemon workers execute trusted callables and private futures. Proposed task/attempt persistence does not serialize live Callable closures or make this pool a durable scheduler.

These are proposed logical schemas with field contracts, not executable database migrations. PK identifies a row, FK references an existing parent, UK is a uniqueness rule. A composite PK identifies the combination. Nullable references are marked with ?. A foreign key protects identity; cross-row limits, timing and state transitions also require the transaction rules below.

scheduler: relationship map

Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.

scheduler relationshipsscheduler 1 to scheduled_task 0..*: accepts tasks. scheduled_task.scheduler_id → scheduler.id. scheduler 1 to worker 0..*: owns workers. worker.scheduler_id → scheduler.id.accepts tasks1 parent · 0..* childrenscheduled_taskid [PK]scheduler_id [FK]due_tickowns workers1 parent · 0..* childrenworkerid [PK]scheduler_id [FK]stateschedulerid [PK]worker_countqueue_capacity
scheduled_task: relationship map

Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.

scheduled_task relationshipsscheduled_task 1 to task_attempt 0..*: executes. task_attempt.task_id → scheduled_task.id.executes1 parent · 0..* childrentask_attemptid [PK]task_id [FK]worker_id [FK]scheduled_taskid [PK]scheduler_id [FK]due_tick
worker: relationship map

Connectors name the relationship; multiplicities state parents per child and children per parent. 0..1 means optional, 1 required, 0..* any number. Self-links show two roles of the same entity.

worker relationshipsworker 1 to task_attempt 0..*: claims work. task_attempt.worker_id → worker.id.claims work1 parent · 0..* childrentask_attemptid [PK]task_id [FK]worker_id [FK]workerid [PK]scheduler_id [FK]state

Entity definitions and field contracts

scheduler

Admission and worker policy.

FieldType / keyMeaning and reference
iduuid · PKPool identity
worker_countinteger · VALUEFixed concurrency
queue_capacityinteger · VALUEPending bound

Code mapping: Scheduler.

  • Worker count 1..32; queue positive.

scheduled_task

Accepted logical task and due time.

FieldType / keyMeaning and reference
idbigint · PKSubmission identity
scheduler_iduuid · FKPool owner · scheduler.id
due_tickbigint · VALUEElapsed deadline
stateenum · VALUEQUEUED RUNNING CANCEL_REQUESTED etc

Code mapping: Task and priority queue.

  • Queued count<=capacity; retained result count is not queue depth.

worker

Local worker identity.

FieldType / keyMeaning and reference
idtext · PKThread identity
scheduler_iduuid · FKPool owner · scheduler.id
stateenum · VALUEWaiting running stopped

Code mapping: Owned worker thread.

  • Live thread/future references are process-local, never persistence handles.

task_attempt

Execution ownership and outcome.

FieldType / keyMeaning and reference
iduuid · PKAttempt identity
task_idbigint · FKAccepted task · scheduled_task.id
worker_idtext · FKClaiming worker · worker.id
outcomeenum · VALUERunning success failure cancelled

Code mapping: Task.worker and result lifecycle.

  • Reference executes each accepted task once; retry/lease history would be a durable extension.

Relationship cardinalities and their reasons

Parent → childMultiplicityFK / why
scheduler → scheduled_task

accepts tasks

1 parent per child; 0..* children per parentscheduled_task.scheduler_id → scheduler.id

Pool admission bounds queued work independently of retained results.

scheduler → worker

owns workers

1 parent per child; 0..* children per parentworker.scheduler_id → scheduler.id

Fixed concurrency limits active task bodies.

scheduled_task → task_attempt

executes

1 parent per child; 0..* children per parenttask_attempt.task_id → scheduled_task.id

Reference permits one attempt; durable retries require a new contract.

worker → task_attempt

claims work

1 parent per child; 0..* children per parenttask_attempt.worker_id → worker.id

A worker executes many historical tasks, one body at a time.

Transaction boundaries

  1. Claim due head under queue monitor, mark RUNNING and assign worker; execute body after releasing monitor.
  2. Queued cancel removes and completes cancelled. Running cancel requests interrupt, then transitions terminal only when body exits.

Indexes and uniqueness

  1. Heap(due_tick,id) orders due work deterministically; linear removal is a known queued-cancel cost.
  2. Durable variant needs absolute due instant, versioned handler identifier/input and fenced claim—not a saved nanosecond clock value.

Schema traps and failure cases

  1. Persisting System.nanoTime-based deadlines across restart misinterprets the clock origin.
  2. Immediate shutdown cannot forcibly terminate a task ignoring interruption.

Trace the records through one operation

  1. One delayed task occupies a pending slot until due.
  2. Worker claims it; cancellation changes state to CANCEL_REQUESTED.
  3. Body exits; result becomes CANCELLED and worker interrupt is cleared before reuse.

Method contracts

submit(Callable<String>,delayNanos) → taskId

Reject closed/full queue or invalid delay. Compute deadline, add one queued task and notify waiting workers.

cancel(taskId) → boolean

Queued cancellation is immediate. Running cancellation records request and interrupts; completed tasks return false.

view(taskId); await(taskId,Duration)

Read immutable local state or wait outside scheduler ownership. Future may return, fail, time out or cancel; waiting does not hold the queue monitor.

shutdown(immediately)

Always stop admission. Graceful leaves accepted work; immediate cancels queued work and requests interruption from running owners.

wake(); awaitTermination(millis)

wake supports changed injected clock observations. Termination wait uses real elapsed time and reports uncooperative workers honestly.

What, when, why and how

Each concept is attached to the requirement it protects, its implementation, an alternative and a test.

Bounded producer/consumer admission

what
Accept finite pending work and separate it from bounded execution slots.
when
Producers can submit faster than tasks finish.
why
An unlimited queue hides overload until memory or latency fails.
how
submit checks queueCapacity under the same owner used by workers, inserts the task and notifies. workerCount fixes executing bodies; running tasks do not consume queue slots.

Code: submit, queueCapacity, workers · Invariants: I1, I2

Alternatives, cost and failure example

Alternative: Reject, caller-runs, block-with-deadline or tenant quotas are different overload policies and must be chosen deliberately.

Cost: Retained completed tasks/results are not bounded by queue capacity.

Counterexample: Claim a queue of100 means only100 task records can ever exist.

Test: Fill a future-due queue and assert extra submission rejects; prove at most two task bodies run with two workers.

Delay heap and wakeup protocol

what
Choose the earliest due task without scanning all future tasks.
when
Immediate and delayed work coexist.
why
Sleeping until an old deadline without notification can ignore a newly submitted earlier task.
how
PriorityQueue sorts due/ID; workers wait for head deadline and submit/wake/shutdown notify. Recheck the condition in a loop after every wake or interruption.

Code: queue, loop, wake · Invariants: I2

Alternatives, cost and failure example

Alternative: A DelayQueue or scheduled executor is an alternative; it still needs cancellation/admission/lifecycle contracts.

Cost: Insertion/removal is O(log Q), but cancelling a particular Task uses PriorityQueue.remove(Object), O(Q).

Counterexample: A worker sleeps for the old one-second task after a new 100ms task arrives.

Test: Advance fake time to the earlier due task, notify and verify it finishes while later task stays QUEUED.

Cooperative cancellation state

what
A request to stop is separate from evidence that user execution stopped.
when
A running task may ignore interruption or already perform external work.
why
Reporting CANCELLED immediately can falsely imply the operation ended or was undone.
how
cancel marks CANCEL_REQUESTED and interrupts the owner. Worker records CANCELLED only after the body returns/throws, then clears interruption before the next task.

Code: cancel, loop · Invariants: I3

Alternatives, cost and failure example

Alternative: A process boundary can enforce stronger local resource termination for untrusted work, but cannot undo external side effects already committed.

Cost: Uncooperative tasks retain worker capacity; no force-stop is attempted.

Counterexample: Task catches InterruptedException and keeps running while UI already shows completed cancellation.

Test: Use latches to hold a task after it catches interrupt; inspect CANCEL_REQUESTED before release and CANCELLED afterwards.

Owned shutdown and result boundaries

what
Stop admission, decide queued policy and wait for actual worker exit.
when
Application lifecycle or overload requires cleanup.
why
A close flag alone neither drains future tasks nor ends running code.
how
shutdown(false) drains due accepted work; shutdown(true) cancels queue and interrupts current owners. awaitTermination uses a real elapsed budget and returns a boolean.

Code: shutdown, awaitTermination, await · Invariants: I1, I3

Alternatives, cost and failure example

Alternative: A higher-level service may use grace deadline then immediate cancellation, retaining unresolved work for durable recovery.

Cost: Graceful delayed tasks may keep workers alive until future due time; injected clocks must advance.

Counterexample: Call shutdown and assume all effects are finished despite a task blocked in noninterruptible I/O.

Test: Check rejection after shutdown, queued cancellation on immediate shutdown and true termination only after cooperative bodies finish.

Read and run the Java reference

Reveal complete Solution.java

Java 21. Save as Solution.java; compile with javac --release 21. The repository also contains Test.java and the lld:java verification command. This reference implements the local contract above.

Solution.java Java 21
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.*;
/** Owned local worker pool with a bounded delayed-ready queue. No durability or recurring jobs. */
public final class Solution {
    private Solution() {}
    public enum State { QUEUED, RUNNING, CANCEL_REQUESTED, SUCCEEDED, FAILED, CANCELLED }
    public record View(long id,State state,long dueNanos){}
    private static final class Task {
        final long id,due;final Callable<String> body;final CompletableFuture<String> result=new CompletableFuture<>();
        State state=State.QUEUED;Thread worker;boolean cancel;
        Task(long id,long due,Callable<String> body){this.id=id;this.due=due;this.body=body;}
    }
    public static final class Scheduler {
        private final Object lock=new Object();
        private final int queueCapacity;
        private final LongSupplier clock;
        private final Map<Long,Task> tasks=new HashMap<>();
        private final PriorityQueue<Task> queue=new PriorityQueue<>(Comparator.comparingLong((Task t)->t.due).thenComparingLong(t->t.id));
        private final List<Thread> workers=new ArrayList<>();
        private long sequence,time;private boolean accepting=true,stopNow;
        public Scheduler(int workerCount,int queueCapacity,LongSupplier clock){
            if(workerCount<1||workerCount>32||queueCapacity<1)throw new IllegalArgumentException("configuration");this.queueCapacity=queueCapacity;this.clock=Objects.requireNonNull(clock);
            for(int i=0;i<workerCount;i++){var worker=new Thread(this::loop,"lld-worker-"+i);worker.setDaemon(true);workers.add(worker);worker.start();}
        }
        private long now(){long value=clock.getAsLong();if(value<time)throw new IllegalStateException("nonmonotonic clock");time=value;return value;}
        public long submit(Callable<String> body,long delayNanos){
            Objects.requireNonNull(body);if(delayNanos<0)throw new IllegalArgumentException("delay");
            synchronized(lock){if(!accepting)throw new IllegalStateException("closed");if(queue.size()==queueCapacity)throw new RejectedExecutionException("queue full");long due=Math.addExact(now(),delayNanos);var task=new Task(++sequence,due,body);tasks.put(task.id,task);queue.add(task);lock.notifyAll();return task.id;}
        }
        private void loop(){
            while(true){Task task;
                synchronized(lock){
                    while(true){if(stopNow||(!accepting&&queue.isEmpty()))return;
                        Task head=queue.peek();try{
                            if(head==null){lock.wait();continue;}
                            long remaining=head.due-now();if(remaining>0){lock.wait(remaining/1_000_000,(int)(remaining%1_000_000));continue;}
                            task=queue.remove();task.state=State.RUNNING;task.worker=Thread.currentThread();break;
                        }catch(InterruptedException ignored){/* Shutdown/cancel flags are authoritative. */}
                    }
                }
                String value=null;Throwable failure=null;
                try{boolean skip;synchronized(lock){skip=task.cancel;}if(!skip)value=task.body.call();}catch(Throwable error){failure=error;}
                synchronized(lock){task.worker=null;if(task.cancel){task.state=State.CANCELLED;task.result.cancel(false);}else if(failure!=null){task.state=State.FAILED;task.result.completeExceptionally(failure);}else{task.state=State.SUCCEEDED;task.result.complete(value);}lock.notifyAll();}
                Thread.interrupted(); // A completed task must not leak interruption into the next one.
                if(failure instanceof VirtualMachineError fatal)throw fatal;
            }
        }
        private Task task(long id){var task=tasks.get(id);if(task==null)throw new IllegalArgumentException("task");return task;}
        public boolean cancel(long id){synchronized(lock){var task=task(id);if(task.state==State.QUEUED){queue.remove(task);task.cancel=true;task.state=State.CANCELLED;task.result.cancel(false);lock.notifyAll();return true;}if(task.state==State.RUNNING||task.state==State.CANCEL_REQUESTED){task.cancel=true;task.state=State.CANCEL_REQUESTED;task.worker.interrupt();return true;}return false;}}
        public View view(long id){synchronized(lock){var t=task(id);return new View(t.id,t.state,t.due);}}
        public String await(long id,Duration timeout)throws InterruptedException,ExecutionException,TimeoutException{final Task t;synchronized(lock){t=task(id);}return t.result.get(timeout.toNanos(),TimeUnit.NANOSECONDS);}
        public void wake(){synchronized(lock){lock.notifyAll();}}
        public void shutdown(boolean immediately){synchronized(lock){accepting=false;if(immediately){stopNow=true;for(var t:queue){t.cancel=true;t.state=State.CANCELLED;t.result.cancel(false);}queue.clear();for(var t:tasks.values())if(t.worker!=null){t.cancel=true;t.state=State.CANCEL_REQUESTED;t.worker.interrupt();}}lock.notifyAll();}}
        public boolean awaitTermination(long millis)throws InterruptedException{if(millis<0||millis>60000)throw new IllegalArgumentException("timeout");long start=System.nanoTime(),budget=TimeUnit.MILLISECONDS.toNanos(millis);for(var worker:workers){long remaining=budget-(System.nanoTime()-start);if(remaining<=0)return workers.stream().noneMatch(Thread::isAlive);worker.join(remaining/1_000_000,(int)(remaining%1_000_000));}return workers.stream().noneMatch(Thread::isAlive);}
    }
}

submit validates before admission

Deadline overflow or full/closed state rejects before inserting a new task. Notify lets workers reconsider the heap head.

loop separates claim from execution

The synchronized loop waits/rechecks and assigns RUNNING ownership. Body invocation occurs outside the monitor, then one terminal result is recorded under ownership.

cancel differentiates pending and active

Queued tasks can be removed immediately. Running tasks retain their owner until completion, so the state records a cancellation request rather than a fictional stop.

awaitTermination uses a separate real-time clock

A frozen fake task clock must not make a test’s termination timeout freeze too. Elapsed real time bounds joins.

Complexity and limits

  • submit/poll are O(log Q) queue operations for Q pending tasks, with expected O(1) task-ID lookup. cancel of a queued Task is O(Q) due to heap identity removal.
  • Shutdown-now scans retained H task records plus Q pending tasks: O(H+Q). Views are expected O(1); termination wait is bounded by supplied real-time budget, excluding caller scheduling.
  • Space includes Q pending bodies, up to W running bodies and unbounded retained records/results H. Task payload bytes are not capped by count limits.
  • Due scheduling and actual task completion latency depend on trusted clock, OS scheduling and user code. No real-time, fairness or distributed execution bound is claimed.

Walk through the actual reference

These walkthrough fixtures are emitted only after the Java harness verifies their operations and expected state. Concurrency tests additionally exercise competing callers.

Delay ordering and bounded admission

Step 1 of 2: Queue future tasks at t=0

Capacity reached; extra submission rejects

Invariant: Pending count bounded

Read the complete trace table
ActionState after actionInvariant
Queue future tasks at t=0Capacity reached; extra submission rejectsPending count bounded
Insert earlier due task after cancellation frees slotWorker wakes and runs earlier deadline firstHeap order is due time then task ID

Cancellation requested is not stopped

Step 1 of 2: Cancel running task that catches interrupt

CANCEL_REQUESTED; user body still running

Invariant: No unsafe force-stop promise

Read the complete trace table
ActionState after actionInvariant
Cancel running task that catches interruptCANCEL_REQUESTED; user body still runningNo unsafe force-stop promise
Task body returns after explicit releaseCANCELLED; future cancelledTerminal state follows actual local completion

Mistakes and counterexamples

Hold the queue monitor during task code

Failing case: A slow Callable blocks cancellation and all producers.

Repair: Claim under lock and execute outside it.

Treat interrupt as force-stop

Failing case: Task catches it and continues.

Repair: Keep CANCEL_REQUESTED until actual body completion.

Forget to wake for an earlier deadline

Failing case: New immediate work waits behind an old future task.

Repair: Notify and recheck heap head in a condition loop.

Advertise bounded memory from queue count

Failing case: Completed results remain retained forever.

Repair: Account for body bytes, workers and result-history retention separately.

Engineer → Senior → Staff

Choose a deep dive

Implement bounded immediate/delayed execution.

Executed: deadline ordering, queue rejection, results and ordinary task failures.

Change exercise

Test equal due times and explain which ordering the heap guarantees.

Worked answer guidance

The heap ties by task ID, so claim order is deterministic. With multiple workers, completion order can differ because task bodies run concurrently.

Interview follow-ups

Why recheck after wait?

Wakeups can be spurious or caused by a different event; the queue/shutdown/clock conditions are authoritative.

Does await hold the scheduler lock?

No. Waiting under that lock could block the worker that must complete the result.

Your design attempt

Each language has a separate draft across all three levels. Saving is device-only and does not sync to your account. Anyone using this browser profile can load it. Loading is always explicit.

Java 21 reference code is provided for local study. Use Run program in the editor to execute your code and test driver. Program runs show output; they do not grade your design.

Solution.java
Run program · Java 21

No unsaved changes · 208/20000

Self-review

Checks apply to this visit. Self-review is not a passed submission or verified mastery.

Open focused practice →

Provenance and scope of evidence

Original teaching material. The local contract is implemented by Solution.java and tested by Test.java on Java 21. Distributed persistence, real provider/device adapters and deployment are not claimed.

Reference verification is not independent editorial review, production deployment verification or a claim that distributed adapters are implemented.