"Design an in-process publish-subscribe / message queue system. Publishers publish messages to named topics; subscribers subscribe to topics and receive every message published to them. Support multiple independent subscribers per topic, and don't lose messages if a subscriber is temporarily slow."
B. Difficulty
Advanced + Concurrency. The publish/subscribe wiring itself is simple Observer — the difficulty is entirely in delivery semantics under concurrency and failure: backpressure when a subscriber falls behind, at-least-once delivery forcing idempotent consumers, and ordering guarantees that only hold within a partition, never globally.
C. What This Problem Tests
Recognizing Observer as the structural foundation, then correctly identifying everything beyond plain Observer that a real message queue actually needs
Push vs. pull delivery model trade-offs, and how each shifts where backpressure has to live
At-least-once delivery forcing idempotent consumers — the fourth occurrence of this exact lesson in this document, at its most explicit
The LLD/HLD boundary between an in-process event bus and a distributed broker (Kafka/Pulsar/SQS/SNS)
D. Requirements
Functional
Publishers publish messages to a named Topic
Subscribers subscribe/unsubscribe to a topic and receive every message published after subscribing
Support multiple independent subscribers per topic, each processing at its own pace
A slow or temporarily unavailable subscriber must not lose messages or block other subscribers
Optionally: partition a topic so that ordering is guaranteed within a partition, and support a dead-letter queue for messages a subscriber repeatedly fails to process
Non-Functional
At-least-once delivery — a message may be redelivered after a failure, so consumers must be idempotent
Backpressure: an unbounded queue per slow subscriber risks unbounded memory growth; the system needs an explicit overflow policy
No global message ordering guarantee across the whole topic — only within a partition, if partitioning is used
E. Clarifying Questions
#
Question
Why It Matters
Design Decision Affected
1
Push model (broker calls the subscriber) or pull model (subscriber polls the broker)?
Determines where backpressure and rate-of-consumption control naturally live
Core delivery loop shape (see I, W)
2
Is at-least-once delivery required, or is at-most-once (fire-and-forget) acceptable?
Presence of an ack/retry protocol and dedup requirement
3
Does ordering need to be guaranteed, and at what granularity (per-topic or per-partition-key)?
Global ordering across concurrent subscribers is expensive; per-partition is the realistic guarantee
Partitioning scheme
4
What should happen when a subscriber's queue is full — drop the message, block the publisher, or evict the oldest message?
This is the actual backpressure policy, and there's no universally "correct" default
OverflowPolicy (Strategy) — direct callback to Problem 6's async-appender overflow decision
5
Is this in-process only, or does it need to survive a process restart?
Determines whether messages need any persistence at all
Scope of X (Persistence)
F. Assumptions
Confirmed: named topics; multiple independent subscribers; a slow subscriber must not block others or silently lose messages.
Assumption: push model with a per-subscriber bounded queue — the broker delivers proactively, but each subscriber's own queue absorbs bursts up to a configured limit, at which point the chosen OverflowPolicy kicks in.
Assumption: at-least-once delivery, which means every subscriber's message handler must be idempotent — duplicate delivery after a retry is expected, not exceptional.
Assumption: ordering is guaranteed only within a partition (if the topic is partitioned by a key); across partitions or across a non-partitioned topic's independent subscribers, no ordering guarantee is made or needed.
G. Actors
Actor
Responsibility
Publisher
Publishes messages to a topic
Subscriber
Registers interest in a topic; processes delivered messages
Broker
Owns topics, fans out messages, applies overflow policy, tracks delivery/acks
H. Core Domain Entities
Class
Responsibility
Key Attributes
Key Methods
Does NOT Handle
Message
Immutable unit of data plus metadata
id, payload, partitionKey, timestamp
—
Delivery mechanics
Topic
A named channel; owns its subscriber list and optional partitions
How a given subscriber actually consumes (delegated to Subscription)
Subscriber (interface)
Business logic for handling one message
—
onMessage(message)
Queueing, retry, or acking — all handled by its Subscription wrapper
Subscription
Per-subscriber delivery state on a topic
bounded queue, ack/retry tracking, offset
enqueue(message), ack(id), nack(id)
The subscriber's own business logic
OverflowPolicy (interface, Strategy)
What happens when a subscriber's queue is full
—
onOverflow(subscription, message)
Anything about why the queue filled up
DeadLetterQueue
Holds messages that exhausted their retry budget
—
add(message, reason)
Automatic reprocessing (manual/operator-triggered by design)
Broker (facade)
Owns all topics; top-level publish/subscribe entry point
Map<String, Topic>
createTopic(), publish(), subscribe()
Per-subscriber delivery details (delegated to Topic/Subscription)
I. Class Relationships
Observer as the foundational pattern: a Topic maintaining a list of Subscribers and notifying them on publish() is exactly the Observer pattern — the same structural idea already used for Ride-Sharing's ride-status notifications (Problem 5), Notification System's event triggers (Problem 7), and Coffee Machine's optional low-inventory alerts (Problem 8). This problem is what all three of those generalize toward: Observer stops being "a hook" and becomes the entire product.
Where plain Observer stops being enough: naive Observer calls every subscriber synchronously and in-line with publish(), which means one slow subscriber blocks the publisher and every other subscriber. The fix is Subscription — each subscriber gets its own bounded queue and its own consumption thread/loop, decoupling "message was published" from "this particular subscriber has processed it yet."
Strategy:OverflowPolicy (drop-oldest, drop-newest, block-publisher) is a mutually exclusive, swappable backpressure choice — the same shape as Problem 6's async-appender overflow decision, applied here per-subscriber instead of per-logger.
Why not a Bridge for delivery-model × persistence: it might seem like "push vs. pull" and "in-memory vs. persisted" are two independent dimensions worth a Bridge, but in practice the delivery model is a fixed architectural decision made once (see F), not something that varies per-topic at runtime — introducing Bridge here would mirror Problem 7's rejected category×channel Bridge for a dimension that, in this design, never actually needs independent runtime variation.
J. Class Diagram
View Mermaid source
classDiagram
class Message {
id
payload
partitionKey
timestamp
}
class Topic {
name
+publish(msg)
+subscribe(sub)
+unsubscribe(sub)
}
class Subscriber {
<<interface>>
+onMessage(msg)
}
class Subscription {
queue
+enqueue(msg)
+ack(id)
+nack(id)
}
class OverflowPolicy {
<<interface>>
+onOverflow(sub, msg)
}
class DeadLetterQueue {
+add(msg, reason)
}
class Broker {
topics
+createTopic()
+publish()
+subscribe()
}
Broker --> Topic
Topic --> Subscription : notifies
Subscription --> Subscriber : wraps
Subscription --> OverflowPolicy
Subscription --> DeadLetterQueue : on retry exhaustion
OverflowPolicy <|.. DropOldest
OverflowPolicy <|.. BlockPublisher
K. Responsibility Mapping
Class
Responsibility
Does NOT Handle
Topic
Owns subscriber list, fans out on publish
Per-subscriber queueing or retry
Subscription
One subscriber's queue, acks, retries, overflow handling
The subscriber's actual business logic
Subscriber
What to do with one message
Queueing, backpressure, dead-lettering
OverflowPolicy
One backpressure behavior
Why the queue is full
Broker
Top-level orchestration across topics
Any single topic's internal fan-out mechanics
L. SOLID Analysis
Principle
How It's Applied
Naive Violation
SRP
Topic only fans out; Subscription only manages one subscriber's delivery state; OverflowPolicy only decides backpressure behavior
One Topic class directly looping over subscribers, calling them synchronously, and inlining retry/backpressure logic per call
OCP
New backpressure behavior = new OverflowPolicy; new dead-letter handling = swap the DeadLetterQueue implementation
if (policy == "DROP_OLDEST") ... else if (policy == "BLOCK") ... inline in Subscription
LSP
Every OverflowPolicy implementation honestly implements onOverflow(), even a no-op "unbounded" policy is explicit about accepting unbounded memory risk rather than silently behaving differently
A policy that silently drops messages when asked to block, breaking the caller's assumption
ISP
Subscriber stays a one-method interface — acking, retrying, and dead-lettering all live in Subscription, not forced onto every subscriber implementation
Forcing every Subscriber to implement onOverflow(), onRetryExhausted(), etc. directly
DIP
Subscription depends on the OverflowPolicy and Subscriber abstractions
Subscription hardcoding one specific overflow behavior inline instead of delegating to a policy object
M. Design Patterns
Pattern
Problem It Solves
Where It Appears
When NOT to Use
Observer
Decoupling "a message was published" from "who needs to know" — the foundational pattern this whole problem generalizes
Topic notifying its Subscriptions
Never really skippable here — it's the core of the problem
Strategy
Backpressure behavior is a swappable, mutually exclusive policy
OverflowPolicy
Skip if the system only ever needs one fixed overflow behavior (unrealistic for a general-purpose queue)
Facade
Single top-level entry point across many topics
Broker
Skip only in a single-topic toy version
Explicitly avoided: Bridge for delivery-model × persistence-model — see I for why this dimension doesn't need independent runtime variation the way Problem 7's category×channel Bridge was rejected for the same reason.
Exactly-once (via idempotency keys + dedup store) if a stronger guarantee is demanded
Ack/retry protocol in Subscription, extended rather than replaced
Ordering
None, or per-partition
Stricter per-key ordering as topics grow
Partition abstraction already isolates ordering scope from the rest of Topic
Deployment scale
In-process, single machine
Distributed broker (Kafka/Pulsar/SQS/SNS-style)
Explicitly named as an LLD/HLD boundary crossing, not a drop-in upgrade (see Y)
O. Design Evolution
V1: plain Observer — Topic.publish() calls every subscriber synchronously, in-line, no queueing.
V2: extract Subscription with a per-subscriber queue and a background consumption loop, once a slow subscriber is shown to block everyone else.
V3: add OverflowPolicy once an unbounded queue is shown to risk unbounded memory growth.
V4: add ack/retry and a DeadLetterQueue once at-least-once delivery and consumer idempotency become explicit requirements.
V5: add Partition once per-key ordering is requested.
V6: discuss (but do not necessarily build) the migration path to a distributed broker.
What stays stable:Broker.publish()/subscribe() signatures and the Subscriber.onMessage() contract. Backpressure, retry, and partitioning are all additive layers around Subscription, never changes to the subscriber-facing interface.
P. API / Interface Design
interface Subscriber {
void onMessage(Message message);
}
interface OverflowPolicy {
void onOverflow(Subscription subscription, Message incoming);
}
class Broker {
Topic createTopic(String name);
void publish(String topicName, Message message);
Subscription subscribe(String topicName, Subscriber subscriber, OverflowPolicy policy);
void unsubscribe(Subscription subscription);
}
class Subscription {
void ack(String messageId);
void nack(String messageId); // triggers redelivery, up to a retry limit, then dead-letter
}
interface Subscriber {
void onMessage(Message message);
}
interface OverflowPolicy {
void onOverflow(Subscription subscription, Message incoming);
}
class Broker {
Topic createTopic(String name);
void publish(String topicName, Message message);
Subscription subscribe(String topicName, Subscriber subscriber, OverflowPolicy policy);
void unsubscribe(Subscription subscription);
}
class Subscription {
void ack(String messageId);
void nack(String messageId); // triggers redelivery, up to a retry limit, then dead-letter
}
class Subscriber {
public:
virtual void onMessage(const Message& message) = 0;
};
class OverflowPolicy {
public:
virtual void onOverflow(std::shared_ptr<Subscription> subscription, const Message& incoming) = 0;
};
class Broker {
public:
std::shared_ptr<Topic> createTopic(const std::string& name);
void publish(const std::string& topicName, const Message& message);
std::shared_ptr<Subscription> subscribe(const std::string& topicName, std::shared_ptr<Subscriber> subscriber,
std::shared_ptr<OverflowPolicy> policy);
void unsubscribe(std::shared_ptr<Subscription> subscription);
};
class Subscription {
public:
void ack(const std::string& messageId);
void nack(const std::string& messageId); // triggers redelivery, up to a retry limit, then dead-letter
};
class Subscriber {
onMessage(message) { throw new Error('not implemented'); }
}
class OverflowPolicy {
onOverflow(subscription, incoming) { throw new Error('not implemented'); }
}
class Broker {
createTopic(name) {}
publish(topicName, message) {}
subscribe(topicName, subscriber, policy) {}
unsubscribe(subscription) {}
}
class Subscription {
ack(messageId) {}
nack(messageId) {} // triggers redelivery, up to a retry limit, then dead-letter
}
interface Subscriber {
onMessage(message: Message): void;
}
interface OverflowPolicy {
onOverflow(subscription: Subscription, incoming: Message): void;
}
class Broker {
createTopic(name: string): Topic { return null as unknown as Topic; }
publish(topicName: string, message: Message): void {}
subscribe(topicName: string, subscriber: Subscriber, policy: OverflowPolicy): Subscription {
return null as unknown as Subscription;
}
unsubscribe(subscription: Subscription): void {}
}
class Subscription {
ack(messageId: string): void {}
nack(messageId: string): void {} // triggers redelivery, up to a retry limit, then dead-letter
}
Ownership/validation notes:subscribe() returns a Subscription handle rather than requiring the caller to track topic/subscriber pairs manually — acking/nacking flows entirely through that handle, keeping the retry/dead-letter bookkeeping out of the subscriber's own code.
Q. Pseudocode — Critical Logic
Topic.publish(message):
for subscription in this.subscriptions:
if not subscription.queue.tryEnqueue(message): // bounded queue full
subscription.overflowPolicy.onOverflow(subscription, message)
Subscription.consumeLoop(): // one per subscriber, background thread
while running:
message = this.queue.take()
try:
this.subscriber.onMessage(message) // must be idempotent — may be a redelivery
this.ack(message.id)
catch:
this.nack(message.id)
Subscription.nack(messageId):
attempts = this.retryCount.increment(messageId)
if attempts > MAX_RETRIES:
this.deadLetterQueue.add(message, reason="retry exhausted")
else:
this.queue.tryEnqueue(message) // redeliver — subscriber will see this message again
void publish(Message message) {
for (Subscription subscription : this.subscriptions) {
if (!subscription.getQueue().tryEnqueue(message)) { // bounded queue full
subscription.getOverflowPolicy().onOverflow(subscription, message);
}
}
}
void consumeLoop() { // one per subscriber, background thread
while (running) {
Message message = this.queue.take();
try {
this.subscriber.onMessage(message); // must be idempotent — may be a redelivery
this.ack(message.getId());
} catch (Exception e) {
this.nack(message.getId());
}
}
}
void nack(String messageId) {
int attempts = this.retryCount.increment(messageId);
if (attempts > MAX_RETRIES) {
this.deadLetterQueue.add(message, "retry exhausted");
} else {
this.queue.tryEnqueue(message); // redeliver — subscriber will see this message again
}
}
void Topic::publish(const Message& message) {
for (auto& subscription : subscriptions_) {
if (!subscription->queue().tryEnqueue(message)) { // bounded queue full
subscription->overflowPolicy()->onOverflow(subscription, message);
}
}
}
void Subscription::consumeLoop() { // one per subscriber, background thread
while (running_) {
auto message = queue_.take();
try {
subscriber_->onMessage(message); // must be idempotent — may be a redelivery
ack(message.id);
} catch (...) {
nack(message.id);
}
}
}
void Subscription::nack(const std::string& messageId) {
int attempts = retryCount_.increment(messageId);
if (attempts > MAX_RETRIES) {
deadLetterQueue_.add(message, "retry exhausted");
} else {
queue_.tryEnqueue(message); // redeliver — subscriber will see this message again
}
}
publish(message) {
for (const subscription of this.subscriptions) {
if (!subscription.queue.tryEnqueue(message)) { // bounded queue full
subscription.overflowPolicy.onOverflow(subscription, message);
}
}
}
async consumeLoop() { // one per subscriber, background thread
while (this.running) {
const message = await this.queue.take();
try {
this.subscriber.onMessage(message); // must be idempotent — may be a redelivery
this.ack(message.id);
} catch (e) {
this.nack(message.id);
}
}
}
nack(messageId) {
const attempts = this.retryCount.increment(messageId);
if (attempts > MAX_RETRIES) {
this.deadLetterQueue.add(message, "retry exhausted");
} else {
this.queue.tryEnqueue(message); // redeliver — subscriber will see this message again
}
}
publish(message: Message): void {
for (const subscription of this.subscriptions) {
if (!subscription.queue.tryEnqueue(message)) { // bounded queue full
subscription.overflowPolicy.onOverflow(subscription, message);
}
}
}
async consumeLoop(): Promise<void> { // one per subscriber, background thread
while (this.running) {
const message: Message = await this.queue.take();
try {
this.subscriber.onMessage(message); // must be idempotent — may be a redelivery
this.ack(message.id);
} catch (e) {
this.nack(message.id);
}
}
}
nack(messageId: string): void {
const attempts = this.retryCount.increment(messageId);
if (attempts > MAX_RETRIES) {
this.deadLetterQueue.add(message, "retry exhausted");
} else {
this.queue.tryEnqueue(message); // redeliver — subscriber will see this message again
}
}
R. End-to-End Use Case
Publisher.publish("orders", OrderPlacedMessage)
│
▼
Topic("orders").publish(message)
│ fans out to every current Subscription
▼
Subscription(EmailSubscriber).queue.tryEnqueue(message) ✓
Subscription(InventorySubscriber).queue.tryEnqueue(message) ✗ (queue full)
│
▼
InventorySubscriber's OverflowPolicy.onOverflow() → e.g. drop-oldest evicts the stalest queued message
│
▼
EmailSubscriber's consumeLoop() dequeues, calls onMessage() → throws (transient failure)
│
▼
nack() → retry count incremented → redelivered once more → succeeds → ack()
S. Data Structures
Structure
Used For
Why
Alternative & Trade-off
Bounded blocking queue per Subscription
Absorbing bursts without unbounded memory growth
Directly enforces the chosen backpressure limit; a full queue is exactly the signal that triggers OverflowPolicy
An unbounded queue is simpler but reintroduces the exact memory-growth risk the requirements explicitly rule out
Map<partitionKey, Partition>
Routing messages that must stay ordered relative to each other
Guarantees per-key ordering without forcing a single global order across the whole topic
A single ordered log for the whole topic gives stronger guarantees but serializes unrelated keys unnecessarily
Map<messageId, retryCount>
Tracking retry attempts per message per subscription
O(1) lookup to decide dead-letter vs. redeliver on each nack()
Encoding the count in the message envelope itself avoids a side map but complicates immutability
T. Complexity Analysis
Operation
Time
Space
publish()
O(subscribers on the topic)
O(1) per subscriber beyond the message reference
Subscription.consumeLoop() per message
O(1) dequeue + subscriber's own handler cost
O(queue capacity) bounded, by design
U. Testing Strategy
Happy path: publish → every current subscriber eventually receives and acks the message exactly once under normal conditions.
Edge cases: a subscriber that unsubscribes mid-delivery must not receive messages published after unsubscribing; a subscriber added after a message was published must not receive that earlier message (no replay unless explicitly designed for).
Backpressure tests: filling one subscriber's queue must trigger its OverflowPolicy without affecting any other subscriber's delivery.
Idempotency tests: deliberately redeliver a message (simulating a retry) and assert the subscriber's handler produces the same end state as a single delivery — this is the test that actually validates the at-least-once contract, not just that redelivery happens.
Ordering tests: messages with the same partition key arrive in publish order; messages with different keys make no ordering claim relative to each other.
V. Interviewer Follow-Ups
Follow-Up
Testing
Design Change
Classes Changed
Pattern/Trade-off
"One subscriber is much slower than the others — how do you stop it from affecting them?"
Recognizing per-subscriber isolation as the fix, not a shared queue
Validates the Subscription-per-subscriber investment
"What if the same message gets delivered twice?"
Idempotent-consumer reasoning under at-least-once delivery
N/A at the broker level — the fix lives in subscriber-side idempotency (e.g. dedup by message ID)
Subscriber implementations, not the broker
The fourth instance of this exact lesson in this document (Splitwise, Ride-Sharing, Notification System, here)
"Migrate this to run across multiple machines."
LLD/HLD boundary awareness
N/A at LLD — candidate should name Kafka/Pulsar/SQS/SNS-style concerns (partition replication, consumer groups, offset commits) and place them explicitly at HLD
N/A
Recognizing the boundary, not attempting to design a distributed broker live
"Add a dead-letter queue for messages that repeatedly fail."
Testing whether retry-exhaustion handling was already designed for
Confirms or adds DeadLetterQueue wiring in Subscription.nack()
Subscription, DeadLetterQueue
Validates the retry-limit design from Q
W. Concurrency
Shared mutable state: each Topic's subscriber list; each Subscription's queue and retry-count map.
Push model shifts backpressure to the subscriber's own queue: since the broker actively delivers, an unbounded queue is the actual risk — bounding it and defining an explicit OverflowPolicy is not optional polish, it's the core non-functional requirement.
At-least-once delivery is a concurrency-adjacent correctness requirement, not just a retry detail: because a message can be redelivered after a failure (or after a crash-and-restart before an ack was recorded), subscriber handlers must be idempotent — the same lesson as Splitwise's settle-up, Ride-Sharing's ride-accept, and Notification System's dedupe, now at its most explicit because retries are the entire point of this system rather than an edge case within it.
Partition-level, not topic-level, ordering: guaranteeing global order across a topic would require serializing all publishes through one lock, killing throughput; guaranteeing it only within a partition (keyed messages routed to the same partition, processed in order there) is the realistic, scalable guarantee — the same trade-off real systems like Kafka make.
X. Persistence
If durability across a restart is required, messages need to be written to a log before being acknowledged as published (write-ahead, same idea as a database's commit log) — otherwise an in-memory-only queue loses everything on a crash. This document treats persistence as an explicit, named trade-off rather than assuming it for free: an in-memory version is a legitimate, simpler answer if the round doesn't ask for restart-survival.
Consensus for partition leader election, exactly-once semantics across machine boundaries — explicitly named as a different, much harder problem than this LLD round, not something to attempt to design live
Z. Failure Scenarios
Subscriber crashes mid-processing, after dequeue but before ack: the message must be considered undelivered and redelivered — this is precisely what makes at-least-once (not exactly-once) the honest guarantee without a persisted, transactional ack log.
Publisher publishes faster than a subscriber can ever consume, indefinitely: the bounded queue and OverflowPolicy are what prevent this from becoming an unbounded memory leak — worth stating that some overflow policy will always eventually apply under sustained overload, it's not a corner case.
A message repeatedly fails processing: without a retry limit and dead-letter queue, it would be redelivered forever, permanently blocking that subscriber's queue behind it — the dead-letter queue exists specifically to prevent one poison message from starving everything behind it.
AA. Common Bad Designs
Synchronous, in-line subscriber calls directly inside publish() — one slow subscriber blocks the publisher and every other subscriber, defeating the entire point of decoupling.
An unbounded queue per subscriber — "solves" backpressure by deferring the problem to an eventual out-of-memory crash.
Assuming exactly-once delivery is free — without a persisted, transactional ack protocol, "exactly-once" is usually actually at-least-once with un-idempotent consumers quietly double-processing.
Global ordering assumed across an entire topic — either silently broken under concurrency, or achieved only by serializing everything through one lock and destroying throughput.
AB. Design Trade-Offs
Decision
Option A
Option B
Recommendation
Delivery model
Push (broker delivers proactively)
Pull (subscriber polls/fetches)
Push is simpler for low-latency fan-out with few subscribers; pull scales better for many slow/bursty consumers since each controls its own rate — state which one and why rather than defaulting silently
Overflow policy default
Drop-oldest
Block the publisher
Drop-oldest protects publisher throughput at the cost of losing old data; blocking protects data completeness at the cost of publisher throughput — this is a business decision to surface explicitly, not a technical one to default silently
Ordering scope
Global (whole topic)
Per-partition only
Per-partition, for the same reason real distributed queues make this choice — global ordering doesn't scale past one lock
AC. Interviewer Evaluation
Strong signal: naming Observer as the structural foundation immediately, then unprompted identifying its limits (synchronous blocking, no backpressure) and fixing them with per-subscriber queues and an explicit overflow policy; independently raising idempotent-consumer requirements once at-least-once delivery is confirmed; correctly scoping ordering to per-partition rather than claiming a global guarantee.
Red flag: calling subscriber.onMessage() synchronously inside publish() as the final design; an unbounded queue with no stated overflow policy; claiming "exactly-once" delivery without a persisted ack protocol to back it up; assuming global ordering without acknowledging the cost.
AD. Interview Simulation
Interviewer: Design a publish-subscribe system.
Candidate: At its core this is Observer — a Topic holds a list of subscribers and notifies them on publish. But plain Observer calls every subscriber synchronously, so one slow subscriber would block the publisher and everyone else. I'd give each subscriber its own bounded queue and background consumption loop instead.
Interviewer: What happens when that queue fills up?
Candidate: That's a backpressure policy decision, not something to hardcode — I'd make it a Strategy, OverflowPolicy, so drop-oldest, drop-newest, or block-the-publisher are all swappable rather than baked in.
Interviewer: Can a message be delivered twice?
Candidate: Yes, under at-least-once delivery — if a subscriber crashes after dequeuing but before acking, it gets redelivered. That means subscriber handlers need to be idempotent; the broker can't make that guarantee for them by itself without a much more expensive exactly-once protocol.
Interviewer: Does this guarantee message order?
Candidate: Only within a partition, if the topic is partitioned by key. Guaranteeing order across the whole topic would mean serializing every publish through one lock, which kills throughput for no benefit if the messages weren't causally related anyway.
No backpressure handling, no bounded queues, no retry/dead-letter concept, and notification is synchronous/in-line — exactly the gap this document's Subscription abstraction and OverflowPolicy are built to close
The core Topic/Subscriber wiring itself; everything about delivery guarantees and backpressure is this document's addition on top
AF. Interview Cheat Sheet
Problem
Pub-Sub / Message Queue
Difficulty
Advanced + Concurrency
Core requirements
Named topics, multiple independent subscribers, no message loss from a slow subscriber
Key clarifications
Push or pull? At-least-once or at-most-once? Ordering guarantee scope? Overflow behavior? Persistence needed?
Don't design a distributed Kafka-style broker when the prompt is in-process; don't build exactly-once semantics unless explicitly required
AG. Full Implementation
A complete, compilable reference implementation of every class in the diagram above (composition, inheritance, interface realization, and dependency-injected strategies all preserved per section I), with a runnable demo exercising the primary flow — in Java, C++, JavaScript, and TypeScript.
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Pub-Sub / Message Queue — full class-diagram implementation.
*
* Broker owns Topics; a Topic owns Subscriptions (one per Subscriber); each Subscription
* has its own bounded queue + background consumer thread so a slow subscriber can never
* block the publisher or other subscribers (decoupling publish() from consumption).
*/
public class p10 {
// ---------- Message ----------
/** Immutable unit of data plus metadata. */
static final class Message {
final String id;
final String payload;
final String partitionKey;
final long timestamp;
Message(String id, String payload, String partitionKey) {
this.id = id;
this.payload = payload;
this.partitionKey = partitionKey;
this.timestamp = System.currentTimeMillis();
}
@Override
public String toString() {
return "Message{id=" + id + ", payload=" + payload + "}";
}
}
// ---------- Subscriber (interface) ----------
/** Business logic for handling one message. Must be idempotent (at-least-once delivery). */
interface Subscriber {
void onMessage(Message message) throws Exception;
}
// ---------- OverflowPolicy (Strategy interface) ----------
/** What happens when a subscriber's queue is full. */
interface OverflowPolicy {
void onOverflow(Subscription subscription, Message incoming);
}
/** Drop the oldest queued message to make room for the incoming one. */
static final class DropOldest implements OverflowPolicy {
@Override
public void onOverflow(Subscription subscription, Message incoming) {
subscription.queue.poll(); // discard oldest
subscription.queue.offer(incoming); // make room for the new message
}
}
/** Block the publishing thread until space is available (backpressure). */
static final class BlockPublisher implements OverflowPolicy {
@Override
public void onOverflow(Subscription subscription, Message incoming) {
try {
subscription.queue.put(incoming); // blocks caller thread until room frees up
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
/** Simply drop the new incoming message, keeping what's already queued. */
static final class DropNewest implements OverflowPolicy {
@Override
public void onOverflow(Subscription subscription, Message incoming) {
// intentionally no-op: newest message is dropped
}
}
// ---------- DeadLetterQueue ----------
/** Holds messages that exhausted their retry budget. Reprocessing is manual/operator-triggered. */
static final class DeadLetterQueue {
private final List<String> entries = new CopyOnWriteArrayList<>();
void add(Message message, String reason) {
entries.add(message.id + " :: " + reason);
}
List<String> entries() {
return entries;
}
}
// ---------- Subscription ----------
/** Per-subscriber delivery state on a topic: bounded queue, ack/retry tracking, background consumer. */
static final class Subscription {
private static final int MAX_RETRIES = 3;
private static final int CAPACITY = 16;
final String id = UUID.randomUUID().toString();
final BlockingQueue<Message> queue = new ArrayBlockingQueue<>(CAPACITY);
private final Subscriber subscriber;
private final OverflowPolicy overflowPolicy;
private final DeadLetterQueue deadLetterQueue;
private final Map<String, Message> inFlight = new ConcurrentHashMap<>();
private final Map<String, AtomicInteger> retryCount = new ConcurrentHashMap<>();
private volatile boolean running = true;
private Thread consumerThread;
Subscription(Subscriber subscriber, OverflowPolicy overflowPolicy, DeadLetterQueue dlq) {
this.subscriber = subscriber;
this.overflowPolicy = overflowPolicy;
this.deadLetterQueue = dlq;
}
/** Try to enqueue; on a full queue delegate to the overflow policy. */
void enqueue(Message message) {
if (!queue.offer(message)) {
overflowPolicy.onOverflow(this, message);
}
}
void start() {
consumerThread = new Thread(this::consumeLoop, "sub-" + id.substring(0, 8));
consumerThread.setDaemon(true);
consumerThread.start();
}
void stop() {
running = false;
if (consumerThread != null) consumerThread.interrupt();
}
/** One consumer loop per subscriber, running on its own background thread. */
private void consumeLoop() {
while (running) {
try {
Message message = queue.poll(200, TimeUnit.MILLISECONDS);
if (message == null) continue;
inFlight.put(message.id, message);
try {
subscriber.onMessage(message); // must be idempotent -- may be a redelivery
ack(message.id);
} catch (Exception e) {
nack(message.id);
}
} catch (InterruptedException ie) {
if (!running) break;
}
}
}
void ack(String messageId) {
inFlight.remove(messageId);
retryCount.remove(messageId);
}
void nack(String messageId) {
Message message = inFlight.remove(messageId);
if (message == null) return;
int attempts = retryCount.computeIfAbsent(messageId, k -> new AtomicInteger(0))
.incrementAndGet();
if (attempts > MAX_RETRIES) {
deadLetterQueue.add(message, "retry exhausted");
retryCount.remove(messageId);
} else {
enqueue(message); // redeliver -- subscriber will see this message again
}
}
}
// ---------- Topic ----------
/** A named channel; owns its subscriber list and fans out on publish. */
static final class Topic {
final String name;
private final List<Subscription> subscriptions = new CopyOnWriteArrayList<>();
Topic(String name) {
this.name = name;
}
Subscription subscribe(Subscriber subscriber, OverflowPolicy policy, DeadLetterQueue dlq) {
Subscription subscription = new Subscription(subscriber, policy, dlq);
subscriptions.add(subscription);
subscription.start();
return subscription;
}
void unsubscribe(Subscription subscription) {
subscriptions.remove(subscription);
subscription.stop();
}
/** Fan out to every subscription's own bounded queue -- never calls the subscriber inline. */
void publish(Message message) {
for (Subscription subscription : subscriptions) {
subscription.enqueue(message);
}
}
}
// ---------- Broker (facade) ----------
/** Owns all topics; top-level publish/subscribe entry point. */
static final class Broker {
private final Map<String, Topic> topics = new ConcurrentHashMap<>();
private final DeadLetterQueue deadLetterQueue = new DeadLetterQueue();
Topic createTopic(String name) {
return topics.computeIfAbsent(name, Topic::new);
}
void publish(String topicName, Message message) {
Topic topic = topics.get(topicName);
if (topic == null) throw new IllegalArgumentException("No such topic: " + topicName);
topic.publish(message);
}
Subscription subscribe(String topicName, Subscriber subscriber, OverflowPolicy policy) {
Topic topic = topics.get(topicName);
if (topic == null) throw new IllegalArgumentException("No such topic: " + topicName);
return topic.subscribe(subscriber, policy, deadLetterQueue);
}
void unsubscribe(String topicName, Subscription subscription) {
Topic topic = topics.get(topicName);
if (topic != null) topic.unsubscribe(subscription);
}
DeadLetterQueue deadLetterQueue() {
return deadLetterQueue;
}
}
// ---------- Demo ----------
public static void main(String[] args) throws InterruptedException {
Broker broker = new Broker();
broker.createTopic("orders");
List<String> fastReceived = new CopyOnWriteArrayList<>();
Subscriber fastSubscriber = msg -> fastReceived.add(msg.payload);
// Always fails for one specific message id to deterministically exercise
// the retry -> dead-letter path while other messages succeed normally.
Subscriber flakySubscriber = msg -> {
if (msg.id.equals("m2")) {
throw new RuntimeException("permanent failure for m2");
}
};
broker.subscribe("orders", fastSubscriber, new DropOldest());
broker.subscribe("orders", flakySubscriber, new DropOldest());
for (int i = 0; i < 5; i++) {
broker.publish("orders", new Message("m" + i, "order-" + i, "p0"));
}
Thread.sleep(1000);
System.out.println("Fast subscriber received: " + fastReceived);
System.out.println("Dead letters: " + broker.deadLetterQueue().entries());
}
}
// Pub-Sub / Message Queue -- full class-diagram implementation.
//
// Broker owns Topics; a Topic owns Subscriptions (one per Subscriber); each Subscription
// has its own bounded queue + background consumer thread so a slow subscriber can never
// block the publisher or other subscribers.
#include <iostream>
#include <string>
#include <vector>
#include <map>
#include <unordered_map>
#include <deque>
#include <memory>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <atomic>
#include <chrono>
#include <functional>
#include <sstream>
#include <algorithm>
#include <stdexcept>
// ---------- Message ----------
struct Message {
std::string id;
std::string payload;
std::string partitionKey;
long long timestamp;
Message(std::string id_, std::string payload_, std::string partitionKey_)
: id(std::move(id_)), payload(std::move(payload_)), partitionKey(std::move(partitionKey_)) {
timestamp = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
}
};
// ---------- Subscriber (interface) ----------
// Business logic for handling one message. Must be idempotent (at-least-once delivery).
struct Subscriber {
virtual ~Subscriber() = default;
virtual void onMessage(const Message& message) = 0; // throws on failure
};
class Subscription; // fwd decl
// ---------- OverflowPolicy (Strategy interface) ----------
struct OverflowPolicy {
virtual ~OverflowPolicy() = default;
virtual void onOverflow(Subscription& subscription, const Message& incoming) = 0;
};
// ---------- DeadLetterQueue ----------
class DeadLetterQueue {
std::mutex mtx;
std::vector<std::string> entries_;
public:
void add(const Message& message, const std::string& reason) {
std::lock_guard<std::mutex> lock(mtx);
entries_.push_back(message.id + " :: " + reason);
}
std::vector<std::string> entries() {
std::lock_guard<std::mutex> lock(mtx);
return entries_;
}
};
// ---------- Subscription ----------
// Per-subscriber delivery state on a topic: bounded queue, ack/retry tracking, background consumer.
class Subscription {
public:
static const size_t CAPACITY = 16;
static const int MAX_RETRIES = 3;
std::string id;
std::deque<Message> queue;
std::mutex queueMutex;
std::condition_variable queueCv;
Subscription(std::shared_ptr<Subscriber> subscriber,
std::shared_ptr<OverflowPolicy> overflowPolicy,
std::shared_ptr<DeadLetterQueue> dlq,
std::string subId)
: id(std::move(subId)), subscriber_(std::move(subscriber)),
overflowPolicy_(std::move(overflowPolicy)), deadLetterQueue_(std::move(dlq)) {}
~Subscription() { stop(); }
// Try to enqueue; on a full queue delegate to the overflow policy.
void enqueue(const Message& message) {
std::unique_lock<std::mutex> lock(queueMutex);
if (queue.size() < CAPACITY) {
queue.push_back(message);
lock.unlock();
queueCv.notify_one();
} else {
lock.unlock();
overflowPolicy_->onOverflow(*this, message); // policy may itself re-lock and enqueue
}
}
// Raw push used only by overflow policies that already decided to bypass capacity checks.
void forcePush(const Message& message) {
std::unique_lock<std::mutex> lock(queueMutex);
queue.push_back(message);
lock.unlock();
queueCv.notify_one();
}
void popOldest() {
std::lock_guard<std::mutex> lock(queueMutex);
if (!queue.empty()) queue.pop_front();
}
void start() {
running_ = true;
thread_ = std::thread(&Subscription::consumeLoop, this);
}
void stop() {
if (!running_) return;
running_ = false;
queueCv.notify_all();
if (thread_.joinable()) thread_.join();
}
void ack(const std::string& messageId) {
std::lock_guard<std::mutex> lock(stateMutex_);
inFlight_.erase(messageId);
retryCount_.erase(messageId);
}
void nack(const std::string& messageId) {
std::lock_guard<std::mutex> lock(stateMutex_);
auto it = inFlight_.find(messageId);
if (it == inFlight_.end()) return;
Message message = it->second;
inFlight_.erase(it);
int attempts = ++retryCount_[messageId];
if (attempts > MAX_RETRIES) {
deadLetterQueue_->add(message, "retry exhausted");
retryCount_.erase(messageId);
} else {
enqueue(message); // redeliver -- subscriber will see this message again
}
}
private:
std::shared_ptr<Subscriber> subscriber_;
std::shared_ptr<OverflowPolicy> overflowPolicy_;
std::shared_ptr<DeadLetterQueue> deadLetterQueue_;
std::atomic<bool> running_{false};
std::thread thread_;
std::mutex stateMutex_;
std::unordered_map<std::string, Message> inFlight_;
std::unordered_map<std::string, int> retryCount_;
// One consumer loop per subscriber, running on its own background thread.
void consumeLoop() {
while (running_) {
Message message("", "", "");
bool got = false;
{
std::unique_lock<std::mutex> lock(queueMutex);
queueCv.wait_for(lock, std::chrono::milliseconds(200),
[this] { return !queue.empty() || !running_; });
if (!queue.empty()) {
message = queue.front();
queue.pop_front();
got = true;
}
}
if (!got) continue;
{
std::lock_guard<std::mutex> lock(stateMutex_);
inFlight_.emplace(message.id, message);
}
try {
subscriber_->onMessage(message); // must be idempotent -- may be a redelivery
ack(message.id);
} catch (...) {
nack(message.id);
}
}
}
};
// Drop the oldest queued message to make room for the incoming one.
struct DropOldest : OverflowPolicy {
void onOverflow(Subscription& subscription, const Message& incoming) override {
subscription.popOldest();
subscription.forcePush(incoming);
}
};
// Block the publishing thread until space is available (backpressure).
struct BlockPublisher : OverflowPolicy {
void onOverflow(Subscription& subscription, const Message& incoming) override {
std::unique_lock<std::mutex> lock(subscription.queueMutex);
subscription.queueCv.wait(lock, [&] { return subscription.queue.size() < Subscription::CAPACITY; });
subscription.queue.push_back(incoming);
lock.unlock();
subscription.queueCv.notify_one();
}
};
// Simply drop the new incoming message, keeping what's already queued.
struct DropNewest : OverflowPolicy {
void onOverflow(Subscription& subscription, const Message& incoming) override {
(void)subscription; (void)incoming; // intentionally no-op
}
};
// ---------- Topic ----------
// A named channel; owns its subscriber list and fans out on publish.
class Topic {
public:
std::string name;
explicit Topic(std::string name_) : name(std::move(name_)) {}
std::shared_ptr<Subscription> subscribe(std::shared_ptr<Subscriber> subscriber,
std::shared_ptr<OverflowPolicy> policy,
std::shared_ptr<DeadLetterQueue> dlq) {
std::lock_guard<std::mutex> lock(mtx_);
auto sub = std::make_shared<Subscription>(subscriber, policy, dlq,
"sub" + std::to_string(nextId_++));
subscriptions_.push_back(sub);
sub->start();
return sub;
}
void unsubscribe(const std::shared_ptr<Subscription>& subscription) {
std::lock_guard<std::mutex> lock(mtx_);
subscription->stop();
subscriptions_.erase(std::remove(subscriptions_.begin(), subscriptions_.end(), subscription),
subscriptions_.end());
}
// Fan out to every subscription's own bounded queue -- never calls the subscriber inline.
void publish(const Message& message) {
std::vector<std::shared_ptr<Subscription>> snapshot;
{
std::lock_guard<std::mutex> lock(mtx_);
snapshot = subscriptions_;
}
for (auto& sub : snapshot) sub->enqueue(message);
}
private:
std::mutex mtx_;
std::vector<std::shared_ptr<Subscription>> subscriptions_;
int nextId_ = 0;
};
// ---------- Broker (facade) ----------
// Owns all topics; top-level publish/subscribe entry point.
class Broker {
public:
Broker() : deadLetterQueue_(std::make_shared<DeadLetterQueue>()) {}
std::shared_ptr<Topic> createTopic(const std::string& name) {
std::lock_guard<std::mutex> lock(mtx_);
auto it = topics_.find(name);
if (it != topics_.end()) return it->second;
auto topic = std::make_shared<Topic>(name);
topics_[name] = topic;
return topic;
}
void publish(const std::string& topicName, const Message& message) {
auto topic = getTopic(topicName);
topic->publish(message);
}
std::shared_ptr<Subscription> subscribe(const std::string& topicName,
std::shared_ptr<Subscriber> subscriber,
std::shared_ptr<OverflowPolicy> policy) {
auto topic = getTopic(topicName);
return topic->subscribe(subscriber, policy, deadLetterQueue_);
}
void unsubscribe(const std::string& topicName, const std::shared_ptr<Subscription>& subscription) {
auto topic = getTopic(topicName);
topic->unsubscribe(subscription);
}
std::shared_ptr<DeadLetterQueue> deadLetterQueue() { return deadLetterQueue_; }
private:
std::mutex mtx_;
std::map<std::string, std::shared_ptr<Topic>> topics_;
std::shared_ptr<DeadLetterQueue> deadLetterQueue_;
std::shared_ptr<Topic> getTopic(const std::string& name) {
std::lock_guard<std::mutex> lock(mtx_);
auto it = topics_.find(name);
if (it == topics_.end()) throw std::invalid_argument("No such topic: " + name);
return it->second;
}
};
// ---------- Demo subscribers ----------
struct FastSubscriber : Subscriber {
std::mutex mtx;
std::vector<std::string> received;
void onMessage(const Message& message) override {
std::lock_guard<std::mutex> lock(mtx);
received.push_back(message.payload);
}
};
// Always fails for one specific message id to deterministically exercise
// the retry -> dead-letter path while other messages succeed normally.
struct FlakySubscriber : Subscriber {
void onMessage(const Message& message) override {
if (message.id == "m2") throw std::runtime_error("permanent failure for m2");
}
};
int main() {
Broker broker;
broker.createTopic("orders");
auto fast = std::make_shared<FastSubscriber>();
auto flaky = std::make_shared<FlakySubscriber>();
broker.subscribe("orders", fast, std::make_shared<DropOldest>());
broker.subscribe("orders", flaky, std::make_shared<DropOldest>());
for (int i = 0; i < 5; i++) {
broker.publish("orders", Message("m" + std::to_string(i), "order-" + std::to_string(i), "p0"));
}
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
std::cout << "Fast subscriber received: ";
for (auto& p : fast->received) std::cout << p << " ";
std::cout << "\n";
std::cout << "Dead letters: ";
for (auto& e : broker.deadLetterQueue()->entries()) std::cout << e << " | ";
std::cout << "\n";
return 0;
}
'use strict';
/**
* Pub-Sub / Message Queue -- full class-diagram implementation.
*
* Broker owns Topics; a Topic owns Subscriptions (one per Subscriber); each Subscription
* has its own bounded queue + async consumption loop so a slow subscriber can never
* block the publisher or other subscribers (decoupling publish() from consumption).
*
* Node is single-threaded, so "concurrency" here means interleaved async consumer loops
* (via setImmediate) rather than OS threads -- the same bounded-queue-per-subscriber
* design guards against one subscriber starving another, and enqueue/ack/nack are each
* atomic within a single synchronous turn of the event loop.
*/
const { randomUUID } = require('crypto');
// ---------- Message ----------
class Message {
constructor(id, payload, partitionKey) {
this.id = id;
this.payload = payload;
this.partitionKey = partitionKey;
this.timestamp = Date.now();
}
}
// ---------- OverflowPolicy (Strategy interface) ----------
class OverflowPolicy {
onOverflow(_subscription, _incoming) {
throw new Error('not implemented');
}
}
class DropOldest extends OverflowPolicy {
onOverflow(subscription, incoming) {
subscription.queue.shift(); // discard oldest
subscription.queue.push(incoming);
}
}
class DropNewest extends OverflowPolicy {
onOverflow(_subscription, _incoming) {
// intentionally no-op: newest message is dropped
}
}
class BlockPublisher extends OverflowPolicy {
onOverflow(subscription, incoming) {
// No real OS blocking available in single-threaded JS; approximate
// backpressure by force-pushing past capacity (publisher "waits its turn"
// conceptually -- the queue will drain before growing further).
subscription.queue.push(incoming);
}
}
// ---------- DeadLetterQueue ----------
class DeadLetterQueue {
constructor() {
this._entries = [];
}
add(message, reason) {
this._entries.push(`${message.id} :: ${reason}`);
}
entries() {
return this._entries;
}
}
// ---------- Subscription ----------
const CAPACITY = 16;
const MAX_RETRIES = 3;
class Subscription {
constructor(subscriber, overflowPolicy, deadLetterQueue) {
this.id = randomUUID();
this.subscriber = subscriber; // Subscriber: { onMessage(message) }
this.overflowPolicy = overflowPolicy;
this.deadLetterQueue = deadLetterQueue;
this.queue = [];
this.inFlight = new Map();
this.retryCount = new Map();
this.running = false;
this._timer = null;
}
// Try to enqueue; on a full queue delegate to the overflow policy.
enqueue(message) {
if (this.queue.length < CAPACITY) {
this.queue.push(message);
} else {
this.overflowPolicy.onOverflow(this, message);
}
}
start() {
this.running = true;
this._scheduleNext();
}
stop() {
this.running = false;
if (this._timer) clearImmediate(this._timer);
}
_scheduleNext() {
if (!this.running) return;
this._timer = setImmediate(() => this._consumeOne());
}
// One "tick" of the consumer loop -- pulls one message and processes it.
async _consumeOne() {
if (!this.running) return;
const message = this.queue.shift();
if (!message) {
this._scheduleNext();
return;
}
this.inFlight.set(message.id, message);
try {
await this.subscriber.onMessage(message); // must be idempotent -- may be a redelivery
this.ack(message.id);
} catch (_err) {
this.nack(message.id);
}
this._scheduleNext();
}
ack(messageId) {
this.inFlight.delete(messageId);
this.retryCount.delete(messageId);
}
nack(messageId) {
const message = this.inFlight.get(messageId);
this.inFlight.delete(messageId);
if (!message) return;
const attempts = (this.retryCount.get(messageId) || 0) + 1;
this.retryCount.set(messageId, attempts);
if (attempts > MAX_RETRIES) {
this.deadLetterQueue.add(message, 'retry exhausted');
this.retryCount.delete(messageId);
} else {
this.enqueue(message); // redeliver -- subscriber will see this message again
}
}
}
// ---------- Topic ----------
class Topic {
constructor(name) {
this.name = name;
this.subscriptions = [];
}
subscribe(subscriber, policy, dlq) {
const subscription = new Subscription(subscriber, policy, dlq);
this.subscriptions.push(subscription);
subscription.start();
return subscription;
}
unsubscribe(subscription) {
subscription.stop();
this.subscriptions = this.subscriptions.filter((s) => s !== subscription);
}
// Fan out to every subscription's own bounded queue -- never calls the subscriber inline.
publish(message) {
for (const subscription of this.subscriptions) {
subscription.enqueue(message);
}
}
}
// ---------- Broker (facade) ----------
class Broker {
constructor() {
this.topics = new Map();
this.deadLetterQueue = new DeadLetterQueue();
}
createTopic(name) {
if (!this.topics.has(name)) this.topics.set(name, new Topic(name));
return this.topics.get(name);
}
_getTopic(name) {
const topic = this.topics.get(name);
if (!topic) throw new Error(`No such topic: ${name}`);
return topic;
}
publish(topicName, message) {
this._getTopic(topicName).publish(message);
}
subscribe(topicName, subscriber, policy) {
return this._getTopic(topicName).subscribe(subscriber, policy, this.deadLetterQueue);
}
unsubscribe(topicName, subscription) {
this._getTopic(topicName).unsubscribe(subscription);
}
}
// ---------- Demo ----------
function main() {
const broker = new Broker();
broker.createTopic('orders');
const fastReceived = [];
const fastSubscriber = { onMessage: (msg) => { fastReceived.push(msg.payload); } };
// Always fails for one specific message id to deterministically exercise
// the retry -> dead-letter path while other messages succeed normally.
const flakySubscriber = {
onMessage: (msg) => {
if (msg.id === 'm2') throw new Error('permanent failure for m2');
},
};
broker.subscribe('orders', fastSubscriber, new DropOldest());
broker.subscribe('orders', flakySubscriber, new DropOldest());
for (let i = 0; i < 5; i++) {
broker.publish('orders', new Message(`m${i}`, `order-${i}`, 'p0'));
}
setTimeout(() => {
console.log('Fast subscriber received:', fastReceived);
console.log('Dead letters:', broker.deadLetterQueue.entries());
}, 500);
}
if (require.main === module) {
main();
}
module.exports = {
Message, Topic, Broker, Subscription,
DropOldest, DropNewest, BlockPublisher, DeadLetterQueue,
};
/**
* Pub-Sub / Message Queue -- full class-diagram implementation.
*
* Broker owns Topics; a Topic owns Subscriptions (one per Subscriber); each Subscription
* has its own bounded queue + async consumption loop so a slow subscriber can never
* block the publisher or other subscribers (decoupling publish() from consumption).
*/
import { randomUUID } from 'crypto';
// ---------- Message ----------
class Message {
readonly timestamp: number;
constructor(
readonly id: string,
readonly payload: string,
readonly partitionKey: string,
) {
this.timestamp = Date.now();
}
}
// ---------- Subscriber (interface) ----------
interface Subscriber {
onMessage(message: Message): void | Promise<void>;
}
// ---------- OverflowPolicy (Strategy interface) ----------
interface OverflowPolicy {
onOverflow(subscription: Subscription, incoming: Message): void;
}
class DropOldest implements OverflowPolicy {
onOverflow(subscription: Subscription, incoming: Message): void {
subscription.queue.shift();
subscription.queue.push(incoming);
}
}
class DropNewest implements OverflowPolicy {
onOverflow(_subscription: Subscription, _incoming: Message): void {
// intentionally no-op: newest message is dropped
}
}
class BlockPublisher implements OverflowPolicy {
onOverflow(subscription: Subscription, incoming: Message): void {
// Single-threaded JS has no real blocking primitive; approximate
// backpressure by force-pushing -- the queue drains before growing further.
subscription.queue.push(incoming);
}
}
// ---------- DeadLetterQueue ----------
class DeadLetterQueue {
private readonly _entries: string[] = [];
add(message: Message, reason: string): void {
this._entries.push(`${message.id} :: ${reason}`);
}
entries(): string[] {
return this._entries;
}
}
// ---------- Subscription ----------
const CAPACITY = 16;
const MAX_RETRIES = 3;
class Subscription {
readonly id: string = randomUUID();
readonly queue: Message[] = [];
private readonly inFlight = new Map<string, Message>();
private readonly retryCount = new Map<string, number>();
private running = false;
constructor(
private readonly subscriber: Subscriber,
private readonly overflowPolicy: OverflowPolicy,
private readonly deadLetterQueue: DeadLetterQueue,
) {}
/** Try to enqueue; on a full queue delegate to the overflow policy. */
enqueue(message: Message): void {
if (this.queue.length < CAPACITY) {
this.queue.push(message);
} else {
this.overflowPolicy.onOverflow(this, message);
}
}
start(): void {
this.running = true;
this.scheduleNext();
}
stop(): void {
this.running = false;
}
private scheduleNext(): void {
if (!this.running) return;
setImmediate(() => this.consumeOne());
}
/** One "tick" of the consumer loop -- pulls one message and processes it. */
private async consumeOne(): Promise<void> {
if (!this.running) return;
const message = this.queue.shift();
if (!message) {
this.scheduleNext();
return;
}
this.inFlight.set(message.id, message);
try {
await this.subscriber.onMessage(message); // must be idempotent -- may be a redelivery
this.ack(message.id);
} catch {
this.nack(message.id);
}
this.scheduleNext();
}
ack(messageId: string): void {
this.inFlight.delete(messageId);
this.retryCount.delete(messageId);
}
nack(messageId: string): void {
const message = this.inFlight.get(messageId);
this.inFlight.delete(messageId);
if (!message) return;
const attempts = (this.retryCount.get(messageId) ?? 0) + 1;
this.retryCount.set(messageId, attempts);
if (attempts > MAX_RETRIES) {
this.deadLetterQueue.add(message, 'retry exhausted');
this.retryCount.delete(messageId);
} else {
this.enqueue(message); // redeliver -- subscriber will see this message again
}
}
}
// ---------- Topic ----------
class Topic {
private subscriptions: Subscription[] = [];
constructor(readonly name: string) {}
subscribe(subscriber: Subscriber, policy: OverflowPolicy, dlq: DeadLetterQueue): Subscription {
const subscription = new Subscription(subscriber, policy, dlq);
this.subscriptions.push(subscription);
subscription.start();
return subscription;
}
unsubscribe(subscription: Subscription): void {
subscription.stop();
this.subscriptions = this.subscriptions.filter((s) => s !== subscription);
}
/** Fan out to every subscription's own bounded queue -- never calls the subscriber inline. */
publish(message: Message): void {
for (const subscription of this.subscriptions) {
subscription.enqueue(message);
}
}
}
// ---------- Broker (facade) ----------
class Broker {
private readonly topics = new Map<string, Topic>();
private readonly _deadLetterQueue = new DeadLetterQueue();
createTopic(name: string): Topic {
if (!this.topics.has(name)) this.topics.set(name, new Topic(name));
return this.topics.get(name)!;
}
private getTopic(name: string): Topic {
const topic = this.topics.get(name);
if (!topic) throw new Error(`No such topic: ${name}`);
return topic;
}
publish(topicName: string, message: Message): void {
this.getTopic(topicName).publish(message);
}
subscribe(topicName: string, subscriber: Subscriber, policy: OverflowPolicy): Subscription {
return this.getTopic(topicName).subscribe(subscriber, policy, this._deadLetterQueue);
}
unsubscribe(topicName: string, subscription: Subscription): void {
this.getTopic(topicName).unsubscribe(subscription);
}
deadLetterQueue(): DeadLetterQueue {
return this._deadLetterQueue;
}
}
// ---------- Demo ----------
function main(): void {
const broker = new Broker();
broker.createTopic('orders');
const fastReceived: string[] = [];
const fastSubscriber: Subscriber = {
onMessage: (msg) => {
fastReceived.push(msg.payload);
},
};
// Always fails for one specific message id to deterministically exercise
// the retry -> dead-letter path while other messages succeed normally.
const flakySubscriber: Subscriber = {
onMessage: (msg) => {
if (msg.id === 'm2') throw new Error('permanent failure for m2');
},
};
broker.subscribe('orders', fastSubscriber, new DropOldest());
broker.subscribe('orders', flakySubscriber, new DropOldest());
for (let i = 0; i < 5; i++) {
broker.publish('orders', new Message(`m${i}`, `order-${i}`, 'p0'));
}
setTimeout(() => {
console.log('Fast subscriber received:', fastReceived);
console.log('Dead letters:', broker.deadLetterQueue().entries());
}, 500);
}
if (require.main === module) {
main();
}
export {
Message, Subscriber, Topic, Broker, Subscription,
OverflowPolicy, DropOldest, DropNewest, BlockPublisher, DeadLetterQueue,
};