InterviewKata
Low-level design · Problem P14 of 15 · Bigger designs

Pub-sub message queue

A small Kafka in one process. It tests whether you understand the log model: messages are kept, not removed, and each consumer group remembers how far it has read. Delivery guarantees fall out of when you commit.

1. The prompt

The prompt"Design an in-memory message queue. Producers publish to topics; several independent consumers read every message; a consumer that crashes must not lose messages."

2. Clarify the requirements

Spend the first five minutes here. Ask, then write the answers down where the interviewer can see them.

AskAssume (unless told otherwise)
Do consumers each get every message, or share them?Each group gets every message; consumers within a group would share (follow-up).
Delivery guarantee?At least once: commit after processing; duplicates are possible after a crash.
Retention?Keep everything for now; trimming is a follow-up.

Must do

  • Publish to a named topic
  • Several consumer groups, each reading every message
  • Poll a batch; commit after processing
  • Report lag

Must be

  • No message lost if a consumer crashes
  • Consumers don't slow down producers
  • Thread-safe

Out of scope

  • Partitions and replication (follow-ups)
  • Persistence to disk
  • Push delivery (poll is simpler and gives back-pressure)

3. The classes

The class diagram: arrows show who uses or holds whom.
ClassIts one job
BrokerTopics and each group's committed offset per topic
TopicAn append-only list of messages
MessageIts offset (position in the log) and value
Consumer groupPolls, processes, commits: outside the broker

4. How it works

5. Patterns and why

PatternWhereWhy it's worth it here
Append-only logTopicReaders never interfere with each other or with writers
Offsets per consumer groupBroker.committedEach group progresses independently; replay is just resetting an offset
Poll, then commitpoll() doesn't advance; commit() doesCrash before commit → redelivery, not loss (at-least-once)
Memory hook: "Keep the log, move the bookmark"Messages stay in the topic; each consumer group just moves its own bookmark forward when it has finished.

6. The code

Offsets are positions in the log. The demo shows two groups, a crash before commit, redelivery, and lag.

from __future__ import annotations
import threading
from dataclasses import dataclass


@dataclass(frozen=True)
class Message:
    offset: int
    value: str


class Topic:
    """An append-only log. Reading never removes anything: each consumer group just remembers how far it got."""

    def __init__(self, name: str) -> None:
        self.name = name
        self.log: list[Message] = []

    def append(self, value: str) -> int:
        self.log.append(Message(len(self.log), value))
        return len(self.log) - 1


class Broker:
    def __init__(self) -> None:
        self.topics: dict[str, Topic] = {}
        self.committed: dict[tuple[str, str], int] = {}   # (group, topic) -> next offset to read
        self.lock = threading.Lock()

    def create_topic(self, name: str) -> None:
        self.topics[name] = Topic(name)

    def publish(self, topic: str, value: str) -> int:
        with self.lock:
            return self.topics[topic].append(value)

    def poll(self, group: str, topic: str, max_messages: int) -> list[Message]:
        """Returns messages after the group's committed offset. Doesn't advance it: the consumer commits after processing."""
        with self.lock:
            start = self.committed.get((group, topic), 0)
            return self.topics[topic].log[start:start + max_messages]

    def commit(self, group: str, topic: str, next_offset: int) -> None:
        with self.lock:
            self.committed[(group, topic)] = max(next_offset, self.committed.get((group, topic), 0))

    def lag(self, group: str, topic: str) -> int:
        return len(self.topics[topic].log) - self.committed.get((group, topic), 0)


def values(msgs: list[Message]) -> str:
    return " ".join(f"{m.offset}:{m.value}" for m in msgs) or "(none)"


if __name__ == "__main__":
    b = Broker()
    b.create_topic("orders")
    for v in ("o-100", "o-101", "o-102", "o-103"):
        b.publish("orders", v)
    got = b.poll("billing", "orders", 2)
    print("billing polls 2:", values(got))
    b.commit("billing", "orders", got[-1].offset + 1)
    print("email polls 3:", values(b.poll("email", "orders", 3)))
    print("email crashes before committing; after restart it polls again")
    got = b.poll("email", "orders", 3)
    print("email polls 3:", values(got))
    b.commit("email", "orders", got[-1].offset + 1)
    print("billing polls 10:", values(b.poll("billing", "orders", 10)))
    print("lag: billing", b.lag("billing", "orders"), "email", b.lag("email", "orders"))
interface Message { offset: number; value: string; }

/** An append-only log. Reading never removes anything: each consumer group just remembers how far it got. */
class Topic {
  readonly log: Message[] = [];
  constructor(readonly name: string) {}
  append(value: string): number {
    this.log.push({ offset: this.log.length, value });
    return this.log.length - 1;
  }
}

class Broker {
  private topics = new Map<string, Topic>();
  private committed = new Map<string, number>(); // "group|topic" -> next offset to read

  createTopic(name: string): void { this.topics.set(name, new Topic(name)); }
  publish(topic: string, value: string): number { return this.topics.get(topic)!.append(value); }

  /** Returns messages after the group's committed offset. Doesn't advance it: the consumer commits after processing. */
  poll(group: string, topic: string, max: number): Message[] {
    const start = this.committed.get(`${group}|${topic}`) ?? 0;
    return this.topics.get(topic)!.log.slice(start, start + max);
  }

