LLD Cohort · Week 8 · Class 2 · recording 2h25m
Concurrency LLDs: Task Scheduler, Thread Pool, Producer-Consumer & Mini Kafka
Concurrency-flavoured LLD problems: a task scheduler (one-time and recurring tasks, ordered by time then priority), a thread pool built from scratch, the producer-consumer pattern, and a toy Kafka broker (topics, partitions, offsets). (The first hour of the recording, 0:00:00 – 1:08:29, is DSA practice and is not covered in these notes.)
Each topic: one-line idea → the problem → real-life picture → the design step by step → Python code → doubts & corrections → remember
- LLD: Task Scheduler
- LLD: Thread Pool from scratch
- LLD: Producer-Consumer
- LLD: Mini Kafka (broker, topic, partition)
LLD: Task Scheduler ▶ 1:08:29 – 1:35:34
Clients submit jobs to run once, at a given time, or repeatedly; the scheduler runs each job when its time comes, using a pool of worker threads.
The problem
Think of cron, an alarm app, or "scheduled tasks" in tools like Claude Code: wake up at 9 AM and scrape mail, run a report every 30 seconds, shut the machine down at midnight. Thousands of tasks, all with different times and importance. How do we always know which one to run next, run them in parallel, and re-run the recurring ones? The teacher's reason to study this: in 45-minute interviews, candidates with no context about the problem are judged harshly, even if they think well.
Your phone alarm for 11 AM. Nothing magical "wakes up" at 11. Deep down, the operating system keeps a ticker: each task stores how many ticks are left; every tick, somebody decrements the counters, and when a counter hits zero, the task fires. Something is always checking — it is just hidden in a lower layer. Our scheduler does the same thing with a ticker thread that checks every 100 ms.
Requirements and entities
- TaskPriority: HIGH, MEDIUM, LOW. TaskStatus: PENDING, RUNNING, COMPLETED, FAILED (+ CANCELLED).
- Task (interface): id, priority, next run time, the job;
execute()andon_complete(). - OneTimeTask: runs once at
run_at;on_completedoes nothing. - RecurringTask: has an
interval;on_completesetsnext_run_time = now + intervaland asks to be queued again (like a daily alarm). - ScheduledTask: a wrapper around a task that adds the ordering rule (and optionally retry count / max retries) without polluting the task class.
- TaskScheduler:
submit,cancel,start,shutdown; holds a priority queue, a status registry, a worker pool and a ticker.
Time first, priority second. Priority never lets a 10 AM task run at 9 AM. Priority only decides the order among tasks due at the same time. That is exactly the custom comparator: compare next run times; only if equal, compare priorities.
How it works
submit(task)wraps it in a ScheduledTask, pushes it into a priority blocking queue and marks it PENDING. The queue is a buffer: clients drop tasks in asynchronously and leave; the scheduler decides when to run them.- A ticker (in Java a
ScheduledExecutorService.scheduleAtFixedRate, every 100 ms) callspoll(). poll(): while the top task's next run time ≤ now, remove it and hand it to the worker pool (worker.submit(...), as in the elevator and notification designs).- A worker runs
run(task): execute, mark COMPLETED, callon_complete(); a recurring task goes back into the queue.
Why a thread pool? With one thread, tasks run one after another. With 5 workers, 5 due tasks run in parallel.
classDiagram
class Task {
+task_id
+priority
+next_run_time
+execute()
+on_complete()
}
class ScheduledTask {
+task
+lt(other)
}
class TaskScheduler {
+submit(task)
+cancel(task_id)
+start()
+shutdown()
}
OneTimeTask --|> Task
RecurringTask --|> Task
ScheduledTask o-- Task
TaskScheduler *-- ScheduledTask
TaskScheduler --> ThreadPoolExecutor
Task --> TaskPriority
TaskScheduler --> TaskStatus
Python code
import heapq
import itertools
import threading
import time
from abc import ABC, abstractmethod
from concurrent.futures import ThreadPoolExecutor
from enum import IntEnum, Enum
from typing import Callable
class TaskPriority(IntEnum): # smaller number = runs first
HIGH = 1
MEDIUM = 2
LOW = 3
class TaskStatus(Enum):
PENDING = "PENDING"
RUNNING = "RUNNING"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
class Task(ABC):
_ids = itertools.count(1)
def __init__(self, priority: TaskPriority, run_at: float, job: Callable[[], None]):
self.task_id = next(Task._ids)
self.priority = priority
self.next_run_time = run_at
self.job = job # Java's Runnable = any Python callable
def execute(self) -> None:
self.job()
@abstractmethod
def on_complete(self) -> bool:
"""Return True if the task must be queued again."""
class OneTimeTask(Task):
def on_complete(self) -> bool:
return False # done forever
class RecurringTask(Task):
def __init__(self, priority, interval: float, job, first_run: float | None = None):
super().__init__(priority, first_run or time.time(), job)
self.interval = interval
def on_complete(self) -> bool:
self.next_run_time = time.time() + self.interval # e.g. alarm: same time tomorrow
return True # re-queue me
class ScheduledTask:
"""Wrapper that knows how to ORDER tasks: time first, then priority."""
def __init__(self, task: Task):
self.task = task
def __lt__(self, other: "ScheduledTask") -> bool:
a, b = self.task, other.task
if a.next_run_time != b.next_run_time:
return a.next_run_time < b.next_run_time # earlier time wins
return a.priority < b.priority # same time: priority decides
class TaskScheduler:
def __init__(self, workers: int = 3, tick: float = 0.1):
self._queue: list[ScheduledTask] = [] # min-heap
self._lock = threading.Lock()
self.registry: dict[int, TaskStatus] = {}
self._workers = ThreadPoolExecutor(max_workers=workers)
self._tick = tick
self._running = False
def submit(self, task: Task) -> int:
with self._lock:
heapq.heappush(self._queue, ScheduledTask(task))
self.registry[task.task_id] = TaskStatus.PENDING
return task.task_id
def cancel(self, task_id: int) -> None:
with self._lock:
self.registry[task_id] = TaskStatus.CANCELLED
def start(self) -> None:
self._running = True
threading.Thread(target=self._ticker, daemon=True).start()
def _ticker(self) -> None: # wakes up every 100 ms
while self._running:
self._poll()
time.sleep(self._tick)
def _poll(self) -> None:
now = time.time()
with self._lock:
while self._queue and self._queue[0].task.next_run_time <= now:
st = heapq.heappop(self._queue)
if self.registry[st.task.task_id] != TaskStatus.CANCELLED:
self._workers.submit(self._run, st.task) # hand to a worker
def _run(self, task: Task) -> None: # runs on a worker thread
self.registry[task.task_id] = TaskStatus.RUNNING
try:
task.execute()
self.registry[task.task_id] = TaskStatus.COMPLETED
if task.on_complete(): # recurring -> back in queue
self.submit(task)
except Exception:
self.registry[task.task_id] = TaskStatus.FAILED
def shutdown(self) -> None:
self._running = False
self._workers.shutdown(wait=True)
# usage
if __name__ == "__main__":
s = TaskScheduler()
s.start()
now = time.time()
s.submit(OneTimeTask(TaskPriority.LOW, now + 0.3, lambda: print("low @0.3s")))
s.submit(OneTimeTask(TaskPriority.HIGH, now + 0.3, lambda: print("high @0.3s")))
rid = s.submit(RecurringTask(TaskPriority.MEDIUM, 0.25, lambda: print("tick (recurring)")))
time.sleep(0.8)
s.cancel(rid)
time.sleep(0.3)
s.shutdown()
# tick (recurring)
# high @0.3s <- same time as "low", but HIGH priority is taken first
# low @0.3s
# tick (recurring)
# tick (recurring)Java's PriorityBlockingQueue + compareTo becomes heapq + __lt__ under a threading.Lock (Python also has queue.PriorityQueue). Runnable becomes any callable. Executors.newFixedThreadPool(5) becomes ThreadPoolExecutor(max_workers=5). The Java ticker ScheduledExecutorService becomes a daemon thread with a sleep loop.
Polling every 100 ms is busy-ish waiting: if the next task is at 10 AM and it is 8 AM, the ticker still wakes up 72,000 times for nothing. The teacher accepted this to keep the logic visible. Better: sleep exactly until the head task is due, and wake early if a sooner task is submitted (Python: threading.Condition.wait(timeout); Java: DelayQueue or Condition.awaitNanos). Some interviewers ask you not to use a ready-made scheduling library, so be able to write the loop yourself.
→
A student said "for ordering". The deeper reason: the queue is a buffer that decouples submitting from running. Clients submit any time from any thread; the scheduler picks tasks when it decides. It is a priority queue so the next-due task is always on top, and a blocking (thread-safe) queue because many threads submit at once.
→
A ticker at the OS level. Each scheduled event has a count of remaining ticks; every tick decrements it; at zero the event fires. A cron job is a similar idea one level higher. Nothing wakes up by magic — some code is always checking.
- Task interface with OneTimeTask and RecurringTask; recurring re-queues itself in
on_complete. - Wrapper (ScheduledTask) holds the ordering: earliest time first, then priority.
- Priority blocking queue + ticker + thread pool + status registry.
- Polling works but wastes wake-ups; timed waits are the better version.
LLD: Thread Pool from scratch ▶ 1:35:34 – 1:57:00
A fixed set of long-living worker threads that take tasks from a shared queue, so you never create a new thread per task.
The problem
Running 500 tasks on one thread is slow (serial). Creating one thread per task is fast for 30 tasks, but with 1,000 requests arriving together it breaks: each thread has its own stack and system resources, so you cannot create threads without limit. Most tasks are short (a few hundred milliseconds), so creating and destroying a thread for each one is wasted work.
A restaurant does not hire a new cook for every order and fire them after one dish. It has 5 cooks. Orders go on a rail; any free cook takes the next ticket. When there are no tickets, the cooks wait; they do not go home. A student summed it up: a thread pool is a collection of reusable threads.
Requirements and entities
- Keep a fixed number of worker threads; accept tasks; run each on an available worker; avoid creating/destroying threads often; shut down gracefully.
- ThreadPool (interface):
submit(task),shutdown()— an interface because there can be many pool types (fixed, cached...). The caller only says how many threads; it never controls individual threads. - TaskQueue: a blocking queue.
putblocks if full;takeblocks if empty. - Worker (extends Thread): holds a reference to the shared queue (it does not own it) and loops: take a task, run it.
- FixedThreadPool: creates the queue and N workers, starts them, submits tasks, shuts down.
Why the worker uses while, not if
When a thread's run() method returns, the thread is dead. We want workers that never quit by themselves: no task → they block (sleep) on the empty queue; a task arrives → they wake up and work; then they loop back. Only the pool decides when they stop (a running flag + interrupt in Java). Without the loop you would keep creating new workers — the exact thing a pool exists to avoid.
classDiagram
class ThreadPool {
+submit(task)
+shutdown()
}
class FixedThreadPool {
+task_queue
+workers
+is_shutdown
}
class Worker {
+task_queue
+run()
}
class TaskQueue {
+put(task)
+get()
}
FixedThreadPool ..|> ThreadPool
FixedThreadPool *-- Worker
FixedThreadPool *-- TaskQueue
Worker --> TaskQueue
Python code
import queue
import threading
import time
from abc import ABC, abstractmethod
from typing import Callable
_STOP = object() # "poison pill": tells a worker to exit its loop
class ThreadPool(ABC):
@abstractmethod
def submit(self, task: Callable[[], None]) -> None: ...
@abstractmethod
def shutdown(self) -> None: ...
class Worker(threading.Thread):
def __init__(self, task_queue: queue.Queue, name: str):
super().__init__(name=name, daemon=True)
self.task_queue = task_queue # a REFERENCE to the shared queue
def run(self) -> None:
while True: # loop forever: a worker never quits on its own
task = self.task_queue.get() # BLOCKS (sleeps) while the queue is empty
if task is _STOP:
break
try:
task()
except Exception as e: # one bad task must not kill the worker
print(f"{self.name} task failed: {e}")
class FixedThreadPool(ThreadPool):
def __init__(self, pool_size: int, queue_capacity: int):
self.task_queue: queue.Queue = queue.Queue(maxsize=queue_capacity) # BOUNDED
self.workers = [Worker(self.task_queue, f"worker-{i}") for i in range(pool_size)]
self.is_shutdown = False
for w in self.workers:
w.start() # start() -> calls run() on a new thread
def submit(self, task: Callable[[], None]) -> None:
if self.is_shutdown:
raise RuntimeError("pool is shut down")
self.task_queue.put(task) # BLOCKS while the queue is full (back-pressure)
def shutdown(self) -> None:
self.is_shutdown = True
for _ in self.workers:
self.task_queue.put(_STOP) # one pill per worker, after the real tasks
for w in self.workers:
w.join()
# usage
if __name__ == "__main__":
pool = FixedThreadPool(pool_size=3, queue_capacity=10)
for n in range(6):
pool.submit(lambda n=n: (time.sleep(0.05),
print(f"task {n} on {threading.current_thread().name}")))
pool.shutdown()
# task 0 on worker-2
# task 2 on worker-0
# task 1 on worker-1
# task 3 on worker-2 <- only 3 threads, reused for 6 tasks (order may vary)
# ...Java stops a worker with isRunning = false; thread.interrupt(), which wakes a thread blocked in take(). Python threads cannot be interrupted, so we put one "poison pill" object per worker into the queue; each worker exits its loop when it takes one. queue.Queue(maxsize=n) is Python's ArrayBlockingQueue. worker.start() runs run() on a new thread in both languages — calling run() directly would run it on the current thread.
Benefits (the teacher read these out)
- Less thread-creation overhead; threads are "pre-warmed" and ready.
- Faster response; better resource management; no out-of-memory from unlimited threads.
- Graceful degradation under load; less context switching; tasks separated from threads.
- Easy tuning: too slow? increase the pool size.
→
It can grow until it eats all memory (answer from a student). Servers run on shared cloud resources; an unbounded queue plus a fast producer can take the whole service down. Keep it bounded. For known peaks (a sale), size it larger — e.g. 2,000 instead of 1,000 — which is still bounded. When it is full: block the submitter, reject, or send overflow to a dead-letter queue.
→
It creates a pool that reuses a fixed number of threads working off a shared unbounded queue. At most n tasks run at a time; extra tasks wait in the queue until a thread is free; a thread that dies from a failure is replaced. Our class is the same idea, but with a bounded queue.
- Pool = list of workers + one blocking task queue.
- Worker = infinite loop: block on take → run task → repeat.
- Bounded queue for safety; graceful shutdown via flag/interrupt (Java) or poison pills (Python).
- Being able to write a thread pool in 5 minutes is a strong interview signal; concurrency is often the deciding round for SDE-2 and above.
LLD: Producer-Consumer ▶ 1:57:00 – 2:06:59
One side produces items at its own speed, the other side consumes them at its own speed, and a bounded buffer between them absorbs the difference.
The problem
A producer that is faster than the consumer would overwhelm it; a consumer that is faster would spin uselessly. A shared buffer lets both run independently: the producer blocks when the buffer is full, the consumer blocks when it is empty.
Any message queue (a student's answer): one microservice drops messages, another picks them up later. And the thread pool we just built is producer-consumer: callers of submit are producers, the task queue is the buffer, workers are consumers. New name, same behaviour: if something has a buffer where requests wait, with one end pushing and the other pulling, it is producer-consumer.
A buffer is just a piece of memory where items wait. A queue is an abstract data type: it only says "process in FIFO order". A buffer can be a queue, a stack, a linked list — whatever order you need. That is why the design starts with a Buffer interface and a BoundedBuffer implementation.
classDiagram
class Buffer {
+put(item)
+take()
}
class BoundedBuffer {
+capacity
}
class Producer {
+run()
}
class Consumer {
+is_running
+run()
}
BoundedBuffer ..|> Buffer
Producer --> Buffer
Consumer --> Buffer
import queue
import threading
import time
from abc import ABC, abstractmethod
class Buffer(ABC): # "a piece of memory" - not necessarily a queue
@abstractmethod
def put(self, item) -> None: ...
@abstractmethod
def take(self): ...
class BoundedBuffer(Buffer):
def __init__(self, capacity: int):
self._q: queue.Queue = queue.Queue(maxsize=capacity) # FIFO + thread-safe
def put(self, item) -> None:
self._q.put(item) # blocks if full
def take(self):
return self._q.get() # blocks if empty
class Producer(threading.Thread):
def __init__(self, buffer: Buffer, count: int):
super().__init__(daemon=True)
self.buffer, self.count = buffer, count
def run(self) -> None:
for i in range(self.count):
self.buffer.put(i)
print(f"produced {i}")
time.sleep(0.01) # simulate work
self.buffer.put(None) # signal "no more items"
class Consumer(threading.Thread):
def __init__(self, buffer: Buffer):
super().__init__(daemon=True)
self.buffer = buffer
self.is_running = True
def run(self) -> None:
while self.is_running: # WHILE, not IF: keep consuming
item = self.buffer.take()
if item is None:
break
print(f" consumed {item}")
time.sleep(0.03) # consumer is slower than producer
# usage
if __name__ == "__main__":
buf = BoundedBuffer(capacity=2)
p, c = Producer(buf, 4), Consumer(buf)
p.start(); c.start(); p.join(); c.join()
# produced 0 / consumed 0 / produced 1 / produced 2 ... producer waits when buffer is fullJava creates a thread by implements Runnable or extends Thread; Python subclasses threading.Thread or passes target=. Java's BlockingQueue.put/take map to queue.Queue.put/get.
- Three parts: Buffer (bounded, thread-safe), Producer thread, Consumer thread.
- Same
while-loop thread shape as the thread pool and the elevator design. - Thread pool and Kafka are both producer-consumer at heart.
LLD: Mini Kafka (broker, topic, partition) ▶ 2:06:59 – 2:24:00
Kafka is a durable, append-only log that sits between services: producers write events into topics, consumers read them at their own pace and can replay them.
The problem
One server generates thousands of events per second; the next service cannot process them that fast. You need a place to store everything durably, so the consumer can pick it up later at its own speed — a buffer that decouples services and gives them "breathing space". Kafka is popular for streaming (data processed continuously as it arrives) rather than batch (data collected and processed at fixed times). Events written on Tuesday can be replayed and processed on Friday.
A student's analogy: a topic is a folder, its partitions are the files inside, and each file is a notebook you only ever add lines to. Each reader keeps a bookmark (the offset) for each notebook. Kafka also resembles the Observer pattern: consumers subscribe to a topic and receive what is published to it.
Entities and relationships
- Message: key + value. The key decides the partition, so the same key always lands in the same partition (keeps per-key order).
- Partition: the physical unit that stores the data — a list (log) of messages plus a lock.
appendreturns the new offset;read_from(offset)reads without removing. - Topic: a name + a list of partitions (aggregation). A logical grouping.
- Broker: the manager; holds
name → Topicand creates topics. A Kafka cluster has several brokers. - Producer: knows the broker; finds the topic, picks the partition from the key, appends.
- Consumer: tracks its own offset per partition; can seek back to replay.
classDiagram
class Broker {
+topics
+create_topic(name, partitions)
}
class Topic {
+name
+partitions
+partition_for(key)
}
class Partition {
+log
+append(msg)
+read_from(offset)
}
class Message {
+key
+value
}
class Producer {
+send(topic, key, value)
}
class Consumer {
+offsets
+poll()
}
Broker *-- Topic
Topic o-- Partition
Partition *-- Message
Producer --> Broker
Consumer --> Topic
Python code
import threading
import zlib
from dataclasses import dataclass
@dataclass(frozen=True)
class Message:
key: str # decides the partition
value: str
class Partition:
"""The physical unit: an append-only log of messages."""
def __init__(self, pid: int):
self.pid = pid
self.log: list[Message] = []
self._lock = threading.Lock() # Java used a ReadWriteLock
def append(self, msg: Message) -> int:
with self._lock: # two writers must not grab the same offset
self.log.append(msg)
return len(self.log) - 1 # offset of the new message
def read_from(self, offset: int) -> list[Message]:
with self._lock:
return self.log[offset:] # reading never removes anything
class Topic:
"""Logical name grouping several partitions (aggregation)."""
def __init__(self, name: str, partitions: int):
self.name = name
self.partitions = [Partition(i) for i in range(partitions)]
def partition_for(self, key: str) -> Partition:
# stable hash: same key -> same partition -> per-key ordering
return self.partitions[zlib.crc32(key.encode()) % len(self.partitions)]
class Broker:
def __init__(self):
self.topics: dict[str, Topic] = {}
def create_topic(self, name: str, partitions: int) -> Topic:
self.topics[name] = Topic(name, partitions)
return self.topics[name]
class Producer:
def __init__(self, broker: Broker):
self.broker = broker
def send(self, topic: str, key: str, value: str) -> tuple[int, int]:
p = self.broker.topics[topic].partition_for(key)
return p.pid, p.append(Message(key, value))
class Consumer:
"""Each consumer remembers its own offset per partition, so it can replay."""
def __init__(self, broker: Broker, topic: str):
self.topic = broker.topics[topic]
self.offsets = {p.pid: 0 for p in self.topic.partitions}
def poll(self) -> list[Message]:
out = []
for p in self.topic.partitions:
msgs = p.read_from(self.offsets[p.pid])
self.offsets[p.pid] += len(msgs)
out.extend(msgs)
return out
def seek_to_beginning(self) -> None: # replay old events
self.offsets = {pid: 0 for pid in self.offsets}
# usage
if __name__ == "__main__":
kafka = Broker()
kafka.create_topic("lld", partitions=3)
prod = Producer(kafka)
for i, user in enumerate(["ajay", "david", "ajay", "sumit"]):
print(prod.send("lld", user, f"event-{i}")) # (partition, offset)
# (1, 0) (0, 0) (1, 1) (1, 2) <- both "ajay" events land in partition 1
cons = Consumer(kafka, "lld")
print([m.value for m in cons.poll()]) # ['event-1', 'event-0', 'event-2', 'event-3']
print(cons.poll()) # [] - nothing new
cons.seek_to_beginning()
print(len(cons.poll())) # 4 - replayedThe class code used a ReentrantReadWriteLock per partition (many readers or one writer). Python's standard library has no read-write lock, so a plain Lock is used. Also, Python's built-in hash() for strings changes between runs, so we use zlib.crc32 to get the same partition every time for the same key (Java's key.hashCode() is already stable).
→
Appending means "put at index size and return that index as the offset". If 5 threads read the same size at the same moment, they all write to the same slot and overwrite each other. The lock makes "read size + append" one step.
→
No. Partitions split the topic's data to spread the load and allow parallel consumers; each holds different messages. Replication (copies for durability) is a separate, internal Kafka feature, and it only makes sense across different servers — copies on the same machine add nothing.
→
It is durable: messages are written to files on disk, which is why you can replay them. And Kafka is not compute: like a queue it does not sort or transform data. It is a storage pipeline/buffer with features such as durability, replication factor, retries and delivery guarantees (e.g. exactly-once).
- Broker → Topics → Partitions → Messages; consumers track offsets.
- Key → partition (stable hash) keeps per-key order.
- Append-only log + offsets = replay; lock the append.
- Partitions spread load; replication is about durability across servers.
- Homework: practise the 7–10 LLD questions covered so far with an editor open; before an interview, revise each one's entities (Splitwise especially). Mock interviews (45 minutes each way, swapping roles) start next week.