Low Level DesignWorked problems
Design an in-memory pub-sub system
Design an in-memory publish-subscribe broker step by step for a low level design interview. Grow from handlers called in a loop to a mailbox and worker per subscriber, bounded mailboxes with a choice of backpressure, retries and dead letters, and one order per topic when many threads publish at once, with code in Java, Python, C#, Go and TypeScript.
- Read first
- ObserverThread safety basics
- Patterns
- Observer
“Design an in-memory pub-sub system: publishers send messages to topics, and subscribers to a topic receive them.” Then, almost always: “what happens when a subscriber is slow?”
Pub-sub is short for publish-subscribe. Code that has news (an order was placed) publishes a message to a named topic, like “orders”. Code that cares (the email sender, the warehouse) subscribes to that topic and receives every message. Neither side knows the other.
The first version is the Observer pattern, and it fits on a whiteboard: a map from topic to a list of handlers, and a publish that calls each one. The real question is what happens around it. Handlers are slow, they fail, they fall behind, and several threads publish at once. Each of those becomes a design decision, and the interviewer wants to hear each one named and made.
The answer in one minute
Section titled “The answer in one minute”If you only have a minute, this is the design. The rest of the page explains how we get here.
- Each subscriber gets its own mailbox and its own worker. Publishing only drops the message into mailboxes and returns. So a slow or failing subscriber only delays itself.
- Every mailbox has a size limit. When it’s full, the subscriber has chosen what happens: make the publisher wait, drop the oldest message, or drop the newest. A mailbox with no limit just means “out of memory, later”.
- A failing message is retried, then set aside. After a few tries, it becomes a dead letter, kept for someone to inspect. One bad message can’t block a subscriber forever, and nothing disappears without a record.
- One worker per subscriber keeps each subscriber’s order. Messages are handled one at a time, in the order they arrived.
- Each topic numbers and hands out its messages one publish at a time. So when many threads publish, every subscriber still sees the same order.
We’ll build this in four stages, then deal with many threads publishing to one topic.
Scope it
Section titled “Scope it”Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.
Design an in-memory pub-sub system.
Whether you ask about delivery guarantees and slow subscribers before drawing classes. “In memory, one process” rules out saving to disk and replaying, and candidates who ask spend their time on the parts that are actually being tested.
Here are the questions that matter:
- One process, or across machines? One process, in memory. Across machines is Kafka or a cloud queue, which is a system design question.
- Must messages survive a restart? No. Surviving a restart would need a log on disk.
- Do all subscribers need to see the same order? Yes, within a topic.
- What happens when a subscriber is slow? It’s the subscriber’s choice: slow the publisher down, or drop messages. One answer for everyone would be wrong for someone.
- Do late subscribers get old messages? No, only messages published after they subscribe.
These answers give us one promise that matters more than the others: a slow or failing subscriber never delays or breaks the others, or the publisher, beyond the limit it chose. Three smaller promises come with it. Every subscriber of a topic sees its messages in the same order. No subscriber can use unlimited memory. And closing the broker delivers what was already accepted, then stops.
We leave out saving to disk, replay, delivery across processes, and wildcard topics.
Go deeperThree more questions, and what each changes1 min
| Question | We assume | If the answer were different |
|---|---|---|
Wildcard topics (orders.*)? |
No, exact names | Matching moves into the topic lookup; delivery doesn’t change |
| Message filters? | No | A check per subscription, before the mailbox |
| Delivery guarantee? | At most once on a crash, at least once on errors | Exactly once needs handlers that are safe to repeat, as everywhere else |
Build it
Section titled “Build it”We’ll build the design in four stages. Each stage adds only the classes that a new requirement needs. In each diagram, the new parts are outlined, and the colors show what each class does (the key is under every diagram).
Stage 1: A map from topic to handlers
Section titled “Stage 1: A map from topic to handlers”Start with the simplest thing: an order service publishes “order placed”, and the email sender and the warehouse both need it. What are your classes?
Whether you reach the Observer shape in two minutes, and then name its problems yourself: the publisher’s thread runs every handler. The interviewer is about to ask about a slow handler, and it’s better if you get there first.
Start with the smallest thing that works. A Message has a topic, a payload, and a number that
counts up within its topic, so a subscriber can tell if it missed one. A Subscriber is anything
with an onMessage method. The Broker keeps a list of subscribers for each topic, and publish
builds the message and calls each subscriber in turn. This is the Observer pattern: publishers
don’t know who’s listening.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// One published message. The sequence number is per topic, so a subscriber can tell whether it// missed something or got something twice.record Message(String topic, long seq, String payload) {}
// Whatever wants messages: an email sender, a cache invalidator, a metrics counter.interface Subscriber { void onMessage(Message m);}
class Broker { private final Map<String, List<Subscriber>> topics = new HashMap<>(); private final Map<String, Long> nextSeq = new HashMap<>();
void subscribe(String topic, Subscriber s) { topics.computeIfAbsent(topic, k -> new ArrayList<>()).add(s); }
// flaw 1: the publisher's thread runs every handler. An email sender waiting on a mail // server slows every publish, and every subscriber after it waits too. // flaw 2: a handler that throws stops delivery to the subscribers after it, and the // exception lands in the publisher, which did nothing wrong. // flaw 3: nothing is safe across threads: two publishers can hand out the same sequence // number, and a subscribe during a publish breaks the loop. Stage 5. void publish(String topic, String payload) { long seq = nextSeq.merge(topic, 1L, Long::sum) - 1; Message m = new Message(topic, seq, payload); for (Subscriber s : topics.getOrDefault(topic, List.of())) s.onMessage(m); }}from dataclasses import dataclassfrom typing import Callable
# One published message. The sequence number is per topic, so a subscriber can tell whether it# missed something or got something twice.@dataclass(frozen=True)class Message: topic: str seq: int payload: str
# Whatever wants messages: an email sender, a cache invalidator, a metrics counter.Subscriber = Callable[[Message], None]
class Broker: def __init__(self): self._topics: dict[str, list[Subscriber]] = {} self._next_seq: dict[str, int] = {}
def subscribe(self, topic: str, s: Subscriber) -> None: self._topics.setdefault(topic, []).append(s)
# flaw 1: the publisher's thread runs every handler. An email sender waiting on a mail # server slows every publish, and every subscriber after it waits too. # flaw 2: a handler that raises stops delivery to the subscribers after it, and the # exception lands in the publisher, which did nothing wrong. # flaw 3: nothing is safe across threads: two publishers can hand out the same sequence # number, and a subscribe during a publish changes the list being looped over. Stage 5. def publish(self, topic: str, payload: str) -> None: seq = self._next_seq.get(topic, 0) self._next_seq[topic] = seq + 1 m = Message(topic, seq, payload) for s in self._topics.get(topic, []): s(m)// One published message. The sequence number is per topic, so a subscriber can tell whether it// missed something or got something twice.public sealed record Message(string Topic, long Seq, string Payload);
// Whatever wants messages: an email sender, a cache invalidator, a metrics counter.public delegate Task Subscriber(Message m);
public sealed class Broker{ private readonly Dictionary<string, List<Subscriber>> _topics = []; private readonly Dictionary<string, long> _nextSeq = [];
public void Subscribe(string topic, Subscriber s) { if (!_topics.TryGetValue(topic, out var list)) _topics[topic] = list = []; list.Add(s); }
// flaw 1: the publisher waits for every handler. An email sender waiting on a mail server // slows every publish, and every subscriber after it waits too. // flaw 2: a handler that throws stops delivery to the subscribers after it, and the // exception lands in the publisher, which did nothing wrong. // flaw 3: nothing is safe across threads: two publishers can hand out the same sequence // number, and a subscribe during a publish breaks the loop. Stage 5. public async Task Publish(string topic, string payload) { var seq = _nextSeq.GetValueOrDefault(topic); _nextSeq[topic] = seq + 1; var m = new Message(topic, seq, payload); foreach (var s in _topics.GetValueOrDefault(topic) ?? []) await s(m); }}// Message is one published message. The sequence number is per topic, so a subscriber can tell// whether it missed something or got something twice.type Message struct { Topic string Seq int64 Payload string}
// Subscriber is whatever wants messages: an email sender, a cache invalidator, a counter.type Subscriber func(Message) error
type Broker struct { topics map[string][]Subscriber nextSeq map[string]int64}
func NewBroker() *Broker { return &Broker{topics: map[string][]Subscriber{}, nextSeq: map[string]int64{}}}
func (b *Broker) Subscribe(topic string, s Subscriber) { b.topics[topic] = append(b.topics[topic], s)}
// Publish runs every handler on the publisher's goroutine (flaw 1): an email sender waiting on// a mail server slows every publish, and every subscriber after it waits too. A handler's// error stops delivery to the ones after it and lands on the publisher, which did nothing// wrong (flaw 2). And nothing is safe across goroutines (flaw 3): two publishers can hand out// the same sequence number. Stage 5.func (b *Broker) Publish(topic, payload string) error { m := Message{topic, b.nextSeq[topic], payload} b.nextSeq[topic]++ for _, s := range b.topics[topic] { if err := s(m); err != nil { return err } } return nil}// One published message. The sequence number is per topic, so a subscriber can tell whether it// missed something or got something twice.interface Message { readonly topic: string; readonly seq: number; readonly payload: string;}
// Whatever wants messages: an email sender, a cache invalidator, a metrics counter.type Subscriber = (m: Message) => void | Promise<void>;
class Broker { private readonly topics = new Map<string, Subscriber[]>(); private readonly nextSeq = new Map<string, number>();
subscribe(topic: string, s: Subscriber): void { const list = this.topics.get(topic) ?? []; list.push(s); this.topics.set(topic, list); }
// flaw 1: the publisher waits for every handler. An email sender waiting on a mail server // slows every publish, and every subscriber after it waits too. // flaw 2: a handler that throws stops delivery to the subscribers after it, and the error // lands in the publisher, which did nothing wrong. // flaw 3: each await is a point where another publish can run, so two publishers' messages // can reach subscribers in different orders. Stage 5. async publish(topic: string, payload: string): Promise<void> { const seq = this.nextSeq.get(topic) ?? 0; this.nextSeq.set(topic, seq + 1); for (const s of this.topics.get(topic) ?? []) await s({ topic, seq, payload }); }}It works, but it has three problems, and we leave them in on purpose. The publisher’s own thread runs every handler. So an email sender waiting on a mail server slows every publish, and every subscriber after it waits too. A handler that throws an error stops delivery to the ones after it, and the error lands in the publisher’s lap. And nothing is safe when several threads publish at once.
Stage 2: A mailbox and a worker per subscriber
Section titled “Stage 2: A mailbox and a worker per subscriber”The email sender takes two seconds per message. The order service publishes fifty orders a second. What happens, and how do you fix it?
Whether you separate publishing from delivering. A thread pool shared by all subscribers is the common first answer. They’ll ask whether a subscriber’s messages can then arrive out of order, and what one slow subscriber does to the pool.
Let’s do the arithmetic. Fifty orders a second, two seconds each, means the email sender needs 100 seconds of work every second. With stage 1, the order service would grind to a halt.
So we separate publishing from delivering. Each subscription gets a mailbox (a queue) and one worker thread that takes messages out and calls the handler. Publishing only puts the message in each subscriber’s mailbox, and returns. The order service never waits on email, and the warehouse never waits behind the email sender. A queue between a fast producer and a slower consumer like this is called the producer-consumer pattern.
Why one worker per subscription, and not a shared pool of threads? A pool could handle two messages for the same subscriber at the same time. Then “order cancelled” could be handled before “order placed”. A single worker handles one message at a time, in the order they arrived.
A handler that throws now loses only that one message, which is counted, and the worker moves on. Closing a subscription puts a stop marker at the back of its mailbox. Everything accepted before the close is still delivered, and then the worker ends.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// A subscriber's mailbox and the one thread that empties it. Publishing only drops the message// in the mailbox, so the publisher never waits on a handler. One thread per subscription keeps// each subscriber's messages in the order they were published, and one slow subscriber only// delays itself.class Subscription { private static final Message STOP = new Message("", -1, ""); // queued last by close()
final String id; final String topic; private final Subscriber handler; private final BlockingQueue<Message> mailbox = new LinkedBlockingQueue<>(); private final Thread worker; final AtomicLong failed = new AtomicLong();
Subscription(String id, String topic, Subscriber handler) { this.id = id; this.topic = topic; this.handler = handler; this.worker = new Thread(this::run, "subscription-" + id); worker.start(); }
void offer(Message m) { mailbox.add(m); }
// Close after delivering everything already in the mailbox: STOP goes in behind it, and the // worker exits when it gets there. void close() throws InterruptedException { mailbox.add(STOP); worker.join(); }
private void run() { try { for (Message m = mailbox.take(); m != STOP; m = mailbox.take()) deliver(m); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }
// A handler that throws loses that message, not the subscription: the worker carries on // with the next one. Stage 4 retries it instead. private void deliver(Message m) { try { handler.onMessage(m); } catch (RuntimeException e) { failed.incrementAndGet(); } }}
class Broker { private final Map<String, List<Subscription>> topics = new HashMap<>(); private final Map<String, Long> nextSeq = new HashMap<>(); private final Supplier<String> ids;
Broker(Supplier<String> ids) { this.ids = ids; }
Subscription subscribe(String topic, Subscriber handler) { Subscription s = new Subscription(ids.get(), topic, handler); topics.computeIfAbsent(topic, k -> new ArrayList<>()).add(s); return s; }
// Nothing new arrives after this; what's already in the mailbox is still delivered. void unsubscribe(Subscription s) throws InterruptedException { topics.getOrDefault(s.topic, new ArrayList<>()).remove(s); s.close(); }
// flaw: a mailbox has no limit. A subscriber that falls behind grows its mailbox until the // process runs out of memory, and nobody notices until then. Stage 3. void publish(String topic, String payload) { long seq = nextSeq.merge(topic, 1L, Long::sum) - 1; Message m = new Message(topic, seq, payload); for (Subscription s : topics.getOrDefault(topic, List.of())) s.offer(m); }
void close() throws InterruptedException { for (List<Subscription> subs : topics.values()) for (Subscription s : subs) s.close(); }}import queueimport threading
_STOP = Message("", -1, "") # queued last by close()
# A subscriber's mailbox and the one thread that empties it. Publishing only drops the message# in the mailbox, so the publisher never waits on a handler. One thread per subscription keeps# each subscriber's messages in the order they were published, and one slow subscriber only# delays itself.class Subscription: def __init__(self, id: str, topic: str, handler: Subscriber): self.id = id self.topic = topic self._handler = handler self._mailbox: queue.Queue[Message] = queue.Queue() self.failed = 0 # only the worker writes it self._worker = threading.Thread(target=self._run, name=f"subscription-{id}") self._worker.start()
def offer(self, m: Message) -> None: self._mailbox.put(m)
# Close after delivering everything already in the mailbox: _STOP goes in behind it, and # the worker exits when it gets there. def close(self) -> None: self._mailbox.put(_STOP) self._worker.join()
def _run(self) -> None: while (m := self._mailbox.get()) is not _STOP: self._deliver(m)
# A handler that raises loses that message, not the subscription: the worker carries on # with the next one. Stage 4 retries it instead. def _deliver(self, m: Message) -> None: try: self._handler(m) except Exception: self.failed += 1
class Broker: def __init__(self, ids: Callable[[], str]): self._topics: dict[str, list[Subscription]] = {} self._next_seq: dict[str, int] = {} self._ids = ids
def subscribe(self, topic: str, handler: Subscriber) -> Subscription: s = Subscription(self._ids(), topic, handler) self._topics.setdefault(topic, []).append(s) return s
# Nothing new arrives after this; what's already in the mailbox is still delivered. def unsubscribe(self, s: Subscription) -> None: self._topics.get(s.topic, []).remove(s) s.close()
# flaw: a mailbox has no limit. A subscriber that falls behind grows its mailbox until the # process runs out of memory, and nobody notices until then. Stage 3. def publish(self, topic: str, payload: str) -> None: seq = self._next_seq.get(topic, 0) self._next_seq[topic] = seq + 1 m = Message(topic, seq, payload) for s in self._topics.get(topic, []): s.offer(m)
def close(self) -> None: for subs in self._topics.values(): for s in subs: s.close()// A subscriber's mailbox and the one task that empties it. A Channel is both: publishing writes// to it, and the worker reads from it. The publisher never waits on a handler, each subscriber// gets its messages in the order they were published, and one slow subscriber only delays// itself.public sealed class Subscription{ private readonly Subscriber _handler; private readonly Channel<Message> _mailbox = Channel.CreateUnbounded<Message>(new() { SingleReader = true }); private readonly Task _worker; private long _failed;
public string Id { get; } public string Topic { get; } public long Failed => Interlocked.Read(ref _failed);
public Subscription(string id, string topic, Subscriber handler) { Id = id; Topic = topic; _handler = handler; _worker = Task.Run(Run); }
public void Offer(Message m) => _mailbox.Writer.TryWrite(m);
// Close after delivering everything already in the mailbox: completing the writer ends the // worker's loop once the channel is drained. public Task Close() { _mailbox.Writer.TryComplete(); return _worker; }
private async Task Run() { await foreach (var m in _mailbox.Reader.ReadAllAsync()) { // A handler that throws loses that message, not the subscription. Stage 4 retries it. try { await _handler(m); } catch (Exception) { Interlocked.Increment(ref _failed); } } }}
public sealed class Broker(Func<string> ids){ private readonly Dictionary<string, List<Subscription>> _topics = []; private readonly Dictionary<string, long> _nextSeq = [];
public Subscription Subscribe(string topic, Subscriber handler) { var s = new Subscription(ids(), topic, handler); if (!_topics.TryGetValue(topic, out var list)) _topics[topic] = list = []; list.Add(s); return s; }
// Nothing new arrives after this; what's already in the mailbox is still delivered. public Task Unsubscribe(Subscription s) { _topics.GetValueOrDefault(s.Topic)?.Remove(s); return s.Close(); }
// flaw: a mailbox has no limit. A subscriber that falls behind grows its mailbox until the // process runs out of memory, and nobody notices until then. Stage 3. public void Publish(string topic, string payload) { var seq = _nextSeq.GetValueOrDefault(topic); _nextSeq[topic] = seq + 1; var m = new Message(topic, seq, payload); foreach (var s in _topics.GetValueOrDefault(topic) ?? []) s.Offer(m); }
public async Task Close() { foreach (var s in _topics.Values.SelectMany(x => x)) await s.Close(); }}// Subscription is a subscriber's mailbox and the one goroutine that empties it. A channel is// both: publishing sends to it, and the worker ranges over it. The publisher never waits on a// handler, each subscriber gets its messages in the order they were published, and one slow// subscriber only delays itself.type Subscription struct { ID string Topic string handler Subscriber mailbox chan Message done chan struct{} failed atomic.Int64}
func newSubscription(id, topic string, handler Subscriber) *Subscription { // Large enough to stand in for "unbounded"; stage 3 makes the size a choice. s := &Subscription{ID: id, Topic: topic, handler: handler, mailbox: make(chan Message, 1<<16), done: make(chan struct{})} go s.run() return s}
func (s *Subscription) offer(m Message) { s.mailbox <- m }
// Close delivers everything already in the mailbox, then stops: closing the channel ends the// worker's range once it is drained.func (s *Subscription) Close() { close(s.mailbox) <-s.done}
func (s *Subscription) run() { defer close(s.done) for m := range s.mailbox { // A handler that fails loses that message, not the subscription. Stage 4 retries it. if s.handler(m) != nil { s.failed.Add(1) } }}
type Broker struct { topics map[string][]*Subscription nextSeq map[string]int64 ids func() string}
func NewBroker(ids func() string) *Broker { return &Broker{topics: map[string][]*Subscription{}, nextSeq: map[string]int64{}, ids: ids}}
func (b *Broker) Subscribe(topic string, handler Subscriber) *Subscription { s := newSubscription(b.ids(), topic, handler) b.topics[topic] = append(b.topics[topic], s) return s}
// Unsubscribe means nothing new arrives; what's already in the mailbox is still delivered.func (b *Broker) Unsubscribe(s *Subscription) { b.topics[s.Topic] = slices.DeleteFunc(b.topics[s.Topic], func(x *Subscription) bool { return x == s }) s.Close()}
// Publish only fills mailboxes. flaw: a subscriber that falls behind fills its mailbox, and// then the publisher blocks with no say in it. Stage 3.func (b *Broker) Publish(topic, payload string) { m := Message{topic, b.nextSeq[topic], payload} b.nextSeq[topic]++ for _, s := range b.topics[topic] { s.offer(m) }}
func (b *Broker) Close() { for _, subs := range b.topics { for _, s := range subs { s.Close() } }}// A subscriber's mailbox and the loop that empties it. Publishing only drops the message in the// mailbox, so the publisher never waits on a handler. One loop per subscription keeps each// subscriber's messages in the order they were published, and one slow subscriber (awaiting a// mail server) only delays itself. A handler that burns CPU without awaiting still blocks the// whole process: that's Node, not the design.class Subscription { private readonly mailbox: Message[] = []; private pumping: Promise<void> = Promise.resolve(); private running = false; failed = 0;
constructor( readonly id: string, readonly topic: string, private readonly handler: Subscriber, ) {}
offer(m: Message): void { this.mailbox.push(m); this.start(); }
// Resolves once everything already in the mailbox has been delivered. async close(): Promise<void> { await this.pumping; }
protected start(): void { if (this.running) return; this.running = true; this.pumping = this.pump(); }
private async pump(): Promise<void> { while (this.mailbox.length > 0) { const m = this.mailbox.shift()!; // A handler that throws loses that message, not the subscription: the loop carries on // with the next one. Stage 4 retries it instead. try { await this.handler(m); } catch { this.failed++; } } this.running = false; }}
class Broker { private readonly topics = new Map<string, Subscription[]>(); private readonly nextSeq = new Map<string, number>();
constructor(private readonly ids: () => string) {}
subscribe(topic: string, handler: Subscriber): Subscription { const s = new Subscription(this.ids(), topic, handler); this.topics.set(topic, [...(this.topics.get(topic) ?? []), s]); return s; }
// Nothing new arrives after this; what's already in the mailbox is still delivered. async unsubscribe(s: Subscription): Promise<void> { this.topics.set( s.topic, (this.topics.get(s.topic) ?? []).filter((x) => x !== s), ); await s.close(); }
// flaw: a mailbox has no limit. A subscriber that falls behind grows its mailbox until the // process runs out of memory, and nobody notices until then. Stage 3. publish(topic: string, payload: string): void { const seq = this.nextSeq.get(topic) ?? 0; this.nextSeq.set(topic, seq + 1); for (const s of this.topics.get(topic) ?? []) s.offer({ topic, seq, payload }); }
async close(): Promise<void> { for (const subs of this.topics.values()) for (const s of subs) await s.close(); }}What does a thread per subscription cost? Ordinary threads don’t scale to tens of thousands of subscribers. But lightweight threads make it cheap: virtual threads in Java, goroutines in Go, and async loops in Node and C#.
Go deeperPublishing returns before anyone handles it1 min
The order service publishes. The broker puts the message in the email mailbox and the warehouse mailbox, and returns right away. Later, each worker takes its message out and calls its handler, at its own pace. The email worker might be two seconds behind, and nobody else notices.
- Color key
- Background worker
- Facade
Go deeperFollowing one order through the system2 min
Let’s follow one message, start to finish, with two subscribers on “orders”: the email sender and the warehouse.
- The order service calls
publish("orders", order 41). The broker finds the “orders” topic and builds a message with number 41. - The broker puts message 41 at the back of the email mailbox, then at the back of the warehouse mailbox. Both are quick: adding to a queue takes microseconds.
publishreturns. The order service goes on to order 42. Total time: a few microseconds, no matter how slow the handlers are.- The warehouse worker was idle, waiting on its empty mailbox. It wakes up, takes message 41 out, and calls the warehouse handler. That takes 20 milliseconds.
- The email worker is still busy with message 39, which is waiting on the mail server. Messages 40 and 41 sit in its mailbox. When 39 finishes, the worker takes 40, then 41, in that order.
Nobody waits for anybody else. The only shared thing is the moment of adding to a mailbox, and that’s tiny. This is also why a mailbox needs a limit: in step 5, nothing stops the email mailbox from growing while the mail server is slow. That’s stage 3.
Stage 3: Size limits and backpressure
Section titled “Stage 3: Size limits and backpressure”The email sender is down for an hour. Its mailbox now has 180,000 messages, and the process is using 4 GB. What should have happened?
Whether you know that a queue with no limit only delays the problem, and can name the choices for a full queue: block the producer, drop old, or drop new. Strong candidates say who should choose, and that drops must be counted.
Every mailbox now has a capacity, and each subscription chooses what happens when it’s full:
- Block: the publisher waits until there’s room. Nothing is lost, and the publisher slows down to the pace of the slowest blocking subscriber. This is called backpressure: the slowness shows up where the messages come from, instead of quietly turning into memory.
- Drop oldest: throw away the oldest waiting message to make room. Right for a price feed, where only the latest price matters.
- Drop newest: throw away the message being published. Right when the messages already waiting matter more, and new ones will come again.
Who should choose? The subscriber, because it knows what its messages mean. Every drop is counted on the subscription, so a dashboard can raise an alert when a subscriber starts losing messages.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// What to do when a subscriber's mailbox is full. There is no right answer for every// subscriber, so each one chooses when it subscribes.enum Overflow { BLOCK, // the publisher waits for room: nothing is lost, and a slow subscriber slows publishing DROP_OLDEST, // make room by discarding the oldest waiting message: fine for "latest price" feeds DROP_NEWEST // discard the message being published: the backlog is delivered in full}
record Options(int capacity, Overflow overflow) {}
class Subscription { private static final Message STOP = new Message("", -1, "");
final String id; final String topic; private final Subscriber handler; private final Overflow overflow; private final BlockingQueue<Message> mailbox; private final Thread worker; final AtomicLong failed = new AtomicLong(); final AtomicLong dropped = new AtomicLong(); // a number someone can alert on
Subscription(String id, String topic, Subscriber handler, Options options) { this.id = id; this.topic = topic; this.handler = handler; this.overflow = options.overflow(); this.mailbox = new ArrayBlockingQueue<>(options.capacity()); this.worker = new Thread(this::run, "subscription-" + id); worker.start(); }
void offer(Message m) throws InterruptedException { switch (overflow) { case BLOCK -> mailbox.put(m); case DROP_NEWEST -> { if (!mailbox.offer(m)) dropped.incrementAndGet(); } case DROP_OLDEST -> { // The worker may take a message between the two calls, so loop until m fits. while (!mailbox.offer(m)) if (mailbox.poll() != null) dropped.incrementAndGet(); } } }
void close() throws InterruptedException { mailbox.put(STOP); worker.join(); }
private void run() { try { for (Message m = mailbox.take(); m != STOP; m = mailbox.take()) deliver(m); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }
private void deliver(Message m) { try { handler.onMessage(m); } catch (RuntimeException e) { failed.incrementAndGet(); } }}
class Broker { private final Map<String, List<Subscription>> topics = new HashMap<>(); private final Map<String, Long> nextSeq = new HashMap<>(); private final Supplier<String> ids;
Broker(Supplier<String> ids) { this.ids = ids; }
Subscription subscribe(String topic, Subscriber handler, Options options) { Subscription s = new Subscription(ids.get(), topic, handler, options); topics.computeIfAbsent(topic, k -> new ArrayList<>()).add(s); return s; }
void unsubscribe(Subscription s) throws InterruptedException { topics.getOrDefault(s.topic, new ArrayList<>()).remove(s); s.close(); }
// With BLOCK, this waits while any subscriber's mailbox is full. That is the backpressure: // the publisher slows to the pace of its slowest blocking subscriber. void publish(String topic, String payload) throws InterruptedException { long seq = nextSeq.merge(topic, 1L, Long::sum) - 1; Message m = new Message(topic, seq, payload); for (Subscription s : topics.getOrDefault(topic, List.of())) s.offer(m); }
void close() throws InterruptedException { for (List<Subscription> subs : topics.values()) for (Subscription s : subs) s.close(); }}from enum import Enum, auto
# What to do when a subscriber's mailbox is full. There is no right answer for every# subscriber, so each one chooses when it subscribes.class Overflow(Enum): BLOCK = auto() # the publisher waits for room: nothing is lost, and a slow subscriber slows publishing DROP_OLDEST = auto() # make room by discarding the oldest waiting message: fine for "latest price" feeds DROP_NEWEST = auto() # discard the message being published: the backlog is delivered in full
@dataclass(frozen=True)class Options: capacity: int overflow: Overflow
class Subscription: def __init__(self, id: str, topic: str, handler: Subscriber, options: Options): self.id = id self.topic = topic self._handler = handler self._overflow = options.overflow self._mailbox: queue.Queue[Message] = queue.Queue(maxsize=options.capacity) self.failed = 0 self.dropped = 0 # a number someone can alert on; only publishers write it self._worker = threading.Thread(target=self._run, name=f"subscription-{id}") self._worker.start()
def offer(self, m: Message) -> None: if self._overflow is Overflow.BLOCK: self._mailbox.put(m) elif self._overflow is Overflow.DROP_NEWEST: try: self._mailbox.put_nowait(m) except queue.Full: self.dropped += 1 else: # The worker may take a message between the two calls, so loop until m fits. while True: try: self._mailbox.put_nowait(m) return except queue.Full: try: self._mailbox.get_nowait() self.dropped += 1 except queue.Empty: pass
def close(self) -> None: self._mailbox.put(_STOP) self._worker.join()
def _run(self) -> None: while (m := self._mailbox.get()) is not _STOP: self._deliver(m)
def _deliver(self, m: Message) -> None: try: self._handler(m) except Exception: self.failed += 1
class Broker: def __init__(self, ids: Callable[[], str]): self._topics: dict[str, list[Subscription]] = {} self._next_seq: dict[str, int] = {} self._ids = ids
def subscribe(self, topic: str, handler: Subscriber, options: Options) -> Subscription: s = Subscription(self._ids(), topic, handler, options) self._topics.setdefault(topic, []).append(s) return s
def unsubscribe(self, s: Subscription) -> None: self._topics.get(s.topic, []).remove(s) s.close()
# With BLOCK, this waits while any subscriber's mailbox is full. That is the backpressure: # the publisher slows to the pace of its slowest blocking subscriber. def publish(self, topic: str, payload: str) -> None: seq = self._next_seq.get(topic, 0) self._next_seq[topic] = seq + 1 m = Message(topic, seq, payload) for s in self._topics.get(topic, []): s.offer(m)
def close(self) -> None: for subs in self._topics.values(): for s in subs: s.close()// What to do when a subscriber's mailbox is full. There is no right answer for every// subscriber, so each one chooses when it subscribes.public enum Overflow{ Block, // the publisher waits for room: nothing is lost, and a slow subscriber slows publishing DropOldest, // make room by discarding the oldest waiting message: fine for "latest price" feeds DropNewest, // discard the message being published: the backlog is delivered in full}
public sealed record Options(int Capacity, Overflow Overflow);
public sealed class Subscription{ private readonly Subscriber _handler; private readonly Channel<Message> _mailbox; private readonly Task _worker; private long _failed, _dropped;
public string Id { get; } public string Topic { get; } public long Failed => Interlocked.Read(ref _failed); public long Dropped => Interlocked.Read(ref _dropped); // a number someone can alert on
public Subscription(string id, string topic, Subscriber handler, Options options) { Id = id; Topic = topic; _handler = handler; // A bounded Channel implements all three policies; the callback counts what it drops. var mode = options.Overflow switch { Overflow.Block => BoundedChannelFullMode.Wait, Overflow.DropOldest => BoundedChannelFullMode.DropOldest, _ => BoundedChannelFullMode.DropWrite, }; _mailbox = Channel.CreateBounded<Message>( new BoundedChannelOptions(options.Capacity) { FullMode = mode, SingleReader = true }, _ => Interlocked.Increment(ref _dropped)); _worker = Task.Run(Run); }
public ValueTask Offer(Message m) => _mailbox.Writer.WriteAsync(m);
public Task Close() { _mailbox.Writer.TryComplete(); return _worker; }
private async Task Run() { await foreach (var m in _mailbox.Reader.ReadAllAsync()) { try { await _handler(m); } catch (Exception) { Interlocked.Increment(ref _failed); } } }}
public sealed class Broker(Func<string> ids){ private readonly Dictionary<string, List<Subscription>> _topics = []; private readonly Dictionary<string, long> _nextSeq = [];
public Subscription Subscribe(string topic, Subscriber handler, Options options) { var s = new Subscription(ids(), topic, handler, options); if (!_topics.TryGetValue(topic, out var list)) _topics[topic] = list = []; list.Add(s); return s; }
public Task Unsubscribe(Subscription s) { _topics.GetValueOrDefault(s.Topic)?.Remove(s); return s.Close(); }
// With Block, this waits while any subscriber's mailbox is full. That is the backpressure: // the publisher slows to the pace of its slowest blocking subscriber. public async Task Publish(string topic, string payload) { var seq = _nextSeq.GetValueOrDefault(topic); _nextSeq[topic] = seq + 1; var m = new Message(topic, seq, payload); foreach (var s in _topics.GetValueOrDefault(topic) ?? []) await s.Offer(m); }
public async Task Close() { foreach (var s in _topics.Values.SelectMany(x => x)) await s.Close(); }}// Overflow says what to do when a subscriber's mailbox is full. There is no right answer for// every subscriber, so each one chooses when it subscribes.type Overflow int
const ( Block Overflow = iota // the publisher waits for room: nothing is lost, and a slow subscriber slows publishing DropOldest // make room by discarding the oldest waiting message: fine for "latest price" feeds DropNewest // discard the message being published: the backlog is delivered in full)
type Options struct { Capacity int Overflow Overflow}
type Subscription struct { ID string Topic string handler Subscriber overflow Overflow mailbox chan Message done chan struct{} failed atomic.Int64 Dropped atomic.Int64 // a number someone can alert on}
func newSubscription(id, topic string, handler Subscriber, o Options) *Subscription { s := &Subscription{ID: id, Topic: topic, handler: handler, overflow: o.Overflow, mailbox: make(chan Message, o.Capacity), done: make(chan struct{})} go s.run() return s}
func (s *Subscription) offer(m Message) { switch s.overflow { case Block: s.mailbox <- m case DropNewest: select { case s.mailbox <- m: default: s.Dropped.Add(1) } case DropOldest: // The worker may take a message between the two selects, so loop until m fits. for { select { case s.mailbox <- m: return default: } select { case <-s.mailbox: s.Dropped.Add(1) default: } } }}
func (s *Subscription) Close() { close(s.mailbox) <-s.done}
func (s *Subscription) run() { defer close(s.done) for m := range s.mailbox { if s.handler(m) != nil { s.failed.Add(1) } }}
type Broker struct { topics map[string][]*Subscription nextSeq map[string]int64 ids func() string}
func NewBroker(ids func() string) *Broker { return &Broker{topics: map[string][]*Subscription{}, nextSeq: map[string]int64{}, ids: ids}}
func (b *Broker) Subscribe(topic string, handler Subscriber, o Options) *Subscription { s := newSubscription(b.ids(), topic, handler, o) b.topics[topic] = append(b.topics[topic], s) return s}
func (b *Broker) Unsubscribe(s *Subscription) { b.topics[s.Topic] = slices.DeleteFunc(b.topics[s.Topic], func(x *Subscription) bool { return x == s }) s.Close()}
// Publish waits, with Block, while any subscriber's mailbox is full. That is the backpressure:// the publisher slows to the pace of its slowest blocking subscriber.func (b *Broker) Publish(topic, payload string) { m := Message{topic, b.nextSeq[topic], payload} b.nextSeq[topic]++ for _, s := range b.topics[topic] { s.offer(m) }}
func (b *Broker) Close() { for _, subs := range b.topics { for _, s := range subs { s.Close() } }}// What to do when a subscriber's mailbox is full. There is no right answer for every// subscriber, so each one chooses when it subscribes.// block: the publisher waits for room: nothing is lost, and a slow subscriber slows publishing// drop-oldest: make room by discarding the oldest waiting message: fine for "latest price" feeds// drop-newest: discard the message being published: the backlog is delivered in fulltype Overflow = 'block' | 'drop-oldest' | 'drop-newest';
interface Options { readonly capacity: number; readonly overflow: Overflow;}
class Subscription { private readonly mailbox: Message[] = []; private readonly waitingForRoom: (() => void)[] = []; private pumping: Promise<void> = Promise.resolve(); private running = false; failed = 0; dropped = 0; // a number someone can alert on
constructor( readonly id: string, readonly topic: string, private readonly handler: Subscriber, private readonly options: Options, ) {}
async offer(m: Message): Promise<void> { if (this.mailbox.length >= this.options.capacity) { if (this.options.overflow === 'drop-newest') { this.dropped++; return; } if (this.options.overflow === 'drop-oldest') { this.mailbox.shift(); this.dropped++; } else { // Wait until the loop takes a message out. Several publishers may be waiting, so check // again after waking. while (this.mailbox.length >= this.options.capacity) { await new Promise<void>((resolve) => this.waitingForRoom.push(resolve)); } } } this.mailbox.push(m); this.start(); }
async close(): Promise<void> { await this.pumping; }
private start(): void { if (this.running) return; this.running = true; this.pumping = this.pump(); }
private async pump(): Promise<void> { while (this.mailbox.length > 0) { const m = this.mailbox.shift()!; this.waitingForRoom.shift()?.(); // one slot freed, one waiting publisher may go try { await this.handler(m); } catch { this.failed++; } } this.running = false; }}
class Broker { private readonly topics = new Map<string, Subscription[]>(); private readonly nextSeq = new Map<string, number>();
constructor(private readonly ids: () => string) {}
subscribe(topic: string, handler: Subscriber, options: Options): Subscription { const s = new Subscription(this.ids(), topic, handler, options); this.topics.set(topic, [...(this.topics.get(topic) ?? []), s]); return s; }
async unsubscribe(s: Subscription): Promise<void> { this.topics.set( s.topic, (this.topics.get(s.topic) ?? []).filter((x) => x !== s), ); await s.close(); }
// With 'block', this waits while any subscriber's mailbox is full. That is the backpressure: // the publisher slows to the pace of its slowest blocking subscriber. async publish(topic: string, payload: string): Promise<void> { const seq = this.nextSeq.get(topic) ?? 0; this.nextSeq.set(topic, seq + 1); for (const s of this.topics.get(topic) ?? []) await s.offer({ topic, seq, payload }); }
async close(): Promise<void> { for (const subs of this.topics.values()) for (const s of subs) await s.close(); }}Go deeperWith Block, can one subscriber stall the whole system?1 min
It can stall publishers to its own topic, and that’s the point: the system slows down to what it can deliver. It can’t stall other topics’ publishers. A subscriber that can’t afford to slow anyone down chooses a drop policy instead. A blocking publish can also take a time limit and fail, so the publisher decides how much a stalled subscriber may cost it.
Stage 4: Retries and dead letters
Section titled “Stage 4: Retries and dead letters”The warehouse handler calls a database that restarts now and then, so a few messages fail. One message has a broken payload and fails every time. Handle both.
Whether you tell brief failures from permanent ones, retry the first with growing waits, and set the second aside without blocking everything behind it. They also check whether retries keep the subscriber’s order.
Most failures are brief: a timeout, a restart, a busy lock. So a failing message is retried, up to a set number of attempts, waiting a little longer before each one. The worker retries the message right away, before taking the next one. That holds up this subscriber’s later messages during the retries, which is the price of keeping its order. A subscriber that doesn’t care about order could move failures to a side queue and carry on.
A message that fails every attempt becomes a dead letter. It’s set aside, together with the subscription and the error, and the worker moves on. Without this, one broken message would either be lost without a trace, or retried forever, blocking the subscriber for good. The broker keeps dead letters for a person or a repair job to look at and publish again.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// How one subscriber wants its messages delivered: how big its mailbox is, what to do when it// is full, and how many times to retry a failing handler before giving up.record Options(int capacity, Overflow overflow, int maxAttempts, long backoffMillis) {}
// A message a subscriber couldn't handle after every attempt, kept with the reason so a person// or a repair job can look at it and publish it again.record DeadLetter(Message message, String subscriptionId, String error) {}
// A subscriber's mailbox and the one worker that empties it. Publishing only drops the message// in the mailbox, so the publisher never waits on a handler. One worker per subscription keeps// each subscriber's messages in order, and a slow subscriber only delays itself.class Subscription { // Queued last by close(), so the worker knows to stop. private static final Message STOP = new Message("", -1, "");
final String id; final String topic; private final Subscriber handler; private final Options options; private final BlockingQueue<Message> mailbox; private final Consumer<DeadLetter> deadLetters; private final Thread worker; // Messages a full mailbox threw away. Counted, so a dashboard can show that this subscriber // is falling behind. final AtomicLong dropped = new AtomicLong();
Subscription(String id, String topic, Subscriber handler, Options options, Consumer<DeadLetter> deadLetters) { this.id = id; this.topic = topic; this.handler = handler; this.options = options; this.mailbox = new ArrayBlockingQueue<>(options.capacity()); this.deadLetters = deadLetters; this.worker = new Thread(this::run, "subscription-" + id); worker.start(); }
// Called by the topic while publishing. The subscriber's Overflow rule decides what a full // mailbox means: wait for room, drop the oldest message, or drop this one. void offer(Message m) throws InterruptedException { switch (options.overflow()) { case BLOCK -> mailbox.put(m); case DROP_NEWEST -> { if (!mailbox.offer(m)) dropped.incrementAndGet(); } case DROP_OLDEST -> { while (!mailbox.offer(m)) if (mailbox.poll() != null) dropped.incrementAndGet(); } } }
// STOP goes in behind every waiting message, so the worker delivers those first and then // exits. join waits for that. void close() throws InterruptedException { mailbox.put(STOP); worker.join(); }
// The worker: one message at a time, in order, until STOP arrives. private void run() { try { for (Message m = mailbox.take(); m != STOP; m = mailbox.take()) deliver(m); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }
// Most failures are brief (a database restarting, a timeout), so try again, waiting a // little longer each time. The next message waits meanwhile: that's the price of keeping // this subscriber's messages in order. After the last attempt the message is set aside as // a dead letter and the subscriber moves on, so one bad message can't stop it for good. private void deliver(Message m) throws InterruptedException { for (int attempt = 1; ; attempt++) { try { handler.onMessage(m); return; } catch (RuntimeException e) { if (attempt == options.maxAttempts()) { deadLetters.accept(new DeadLetter(m, id, String.valueOf(e.getMessage()))); return; } Thread.sleep(options.backoffMillis() * attempt); } } }}
class Broker { private final Map<String, List<Subscription>> topics = new HashMap<>(); private final Map<String, Long> nextSeq = new HashMap<>(); private final Queue<DeadLetter> deadLetters = new ConcurrentLinkedQueue<>(); // workers add from their own threads private final Supplier<String> ids;
Broker(Supplier<String> ids) { this.ids = ids; }
Subscription subscribe(String topic, Subscriber handler, Options options) { Subscription s = new Subscription(ids.get(), topic, handler, options, deadLetters::add); topics.computeIfAbsent(topic, k -> new ArrayList<>()).add(s); return s; }
void unsubscribe(Subscription s) throws InterruptedException { topics.getOrDefault(s.topic, new ArrayList<>()).remove(s); s.close(); }
void publish(String topic, String payload) throws InterruptedException { long seq = nextSeq.merge(topic, 1L, Long::sum) - 1; Message m = new Message(topic, seq, payload); for (Subscription s : topics.getOrDefault(topic, List.of())) s.offer(m); }
List<DeadLetter> deadLetters() { return List.copyOf(deadLetters); }
void close() throws InterruptedException { for (List<Subscription> subs : topics.values()) for (Subscription s : subs) s.close(); }}import time
# How one subscriber wants its messages delivered: how big its mailbox is, what to do when it is# full, and how many times to retry a failing handler before giving up.@dataclass(frozen=True)class Options: capacity: int overflow: Overflow max_attempts: int = 3 backoff_seconds: float = 0.1
# A message a subscriber couldn't handle after every attempt, kept with the reason so a person# or a repair job can look at it and publish it again.@dataclass(frozen=True)class DeadLetter: message: Message subscription_id: str error: str
# A subscriber's mailbox and the one worker that empties it. Publishing only drops the message# in the mailbox, so the publisher never waits on a handler. One worker per subscription keeps# each subscriber's messages in order, and a slow subscriber only delays itself.class Subscription: def __init__(self, id: str, topic: str, handler: Subscriber, options: Options, dead_letters: Callable[[DeadLetter], None]): self.id = id self.topic = topic self._handler = handler self._options = options self._mailbox: queue.Queue[Message] = queue.Queue(maxsize=options.capacity) self._dead_letters = dead_letters # Messages a full mailbox threw away. Counted, so a dashboard can show that this # subscriber is falling behind. self.dropped = 0 self._worker = threading.Thread(target=self._run, name=f"subscription-{id}") self._worker.start()
# Called by the topic while publishing. The subscriber's Overflow rule decides what a full # mailbox means: wait for room, drop the oldest message, or drop this one. def offer(self, m: Message) -> None: if self._options.overflow is Overflow.BLOCK: self._mailbox.put(m) elif self._options.overflow is Overflow.DROP_NEWEST: try: self._mailbox.put_nowait(m) except queue.Full: self.dropped += 1 else: while True: try: self._mailbox.put_nowait(m) return except queue.Full: try: self._mailbox.get_nowait() self.dropped += 1 except queue.Empty: pass
# _STOP goes in behind every waiting message, so the worker delivers those first and then # exits. join waits for that. def close(self) -> None: self._mailbox.put(_STOP) self._worker.join()
# The worker: one message at a time, in order, until _STOP arrives. def _run(self) -> None: while (m := self._mailbox.get()) is not _STOP: self._deliver(m)
# Most failures are brief (a database restarting, a timeout), so try again, waiting a # little longer each time. The next message waits meanwhile: that's the price of keeping # this subscriber's messages in order. After the last attempt the message is set aside as # a dead letter and the subscriber moves on, so one bad message can't stop it for good. def _deliver(self, m: Message) -> None: for attempt in range(1, self._options.max_attempts + 1): try: self._handler(m) return except Exception as e: if attempt == self._options.max_attempts: self._dead_letters(DeadLetter(m, self.id, str(e))) return time.sleep(self._options.backoff_seconds * attempt)
class Broker: def __init__(self, ids: Callable[[], str]): self._topics: dict[str, list[Subscription]] = {} self._next_seq: dict[str, int] = {} self._dead_letters: list[DeadLetter] = [] # list.append is atomic, so workers can add self._ids = ids
def subscribe(self, topic: str, handler: Subscriber, options: Options) -> Subscription: s = Subscription(self._ids(), topic, handler, options, self._dead_letters.append) self._topics.setdefault(topic, []).append(s) return s
def unsubscribe(self, s: Subscription) -> None: self._topics.get(s.topic, []).remove(s) s.close()
def publish(self, topic: str, payload: str) -> None: seq = self._next_seq.get(topic, 0) self._next_seq[topic] = seq + 1 m = Message(topic, seq, payload) for s in self._topics.get(topic, []): s.offer(m)
def dead_letters(self) -> list[DeadLetter]: return list(self._dead_letters)
def close(self) -> None: for subs in self._topics.values(): for s in subs: s.close()// How one subscriber wants its messages delivered: how big its mailbox is, what to do when it// is full, and how many times to retry a failing handler before giving up.public sealed record Options(int Capacity, Overflow Overflow, int MaxAttempts = 3, TimeSpan Backoff = default);
// A message a subscriber couldn't handle after every attempt, kept with the reason so a person// or a repair job can look at it and publish it again.public sealed record DeadLetter(Message Message, string SubscriptionId, string Error);
// A subscriber's mailbox and the one worker that empties it. Publishing only drops the message// in the mailbox, so the publisher never waits on a handler. One worker per subscription keeps// each subscriber's messages in order, and a slow subscriber only delays itself.public sealed class Subscription{ private readonly Subscriber _handler; private readonly Options _options; private readonly Action<DeadLetter> _deadLetters; private readonly Channel<Message> _mailbox; private readonly Task _worker; // Messages a full mailbox threw away. Counted, so a dashboard can show that this subscriber // is falling behind. Read and written with Interlocked, because the channel's drop callback // runs on the publisher's thread. private long _dropped;
public string Id { get; } public string Topic { get; } public long Dropped => Interlocked.Read(ref _dropped);
public Subscription(string id, string topic, Subscriber handler, Options options, Action<DeadLetter> deadLetters) { Id = id; Topic = topic; _handler = handler; _options = options; _deadLetters = deadLetters; var mode = options.Overflow switch { Overflow.Block => BoundedChannelFullMode.Wait, Overflow.DropOldest => BoundedChannelFullMode.DropOldest, _ => BoundedChannelFullMode.DropWrite, }; _mailbox = Channel.CreateBounded<Message>( new BoundedChannelOptions(options.Capacity) { FullMode = mode, SingleReader = true }, _ => Interlocked.Increment(ref _dropped)); _worker = Task.Run(Run); }
// Called by the topic while publishing. The channel was built with the subscriber's // Overflow rule, so it already knows what a full mailbox means: wait for room, drop the // oldest message, or drop this one. public ValueTask Offer(Message m) => _mailbox.Writer.WriteAsync(m);
// No new messages after this. The worker delivers what is already waiting, and the returned // task ends when it has. public Task Close() { _mailbox.Writer.TryComplete(); return _worker; }
// The worker: one message at a time, in order, until the mailbox is completed. private async Task Run() { await foreach (var m in _mailbox.Reader.ReadAllAsync()) await Deliver(m); }
// Most failures are brief (a database restarting, a timeout), so try again, waiting a // little longer each time. The next message waits meanwhile: that's the price of keeping // this subscriber's messages in order. After the last attempt the message is set aside as // a dead letter and the subscriber moves on, so one bad message can't stop it for good. private async Task Deliver(Message m) { for (var attempt = 1; ; attempt++) { try { await _handler(m); return; } catch (Exception e) { if (attempt == _options.MaxAttempts) { _deadLetters(new DeadLetter(m, Id, e.Message)); return; } await Task.Delay(_options.Backoff * attempt); } } }}
public sealed class Broker(Func<string> ids){ private readonly Dictionary<string, List<Subscription>> _topics = []; private readonly Dictionary<string, long> _nextSeq = []; private readonly ConcurrentQueue<DeadLetter> _deadLetters = new(); // workers add from their own threads
public Subscription Subscribe(string topic, Subscriber handler, Options options) { var s = new Subscription(ids(), topic, handler, options, _deadLetters.Enqueue); if (!_topics.TryGetValue(topic, out var list)) _topics[topic] = list = []; list.Add(s); return s; }
public Task Unsubscribe(Subscription s) { _topics.GetValueOrDefault(s.Topic)?.Remove(s); return s.Close(); }
public async Task Publish(string topic, string payload) { var seq = _nextSeq.GetValueOrDefault(topic); _nextSeq[topic] = seq + 1; var m = new Message(topic, seq, payload); foreach (var s in _topics.GetValueOrDefault(topic) ?? []) await s.Offer(m); }
public List<DeadLetter> DeadLetters() => [.. _deadLetters];
public async Task Close() { foreach (var s in _topics.Values.SelectMany(x => x)) await s.Close(); }}// Options says how one subscriber wants its messages delivered: how big its mailbox is, what to// do when it is full, and how many times to retry a failing handler before giving up.type Options struct { Capacity int Overflow Overflow MaxAttempts int Backoff time.Duration}
// DeadLetter is a message a subscriber couldn't handle after every attempt, kept with the// reason so a person or a repair job can look at it and publish it again.type DeadLetter struct { Message Message SubscriptionID string Err error}
// Subscription is a subscriber's mailbox and the one goroutine that empties it. Publishing only// drops the message in the mailbox, so the publisher never waits on a handler. One goroutine// per subscription keeps each subscriber's messages in order, and a slow subscriber only delays// itself.type Subscription struct { ID string Topic string handler Subscriber options Options mailbox chan Message // Closed by run once the last message is delivered. done chan struct{} deadLetters func(DeadLetter) // Messages a full mailbox threw away. Counted, so a dashboard can show that this subscriber is // falling behind. Dropped atomic.Int64}
// newSubscription starts the worker at once, so messages are delivered as soon as they arrive.func newSubscription(id, topic string, handler Subscriber, o Options, deadLetters func(DeadLetter)) *Subscription { s := &Subscription{ID: id, Topic: topic, handler: handler, options: o, mailbox: make(chan Message, o.Capacity), done: make(chan struct{}), deadLetters: deadLetters} go s.run() return s}
// Called by the topic while publishing. The subscriber's Overflow rule decides what a full// mailbox means: wait for room, drop the oldest message, or drop this one.func (s *Subscription) offer(m Message) { switch s.options.Overflow { case Block: s.mailbox <- m case DropNewest: select { case s.mailbox <- m: default: s.Dropped.Add(1) } case DropOldest: for { select { case s.mailbox <- m: return default: } select { case <-s.mailbox: s.Dropped.Add(1) default: } } }}
// Close lets run deliver what is already in the mailbox, then waits until it has.func (s *Subscription) Close() { close(s.mailbox) <-s.done}
// run is the worker: one message at a time, in order, until the mailbox is closed.func (s *Subscription) run() { defer close(s.done) for m := range s.mailbox { s.deliver(m) }}
// deliver retries, because most failures are brief (a database restarting, a timeout), waiting// a little longer each time. The next message waits meanwhile: that's the price of keeping this// subscriber's messages in order. After the last attempt the message is set aside as a dead// letter and the subscriber moves on, so one bad message can't stop it for good.func (s *Subscription) deliver(m Message) { for attempt := 1; ; attempt++ { err := s.handler(m) if err == nil { return } if attempt == s.options.MaxAttempts { s.deadLetters(DeadLetter{m, s.ID, err}) return } time.Sleep(s.options.Backoff * time.Duration(attempt)) }}
type Broker struct { topics map[string][]*Subscription nextSeq map[string]int64 mu sync.Mutex // guards deadLetters, which workers add to from their own goroutines deadLetters []DeadLetter ids func() string}
func NewBroker(ids func() string) *Broker { return &Broker{topics: map[string][]*Subscription{}, nextSeq: map[string]int64{}, ids: ids}}
func (b *Broker) Subscribe(topic string, handler Subscriber, o Options) *Subscription { s := newSubscription(b.ids(), topic, handler, o, b.addDeadLetter) b.topics[topic] = append(b.topics[topic], s) return s}
func (b *Broker) Unsubscribe(s *Subscription) { b.topics[s.Topic] = slices.DeleteFunc(b.topics[s.Topic], func(x *Subscription) bool { return x == s }) s.Close()}
func (b *Broker) Publish(topic, payload string) { m := Message{topic, b.nextSeq[topic], payload} b.nextSeq[topic]++ for _, s := range b.topics[topic] { s.offer(m) }}
func (b *Broker) DeadLetters() []DeadLetter { b.mu.Lock() defer b.mu.Unlock() return slices.Clone(b.deadLetters)}
func (b *Broker) Close() { for _, subs := range b.topics { for _, s := range subs { s.Close() } }}
func (b *Broker) addDeadLetter(d DeadLetter) { b.mu.Lock() defer b.mu.Unlock() b.deadLetters = append(b.deadLetters, d)}// How one subscriber wants its messages delivered: how big its mailbox is, what to do when it// is full, and how many times to retry a failing handler before giving up.interface Options { readonly capacity: number; readonly overflow: Overflow; readonly maxAttempts: number; readonly backoffMs: number;}
// A message a subscriber couldn't handle after every attempt, kept with the reason so a person// or a repair job can look at it and publish it again.interface DeadLetter { readonly message: Message; readonly subscriptionId: string; readonly error: string;}
// A subscriber's mailbox and the one loop that empties it. Publishing only drops the message in// the mailbox, so the publisher never waits on a handler. One loop per subscription keeps each// subscriber's messages in order, and a slow subscriber only delays itself.class Subscription { private readonly mailbox: Message[] = []; // Publishers waiting on a full mailbox. Each message taken out wakes one of them. private readonly waitingForRoom: (() => void)[] = []; private pumping: Promise<void> = Promise.resolve(); private running = false; // Messages a full mailbox threw away. Counted, so a dashboard can show that this subscriber // is falling behind. dropped = 0;
constructor( readonly id: string, readonly topic: string, private readonly handler: Subscriber, private readonly options: Options, private readonly deadLetters: (d: DeadLetter) => void, ) {}
// Called by the topic while publishing. The subscriber's Overflow rule decides what a full // mailbox means: wait for room, drop the oldest message, or drop this one. async offer(m: Message): Promise<void> { if (this.mailbox.length >= this.options.capacity) { if (this.options.overflow === 'drop-newest') { this.dropped++; return; } if (this.options.overflow === 'drop-oldest') { this.mailbox.shift(); this.dropped++; } else { // Wait until the loop takes a message out. Several publishers may be waiting, so check // again after waking. while (this.mailbox.length >= this.options.capacity) { await new Promise<void>((resolve) => this.waitingForRoom.push(resolve)); } } } this.mailbox.push(m); this.start(); }
// Waits for the loop to deliver what is already in the mailbox. async close(): Promise<void> { await this.pumping; }
// Starts the delivery loop if it isn't running already. Only one loop runs at a time, so // messages stay in order. private start(): void { if (this.running) return; this.running = true; this.pumping = this.pump(); }
// Empties the mailbox one message at a time, then stops until the next offer starts it again. private async pump(): Promise<void> { while (this.mailbox.length > 0) { const m = this.mailbox.shift()!; this.waitingForRoom.shift()?.(); // one slot freed, one waiting publisher may go await this.deliver(m); } this.running = false; }
// Most failures are brief (a database restarting, a timeout), so try again, waiting a // little longer each time. The next message waits meanwhile: that's the price of keeping // this subscriber's messages in order. After the last attempt the message is set aside as // a dead letter and the subscriber moves on, so one bad message can't stop it for good. private async deliver(m: Message): Promise<void> { for (let attempt = 1; ; attempt++) { try { await this.handler(m); return; } catch (e) { if (attempt === this.options.maxAttempts) { this.deadLetters({ message: m, subscriptionId: this.id, error: String((e as Error).message) }); return; } await new Promise((resolve) => setTimeout(resolve, this.options.backoffMs * attempt)); } } }}
class Broker { private readonly topics = new Map<string, Subscription[]>(); private readonly nextSeq = new Map<string, number>(); private readonly dead: DeadLetter[] = [];
constructor(private readonly ids: () => string) {}
subscribe(topic: string, handler: Subscriber, options: Options): Subscription { const s = new Subscription(this.ids(), topic, handler, options, (d) => this.dead.push(d)); this.topics.set(topic, [...(this.topics.get(topic) ?? []), s]); return s; }
async unsubscribe(s: Subscription): Promise<void> { this.topics.set( s.topic, (this.topics.get(s.topic) ?? []).filter((x) => x !== s), ); await s.close(); }
async publish(topic: string, payload: string): Promise<void> { const seq = this.nextSeq.get(topic) ?? 0; this.nextSeq.set(topic, seq + 1); for (const s of this.topics.get(topic) ?? []) await s.offer({ topic, seq, payload }); }
deadLetters(): DeadLetter[] { return [...this.dead]; }
async close(): Promise<void> { for (const subs of this.topics.values()) for (const s of subs) await s.close(); }}Go deeperHow long to wait between retries, and how many tries2 min
Our code waits backoff × attempt before each retry: with a backoff of 100 milliseconds, that’s
100, then 200, then 300. The waits grow, so a database that is restarting gets more time with each
try, and a brief failure is retried quickly.
Why not retry at once, with no wait? Because the thing that failed is often overloaded. Retrying at full speed adds load at the worst moment, and every attempt fails the same way.
Many systems double the wait each time (100, 200, 400, 800), which is called exponential backoff, and add a small random amount to each wait, called jitter. Jitter matters when many workers fail at the same moment: without it, they all retry at the same moment too, and knock the service over again.
How many tries? Few. Three to five covers most brief failures. More tries mean a subscriber stuck for longer on a message that was never going to work, since every message behind it waits. The total time a message can spend in retries should be smaller than what the subscriber can afford to fall behind.
This is enough for a mid-level answer. Publishing is separate from delivering, with a mailbox per subscriber, the mailbox has a limit, and handler errors are caught. The rest of the page is what makes it a senior answer.
The full design
Section titled “The full design”Here is everything together. Each class is here because a requirement needed it.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
// The complete design, built from every stage on this page.
import java.util.*;import java.util.concurrent.*;import java.util.concurrent.atomic.*;import java.util.function.*;
// One published message. The sequence number is per topic, so a subscriber can tell whether it// missed something or got something twice.record Message(String topic, long seq, String payload) {}
// Whatever wants messages: an email sender, a cache invalidator, a metrics counter.interface Subscriber { void onMessage(Message m);}
// The front door for publishers and subscribers. It keeps one Topic per name and a shared list// of dead letters. It never runs a subscriber's code itself: each Subscription has its own// worker, so one slow subscriber can't hold up the others.class Broker { // Created on first use. Concurrent, because publishers and subscribers call in from // different threads. private final Map<String, Topic> topics = new ConcurrentHashMap<>(); // Messages that failed every retry. Workers add to it from their own threads. private final Queue<DeadLetter> deadLetters = new ConcurrentLinkedQueue<>(); // Injected, so tests get predictable subscription ids. private final Supplier<String> ids;
Broker(Supplier<String> ids) { this.ids = ids; }
// Each subscription gets its own mailbox and worker. Its failures go to the shared dead- // letter list. Subscription subscribe(String topic, Subscriber handler, Options options) { Subscription s = new Subscription(ids.get(), topic, handler, options, deadLetters::add); topic(topic).add(s); return s; }
// Stop new messages first, then let the worker finish what is already in its mailbox. void unsubscribe(Subscription s) throws InterruptedException { topic(s.topic).remove(s); s.close(); }
// The topic numbers the message and puts it in every mailbox, so all subscribers see the // same order. A full mailbox can make this wait, depending on the subscriber's Overflow // rule. void publish(String topic, String payload) throws InterruptedException { topic(topic).publish(payload); }
// A copy, so callers can't change the broker's list. List<DeadLetter> deadLetters() { return List.copyOf(deadLetters); }
// Closes every subscription. Each close waits for its worker to finish. void close() throws InterruptedException { for (Topic t : topics.values()) for (Subscription s : t.subscriptions()) s.close(); }
private Topic topic(String name) { return topics.computeIfAbsent(name, Topic::new); }}
// A subscriber's mailbox and the one worker that empties it. Publishing only drops the message// in the mailbox, so the publisher never waits on a handler. One worker per subscription keeps// each subscriber's messages in order, and a slow subscriber only delays itself.class Subscription { // Queued last by close(), so the worker knows to stop. private static final Message STOP = new Message("", -1, "");
final String id; final String topic; private final Subscriber handler; private final Options options; private final BlockingQueue<Message> mailbox; private final Consumer<DeadLetter> deadLetters; private final Thread worker; // Messages a full mailbox threw away. Counted, so a dashboard can show that this subscriber // is falling behind. final AtomicLong dropped = new AtomicLong();
Subscription(String id, String topic, Subscriber handler, Options options, Consumer<DeadLetter> deadLetters) { this.id = id; this.topic = topic; this.handler = handler; this.options = options; this.mailbox = new ArrayBlockingQueue<>(options.capacity()); this.deadLetters = deadLetters; this.worker = new Thread(this::run, "subscription-" + id); worker.start(); }
// Called by the topic while publishing. The subscriber's Overflow rule decides what a full // mailbox means: wait for room, drop the oldest message, or drop this one. void offer(Message m) throws InterruptedException { switch (options.overflow()) { case BLOCK -> mailbox.put(m); case DROP_NEWEST -> { if (!mailbox.offer(m)) dropped.incrementAndGet(); } case DROP_OLDEST -> { while (!mailbox.offer(m)) if (mailbox.poll() != null) dropped.incrementAndGet(); } } }
// STOP goes in behind every waiting message, so the worker delivers those first and then // exits. join waits for that. void close() throws InterruptedException { mailbox.put(STOP); worker.join(); }
// The worker: one message at a time, in order, until STOP arrives. private void run() { try { for (Message m = mailbox.take(); m != STOP; m = mailbox.take()) deliver(m); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }
// Most failures are brief (a database restarting, a timeout), so try again, waiting a // little longer each time. The next message waits meanwhile: that's the price of keeping // this subscriber's messages in order. After the last attempt the message is set aside as // a dead letter and the subscriber moves on, so one bad message can't stop it for good. private void deliver(Message m) throws InterruptedException { for (int attempt = 1; ; attempt++) { try { handler.onMessage(m); return; } catch (RuntimeException e) { if (attempt == options.maxAttempts()) { deadLetters.accept(new DeadLetter(m, id, String.valueOf(e.getMessage()))); return; } Thread.sleep(options.backoffMillis() * attempt); } } }}
// What to do when a subscriber's mailbox is full. There is no right answer for every// subscriber, so each one chooses when it subscribes.enum Overflow { BLOCK, // the publisher waits for room: nothing is lost, and a slow subscriber slows publishing DROP_OLDEST, // make room by discarding the oldest waiting message: fine for "latest price" feeds DROP_NEWEST // discard the message being published: the backlog is delivered in full}
// How one subscriber wants its messages delivered: how big its mailbox is, what to do when it// is full, and how many times to retry a failing handler before giving up.record Options(int capacity, Overflow overflow, int maxAttempts, long backoffMillis) {}
// A message a subscriber couldn't handle after every attempt, kept with the reason so a person// or a repair job can look at it and publish it again.record DeadLetter(Message message, String subscriptionId, String error) {}
// Two problems appear once several threads publish. Two publishers can read the same sequence// number. And publisher A can put its message in Ana's mailbox, then publisher B puts its// message in both mailboxes, then A reaches Ben's: Ana sees A, B and Ben sees B, A. Each topic// therefore hands out the number and fills every mailbox as one step, under the topic's lock,// so every subscriber sees the same order. Different topics never wait on each other.//// The subscriber list is copied on every change instead of locked. A publish that is blocked// on a full mailbox holds the topic's lock, and an unsubscribe, often of that very subscriber,// must not wait behind it.class Topic { final String name; private final Object publishLock = new Object(); private volatile List<Subscription> subscriptions = List.of(); private long nextSeq; // only touched under publishLock
Topic(String name) { this.name = name; }
// The lock makes numbering the message and filling every mailbox one step. void publish(String payload) throws InterruptedException { synchronized (publishLock) { Message m = new Message(name, nextSeq++, payload); for (Subscription s : subscriptions) s.offer(m); // a snapshot: changes don't affect this loop } }
// Builds a new list with the change. A publish already looping over the old list is not // disturbed. synchronized void add(Subscription s) { List<Subscription> next = new ArrayList<>(subscriptions); next.add(s); subscriptions = List.copyOf(next); }
// The same for removing. The lock here only stops two changes from racing; it never waits // on a publish. synchronized void remove(Subscription s) { List<Subscription> next = new ArrayList<>(subscriptions); next.remove(s); subscriptions = List.copyOf(next); }
// Read without a lock: the list is never changed in place. List<Subscription> subscriptions() { return subscriptions; }}# The complete design, built from every stage on this page.
from __future__ import annotations
import queueimport threadingimport timefrom dataclasses import dataclassfrom enum import Enum, autofrom typing import Callable
# One published message. The sequence number is per topic, so a subscriber can tell whether it# missed something or got something twice.@dataclass(frozen=True)class Message: topic: str seq: int payload: str
# Whatever wants messages: an email sender, a cache invalidator, a metrics counter.Subscriber = Callable[[Message], None]
# The front door for publishers and subscribers. It keeps one Topic per name and a shared list# of dead letters. It never runs a subscriber's code itself: each Subscription has its own# worker, so one slow subscriber can't hold up the others.class Broker: def __init__(self, ids: Callable[[], str]): # Created on first use, under _topics_lock, so two threads can't create the same topic # twice. self._topics: dict[str, Topic] = {} self._topics_lock = threading.Lock() # Messages that failed every retry. list.append is atomic, so workers can add from their # own threads. self._dead_letters: list[DeadLetter] = [] # Injected, so tests get predictable subscription ids. self._ids = ids
# Each subscription gets its own mailbox and worker. Its failures go to the shared dead- # letter list. def subscribe(self, topic: str, handler: Subscriber, options: Options) -> Subscription: s = Subscription(self._ids(), topic, handler, options, self._dead_letters.append) self._topic(topic).add(s) return s
# Stop new messages first, then let the worker finish what is already in its mailbox. def unsubscribe(self, s: Subscription) -> None: self._topic(s.topic).remove(s) s.close()
# The topic numbers the message and puts it in every mailbox, so all subscribers see the # same order. A full mailbox can make this wait, depending on the subscriber's Overflow # rule. def publish(self, topic: str, payload: str) -> None: self._topic(topic).publish(payload)
# A copy, so callers can't change the broker's list. def dead_letters(self) -> list[DeadLetter]: return list(self._dead_letters)
# Closes every subscription. Each close waits for its worker to finish. def close(self) -> None: for t in list(self._topics.values()): for s in t.subscriptions: s.close()
# Created on first use. def _topic(self, name: str) -> Topic: with self._topics_lock: if name not in self._topics: self._topics[name] = Topic(name) return self._topics[name]
_STOP = Message("", -1, "") # queued last by close()
# A subscriber's mailbox and the one worker that empties it. Publishing only drops the message# in the mailbox, so the publisher never waits on a handler. One worker per subscription keeps# each subscriber's messages in order, and a slow subscriber only delays itself.class Subscription: def __init__(self, id: str, topic: str, handler: Subscriber, options: Options, dead_letters: Callable[[DeadLetter], None]): self.id = id self.topic = topic self._handler = handler self._options = options self._mailbox: queue.Queue[Message] = queue.Queue(maxsize=options.capacity) self._dead_letters = dead_letters # Messages a full mailbox threw away. Counted, so a dashboard can show that this # subscriber is falling behind. self.dropped = 0 self._worker = threading.Thread(target=self._run, name=f"subscription-{id}") self._worker.start()
# Called by the topic while publishing. The subscriber's Overflow rule decides what a full # mailbox means: wait for room, drop the oldest message, or drop this one. def offer(self, m: Message) -> None: if self._options.overflow is Overflow.BLOCK: self._mailbox.put(m) elif self._options.overflow is Overflow.DROP_NEWEST: try: self._mailbox.put_nowait(m) except queue.Full: self.dropped += 1 else: while True: try: self._mailbox.put_nowait(m) return except queue.Full: try: self._mailbox.get_nowait() self.dropped += 1 except queue.Empty: pass
# _STOP goes in behind every waiting message, so the worker delivers those first and then # exits. join waits for that. def close(self) -> None: self._mailbox.put(_STOP) self._worker.join()
# The worker: one message at a time, in order, until _STOP arrives. def _run(self) -> None: while (m := self._mailbox.get()) is not _STOP: self._deliver(m)
# Most failures are brief (a database restarting, a timeout), so try again, waiting a # little longer each time. The next message waits meanwhile: that's the price of keeping # this subscriber's messages in order. After the last attempt the message is set aside as # a dead letter and the subscriber moves on, so one bad message can't stop it for good. def _deliver(self, m: Message) -> None: for attempt in range(1, self._options.max_attempts + 1): try: self._handler(m) return except Exception as e: if attempt == self._options.max_attempts: self._dead_letters(DeadLetter(m, self.id, str(e))) return time.sleep(self._options.backoff_seconds * attempt)
# What to do when a subscriber's mailbox is full. There is no right answer for every# subscriber, so each one chooses when it subscribes.class Overflow(Enum): BLOCK = auto() # the publisher waits for room: nothing is lost, and a slow subscriber slows publishing DROP_OLDEST = auto() # make room by discarding the oldest waiting message: fine for "latest price" feeds DROP_NEWEST = auto() # discard the message being published: the backlog is delivered in full
# How one subscriber wants its messages delivered: how big its mailbox is, what to do when it is# full, and how many times to retry a failing handler before giving up.@dataclass(frozen=True)class Options: capacity: int overflow: Overflow max_attempts: int = 3 backoff_seconds: float = 0.1
# A message a subscriber couldn't handle after every attempt, kept with the reason so a person# or a repair job can look at it and publish it again.@dataclass(frozen=True)class DeadLetter: message: Message subscription_id: str error: str
# Two problems appear once several threads publish. Two publishers can read the same sequence# number. And publisher A can put its message in Ana's mailbox, then publisher B puts its# message in both mailboxes, then A reaches Ben's: Ana sees A, B and Ben sees B, A. Each topic# therefore hands out the number and fills every mailbox as one step, under the topic's lock,# so every subscriber sees the same order. Different topics never wait on each other.## The subscriber list is replaced on every change instead of locked. A publish that is blocked# on a full mailbox holds the topic's lock, and an unsubscribe, often of that very subscriber,# must not wait behind it.class Topic: def __init__(self, name: str): self.name = name self._publish_lock = threading.Lock() self._change_lock = threading.Lock() self.subscriptions: tuple[Subscription, ...] = () # replaced, never changed in place self._next_seq = 0 # only touched under _publish_lock
# The lock makes numbering the message and filling every mailbox one step. def publish(self, payload: str) -> None: with self._publish_lock: m = Message(self.name, self._next_seq, payload) self._next_seq += 1 for s in self.subscriptions: # a snapshot: changes don't affect this loop s.offer(m)
# Builds a new list with the change. A publish already looping over the old list is not # disturbed. def add(self, s: Subscription) -> None: with self._change_lock: self.subscriptions = (*self.subscriptions, s)
# The same for removing. _change_lock only stops two changes from racing; it never waits on # a publish. def remove(self, s: Subscription) -> None: with self._change_lock: self.subscriptions = tuple(x for x in self.subscriptions if x is not s)// The complete design, built from every stage on this page.
using System.Collections.Concurrent;using System.Collections.Immutable;using System.Threading.Channels;
// One published message. The sequence number is per topic, so a subscriber can tell whether it// missed something or got something twice.public sealed record Message(string Topic, long Seq, string Payload);
// Whatever wants messages: an email sender, a cache invalidator, a metrics counter.public delegate Task Subscriber(Message m);
// The front door for publishers and subscribers. It keeps one Topic per name and a shared list// of dead letters. It never runs a subscriber's code itself: each Subscription has its own// worker, so one slow subscriber can't hold up the others. ids is injected, so tests get// predictable subscription ids.public sealed class Broker(Func<string> ids){ // Created on first use. Concurrent, because publishers and subscribers call in from // different threads. private readonly ConcurrentDictionary<string, Topic> _topics = new(); // Messages that failed every retry. Workers add to it from their own threads. private readonly ConcurrentQueue<DeadLetter> _deadLetters = new();
// Each subscription gets its own mailbox and worker. Its failures go to the shared dead- // letter list. public Subscription Subscribe(string topic, Subscriber handler, Options options) { var s = new Subscription(ids(), topic, handler, options, _deadLetters.Enqueue); TopicFor(topic).Add(s); return s; }
// Stop new messages first, then let the worker finish what is already in its mailbox. public Task Unsubscribe(Subscription s) { TopicFor(s.Topic).Remove(s); return s.Close(); }
// The topic numbers the message and puts it in every mailbox, so all subscribers see the // same order. A full mailbox can make this wait, depending on the subscriber's Overflow // rule. public Task Publish(string topic, string payload) => TopicFor(topic).Publish(payload);
// A copy, so callers can't change the broker's list. public List<DeadLetter> DeadLetters() => [.. _deadLetters];
// Closes every subscription. Each close waits for its worker to finish. public async Task Close() { foreach (var s in _topics.Values.SelectMany(t => t.Subscriptions)) await s.Close(); }
private Topic TopicFor(string name) => _topics.GetOrAdd(name, n => new Topic(n));}
// A subscriber's mailbox and the one worker that empties it. Publishing only drops the message// in the mailbox, so the publisher never waits on a handler. One worker per subscription keeps// each subscriber's messages in order, and a slow subscriber only delays itself.public sealed class Subscription{ private readonly Subscriber _handler; private readonly Options _options; private readonly Action<DeadLetter> _deadLetters; private readonly Channel<Message> _mailbox; private readonly Task _worker; // Messages a full mailbox threw away. Counted, so a dashboard can show that this subscriber // is falling behind. Read and written with Interlocked, because the channel's drop callback // runs on the publisher's thread. private long _dropped;
public string Id { get; } public string Topic { get; } public long Dropped => Interlocked.Read(ref _dropped);
public Subscription(string id, string topic, Subscriber handler, Options options, Action<DeadLetter> deadLetters) { Id = id; Topic = topic; _handler = handler; _options = options; _deadLetters = deadLetters; var mode = options.Overflow switch { Overflow.Block => BoundedChannelFullMode.Wait, Overflow.DropOldest => BoundedChannelFullMode.DropOldest, _ => BoundedChannelFullMode.DropWrite, }; _mailbox = Channel.CreateBounded<Message>( new BoundedChannelOptions(options.Capacity) { FullMode = mode, SingleReader = true }, _ => Interlocked.Increment(ref _dropped)); _worker = Task.Run(Run); }
// Called by the topic while publishing. The channel was built with the subscriber's // Overflow rule, so it already knows what a full mailbox means: wait for room, drop the // oldest message, or drop this one. public ValueTask Offer(Message m) => _mailbox.Writer.WriteAsync(m);
// No new messages after this. The worker delivers what is already waiting, and the returned // task ends when it has. public Task Close() { _mailbox.Writer.TryComplete(); return _worker; }
// The worker: one message at a time, in order, until the mailbox is completed. private async Task Run() { await foreach (var m in _mailbox.Reader.ReadAllAsync()) await Deliver(m); }
// Most failures are brief (a database restarting, a timeout), so try again, waiting a // little longer each time. The next message waits meanwhile: that's the price of keeping // this subscriber's messages in order. After the last attempt the message is set aside as // a dead letter and the subscriber moves on, so one bad message can't stop it for good. private async Task Deliver(Message m) { for (var attempt = 1; ; attempt++) { try { await _handler(m); return; } catch (Exception e) { if (attempt == _options.MaxAttempts) { _deadLetters(new DeadLetter(m, Id, e.Message)); return; } await Task.Delay(_options.Backoff * attempt); } } }}
// What to do when a subscriber's mailbox is full. There is no right answer for every// subscriber, so each one chooses when it subscribes.public enum Overflow{ Block, // the publisher waits for room: nothing is lost, and a slow subscriber slows publishing DropOldest, // make room by discarding the oldest waiting message: fine for "latest price" feeds DropNewest, // discard the message being published: the backlog is delivered in full}
// How one subscriber wants its messages delivered: how big its mailbox is, what to do when it// is full, and how many times to retry a failing handler before giving up.public sealed record Options(int Capacity, Overflow Overflow, int MaxAttempts = 3, TimeSpan Backoff = default);
// A message a subscriber couldn't handle after every attempt, kept with the reason so a person// or a repair job can look at it and publish it again.public sealed record DeadLetter(Message Message, string SubscriptionId, string Error);
// Two problems appear once several threads publish. Two publishers can read the same sequence// number. And publisher A can put its message in Ana's mailbox, then publisher B puts its// message in both mailboxes, then A reaches Ben's: Ana sees A, B and Ben sees B, A. Each topic// therefore hands out the number and fills every mailbox as one step, under the topic's lock,// so every subscriber sees the same order. Different topics never wait on each other. The lock// is a SemaphoreSlim because a publish awaits room in a mailbox, and a C# lock can't be held// across an await.//// The subscriber list is replaced on every change instead of locked. A publish that is waiting// for room holds the topic's lock, and an unsubscribe, often of that very subscriber, must not// wait behind it.public sealed class Topic(string name){ private readonly SemaphoreSlim _publishLock = new(1, 1); private readonly Lock _changeLock = new(); private long _nextSeq; // only touched while holding _publishLock
public string Name { get; } = name; public ImmutableArray<Subscription> Subscriptions { get; private set; } = [];
// The lock makes numbering the message and filling every mailbox one step. public async Task Publish(string payload) { await _publishLock.WaitAsync(); try { var m = new Message(Name, _nextSeq++, payload); foreach (var s in Subscriptions) await s.Offer(m); // a snapshot: changes don't affect this loop } finally { _publishLock.Release(); } }
// Builds a new list with the change. A publish already looping over the old list is not // disturbed. public void Add(Subscription s) { lock (_changeLock) Subscriptions = Subscriptions.Add(s); }
// The same for removing. _changeLock only stops two changes from racing; it never waits on // a publish. public void Remove(Subscription s) { lock (_changeLock) Subscriptions = Subscriptions.Remove(s); }}// The complete design, built from every stage on this page.
package pubsub
import ( "maps" "slices" "sync" "sync/atomic" "time")
// Message is one published message. The sequence number is per topic, so a subscriber can tell// whether it missed something or got something twice.type Message struct { Topic string Seq int64 Payload string}
// Subscriber is whatever wants messages: an email sender, a cache invalidator, a counter.type Subscriber func(Message) error
// Overflow says what to do when a subscriber's mailbox is full. There is no right answer for// every subscriber, so each one chooses when it subscribes.type Overflow int
const ( Block Overflow = iota // the publisher waits for room: nothing is lost, and a slow subscriber slows publishing DropOldest // make room by discarding the oldest waiting message: fine for "latest price" feeds DropNewest // discard the message being published: the backlog is delivered in full)
// Options says how one subscriber wants its messages delivered: how big its mailbox is, what to// do when it is full, and how many times to retry a failing handler before giving up.type Options struct { Capacity int Overflow Overflow MaxAttempts int Backoff time.Duration}
// DeadLetter is a message a subscriber couldn't handle after every attempt, kept with the// reason so a person or a repair job can look at it and publish it again.type DeadLetter struct { Message Message SubscriptionID string Err error}
// Subscription is a subscriber's mailbox and the one goroutine that empties it. Publishing only// drops the message in the mailbox, so the publisher never waits on a handler. One goroutine// per subscription keeps each subscriber's messages in order, and a slow subscriber only delays// itself.type Subscription struct { ID string Topic string handler Subscriber options Options mailbox chan Message // Closed by run once the last message is delivered. done chan struct{} deadLetters func(DeadLetter) // Messages a full mailbox threw away. Counted, so a dashboard can show that this subscriber is // falling behind. Dropped atomic.Int64}
// newSubscription starts the worker at once, so messages are delivered as soon as they arrive.func newSubscription(id, topic string, handler Subscriber, o Options, deadLetters func(DeadLetter)) *Subscription { s := &Subscription{ID: id, Topic: topic, handler: handler, options: o, mailbox: make(chan Message, o.Capacity), done: make(chan struct{}), deadLetters: deadLetters} go s.run() return s}
// Called by the topic while publishing. The subscriber's Overflow rule decides what a full// mailbox means: wait for room, drop the oldest message, or drop this one.func (s *Subscription) offer(m Message) { switch s.options.Overflow { case Block: s.mailbox <- m case DropNewest: select { case s.mailbox <- m: default: s.Dropped.Add(1) } case DropOldest: for { select { case s.mailbox <- m: return default: } select { case <-s.mailbox: s.Dropped.Add(1) default: } } }}
// Close lets run deliver what is already in the mailbox, then waits until it has.func (s *Subscription) Close() { close(s.mailbox) <-s.done}
// run is the worker: one message at a time, in order, until the mailbox is closed.func (s *Subscription) run() { defer close(s.done) for m := range s.mailbox { s.deliver(m) }}
// deliver retries, because most failures are brief (a database restarting, a timeout), waiting// a little longer each time. The next message waits meanwhile: that's the price of keeping this// subscriber's messages in order. After the last attempt the message is set aside as a dead// letter and the subscriber moves on, so one bad message can't stop it for good.func (s *Subscription) deliver(m Message) { for attempt := 1; ; attempt++ { err := s.handler(m) if err == nil { return } if attempt == s.options.MaxAttempts { s.deadLetters(DeadLetter{m, s.ID, err}) return } time.Sleep(s.options.Backoff * time.Duration(attempt)) }}
// Topic exists because of two problems that appear once several goroutines publish. Two// publishers can read the same sequence number. And publisher A can put its message in Ana's// mailbox, then publisher B puts its message in both mailboxes, then A reaches Ben's: Ana sees// A, B and Ben sees B, A. Each topic therefore hands out the number and fills every mailbox as// one step, under the topic's lock, so every subscriber sees the same order. Different topics// never wait on each other.//// The subscriber list is replaced on every change instead of locked. A publish that is blocked// on a full mailbox holds publishMu, and an unsubscribe, often of that very subscriber, must// not wait behind it.type Topic struct { name string publishMu sync.Mutex nextSeq int64 // only touched under publishMu changeMu sync.Mutex subscriptions atomic.Pointer[[]*Subscription] // replaced, never changed in place}
// newTopic starts with an empty list, so Load never returns nil.func newTopic(name string) *Topic { t := &Topic{name: name} t.subscriptions.Store(&[]*Subscription{}) return t}
// Publish holds publishMu so numbering the message and filling every mailbox are one step.func (t *Topic) Publish(payload string) { t.publishMu.Lock() defer t.publishMu.Unlock() m := Message{t.name, t.nextSeq, payload} t.nextSeq++ for _, s := range *t.subscriptions.Load() { // a snapshot: changes don't affect this loop s.offer(m) }}
// Builds a new list with the change. A publish already looping over the old list is not// disturbed.func (t *Topic) add(s *Subscription) { t.changeMu.Lock() defer t.changeMu.Unlock() next := append(slices.Clone(*t.subscriptions.Load()), s) t.subscriptions.Store(&next)}
// remove does the same. changeMu only stops two changes from racing; it never waits on a// publish.func (t *Topic) remove(s *Subscription) { t.changeMu.Lock() defer t.changeMu.Unlock() next := slices.DeleteFunc(slices.Clone(*t.subscriptions.Load()), func(x *Subscription) bool { return x == s }) t.subscriptions.Store(&next)}
// Broker is the front door for publishers and subscribers. It keeps one Topic per name and a// shared list of dead letters. It never runs a subscriber's code itself: each Subscription has// its own goroutine, so one slow subscriber can't hold up the others.type Broker struct { mu sync.Mutex // guards topics and deadLetters topics map[string]*Topic deadLetters []DeadLetter // Injected, so tests get predictable subscription ids. ids func() string}
func NewBroker(ids func() string) *Broker { return &Broker{topics: map[string]*Topic{}, ids: ids}}
// Subscribe gives each subscription its own mailbox and worker. Its failures go to the shared// dead-letter list.func (b *Broker) Subscribe(topic string, handler Subscriber, o Options) *Subscription { s := newSubscription(b.ids(), topic, handler, o, b.addDeadLetter) b.topic(topic).add(s) return s}
// Unsubscribe takes the subscription off the list first, so no publish sends to its mailbox// once it is closed. A publish already holding the old list finishes before Close returns,// because Close waits for the topic's publish lock.func (b *Broker) Unsubscribe(s *Subscription) { t := b.topic(s.Topic) t.remove(s) t.publishMu.Lock() s.Close() t.publishMu.Unlock()}
// The topic numbers the message and puts it in every mailbox, so all subscribers see the same// order. A full mailbox can make this wait, depending on the subscriber's Overflow rule.func (b *Broker) Publish(topic, payload string) { b.topic(topic).Publish(payload) }
// DeadLetters returns a copy, so callers can't change the broker's list.func (b *Broker) DeadLetters() []DeadLetter { b.mu.Lock() defer b.mu.Unlock() return slices.Clone(b.deadLetters)}
// Closes every subscription. Each close waits for its worker to finish. The topics are copied// first, so the lock isn't held while waiting.func (b *Broker) Close() { b.mu.Lock() topics := slices.Collect(maps.Values(b.topics)) b.mu.Unlock() for _, t := range topics { for _, s := range *t.subscriptions.Load() { s.Close() } }}
// topic creates the topic on first use, under mu so two callers can't create it twice.func (b *Broker) topic(name string) *Topic { b.mu.Lock() defer b.mu.Unlock() if b.topics[name] == nil { b.topics[name] = newTopic(name) } return b.topics[name]}
// addDeadLetter is called by workers from their own goroutines, hence the lock.func (b *Broker) addDeadLetter(d DeadLetter) { b.mu.Lock() defer b.mu.Unlock() b.deadLetters = append(b.deadLetters, d)}// The complete design, built from every stage on this page.
// One published message. The sequence number is per topic, so a subscriber can tell whether it// missed something or got something twice.interface Message { readonly topic: string; readonly seq: number; readonly payload: string;}
// Whatever wants messages: an email sender, a cache invalidator, a metrics counter.type Subscriber = (m: Message) => void | Promise<void>;
// The front door for publishers and subscribers. It keeps one Topic per name and a shared list// of dead letters. It never runs a subscriber's code itself: each Subscription has its own// delivery loop, so one slow subscriber can't hold up the others.class Broker { // Created on first use. private readonly topics = new Map<string, Topic>(); // Messages that failed every retry. private readonly dead: DeadLetter[] = [];
constructor(private readonly ids: () => string) {}
// Each subscription gets its own mailbox and worker. Its failures go to the shared dead- // letter list. subscribe(topic: string, handler: Subscriber, options: Options): Subscription { const s = new Subscription(this.ids(), topic, handler, options, (d) => this.dead.push(d)); this.topic(topic).add(s); return s; }
// Stop new messages first, then let the worker finish what is already in its mailbox. async unsubscribe(s: Subscription): Promise<void> { this.topic(s.topic).remove(s); await s.close(); }
// The topic numbers the message and puts it in every mailbox, so all subscribers see the same // order. A full mailbox can make this wait, depending on the subscriber's Overflow rule. publish(topic: string, payload: string): Promise<void> { return this.topic(topic).publish(payload); }
// A copy, so callers can't change the broker's list. deadLetters(): DeadLetter[] { return [...this.dead]; }
// Closes every subscription. Each close waits for its worker to finish. async close(): Promise<void> { for (const t of this.topics.values()) for (const s of t.subscriptions) await s.close(); }
private topic(name: string): Topic { let t = this.topics.get(name); if (!t) this.topics.set(name, (t = new Topic(name))); return t; }}
// A subscriber's mailbox and the one loop that empties it. Publishing only drops the message in// the mailbox, so the publisher never waits on a handler. One loop per subscription keeps each// subscriber's messages in order, and a slow subscriber only delays itself.class Subscription { private readonly mailbox: Message[] = []; // Publishers waiting on a full mailbox. Each message taken out wakes one of them. private readonly waitingForRoom: (() => void)[] = []; private pumping: Promise<void> = Promise.resolve(); private running = false; // Messages a full mailbox threw away. Counted, so a dashboard can show that this subscriber // is falling behind. dropped = 0;
constructor( readonly id: string, readonly topic: string, private readonly handler: Subscriber, private readonly options: Options, private readonly deadLetters: (d: DeadLetter) => void, ) {}
// Called by the topic while publishing. The subscriber's Overflow rule decides what a full // mailbox means: wait for room, drop the oldest message, or drop this one. async offer(m: Message): Promise<void> { if (this.mailbox.length >= this.options.capacity) { if (this.options.overflow === 'drop-newest') { this.dropped++; return; } if (this.options.overflow === 'drop-oldest') { this.mailbox.shift(); this.dropped++; } else { // Wait until the loop takes a message out. Several publishers may be waiting, so check // again after waking. while (this.mailbox.length >= this.options.capacity) { await new Promise<void>((resolve) => this.waitingForRoom.push(resolve)); } } } this.mailbox.push(m); this.start(); }
// Waits for the loop to deliver what is already in the mailbox. async close(): Promise<void> { await this.pumping; }
// Starts the delivery loop if it isn't running already. Only one loop runs at a time, so // messages stay in order. private start(): void { if (this.running) return; this.running = true; this.pumping = this.pump(); }
// Empties the mailbox one message at a time, then stops until the next offer starts it again. private async pump(): Promise<void> { while (this.mailbox.length > 0) { const m = this.mailbox.shift()!; this.waitingForRoom.shift()?.(); // one slot freed, one waiting publisher may go await this.deliver(m); } this.running = false; }
// Most failures are brief (a database restarting, a timeout), so try again, waiting a // little longer each time. The next message waits meanwhile: that's the price of keeping // this subscriber's messages in order. After the last attempt the message is set aside as // a dead letter and the subscriber moves on, so one bad message can't stop it for good. private async deliver(m: Message): Promise<void> { for (let attempt = 1; ; attempt++) { try { await this.handler(m); return; } catch (e) { if (attempt === this.options.maxAttempts) { this.deadLetters({ message: m, subscriptionId: this.id, error: String((e as Error).message) }); return; } await new Promise((resolve) => setTimeout(resolve, this.options.backoffMs * attempt)); } } }}
// What to do when a subscriber's mailbox is full. There is no right answer for every// subscriber, so each one chooses when it subscribes.// block: the publisher waits for room: nothing is lost, and a slow subscriber slows publishing// drop-oldest: make room by discarding the oldest waiting message: fine for "latest price" feeds// drop-newest: discard the message being published: the backlog is delivered in fulltype Overflow = 'block' | 'drop-oldest' | 'drop-newest';
// How one subscriber wants its messages delivered: how big its mailbox is, what to do when it// is full, and how many times to retry a failing handler before giving up.interface Options { readonly capacity: number; readonly overflow: Overflow; readonly maxAttempts: number; readonly backoffMs: number;}
// A message a subscriber couldn't handle after every attempt, kept with the reason so a person// or a repair job can look at it and publish it again.interface DeadLetter { readonly message: Message; readonly subscriptionId: string; readonly error: string;}
// Node has no threads, but every await is a point where another publish can run. Publisher A// puts its message in Ana's mailbox, then waits for room in Ben's. A slot frees up, and before// A wakes, publisher B (already past Ana's mailbox) takes it. Ana sees A, B and Ben sees B, A.// So each topic runs its publishes one at a time: a promise chain acts as the topic's lock,// and the sequence number is handed out inside it. Different topics never wait on each other.//// The subscriber list is replaced on every change instead of edited in place, so a publish// waiting for room loops over the list it started with, and an unsubscribe doesn't wait.class Topic { private tail: Promise<void> = Promise.resolve(); private nextSeq = 0; subscriptions: readonly Subscription[] = [];
constructor(readonly name: string) {}
// Queues this publish behind the last one, so a topic's publishes run one at a time. publish(payload: string): Promise<void> { const run = this.tail.then(async () => { const m = { topic: this.name, seq: this.nextSeq++, payload }; for (const s of this.subscriptions) await s.offer(m); }); this.tail = run.catch(() => {}); // a failed publish must not stop the ones queued behind it return run; }
// Builds a new list with the change. A publish already looping over the old list is not // disturbed. add(s: Subscription): void { this.subscriptions = [...this.subscriptions, s]; }
// The same for removing. remove(s: Subscription): void { this.subscriptions = this.subscriptions.filter((x) => x !== s); }}Three ideas hold it together:
- Publishing and delivering never share a thread. A publisher’s only contact with a subscriber is its mailbox (producer-consumer), and the mailbox’s policy decides how much a slow subscriber can cost.
- One reader per mailbox. That single worker is what gives each subscriber its order, and lets handlers be written without locks. Subscribers are plugged in through one interface (Observer).
- Nothing is lost silently. A message is delivered, dropped and counted, or set aside as a dead letter with its error. A set-aside store like this is called a dead letter queue.
Stress it
Section titled “Stress it”So far, one thread publishes at a time. But the order service runs eight threads, all publishing to “orders”.
Eight publishers, one topic
Section titled “Eight publishers, one topic”The order service runs eight threads, all publishing to “orders”. The email sender and the audit log both subscribe. Do they see the orders in the same order?
Whether you spot that each subscriber’s own order isn’t the same order for everyone, find the timing that breaks it, and fix it per topic without a global lock. They also listen for what an unsubscribe does while a publish is waiting.
Two things go wrong when several threads publish to one topic.
Two publishers can get the same number. Both read “the next number is 41” before either adds one.
The hand-out can interleave. Picture publishers A and B, each handing a message to two mailboxes:
- A puts its message in the email mailbox.
- B puts its message in the email mailbox, then in the audit mailbox.
- A puts its message in the audit mailbox.
Email sees A then B. Audit sees B then A. Each subscriber’s order is still consistent with itself, but they disagree. And an audit log that disagrees with what was emailed is a bug report waiting to happen.
The fix is a lock per topic. Each topic numbers and hands out a message as one step, under the topic’s own lock. So publishes to one topic happen one at a time, and every subscriber sees messages in number order. Publishers to different topics use different locks, so they never wait for each other.
The subscriber list is the other shared thing. A publish that’s blocked on a full mailbox holds the topic’s lock. An unsubscribe, often of that very slow subscriber, must not wait behind it. So the list is never changed in place. Subscribe and unsubscribe build a new list and swap it in, and a publish goes through the list it started with. This is called copy on write.
| Option | How | Same order for all? | Cost |
|---|---|---|---|
| 1. A lock per topic for numbering and hand-out (our choice) | The subscriber list is a swapped copy | Yes | A blocked publish holds up other publishers to that topic |
| 2. One global lock | Every publish takes it | Yes | Topics wait for each other for no reason |
| 3. A queue per topic with its own dispatcher | Publishers add to the topic’s queue; one thread hands out | Yes | One more thread per topic, and one more queue to limit |
| 4. No lock; subscribers sort by number | Subscribers reorder by sequence number themselves | Eventually | Every subscriber needs a reorder buffer and gap handling |
| 5. Thread-safe collections only | A thread-safe map and lists | No | Safe from crashes, still interleaves |
// Two problems appear once several threads publish. Two publishers can read the same sequence// number. And publisher A can put its message in Ana's mailbox, then publisher B puts its// message in both mailboxes, then A reaches Ben's: Ana sees A, B and Ben sees B, A. Each topic// therefore hands out the number and fills every mailbox as one step, under the topic's lock,// so every subscriber sees the same order. Different topics never wait on each other.//// The subscriber list is copied on every change instead of locked. A publish that is blocked// on a full mailbox holds the topic's lock, and an unsubscribe, often of that very subscriber,// must not wait behind it.class Topic { final String name; private final Object publishLock = new Object(); private volatile List<Subscription> subscriptions = List.of(); private long nextSeq; // only touched under publishLock
Topic(String name) { this.name = name; }
// The lock makes numbering the message and filling every mailbox one step. void publish(String payload) throws InterruptedException { synchronized (publishLock) { Message m = new Message(name, nextSeq++, payload); for (Subscription s : subscriptions) s.offer(m); // a snapshot: changes don't affect this loop } }
// Builds a new list with the change. A publish already looping over the old list is not // disturbed. synchronized void add(Subscription s) { List<Subscription> next = new ArrayList<>(subscriptions); next.add(s); subscriptions = List.copyOf(next); }
// The same for removing. The lock here only stops two changes from racing; it never waits // on a publish. synchronized void remove(Subscription s) { List<Subscription> next = new ArrayList<>(subscriptions); next.remove(s); subscriptions = List.copyOf(next); }
// Read without a lock: the list is never changed in place. List<Subscription> subscriptions() { return subscriptions; }}
// The front door for publishers and subscribers. It keeps one Topic per name and a shared list// of dead letters. It never runs a subscriber's code itself: each Subscription has its own// worker, so one slow subscriber can't hold up the others.class Broker { // Created on first use. Concurrent, because publishers and subscribers call in from // different threads. private final Map<String, Topic> topics = new ConcurrentHashMap<>(); // Messages that failed every retry. Workers add to it from their own threads. private final Queue<DeadLetter> deadLetters = new ConcurrentLinkedQueue<>(); // Injected, so tests get predictable subscription ids. private final Supplier<String> ids;
Broker(Supplier<String> ids) { this.ids = ids; }
// Each subscription gets its own mailbox and worker. Its failures go to the shared dead- // letter list. Subscription subscribe(String topic, Subscriber handler, Options options) { Subscription s = new Subscription(ids.get(), topic, handler, options, deadLetters::add); topic(topic).add(s); return s; }
// Stop new messages first, then let the worker finish what is already in its mailbox. void unsubscribe(Subscription s) throws InterruptedException { topic(s.topic).remove(s); s.close(); }
// The topic numbers the message and puts it in every mailbox, so all subscribers see the // same order. A full mailbox can make this wait, depending on the subscriber's Overflow // rule. void publish(String topic, String payload) throws InterruptedException { topic(topic).publish(payload); }
// A copy, so callers can't change the broker's list. List<DeadLetter> deadLetters() { return List.copyOf(deadLetters); }
// Closes every subscription. Each close waits for its worker to finish. void close() throws InterruptedException { for (Topic t : topics.values()) for (Subscription s : t.subscriptions()) s.close(); }
private Topic topic(String name) { return topics.computeIfAbsent(name, Topic::new); }}# Two problems appear once several threads publish. Two publishers can read the same sequence# number. And publisher A can put its message in Ana's mailbox, then publisher B puts its# message in both mailboxes, then A reaches Ben's: Ana sees A, B and Ben sees B, A. Each topic# therefore hands out the number and fills every mailbox as one step, under the topic's lock,# so every subscriber sees the same order. Different topics never wait on each other.## The subscriber list is replaced on every change instead of locked. A publish that is blocked# on a full mailbox holds the topic's lock, and an unsubscribe, often of that very subscriber,# must not wait behind it.class Topic: def __init__(self, name: str): self.name = name self._publish_lock = threading.Lock() self._change_lock = threading.Lock() self.subscriptions: tuple[Subscription, ...] = () # replaced, never changed in place self._next_seq = 0 # only touched under _publish_lock
# The lock makes numbering the message and filling every mailbox one step. def publish(self, payload: str) -> None: with self._publish_lock: m = Message(self.name, self._next_seq, payload) self._next_seq += 1 for s in self.subscriptions: # a snapshot: changes don't affect this loop s.offer(m)
# Builds a new list with the change. A publish already looping over the old list is not # disturbed. def add(self, s: Subscription) -> None: with self._change_lock: self.subscriptions = (*self.subscriptions, s)
# The same for removing. _change_lock only stops two changes from racing; it never waits on # a publish. def remove(self, s: Subscription) -> None: with self._change_lock: self.subscriptions = tuple(x for x in self.subscriptions if x is not s)
# The front door for publishers and subscribers. It keeps one Topic per name and a shared list# of dead letters. It never runs a subscriber's code itself: each Subscription has its own# worker, so one slow subscriber can't hold up the others.class Broker: def __init__(self, ids: Callable[[], str]): # Created on first use, under _topics_lock, so two threads can't create the same topic # twice. self._topics: dict[str, Topic] = {} self._topics_lock = threading.Lock() # Messages that failed every retry. list.append is atomic, so workers can add from their # own threads. self._dead_letters: list[DeadLetter] = [] # Injected, so tests get predictable subscription ids. self._ids = ids
# Each subscription gets its own mailbox and worker. Its failures go to the shared dead- # letter list. def subscribe(self, topic: str, handler: Subscriber, options: Options) -> Subscription: s = Subscription(self._ids(), topic, handler, options, self._dead_letters.append) self._topic(topic).add(s) return s
# Stop new messages first, then let the worker finish what is already in its mailbox. def unsubscribe(self, s: Subscription) -> None: self._topic(s.topic).remove(s) s.close()
# The topic numbers the message and puts it in every mailbox, so all subscribers see the # same order. A full mailbox can make this wait, depending on the subscriber's Overflow # rule. def publish(self, topic: str, payload: str) -> None: self._topic(topic).publish(payload)
# A copy, so callers can't change the broker's list. def dead_letters(self) -> list[DeadLetter]: return list(self._dead_letters)
# Closes every subscription. Each close waits for its worker to finish. def close(self) -> None: for t in list(self._topics.values()): for s in t.subscriptions: s.close()
# Created on first use. def _topic(self, name: str) -> Topic: with self._topics_lock: if name not in self._topics: self._topics[name] = Topic(name) return self._topics[name]// Two problems appear once several threads publish. Two publishers can read the same sequence// number. And publisher A can put its message in Ana's mailbox, then publisher B puts its// message in both mailboxes, then A reaches Ben's: Ana sees A, B and Ben sees B, A. Each topic// therefore hands out the number and fills every mailbox as one step, under the topic's lock,// so every subscriber sees the same order. Different topics never wait on each other. The lock// is a SemaphoreSlim because a publish awaits room in a mailbox, and a C# lock can't be held// across an await.//// The subscriber list is replaced on every change instead of locked. A publish that is waiting// for room holds the topic's lock, and an unsubscribe, often of that very subscriber, must not// wait behind it.public sealed class Topic(string name){ private readonly SemaphoreSlim _publishLock = new(1, 1); private readonly Lock _changeLock = new(); private long _nextSeq; // only touched while holding _publishLock
public string Name { get; } = name; public ImmutableArray<Subscription> Subscriptions { get; private set; } = [];
// The lock makes numbering the message and filling every mailbox one step. public async Task Publish(string payload) { await _publishLock.WaitAsync(); try { var m = new Message(Name, _nextSeq++, payload); foreach (var s in Subscriptions) await s.Offer(m); // a snapshot: changes don't affect this loop } finally { _publishLock.Release(); } }
// Builds a new list with the change. A publish already looping over the old list is not // disturbed. public void Add(Subscription s) { lock (_changeLock) Subscriptions = Subscriptions.Add(s); }
// The same for removing. _changeLock only stops two changes from racing; it never waits on // a publish. public void Remove(Subscription s) { lock (_changeLock) Subscriptions = Subscriptions.Remove(s); }}
// The front door for publishers and subscribers. It keeps one Topic per name and a shared list// of dead letters. It never runs a subscriber's code itself: each Subscription has its own// worker, so one slow subscriber can't hold up the others. ids is injected, so tests get// predictable subscription ids.public sealed class Broker(Func<string> ids){ // Created on first use. Concurrent, because publishers and subscribers call in from // different threads. private readonly ConcurrentDictionary<string, Topic> _topics = new(); // Messages that failed every retry. Workers add to it from their own threads. private readonly ConcurrentQueue<DeadLetter> _deadLetters = new();
// Each subscription gets its own mailbox and worker. Its failures go to the shared dead- // letter list. public Subscription Subscribe(string topic, Subscriber handler, Options options) { var s = new Subscription(ids(), topic, handler, options, _deadLetters.Enqueue); TopicFor(topic).Add(s); return s; }
// Stop new messages first, then let the worker finish what is already in its mailbox. public Task Unsubscribe(Subscription s) { TopicFor(s.Topic).Remove(s); return s.Close(); }
// The topic numbers the message and puts it in every mailbox, so all subscribers see the // same order. A full mailbox can make this wait, depending on the subscriber's Overflow // rule. public Task Publish(string topic, string payload) => TopicFor(topic).Publish(payload);
// A copy, so callers can't change the broker's list. public List<DeadLetter> DeadLetters() => [.. _deadLetters];
// Closes every subscription. Each close waits for its worker to finish. public async Task Close() { foreach (var s in _topics.Values.SelectMany(t => t.Subscriptions)) await s.Close(); }
private Topic TopicFor(string name) => _topics.GetOrAdd(name, n => new Topic(n));}// Topic exists because of two problems that appear once several goroutines publish. Two// publishers can read the same sequence number. And publisher A can put its message in Ana's// mailbox, then publisher B puts its message in both mailboxes, then A reaches Ben's: Ana sees// A, B and Ben sees B, A. Each topic therefore hands out the number and fills every mailbox as// one step, under the topic's lock, so every subscriber sees the same order. Different topics// never wait on each other.//// The subscriber list is replaced on every change instead of locked. A publish that is blocked// on a full mailbox holds publishMu, and an unsubscribe, often of that very subscriber, must// not wait behind it.type Topic struct { name string publishMu sync.Mutex nextSeq int64 // only touched under publishMu changeMu sync.Mutex subscriptions atomic.Pointer[[]*Subscription] // replaced, never changed in place}
// newTopic starts with an empty list, so Load never returns nil.func newTopic(name string) *Topic { t := &Topic{name: name} t.subscriptions.Store(&[]*Subscription{}) return t}
// Publish holds publishMu so numbering the message and filling every mailbox are one step.func (t *Topic) Publish(payload string) { t.publishMu.Lock() defer t.publishMu.Unlock() m := Message{t.name, t.nextSeq, payload} t.nextSeq++ for _, s := range *t.subscriptions.Load() { // a snapshot: changes don't affect this loop s.offer(m) }}
// Builds a new list with the change. A publish already looping over the old list is not// disturbed.func (t *Topic) add(s *Subscription) { t.changeMu.Lock() defer t.changeMu.Unlock() next := append(slices.Clone(*t.subscriptions.Load()), s) t.subscriptions.Store(&next)}
// remove does the same. changeMu only stops two changes from racing; it never waits on a// publish.func (t *Topic) remove(s *Subscription) { t.changeMu.Lock() defer t.changeMu.Unlock() next := slices.DeleteFunc(slices.Clone(*t.subscriptions.Load()), func(x *Subscription) bool { return x == s }) t.subscriptions.Store(&next)}
// Broker is the front door for publishers and subscribers. It keeps one Topic per name and a// shared list of dead letters. It never runs a subscriber's code itself: each Subscription has// its own goroutine, so one slow subscriber can't hold up the others.type Broker struct { mu sync.Mutex // guards topics and deadLetters topics map[string]*Topic deadLetters []DeadLetter // Injected, so tests get predictable subscription ids. ids func() string}
func NewBroker(ids func() string) *Broker { return &Broker{topics: map[string]*Topic{}, ids: ids}}
// Subscribe gives each subscription its own mailbox and worker. Its failures go to the shared// dead-letter list.func (b *Broker) Subscribe(topic string, handler Subscriber, o Options) *Subscription { s := newSubscription(b.ids(), topic, handler, o, b.addDeadLetter) b.topic(topic).add(s) return s}
// Unsubscribe takes the subscription off the list first, so no publish sends to its mailbox// once it is closed. A publish already holding the old list finishes before Close returns,// because Close waits for the topic's publish lock.func (b *Broker) Unsubscribe(s *Subscription) { t := b.topic(s.Topic) t.remove(s) t.publishMu.Lock() s.Close() t.publishMu.Unlock()}
// The topic numbers the message and puts it in every mailbox, so all subscribers see the same// order. A full mailbox can make this wait, depending on the subscriber's Overflow rule.func (b *Broker) Publish(topic, payload string) { b.topic(topic).Publish(payload) }
// DeadLetters returns a copy, so callers can't change the broker's list.func (b *Broker) DeadLetters() []DeadLetter { b.mu.Lock() defer b.mu.Unlock() return slices.Clone(b.deadLetters)}
// Closes every subscription. Each close waits for its worker to finish. The topics are copied// first, so the lock isn't held while waiting.func (b *Broker) Close() { b.mu.Lock() topics := slices.Collect(maps.Values(b.topics)) b.mu.Unlock() for _, t := range topics { for _, s := range *t.subscriptions.Load() { s.Close() } }}
// topic creates the topic on first use, under mu so two callers can't create it twice.func (b *Broker) topic(name string) *Topic { b.mu.Lock() defer b.mu.Unlock() if b.topics[name] == nil { b.topics[name] = newTopic(name) } return b.topics[name]}
// addDeadLetter is called by workers from their own goroutines, hence the lock.func (b *Broker) addDeadLetter(d DeadLetter) { b.mu.Lock() defer b.mu.Unlock() b.deadLetters = append(b.deadLetters, d)}// Node has no threads, but every await is a point where another publish can run. Publisher A// puts its message in Ana's mailbox, then waits for room in Ben's. A slot frees up, and before// A wakes, publisher B (already past Ana's mailbox) takes it. Ana sees A, B and Ben sees B, A.// So each topic runs its publishes one at a time: a promise chain acts as the topic's lock,// and the sequence number is handed out inside it. Different topics never wait on each other.//// The subscriber list is replaced on every change instead of edited in place, so a publish// waiting for room loops over the list it started with, and an unsubscribe doesn't wait.class Topic { private tail: Promise<void> = Promise.resolve(); private nextSeq = 0; subscriptions: readonly Subscription[] = [];
constructor(readonly name: string) {}
// Queues this publish behind the last one, so a topic's publishes run one at a time. publish(payload: string): Promise<void> { const run = this.tail.then(async () => { const m = { topic: this.name, seq: this.nextSeq++, payload }; for (const s of this.subscriptions) await s.offer(m); }); this.tail = run.catch(() => {}); // a failed publish must not stop the ones queued behind it return run; }
// Builds a new list with the change. A publish already looping over the old list is not // disturbed. add(s: Subscription): void { this.subscriptions = [...this.subscriptions, s]; }
// The same for removing. remove(s: Subscription): void { this.subscriptions = this.subscriptions.filter((x) => x !== s); }}
// The front door for publishers and subscribers. It keeps one Topic per name and a shared list// of dead letters. It never runs a subscriber's code itself: each Subscription has its own// delivery loop, so one slow subscriber can't hold up the others.class Broker { // Created on first use. private readonly topics = new Map<string, Topic>(); // Messages that failed every retry. private readonly dead: DeadLetter[] = [];
constructor(private readonly ids: () => string) {}
// Each subscription gets its own mailbox and worker. Its failures go to the shared dead- // letter list. subscribe(topic: string, handler: Subscriber, options: Options): Subscription { const s = new Subscription(this.ids(), topic, handler, options, (d) => this.dead.push(d)); this.topic(topic).add(s); return s; }
// Stop new messages first, then let the worker finish what is already in its mailbox. async unsubscribe(s: Subscription): Promise<void> { this.topic(s.topic).remove(s); await s.close(); }
// The topic numbers the message and puts it in every mailbox, so all subscribers see the same // order. A full mailbox can make this wait, depending on the subscriber's Overflow rule. publish(topic: string, payload: string): Promise<void> { return this.topic(topic).publish(payload); }
// A copy, so callers can't change the broker's list. deadLetters(): DeadLetter[] { return [...this.dead]; }
// Closes every subscription. Each close waits for its worker to finish. async close(): Promise<void> { for (const t of this.topics.values()) for (const s of t.subscriptions) await s.close(); }
private topic(name: string): Topic { let t = this.topics.get(name); if (!t) this.topics.set(name, (t = new Topic(name))); return t; }}Why is this correct? Under the topic’s lock, a message gets its number and goes into every mailbox before the next publish can start. So the order of numbers is the order every mailbox receives them, and each worker hands them to its handler in that same order.
What does it cost? The topic lock is held while a publish waits for room in a blocking mailbox. That’s intended: publishers to that topic slow to its slowest blocking subscriber, which is what Block means.
Go deeperHow each language does the topic lock1 min
Each language uses a lock that can wait correctly. Java uses a monitor (synchronized with
wait), and Go and Python use mutexes. C# uses a SemaphoreSlim, because a plain lock can’t be
held across an await. TypeScript uses a chain of promises: in Node, every await is a point where
another publish can run, so the chain makes each publish wait for the previous one to finish.
Go deeperWhy not the other options2 min
One global lock (option 2) is correct, and it’s a fine first answer. But one slow, blocking subscriber on “orders” would then stall publishers to “payments”, which have nothing to do with it. A lock per topic costs nothing more, so there’s no reason to share one.
A dispatcher thread per topic (option 3) is a real alternative, and it’s in the different designs at the end. Publishers no longer wait on the hand-out. But the topic queue needs its own limit and policy, so we’ve moved the backpressure question, not removed it.
Letting subscribers sort by number (option 4) is how systems spread across machines often work, because there’s no lock that every machine can share. Inside one process there is one, and it’s much simpler than asking every subscriber to buffer, reorder and handle gaps.
Thread-safe collections alone (option 5) are the trap. They stop the map and lists from being corrupted, so nothing crashes, and the tests that only count messages pass. But the order problem is about two steps, numbering and handing out, happening together. No collection can make two separate calls into one step. That needs a lock around both.
How you’d prove it works
Section titled “How you’d prove it works”Two tools make the tests reliable. close() waits until every worker has delivered what it
accepted, so a test can publish, close, then check. And a “stuck” handler signals when it has
started a message, then waits to be released. That lets a test fill a mailbox behind it at an exact
moment. The tests that matter:
- Every subscriber gets all 100 messages, in order, and only its own topic’s messages.
- A slow subscriber doesn’t hold up another subscriber.
- A message that always fails becomes a dead letter, and the next message is still delivered. A message that fails once is retried and delivered.
- With capacity 2 and the worker stuck, drop-oldest delivers 1, 3, 4, and drop-newest delivers 1, 2, 3, each with one drop counted.
- With Block, a publisher waits while the mailbox is full, and finishes once there’s room.
- Unsubscribing stops new deliveries.
- Four threads publishing 250 messages each produce the same 1,000-message order for two subscribers.
Go deeperThe tests in five languages2 min
The last test catches the interleaving reliably in Java and Go. In Python and TypeScript, the window for the race is so small that the test may pass even without the topic lock. That’s a reminder that a passing concurrency test is evidence, not proof.
AtomicInteger nextId = new AtomicInteger();Broker broker;
static final Options ROOMY = new Options(1000, Overflow.BLOCK, 3, 0);
@BeforeEachvoid setUp() { broker = new Broker(() -> "s" + nextId.incrementAndGet()); }
// Collects payloads from the worker thread; read them only after close(), which joins it.static class Inbox implements Subscriber { final List<String> got = Collections.synchronizedList(new ArrayList<>()); public void onMessage(Message m) { got.add(m.payload()); }}
// A handler that signals when it starts the first message and then waits to be released, so a// test can fill the mailbox behind it.static class Stuck extends Inbox { final CountDownLatch started = new CountDownLatch(1); final CountDownLatch release = new CountDownLatch(1); public void onMessage(Message m) { started.countDown(); try { release.await(); } catch (InterruptedException e) { throw new RuntimeException(e); } super.onMessage(m); }}
void publishAll(String topic, String... payloads) throws InterruptedException { for (String p : payloads) broker.publish(topic, p);}
@Testvoid everySubscriberGetsEveryMessageInOrder() throws Exception { Inbox a = new Inbox(), b = new Inbox(); broker.subscribe("orders", a, ROOMY); broker.subscribe("orders", b, ROOMY); for (int i = 0; i < 100; i++) broker.publish("orders", "o" + i); broker.close(); assertEquals(100, a.got.size()); assertEquals(a.got, b.got); assertEquals("o99", a.got.get(99));}
@Testvoid onlyTheTopicsSubscribersHearIt() throws Exception { Inbox orders = new Inbox(), prices = new Inbox(); broker.subscribe("orders", orders, ROOMY); broker.subscribe("prices", prices, ROOMY); publishAll("orders", "o1"); broker.close(); assertEquals(List.of("o1"), orders.got); assertEquals(List.of(), prices.got);}
@Testvoid aSlowSubscriberDoesNotHoldUpTheOthers() throws Exception { Stuck slow = new Stuck(); CountDownLatch fastGotIt = new CountDownLatch(1); broker.subscribe("orders", slow, ROOMY); broker.subscribe("orders", m -> fastGotIt.countDown(), ROOMY); publishAll("orders", "o1"); assertTrue(fastGotIt.await(5, TimeUnit.SECONDS)); // while the slow one is still stuck slow.release.countDown(); broker.close();}
@Testvoid aMessageThatKeepsFailingBecomesADeadLetter() throws Exception { Inbox ok = new Inbox(); broker.subscribe("orders", m -> { if (m.payload().equals("bad")) throw new IllegalStateException("cannot parse"); ok.onMessage(m); }, ROOMY); publishAll("orders", "o1", "bad", "o2"); broker.close(); assertEquals(List.of("o1", "o2"), ok.got); // the bad one didn't stop the next DeadLetter d = broker.deadLetters().get(0); assertEquals("bad", d.message().payload()); assertEquals("cannot parse", d.error());}
@Testvoid aBriefFailureIsRetried() throws Exception { AtomicInteger calls = new AtomicInteger(); Inbox ok = new Inbox(); broker.subscribe("orders", m -> { if (calls.incrementAndGet() == 1) throw new IllegalStateException("timeout"); ok.onMessage(m); }, ROOMY); publishAll("orders", "o1"); broker.close(); assertEquals(List.of("o1"), ok.got); assertEquals(0, broker.deadLetters().size());}
@Testvoid dropOldestKeepsTheLatest() throws Exception { Stuck s = new Stuck(); Subscription sub = broker.subscribe("prices", s, new Options(2, Overflow.DROP_OLDEST, 1, 0)); publishAll("prices", "p1"); s.started.await(); // the worker holds p1, the mailbox is empty publishAll("prices", "p2", "p3", "p4"); s.release.countDown(); broker.close(); assertEquals(List.of("p1", "p3", "p4"), s.got); assertEquals(1, sub.dropped.get());}
@Testvoid dropNewestKeepsTheBacklog() throws Exception { Stuck s = new Stuck(); Subscription sub = broker.subscribe("prices", s, new Options(2, Overflow.DROP_NEWEST, 1, 0)); publishAll("prices", "p1"); s.started.await(); publishAll("prices", "p2", "p3", "p4"); s.release.countDown(); broker.close(); assertEquals(List.of("p1", "p2", "p3"), s.got); assertEquals(1, sub.dropped.get());}
@Testvoid blockMakesThePublisherWait() throws Exception { Stuck s = new Stuck(); broker.subscribe("orders", s, new Options(1, Overflow.BLOCK, 1, 0)); publishAll("orders", "o1"); s.started.await(); publishAll("orders", "o2"); // fills the mailbox Thread publisher = new Thread(() -> { try { broker.publish("orders", "o3"); } catch (InterruptedException e) { throw new RuntimeException(e); } }); publisher.start(); publisher.join(200); assertTrue(publisher.isAlive()); // still waiting for room s.release.countDown(); publisher.join(); broker.close(); assertEquals(List.of("o1", "o2", "o3"), s.got);}
@Testvoid unsubscribingStopsDelivery() throws Exception { Inbox a = new Inbox(); Subscription sub = broker.subscribe("orders", a, ROOMY); publishAll("orders", "o1"); broker.unsubscribe(sub); publishAll("orders", "o2"); broker.close(); assertEquals(List.of("o1"), a.got);}
@Testvoid concurrentPublishersProduceOneOrderForEveryone() throws Exception { Inbox a = new Inbox(), b = new Inbox(); broker.subscribe("orders", a, ROOMY); broker.subscribe("orders", b, ROOMY); ExecutorService pool = Executors.newFixedThreadPool(4); List<Future<?>> done = new ArrayList<>(); for (int t = 0; t < 4; t++) { int publisher = t; done.add(pool.submit(() -> { for (int i = 0; i < 250; i++) broker.publish("orders", publisher + "-" + i); return null; })); } for (Future<?> f : done) f.get(); pool.shutdown(); broker.close(); assertEquals(1000, a.got.size()); assertEquals(a.got, b.got);}import itertoolsimport threadingfrom concurrent.futures import ThreadPoolExecutor
import pytest
ROOMY = Options(1000, Overflow.BLOCK, max_attempts=3, backoff_seconds=0)
@pytest.fixturedef broker() -> Broker: counter = itertools.count(1) return Broker(lambda: f"s{next(counter)}")
# Collects payloads from the worker thread; read them only after close(), which joins it.class Inbox: def __init__(self): self.got: list[str] = []
def __call__(self, m: Message) -> None: self.got.append(m.payload)
# A handler that signals when it starts the first message and then waits to be released, so a# test can fill the mailbox behind it.class Stuck(Inbox): def __init__(self): super().__init__() self.started = threading.Event() self.release = threading.Event()
def __call__(self, m: Message) -> None: self.started.set() self.release.wait() super().__call__(m)
def test_every_subscriber_gets_every_message_in_order(broker): a, b = Inbox(), Inbox() broker.subscribe("orders", a, ROOMY) broker.subscribe("orders", b, ROOMY) for i in range(100): broker.publish("orders", f"o{i}") broker.close() assert len(a.got) == 100 assert a.got == b.got assert a.got[99] == "o99"
def test_only_the_topics_subscribers_hear_it(broker): orders, prices = Inbox(), Inbox() broker.subscribe("orders", orders, ROOMY) broker.subscribe("prices", prices, ROOMY) broker.publish("orders", "o1") broker.close() assert orders.got == ["o1"] assert prices.got == []
def test_a_slow_subscriber_does_not_hold_up_the_others(broker): slow = Stuck() fast_got_it = threading.Event() broker.subscribe("orders", slow, ROOMY) broker.subscribe("orders", lambda m: fast_got_it.set(), ROOMY) broker.publish("orders", "o1") assert fast_got_it.wait(5) # while the slow one is still stuck slow.release.set() broker.close()
def test_a_message_that_keeps_failing_becomes_a_dead_letter(broker): ok = Inbox()
def handler(m: Message) -> None: if m.payload == "bad": raise ValueError("cannot parse") ok(m)
broker.subscribe("orders", handler, ROOMY) for p in ("o1", "bad", "o2"): broker.publish("orders", p) broker.close() assert ok.got == ["o1", "o2"] # the bad one didn't stop the next d = broker.dead_letters()[0] assert d.message.payload == "bad" assert d.error == "cannot parse"
def test_a_brief_failure_is_retried(broker): calls = itertools.count(1) ok = Inbox()
def handler(m: Message) -> None: if next(calls) == 1: raise TimeoutError("timeout") ok(m)
broker.subscribe("orders", handler, ROOMY) broker.publish("orders", "o1") broker.close() assert ok.got == ["o1"] assert broker.dead_letters() == []
@pytest.mark.parametrize( "overflow, expected", [(Overflow.DROP_OLDEST, ["p1", "p3", "p4"]), (Overflow.DROP_NEWEST, ["p1", "p2", "p3"])],)def test_a_full_mailbox_drops_by_its_policy(broker, overflow, expected): s = Stuck() sub = broker.subscribe("prices", s, Options(2, overflow, max_attempts=1)) broker.publish("prices", "p1") s.started.wait() # the worker holds p1, the mailbox is empty for p in ("p2", "p3", "p4"): broker.publish("prices", p) s.release.set() broker.close() assert s.got == expected assert sub.dropped == 1
def test_block_makes_the_publisher_wait(broker): s = Stuck() broker.subscribe("orders", s, Options(1, Overflow.BLOCK, max_attempts=1)) broker.publish("orders", "o1") s.started.wait() broker.publish("orders", "o2") # fills the mailbox publisher = threading.Thread(target=broker.publish, args=("orders", "o3")) publisher.start() publisher.join(0.2) assert publisher.is_alive() # still waiting for room s.release.set() publisher.join() broker.close() assert s.got == ["o1", "o2", "o3"]
def test_unsubscribing_stops_delivery(broker): a = Inbox() sub = broker.subscribe("orders", a, ROOMY) broker.publish("orders", "o1") broker.unsubscribe(sub) broker.publish("orders", "o2") broker.close() assert a.got == ["o1"]
def test_concurrent_publishers_produce_one_order_for_everyone(broker): a, b = Inbox(), Inbox() broker.subscribe("orders", a, ROOMY) broker.subscribe("orders", b, ROOMY)
def publish_many(publisher: int) -> None: for i in range(250): broker.publish("orders", f"{publisher}-{i}")
with ThreadPoolExecutor(4) as pool: list(pool.map(publish_many, range(4))) broker.close() assert len(a.got) == 1000 assert a.got == b.gotprivate static readonly Options Roomy = new(1000, Overflow.Block, MaxAttempts: 3);
private readonly Broker _broker;private int _nextId;
public BrokerTests() => _broker = new Broker(() => $"s{Interlocked.Increment(ref _nextId)}");
// Collects payloads from the worker; read them only after Close(), which waits for it.private class Inbox{ public readonly ConcurrentQueue<string> Got = new(); public virtual Task Handle(Message m) { Got.Enqueue(m.Payload); return Task.CompletedTask; }}
// A handler that signals when it starts the first message and then waits to be released, so a// test can fill the mailbox behind it.private sealed class Stuck : Inbox{ public readonly TaskCompletionSource Started = new(TaskCreationOptions.RunContinuationsAsynchronously); public readonly TaskCompletionSource Release = new(TaskCreationOptions.RunContinuationsAsynchronously); public override async Task Handle(Message m) { Started.TrySetResult(); await Release.Task; await base.Handle(m); }}
private async Task PublishAll(string topic, params string[] payloads){ foreach (var p in payloads) await _broker.Publish(topic, p);}
[Fact]public async Task EverySubscriberGetsEveryMessageInOrder(){ Inbox a = new(), b = new(); _broker.Subscribe("orders", a.Handle, Roomy); _broker.Subscribe("orders", b.Handle, Roomy); for (var i = 0; i < 100; i++) await _broker.Publish("orders", $"o{i}"); await _broker.Close(); Assert.Equal(100, a.Got.Count); Assert.Equal(a.Got, b.Got); Assert.Equal("o99", a.Got.Last());}
[Fact]public async Task OnlyTheTopicsSubscribersHearIt(){ Inbox orders = new(), prices = new(); _broker.Subscribe("orders", orders.Handle, Roomy); _broker.Subscribe("prices", prices.Handle, Roomy); await PublishAll("orders", "o1"); await _broker.Close(); Assert.Equal(new[] { "o1" }, orders.Got); Assert.Empty(prices.Got);}
[Fact]public async Task ASlowSubscriberDoesNotHoldUpTheOthers(){ var slow = new Stuck(); var fastGotIt = new TaskCompletionSource(); _broker.Subscribe("orders", slow.Handle, Roomy); _broker.Subscribe("orders", _ => { fastGotIt.TrySetResult(); return Task.CompletedTask; }, Roomy); await PublishAll("orders", "o1"); await fastGotIt.Task.WaitAsync(TimeSpan.FromSeconds(5)); // while the slow one is still stuck slow.Release.SetResult(); await _broker.Close();}
[Fact]public async Task AMessageThatKeepsFailingBecomesADeadLetter(){ var ok = new Inbox(); _broker.Subscribe("orders", m => m.Payload == "bad" ? throw new FormatException("cannot parse") : ok.Handle(m), Roomy); await PublishAll("orders", "o1", "bad", "o2"); await _broker.Close(); Assert.Equal(new[] { "o1", "o2" }, ok.Got); // the bad one didn't stop the next var d = Assert.Single(_broker.DeadLetters()); Assert.Equal("bad", d.Message.Payload); Assert.Equal("cannot parse", d.Error);}
[Fact]public async Task ABriefFailureIsRetried(){ var calls = 0; var ok = new Inbox(); _broker.Subscribe("orders", m => ++calls == 1 ? throw new TimeoutException("timeout") : ok.Handle(m), Roomy); await PublishAll("orders", "o1"); await _broker.Close(); Assert.Equal(new[] { "o1" }, ok.Got); Assert.Empty(_broker.DeadLetters());}
[Theory][InlineData(Overflow.DropOldest, new[] { "p1", "p3", "p4" })][InlineData(Overflow.DropNewest, new[] { "p1", "p2", "p3" })]public async Task AFullMailboxDropsByItsPolicy(Overflow overflow, string[] expected){ var s = new Stuck(); var sub = _broker.Subscribe("prices", s.Handle, new Options(2, overflow, MaxAttempts: 1)); await PublishAll("prices", "p1"); await s.Started.Task; // the worker holds p1, the mailbox is empty await PublishAll("prices", "p2", "p3", "p4"); s.Release.SetResult(); await _broker.Close(); Assert.Equal(expected, s.Got); Assert.Equal(1, sub.Dropped);}
[Fact]public async Task BlockMakesThePublisherWait(){ var s = new Stuck(); _broker.Subscribe("orders", s.Handle, new Options(1, Overflow.Block, MaxAttempts: 1)); await PublishAll("orders", "o1"); await s.Started.Task; await PublishAll("orders", "o2"); // fills the mailbox var third = _broker.Publish("orders", "o3"); await Task.Delay(200); Assert.False(third.IsCompleted); // still waiting for room s.Release.SetResult(); await third; await _broker.Close(); Assert.Equal(new[] { "o1", "o2", "o3" }, s.Got);}
[Fact]public async Task UnsubscribingStopsDelivery(){ var a = new Inbox(); var sub = _broker.Subscribe("orders", a.Handle, Roomy); await PublishAll("orders", "o1"); await _broker.Unsubscribe(sub); await PublishAll("orders", "o2"); await _broker.Close(); Assert.Equal(new[] { "o1" }, a.Got);}
[Fact]public async Task ConcurrentPublishersProduceOneOrderForEveryone(){ Inbox a = new(), b = new(); _broker.Subscribe("orders", a.Handle, Roomy); _broker.Subscribe("orders", b.Handle, Roomy); var publishers = Enumerable.Range(0, 4).Select(p => Task.Run(async () => { for (var i = 0; i < 250; i++) await _broker.Publish("orders", $"{p}-{i}"); })); await Task.WhenAll(publishers); await _broker.Close(); Assert.Equal(1000, a.Got.Count); Assert.Equal(a.Got, b.Got);}var roomy = Options{Capacity: 1000, Overflow: Block, MaxAttempts: 3}
func newBroker() *Broker { var next atomic.Int64 return NewBroker(func() string { return fmt.Sprintf("s%d", next.Add(1)) })}
// inbox collects payloads from the worker goroutine; read them only after Close.type inbox struct { mu sync.Mutex got []string}
func (in *inbox) handle(m Message) error { in.mu.Lock() defer in.mu.Unlock() in.got = append(in.got, m.Payload) return nil}
// stuck signals when it starts the first message and then waits to be released, so a test can// fill the mailbox behind it.type stuck struct { inbox started, release chan struct{} once sync.Once}
func newStuck() *stuck { return &stuck{started: make(chan struct{}), release: make(chan struct{})} }
func (s *stuck) handle(m Message) error { s.once.Do(func() { close(s.started) }) <-s.release return s.inbox.handle(m)}
func expect[T comparable](t *testing.T, want, got T) { t.Helper() if want != got { t.Fatalf("want %v, got %v", want, got) }}
func TestEverySubscriberGetsEveryMessageInOrder(t *testing.T) { b := newBroker() var x, y inbox b.Subscribe("orders", x.handle, roomy) b.Subscribe("orders", y.handle, roomy) for i := range 100 { b.Publish("orders", fmt.Sprintf("o%d", i)) } b.Close() expect(t, 100, len(x.got)) expect(t, true, slices.Equal(x.got, y.got)) expect(t, "o99", x.got[99])}
func TestOnlyTheTopicsSubscribersHearIt(t *testing.T) { b := newBroker() var orders, prices inbox b.Subscribe("orders", orders.handle, roomy) b.Subscribe("prices", prices.handle, roomy) b.Publish("orders", "o1") b.Close() expect(t, true, slices.Equal([]string{"o1"}, orders.got)) expect(t, 0, len(prices.got))}
func TestASlowSubscriberDoesNotHoldUpTheOthers(t *testing.T) { b := newBroker() slow := newStuck() fastGotIt := make(chan struct{}, 1) b.Subscribe("orders", slow.handle, roomy) b.Subscribe("orders", func(Message) error { fastGotIt <- struct{}{}; return nil }, roomy) b.Publish("orders", "o1") select { case <-fastGotIt: // while the slow one is still stuck case <-time.After(5 * time.Second): t.Fatal("the fast subscriber waited for the slow one") } close(slow.release) b.Close()}
func TestAMessageThatKeepsFailingBecomesADeadLetter(t *testing.T) { b := newBroker() var ok inbox b.Subscribe("orders", func(m Message) error { if m.Payload == "bad" { return errors.New("cannot parse") } return ok.handle(m) }, roomy) for _, p := range []string{"o1", "bad", "o2"} { b.Publish("orders", p) } b.Close() expect(t, true, slices.Equal([]string{"o1", "o2"}, ok.got)) // the bad one didn't stop the next d := b.DeadLetters()[0] expect(t, "bad", d.Message.Payload) expect(t, "cannot parse", d.Err.Error())}
func TestABriefFailureIsRetried(t *testing.T) { b := newBroker() var ok inbox calls := 0 b.Subscribe("orders", func(m Message) error { if calls++; calls == 1 { return errors.New("timeout") } return ok.handle(m) }, roomy) b.Publish("orders", "o1") b.Close() expect(t, true, slices.Equal([]string{"o1"}, ok.got)) expect(t, 0, len(b.DeadLetters()))}
func TestAFullMailboxDropsByItsPolicy(t *testing.T) { for _, tc := range []struct { overflow Overflow want []string }{ {DropOldest, []string{"p1", "p3", "p4"}}, {DropNewest, []string{"p1", "p2", "p3"}}, } { b := newBroker() s := newStuck() sub := b.Subscribe("prices", s.handle, Options{Capacity: 2, Overflow: tc.overflow, MaxAttempts: 1}) b.Publish("prices", "p1") <-s.started // the worker holds p1, the mailbox is empty for _, p := range []string{"p2", "p3", "p4"} { b.Publish("prices", p) } close(s.release) b.Close() expect(t, true, slices.Equal(tc.want, s.got)) expect(t, 1, sub.Dropped.Load()) }}
func TestBlockMakesThePublisherWait(t *testing.T) { b := newBroker() s := newStuck() b.Subscribe("orders", s.handle, Options{Capacity: 1, Overflow: Block, MaxAttempts: 1}) b.Publish("orders", "o1") <-s.started b.Publish("orders", "o2") // fills the mailbox published := make(chan struct{}) go func() { b.Publish("orders", "o3"); close(published) }() select { case <-published: t.Fatal("the publisher should still be waiting for room") case <-time.After(200 * time.Millisecond): } close(s.release) <-published b.Close() expect(t, true, slices.Equal([]string{"o1", "o2", "o3"}, s.got))}
func TestUnsubscribingStopsDelivery(t *testing.T) { b := newBroker() var x inbox sub := b.Subscribe("orders", x.handle, roomy) b.Publish("orders", "o1") b.Unsubscribe(sub) b.Publish("orders", "o2") b.Close() expect(t, true, slices.Equal([]string{"o1"}, x.got))}
func TestConcurrentPublishersProduceOneOrderForEveryone(t *testing.T) { b := newBroker() var x, y inbox b.Subscribe("orders", x.handle, roomy) b.Subscribe("orders", y.handle, roomy) var wg sync.WaitGroup for p := range 4 { wg.Add(1) go func() { defer wg.Done() for i := range 250 { b.Publish("orders", fmt.Sprintf("%d-%d", p, i)) } }() } wg.Wait() b.Close() expect(t, 1000, len(x.got)) expect(t, true, slices.Equal(x.got, y.got))}import { test } from 'node:test';import assert from 'node:assert/strict';
const ROOMY: Options = { capacity: 1000, overflow: 'block', maxAttempts: 3, backoffMs: 0 };
function broker(): Broker { let next = 0; return new Broker(() => `s${++next}`);}
function inbox() { const got: string[] = []; return { got, handle: (m: Message) => void got.push(m.payload) };}
// A handler that signals when it starts the first message and then waits to be released, so a// test can fill the mailbox behind it.function stuck() { const box = inbox(); let started!: () => void; let release!: () => void; const startedP = new Promise<void>((r) => (started = r)); const releaseP = new Promise<void>((r) => (release = r)); const handle = async (m: Message) => { started(); await releaseP; box.handle(m); }; return { got: box.got, handle, started: startedP, release };}
test('every subscriber gets every message in order', async () => { const b = broker(); const x = inbox(); const y = inbox(); b.subscribe('orders', x.handle, ROOMY); b.subscribe('orders', y.handle, ROOMY); for (let i = 0; i < 100; i++) await b.publish('orders', `o${i}`); await b.close(); assert.equal(x.got.length, 100); assert.deepEqual(x.got, y.got); assert.equal(x.got[99], 'o99');});
test("only the topic's subscribers hear it", async () => { const b = broker(); const orders = inbox(); const prices = inbox(); b.subscribe('orders', orders.handle, ROOMY); b.subscribe('prices', prices.handle, ROOMY); await b.publish('orders', 'o1'); await b.close(); assert.deepEqual(orders.got, ['o1']); assert.deepEqual(prices.got, []);});
test('a slow subscriber does not hold up the others', async () => { const b = broker(); const slow = stuck(); let fastGotIt!: () => void; const fast = new Promise<void>((r) => (fastGotIt = r)); b.subscribe('orders', slow.handle, ROOMY); b.subscribe('orders', () => fastGotIt(), ROOMY); await b.publish('orders', 'o1'); await fast; // while the slow one is still stuck slow.release(); await b.close();});
test('a message that keeps failing becomes a dead letter', async () => { const b = broker(); const ok = inbox(); b.subscribe( 'orders', (m) => { if (m.payload === 'bad') throw new Error('cannot parse'); ok.handle(m); }, ROOMY, ); for (const p of ['o1', 'bad', 'o2']) await b.publish('orders', p); await b.close(); assert.deepEqual(ok.got, ['o1', 'o2']); // the bad one didn't stop the next const [d] = b.deadLetters(); assert.equal(d.message.payload, 'bad'); assert.equal(d.error, 'cannot parse');});
test('a brief failure is retried', async () => { const b = broker(); const ok = inbox(); let calls = 0; b.subscribe( 'orders', (m) => { if (++calls === 1) throw new Error('timeout'); ok.handle(m); }, ROOMY, ); await b.publish('orders', 'o1'); await b.close(); assert.deepEqual(ok.got, ['o1']); assert.equal(b.deadLetters().length, 0);});
for (const [overflow, expected] of [ ['drop-oldest', ['p1', 'p3', 'p4']], ['drop-newest', ['p1', 'p2', 'p3']],] as const) { test(`a full mailbox drops by its policy: ${overflow}`, async () => { const b = broker(); const s = stuck(); const sub = b.subscribe('prices', s.handle, { capacity: 2, overflow, maxAttempts: 1, backoffMs: 0 }); await b.publish('prices', 'p1'); await s.started; // the loop holds p1, the mailbox is empty for (const p of ['p2', 'p3', 'p4']) await b.publish('prices', p); s.release(); await b.close(); assert.deepEqual(s.got, expected); assert.equal(sub.dropped, 1); });}
test('block makes the publisher wait', async () => { const b = broker(); const s = stuck(); b.subscribe('orders', s.handle, { capacity: 1, overflow: 'block', maxAttempts: 1, backoffMs: 0 }); await b.publish('orders', 'o1'); await s.started; await b.publish('orders', 'o2'); // fills the mailbox let published = false; const third = b.publish('orders', 'o3').then(() => (published = true)); await new Promise((r) => setTimeout(r, 50)); assert.equal(published, false); // still waiting for room s.release(); await third; await b.close(); assert.deepEqual(s.got, ['o1', 'o2', 'o3']);});
test('unsubscribing stops delivery', async () => { const b = broker(); const x = inbox(); const sub = b.subscribe('orders', x.handle, ROOMY); await b.publish('orders', 'o1'); await b.unsubscribe(sub); await b.publish('orders', 'o2'); await b.close(); assert.deepEqual(x.got, ['o1']);});
test('concurrent publishers produce one order for everyone', async () => { const b = broker(); const x = inbox(); const y = inbox(); // Tiny mailboxes and handlers that yield make publishers wait for room, which is where one // publisher can overtake another between two subscribers. const tight: Options = { capacity: 1, overflow: 'block', maxAttempts: 1, backoffMs: 0 }; const yieldThen = (h: Subscriber) => async (m: Message) => { await new Promise((r) => setImmediate(r)); await h(m); }; b.subscribe('orders', yieldThen(x.handle), tight); b.subscribe('orders', y.handle, tight); await Promise.all( [0, 1, 2, 3].map(async (p) => { for (let i = 0; i < 250; i++) await b.publish('orders', `${p}-${i}`); }), ); await b.close(); assert.equal(x.got.length, 1000); assert.deepEqual(x.got, y.got);});Go deeperEvery edge case, and where it is handled2 min
| Case | What happens | Where |
|---|---|---|
| Publish to a topic nobody subscribes to | Numbered, delivered to no one | Topic.publish |
| Subscriber added during a publish | Starts with the next message | Topic.add (copy) |
| Unsubscribe with messages still queued | They’re delivered, then the worker stops | Subscription.close |
| Handler throws | Retried, then a dead letter; the next message continues | Subscription.deliver |
| Mailbox full | The subscriber’s policy: wait, drop oldest, drop newest | Subscription.offer |
| Drops | Counted on the subscription | dropped |
| Close while messages are queued | Delivered first, then workers end | Broker.close |
| Handler republishes to its own topic | Can freeze with Block; use another topic | the handler’s choice |
| Process crashes | Queued messages are lost: this broker is in memory | out of scope |
Wrap up
Section titled “Wrap up”The decisions, and what each one costs
Section titled “The decisions, and what each one costs”| Decision | We chose | We gave up |
|---|---|---|
| Who runs handlers | A worker per subscription, never the publisher | A thread per subscriber, cheap only with light threads |
| Each subscriber’s order | One worker reading one mailbox | Handling one subscriber’s messages in parallel |
| Full mailboxes | A size limit, with block, drop-oldest or drop-newest per subscriber | One rule that’s simpler to explain |
| Failures | Retry in place with growing waits, then a dead letter | Later messages wait during retries |
| Same order for everyone | Number and hand out under a lock per topic | A blocked publish holds up its topic’s publishers |
| Subscribe and unsubscribe | A copied list, swapped on change | A copy of the list per change |
| Saving to disk | None: in memory, one process | Messages queued at a crash |
Go deeperGenuinely different designs, and when to pick them2 min
A dispatcher thread per topic. Publishers add to a topic queue and return. One thread per topic hands messages out to subscriber mailboxes. Publishers never wait on the hand-out, and the order comes from the single dispatcher. It adds a queue to limit and a thread per topic. It suits many publishers on few topics.
Reactive streams. Libraries such as Reactor, RxJava and Akka Streams turn backpressure into a conversation: a subscriber asks for n messages, and the publisher sends no more until asked again. It’s the same idea as Block, written as a protocol, and it works across a whole chain of steps.
A synchronous event bus inside the application. Libraries like Guava’s EventBus deliver on the publisher’s thread by default, which is stage 1, on purpose. Inside one request, a synchronous handler that fails can undo the whole request. It suits events inside a database transaction, not slow side effects like email.
How to spend 45 minutes
Section titled “How to spend 45 minutes”- 0 to 3. One process, no saving to disk, same order per topic, and slow subscribers.
- 3 to 5. The promises. Name “a slow subscriber can’t hurt the others” as the hard one.
- 5 to 25. Handlers in a loop, a mailbox and worker per subscriber, size limits with policies,
retries and dead letters. Code
Subscription.offerand the worker loop. - 25 to 27. Walk through the full diagram: publishing and delivering never share a thread.
- 27 to 34. Eight publishers on one topic: the interleaving, the topic lock, and the copied list.
- 34 to 45. Replay or consumer groups, then your own questions.
If time is short, skip retries and spend it on the overflow policies and the topic lock. Those are the two places this question is decided.
Follow-up questions
Section titled “Follow-up questions”A new service starts up and wants the last hour of orders, not just new ones. What changes?
Whether you see that replay turns mailboxes into one shared log with a position per subscriber, which is how Kafka works, and what that costs in memory.
Most extensions fit into one place:
| Extension | What changes | Touches delivery? |
|---|---|---|
| Replay from the last hour | A log per topic, a position per subscription | Yes |
Wildcard topics (orders.*) |
Topic lookup matches patterns; a subscription can belong to several topics | No |
| Message filters | A check per subscription, before the mailbox | No |
| Consumer groups (share the work) | A group shares one mailbox with several workers; order per key, not per group | Yes |
| Priorities | A priority queue as the mailbox | No |
| Metrics | Mailbox depth, drops and dead letters per subscription | No |
Go deeperAdding replay, step by step2 min
Replay means messages must outlive delivery. Today, a message lives in a mailbox until its worker takes it out, then it’s gone. A new subscriber can’t see what’s already gone.
- Each topic keeps a log. Every published message is added to the end of a list for its topic, and never changed. The log is trimmed from the front by age or size, for example “keep one hour”.
- A subscription keeps a position, not a mailbox. The position (often called an offset) is the number of the next message it will read. Its worker reads from the log at its position, handles the message, and moves the position forward.
- A new subscriber chooses where to start. “From now” sets its position to the end of the log. “From one hour ago” sets it to the first message still in the log. Nothing else is needed.
- Memory changes shape. Slow subscribers no longer each hold their own backlog: they all read the same log, at different positions. Memory now depends on the log’s size, not on how far behind the slowest subscriber is.
There’s a new failure to report. If a subscriber falls so far behind that the trim removes messages it hasn’t read yet, those messages are lost to it. The broker should notice (the position is before the start of the log) and report it, just as it counts drops today.
This is exactly the idea behind Kafka. Kafka adds disks, many machines and copies of each log, but “a log per topic, and a position per reader” is the core.
Go deeperAdding consumer groups, step by step2 min
A consumer group is several workers sharing one subscription’s work. The warehouse runs four copies, and each order should go to just one of them, not all four.
- The group shares one mailbox. Publishing to the topic still puts one copy into the group’s mailbox, as for any subscriber.
- Several workers read from it. Four workers take messages out of the same mailbox. Each message is handled once, by whichever worker takes it.
- Order is now per key, not per group. With four workers, message 41 and message 42 can be handled at the same time, so the group as a whole no longer sees one order. Usually only the order within one key matters, for example all messages for one customer.
- To keep order per key, split the mailbox. Give the group four mailboxes, one per worker. Pick the mailbox by a hash of the key, so one customer’s messages always go to the same worker, in order. Kafka calls these partitions.
The overflow policy and dead letters stay as they are, per mailbox. The new cost is a hot key: if one customer sends half the orders, one worker gets half the work.
Go deeperFour more follow-up questions2 min
Interviewer asksWhat delivery guarantee does this broker give?
At least once for errors, since a failing handler is retried and may have partly done its work before failing. At most once for crashes, since queued messages live in memory. Handlers should be safe to repeat, using the topic and sequence number as a key. That’s what the number is for.
Interviewer asksHow would you shut down without losing messages?
Stop accepting publishes, then close each subscription, which delivers what’s queued and waits for
the worker. A deadline puts a limit on it: anything still queued at the deadline is reported, so
the operator knows what was lost. That’s what close() does, minus the deadline.
Interviewer asksWhy not use a thread pool for all subscribers?
Because two threads could handle the same subscriber’s messages at once, out of order, and the handler would need its own locking. A pool works if a subscriber runs on at most one thread at a time, which is the actor model. One worker per subscriber is the simplest way to get that, and with lightweight threads it’s cheap.
Interviewer asksHow do you find out a subscriber is struggling?
Three numbers per subscription: mailbox depth, drops, and dead letters. Depth climbing means it can’t keep up. Drops mean it’s losing messages, by its own choice. Dead letters mean its handler is failing. Each can raise its own alert.
Further reading
Section titled “Further reading”- Observer pattern, the shape of stage 1
- Design a logging framework, another producer-consumer queue with a full-queue policy
- Design a task scheduler, workers taking jobs from a queue
- Enterprise Integration Patterns: Dead Letter Channel, where dead letters come from
- Reactive Streams, backpressure as a protocol between publisher and subscriber
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.