  commit(group: string, topic: string, nextOffset: number): void {
    const k = `${group}|${topic}`;
    this.committed.set(k, Math.max(nextOffset, this.committed.get(k) ?? 0));
  }

  lag(group: string, topic: string): number {
    return this.topics.get(topic)!.log.length - (this.committed.get(`${group}|${topic}`) ?? 0);
  }
}

const values = (ms: Message[]) => ms.map((m) => `${m.offset}:${m.value}`).join(" ") || "(none)";

const b = new Broker();
b.createTopic("orders");
for (const v of ["o-100", "o-101", "o-102", "o-103"]) b.publish("orders", v);
let got = b.poll("billing", "orders", 2);
console.log("billing polls 2:", values(got));
b.commit("billing", "orders", got[got.length - 1].offset + 1);
console.log("email polls 3:", values(b.poll("email", "orders", 3)));
console.log("email crashes before committing; after restart it polls again");
got = b.poll("email", "orders", 3);
console.log("email polls 3:", values(got));
b.commit("email", "orders", got[got.length - 1].offset + 1);
console.log("billing polls 10:", values(b.poll("billing", "orders", 10)));
console.log("lag: billing", b.lag("billing", "orders"), "email", b.lag("email", "orders"));
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

record Message(int offset, String value) {}

/** An append-only log. Reading never removes anything: each consumer group just remembers how far it got. */
class Topic {
    final String name;
    final List<Message> log = new ArrayList<>();

    Topic(String name) {
        this.name = name;
    }

    int append(String value) {
        log.add(new Message(log.size(), value));
        return log.size() - 1;
    }
}

class Broker {
    private final Map<String, Topic> topics = new HashMap<>();
    private final Map<String, Integer> committed = new HashMap<>(); // "group|topic" -> next offset to read

    synchronized void createTopic(String name) {
        topics.put(name, new Topic(name));
    }

    synchronized int publish(String topic, String value) {
        return topics.get(topic).append(value);
    }

    /** Returns messages after the group's committed offset. Doesn't advance it: the consumer commits after processing. */
    synchronized List<Message> poll(String group, String topic, int max) {
        List<Message> log = topics.get(topic).log;
        int start = committed.getOrDefault(group + "|" + topic, 0);
        return List.copyOf(log.subList(start, Math.min(log.size(), start + max)));
    }

    synchronized void commit(String group, String topic, int nextOffset) {
        committed.merge(group + "|" + topic, nextOffset, Math::max);
    }

    synchronized int lag(String group, String topic) {
        return topics.get(topic).log.size() - committed.getOrDefault(group + "|" + topic, 0);
    }
}

public class Main {
    static String values(List<Message> ms) {
        return ms.isEmpty() ? "(none)" : ms.stream().map(m -> m.offset() + ":" + m.value()).collect(Collectors.joining(" "));
    }

    public static void main(String[] args) {
        Broker b = new Broker();
        b.createTopic("orders");
        for (String v : List.of("o-100", "o-101", "o-102", "o-103")) b.publish("orders", v);
        List<Message> got = b.poll("billing", "orders", 2);
        System.out.println("billing polls 2: " + values(got));
        b.commit("billing", "orders", got.get(got.size() - 1).offset() + 1);
        System.out.println("email polls 3: " + values(b.poll("email", "orders", 3)));
        System.out.println("email crashes before committing; after restart it polls again");
        got = b.poll("email", "orders", 3);
        System.out.println("email polls 3: " + values(got));
        b.commit("email", "orders", got.get(got.size() - 1).offset() + 1);
        System.out.println("billing polls 10: " + values(b.poll("billing", "orders", 10)));
        System.out.println("lag: billing " + b.lag("billing", "orders") + " email " + b.lag("email", "orders"));
    }
}

Output (the same in all three languages)

billing polls 2: 0:o-100 1:o-101
email polls 3: 0:o-100 1:o-101 2:o-102
email crashes before committing; after restart it polls again
email polls 3: 0:o-100 1:o-101 2:o-102
billing polls 10: 2:o-102 3:o-103
lag: billing 2 email 1

7. Concurrency and edge cases

8. Follow-up questions

What interviewers ask once the core works, and a short answer for each.

How would consumers in one group share the work?
Split a topic into partitions; assign each partition to one consumer in the group; offsets are per partition.
Push instead of poll?
The broker calls subscribers; then you need per-subscriber queues and back-pressure. Polling lets slow consumers set their own pace.
Message ordering?
Guaranteed within a partition only; use a key (e.g. order id) to route related messages to one partition.
Dead letters?
After N failed attempts, publish the message to a dead-letter topic and commit, so one bad message doesn't block the group.

9. Common mistakes

Deleting messages when one consumer reads themThen other groups never see them, and a crash loses them.
Committing before processingA crash after commit loses the message: at-most-once.
One offset for everyoneGroups must progress independently.

10. Check yourself

A consumer crashes after polling but before committing. What happens on restart?

At-least-once: the committed offset didn't move.

Why is the topic append-only?

Reading doesn't remove; each group has its own position.

What does lag measure?

Log end minus committed offset.

Your turn

  1. Add partitions (20 min). Route by key; offsets per partition.
  2. Add a dead-letter topic (10 min). Move a message there after three failed attempts.