10. Pub-Sub / Message Queue Advanced + Concurrency

A. Realistic Interview Prompt

"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

D. Requirements

Functional

  1. Publishers publish messages to a named Topic
  2. Subscribers subscribe/unsubscribe to a topic and receive every message published after subscribing
  3. Support multiple independent subscribers per topic, each processing at its own pace
  4. A slow or temporarily unavailable subscriber must not lose messages or block other subscribers
  5. 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

E. Clarifying Questions

#QuestionWhy It MattersDesign Decision Affected
1Push model (broker calls the subscriber) or pull model (subscriber polls the broker)?Determines where backpressure and rate-of-consumption control naturally liveCore delivery loop shape (see I, W)
2Is at-least-once delivery required, or is at-most-once (fire-and-forget) acceptable?At-least-once forces retry logic and, therefore, idempotent consumersPresence of an ack/retry protocol and dedup requirement
3Does 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 guaranteePartitioning scheme
4What 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" defaultOverflowPolicy (Strategy) — direct callback to Problem 6's async-appender overflow decision
5Is this in-process only, or does it need to survive a process restart?Determines whether messages need any persistence at allScope of X (Persistence)

F. Assumptions

G. Actors

ActorResponsibility
PublisherPublishes messages to a topic
SubscriberRegisters interest in a topic; processes delivered messages
BrokerOwns topics, fans out messages, applies overflow policy, tracks delivery/acks

H. Core Domain Entities

ClassResponsibilityKey AttributesKey MethodsDoes NOT Handle
MessageImmutable unit of data plus metadataid, payload, partitionKey, timestamp—Delivery mechanics
TopicA named channel; owns its subscriber list and optional partitionsname, subscriptions, partitionspublish(message), subscribe(sub), unsubscribe(sub)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
SubscriptionPer-subscriber delivery state on a topicbounded queue, ack/retry tracking, offsetenqueue(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
DeadLetterQueueHolds 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 pointMap<String, Topic>createTopic(), publish(), subscribe()Per-subscriber delivery details (delegated to Topic/Subscription)

I. Class Relationships

J. Class Diagram

notifies

wraps

on retry exhaustion

Message

id

payload

partitionKey

timestamp

Topic

name

+publish(msg)

+subscribe(sub)

+unsubscribe(sub)

«interface»

Subscriber

+onMessage(msg)

Subscription

queue

+enqueue(msg)

+ack(id)

+nack(id)

«interface»

OverflowPolicy

+onOverflow(sub, msg)

DeadLetterQueue

+add(msg, reason)

Broker

topics

+createTopic()

+publish()

+subscribe()

DropOldest

BlockPublisher

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

ClassResponsibilityDoes NOT Handle
TopicOwns subscriber list, fans out on publishPer-subscriber queueing or retry
SubscriptionOne subscriber's queue, acks, retries, overflow handlingThe subscriber's actual business logic
SubscriberWhat to do with one messageQueueing, backpressure, dead-lettering
OverflowPolicyOne backpressure behaviorWhy the queue is full
BrokerTop-level orchestration across topicsAny single topic's internal fan-out mechanics

L. SOLID Analysis

PrincipleHow It's AppliedNaive Violation
SRPTopic only fans out; Subscription only manages one subscriber's delivery state; OverflowPolicy only decides backpressure behaviorOne Topic class directly looping over subscribers, calling them synchronously, and inlining retry/backpressure logic per call
OCPNew backpressure behavior = new OverflowPolicy; new dead-letter handling = swap the DeadLetterQueue implementationif (policy == "DROP_OLDEST") ... else if (policy == "BLOCK") ... inline in Subscription
LSPEvery OverflowPolicy implementation honestly implements onOverflow(), even a no-op "unbounded" policy is explicit about accepting unbounded memory risk rather than silently behaving differentlyA policy that silently drops messages when asked to block, breaking the caller's assumption
ISPSubscriber stays a one-method interface — acking, retrying, and dead-lettering all live in Subscription, not forced onto every subscriber implementationForcing every Subscriber to implement onOverflow(), onRetryExhausted(), etc. directly
DIPSubscription depends on the OverflowPolicy and Subscriber abstractionsSubscription hardcoding one specific overflow behavior inline instead of delegating to a policy object

M. Design Patterns

PatternProblem It SolvesWhere It AppearsWhen NOT to Use
ObserverDecoupling "a message was published" from "who needs to know" — the foundational pattern this whole problem generalizesTopic notifying its SubscriptionsNever really skippable here — it's the core of the problem
StrategyBackpressure behavior is a swappable, mutually exclusive policyOverflowPolicySkip if the system only ever needs one fixed overflow behavior (unrealistic for a general-purpose queue)
FacadeSingle top-level entry point across many topicsBrokerSkip 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.

N. What Is Likely to Change?

BehaviorCurrent DesignAnticipated ChangeProtecting Abstraction
Backpressure policyFixed drop-oldest defaultPer-topic configurable policy (block, drop-newest, drop-oldest)OverflowPolicy interface
Delivery guaranteeAt-least-onceExactly-once (via idempotency keys + dedup store) if a stronger guarantee is demandedAck/retry protocol in Subscription, extended rather than replaced
OrderingNone, or per-partitionStricter per-key ordering as topics growPartition abstraction already isolates ordering scope from the rest of Topic
Deployment scaleIn-process, single machineDistributed broker (Kafka/Pulsar/SQS/SNS-style)Explicitly named as an LLD/HLD boundary crossing, not a drop-in upgrade (see Y)

O. Design Evolution

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
}

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

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

