π§ Pub-Sub System LLD β Thought Process Guide¶
Goal: Learn how to think when designing a Low-Level Design.
π Class Diagram¶
classDiagram
class MessagePriority {
<<enumeration>>
LOW
NORMAL
HIGH
CRITICAL
}
class Message {
<<dataclass>>
+topic: str
+payload: Any
+priority: MessagePriority
+headers: Mapping~str, str~
+message_id: str
+timestamp: float
}
class DeadLetterReason {
<<enumeration>>
RETRIES_EXHAUSTED
OVERFLOW
}
class DeadLetter {
<<dataclass>>
+subscription_id: str
+message: Message
+reason: DeadLetterReason
+error: str
}
class Subscriber {
<<abstract>>
+subscriber_id: str
+on_message(message: Message)* None
}
class CallbackSubscriber {
-_name: str
-_callback: Callable
+on_message(message: Message) None
}
class DedupingSubscriber {
-_inner: Subscriber
-_seen: OrderedDict~str, None~
-_capacity: int
+on_message(message: Message) None
}
class RetryPolicy {
<<dataclass>>
+max_attempts: int
+base_delay: float
+multiplier: float
+max_delay: float
+delay(attempt: int) float
}
class Subscription {
+topic: str
+subscriber: Subscriber
+subscription_id: str
+delivered: int
+dead_lettered: int
-_predicate: Optional~Predicate~
-_retry: RetryPolicy
-_max_queue: int
-_heap: List~tuple~
-_cond: Condition
+offer(message: Message) bool
+dispatch_one(block: bool, timeout: Optional~float~) bool
+wait_idle(timeout: Optional~float~) bool
+close() None
+pending: int
}
class MessageBroker {
-_topics: Dict~str, Dict~
-_lock: Lock
-_workers: Dict~str, Thread~
-_dlq: List~DeadLetter~
+create_topic(name: str) None
+delete_topic(name: str) None
+subscribe(topic: str, subscriber: Subscriber, predicate, retry) Subscription
+unsubscribe(topic: str, subscriber_id: str) None
+publish(topic: str, payload: Any, priority, headers) Message
+run_until_idle() int
+start() None
+wait_idle(timeout: float) bool
+close() None
+dead_letters() List~DeadLetter~
}
Subscriber <|-- CallbackSubscriber
Subscriber <|-- DedupingSubscriber
DedupingSubscriber o-- "1" Subscriber : decorates
MessageBroker "1" *-- "*" Subscription : per topic and subscriber
MessageBroker "1" *-- "*" DeadLetter : DLQ
MessageBroker ..> Message : publish creates
Subscription o-- "1" Subscriber
Subscription --> "1" RetryPolicy
Subscription o-- "0..max_queue" Message : bounded priority queue
Subscription ..> DeadLetter : emits
DeadLetter --> Message
DeadLetter --> DeadLetterReason
Message --> MessagePriority
β±οΈ How to run this in a 45β60 min interview¶
| Time | Step | What to say out loud |
|---|---|---|
| 0β7 min | Clarify | "In-process library or a networked broker? Push or pull? What delivery guarantee: at-most-once or at-least-once? Ordering per topic, per key, or none? What happens when a subscriber is slow or keeps failing?" |
| 7β15 min | Entities and interfaces | Message (immutable), Subscriber ABC, Subscription (subscriber + topic + its own queue), MessageBroker facade. "The key decision: one queue per subscription, not per topic, so subscribers are isolated." |
| 15β30 min | Core code | publish β snapshot subscriptions β offer to each; dispatch_one pops and calls the subscriber. Get fan-out working synchronously first (run_until_idle). |
| 30β40 min | Concurrency | "One dispatcher thread per subscription: ordered, never concurrent per subscriber. The broker lock guards the map only; never hold a lock while calling subscriber code." |
| 40β50 min | Failure handling and extension | Retry with backoff β DLQ; bounded queue and overflow policy; idempotent consumer. Then whatever they add: wildcards, TTL, durability, ordering per key. |
| 50β60 min | Testing and trade-offs | "Inject the sleep so retry tests are instant; a slow-subscriber test proves isolation; a multi-producer test asserts per-producer order and max_active == 1." |
Clarifying questions worth asking¶
- Scope: in-process event bus, or a broker other services connect to? (The second needs persistence and acks; say which one you're building.)
- Delivery guarantee: can a message be lost? Can it be delivered twice?
- Ordering: none, per topic, or per key (e.g. per order id)?
- Slow consumers: block the publisher, buffer (how much?), or drop?
- Failures: retry how many times? What happens to a message that never succeeds?
- Filtering / wildcards: do subscribers want every message on the topic?
Phase 1: Identify the Nouns¶
"Publishers send messages to topics. Each subscriber receives, in order, the messages on topics it subscribed to."
| Noun | Decision | Why |
|---|---|---|
Message |
Frozen dataclass | Shared by all subscribers, so immutable |
MessagePriority |
IntEnum |
Comparable, so the heap key is just -priority |
Subscriber |
ABC | Observer interface; raising = failure |
Subscription |
Class | The real unit of work: filter + bounded queue + dispatch |
RetryPolicy |
Frozen dataclass | Strategy for backoff; per subscription |
DeadLetter |
Frozen dataclass | Failed message + reason, for inspection and replay |
MessageBroker |
Facade | Topics, subscriptions, publish, run modes |
The noun that is easy to miss is Subscription. Without it you end up with one queue per topic and a delivery loop that couples all subscribers together.
Phase 2: Assigning Responsibilities¶
| Action | Owner | Why |
|---|---|---|
| Route a publish to subscribers | MessageBroker.publish() |
Owns the topic β subscriptions map |
| Filter, buffer, apply overflow policy | Subscription.offer() |
Per-subscriber concern |
| Deliver with retry, dead-letter on failure | Subscription.dispatch_one() |
Keeps retry state next to the queue it blocks |
| Backoff schedule | RetryPolicy.delay() |
Swappable strategy |
| Collect dead letters | MessageBroker |
One place to inspect and replay |
| Idempotency | DedupingSubscriber (decorator) |
Consumer concern; the broker can't know what "duplicate side effect" means |
Phase 3: Patterns, and why each is there¶
- Observer:
Subscriber.on_message. Publishers don't know who listens. - Facade:
MessageBrokeris the only class callers touch. - Strategy:
RetryPolicy; per-subscriptionpredicatefor filtering. - Decorator:
DedupingSubscriberwraps any subscriber without changing it.
Phase 4: Concurrency model¶
publisher threads ββpublishβββΆ broker._lock (map snapshot only)
β
offer() ββββββββΌβββΆ Subscription A: Condition + heap βββΆ 1 dispatcher βββΆ subscriber A
ββββΆ Subscription B: Condition + heap βββΆ 1 dispatcher βββΆ subscriber B
- Two locks only, never nested with subscriber code running.
- Per-subscription ordering comes from "exactly one consumer per queue", not from a lock around the subscriber.
wait_idle()waits onqueue empty and in_flight == 0, both under the sameCondition, so there is no window where a popped-but-unfinished message is invisible.
Phase 5: Quick Checklist¶
β Isolation: a slow or failing subscriber only affects its own queue β Ordering: priority, then FIFO, per subscriber; no concurrent callbacks β Bounded memory: queue cap with an explicit overflow policy β Failure path: retry with backoff, then DLQ with a reason β No lock held across subscriber code β Guarantee stated: at-least-once on subscriber failure, not durable across crashes