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.
| Ask | Assume (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
| Class | Its one job |
|---|---|
| Broker | Topics and each group's committed offset per topic |
| Topic | An append-only list of messages |
| Message | Its offset (position in the log) and value |
| Consumer group | Polls, processes, commits: outside the broker |
4. How it works
5. Patterns and why
| Pattern | Where | Why it's worth it here |
|---|---|---|
| Append-only log | Topic | Readers never interfere with each other or with writers |
| Offsets per consumer group | Broker.committed | Each group progresses independently; replay is just resetting an offset |
| Poll, then commit | poll() doesn't advance; commit() does | Crash 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
- Publish and poll at the same time: appends and reads of the list must be synchronized (the code locks the broker; a real log uses one writer per partition).
- Commit is monotonic: committing an older offset (a slow, stale consumer) must not move the bookmark back, hence the max().
- Exactly once needs the consumer's own write and the commit to be atomic, or idempotent processing (dedupe by message id).
- Unbounded memory: real logs delete old segments by time or size; consumers that fall behind retention lose data.
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
- Add partitions (20 min). Route by key; offsets per partition.
- Add a dead-letter topic (10 min). Move a message there after three failed attempts.