StructureUsed ForWhyAlternative & Trade-off
Bounded blocking queue per SubscriptionAbsorbing bursts without unbounded memory growthDirectly enforces the chosen backpressure limit; a full queue is exactly the signal that triggers OverflowPolicyAn 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 otherGuarantees per-key ordering without forcing a single global order across the whole topicA single ordered log for the whole topic gives stronger guarantees but serializes unrelated keys unnecessarily
Map<messageId, retryCount>Tracking retry attempts per message per subscriptionO(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

OperationTimeSpace
publish()O(subscribers on the topic)O(1) per subscriber beyond the message reference
Subscription.consumeLoop() per messageO(1) dequeue + subscriber's own handler costO(queue capacity) bounded, by design

U. Testing Strategy

V. Interviewer Follow-Ups

Follow-UpTestingDesign ChangeClasses ChangedPattern/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 queueConfirms Subscription's already-isolated queue designN/A if already designed this wayValidates the Subscription-per-subscriber investment
"What if the same message gets delivered twice?"Idempotent-consumer reasoning under at-least-once deliveryN/A at the broker level — the fix lives in subscriber-side idempotency (e.g. dedup by message ID)Subscriber implementations, not the brokerThe 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 awarenessN/A at LLD — candidate should name Kafka/Pulsar/SQS/SNS-style concerns (partition replication, consumer groups, offset commits) and place them explicitly at HLDN/ARecognizing 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 forConfirms or adds DeadLetterQueue wiring in Subscription.nack()Subscription, DeadLetterQueueValidates the retry-limit design from Q

W. Concurrency

Shared mutable state: each Topic's subscriber list; each Subscription's queue and retry-count map.

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.

Y. Scaling Boundary

LayerWhat Belongs Here
LLDTopic/Subscription/OverflowPolicy/retry-and-dead-letter classes, in-process Observer-based fan-out
HLDDistributed broker architecture — partition replication, consumer groups, offset commits, cross-machine failover (Kafka/Pulsar/SQS/SNS-style)
Distributed SystemsConsensus 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

AA. Common Bad Designs

AB. Design Trade-Offs

DecisionOption AOption BRecommendation
Delivery modelPush (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 defaultDrop-oldestBlock the publisherDrop-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 scopeGlobal (whole topic)Per-partition onlyPer-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.

AE. Online Solution Comparison

SourceApproachStrengthWeaknessWorth Learning
ashishps1/awesome-low-level-design (GitHub)Topic/Publisher/Subscriber classes with a central broker maintaining topic-to-subscriber maps; synchronous notify loop on publishClean, minimal core Observer structure — validates this document's foundational pattern choiceNo 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 closeThe core Topic/Subscriber wiring itself; everything about delivery guarantees and backpressure is this document's addition on top

AF. Interview Cheat Sheet

ProblemPub-Sub / Message Queue
DifficultyAdvanced + Concurrency
Core requirementsNamed topics, multiple independent subscribers, no message loss from a slow subscriber
Key clarificationsPush or pull? At-least-once or at-most-once? Ordering guarantee scope? Overflow behavior? Persistence needed?
Core entitiesMessage, Topic, Subscriber, Subscription, OverflowPolicy, DeadLetterQueue, Broker
Key relationshipsObserver is the foundation; Subscription isolates per-subscriber delivery state; OverflowPolicy is a Strategy for backpressure
Key patternsObserver (headline/foundational), Strategy (overflow policy), Facade (Broker)
Most important decisionPer-subscriber bounded queues with an explicit overflow policy, so one slow subscriber can't block or starve any other
Likely-changing behaviorBackpressure policy, delivery guarantee strength, ordering granularity, deployment scale
Concurrency concernAt-least-once delivery forcing idempotent consumers; per-partition-only ordering; bounded-queue backpressure
Top follow-upsIsolating a slow subscriber, duplicate-delivery handling, distributed-broker pivot (LLD/HLD boundary), dead-letter queue
Common mistakesSynchronous in-line subscriber calls; unbounded queues; assuming free exactly-once delivery; assuming global ordering
What NOT to over-engineerDon'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());
    }
}