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

Real-life picture

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

Remember

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

  1. 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.
  2. A ticker (in Java a ScheduledExecutorService.scheduleAtFixedRate, every 100 ms) calls poll().
  3. 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).
  4. A worker runs run(task): execute, mark COMPLETED, call on_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

Python
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 vs Python

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.

Pitfall:

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.

Doubt: Why keep the tasks in a queue?
→

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.

Doubt: How does an alarm know it is 11 AM?
→

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.

Key takeaways
  • 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.

Real-life picture

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

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

Python
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 vs Python

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)

Doubt: Why is an unbounded task queue a bad idea?
→

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.

Doubt: What does Java's Executors.newFixedThreadPool actually do?
→

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.

Key takeaways
  • 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.

Real-life picture

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.

Remember

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
Python
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 full
Java vs Python

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

Key takeaways
  • 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.

Real-life picture

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

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

Python
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 - replayed
Java vs Python

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

Doubt: Why does a partition need a lock?
→

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.

Doubt: Are partition 1, 2, 3 of a topic copies of each other?
→

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.

Doubt: Is Kafka in-memory? Can it sort the messages?
→

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

Key takeaways
  • 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.