File: distributed-message-queue/solution.py

Date: 2026-06-05

Time: 13:17

distributed-message-queue/solution.py

Purpose

This file implements an in-memory Kafka-like distributed message queue — one of the system design interview implementations in the sdi-implementations repo. It models the core abstractions of a partitioned, consumer-group-based message broker: topics with configurable partitions, key-based and round-robin routing, consumer group rebalancing, offset tracking with multiple delivery semantics, and a dead-letter queue for poison messages.

It's a teaching implementation — no networking, no persistence, no replication — but it faithfully models the logical contracts that a real distributed message queue like Kafka enforces.

Key Components

Data Classes

Partition

Models a single append-only log segment. Key contract:

ConsumerGroup

Tracks the full consumer-side state for a group subscribed to one topic:

rebalance() uses simple modular assignment: partition p goes to consumers[p % len(consumers)]. This is a simplified version of Kafka's range/round-robin assignors.

MessageQueue

The top-level broker. Owns all topics, partitions, consumer groups, and routing state.

| Method | Contract |

|--------|----------|

| createtopic / deletetopic | Topic lifecycle. delete_topic cascades to remove all consumer groups bound to that topic. |

| publish | Routes message to partition (key-hash or round-robin), appends, trims for retention, returns the StoredMessage. |

| createconsumergroup | Binds a group to a topic with a delivery semantic. |

| addconsumer / removeconsumer | Membership changes trigger rebalance(). |

| poll | Fetches up to max_messages from the consumer's assigned partitions, respecting delivery semantics. |

| commit | Advances committedoffset to match currentoffset for a consumer's partitions. |

| seek | Random-access offset reset. Supports sentinel values: -1 = beginning, -2 = end. |

| acknowledge | Negative-ack path. Tracks failure counts per message; after max_failures (default 3), routes to DLQ. |

| gettopicinfo / getconsumerlag | Observability: partition metadata and per-partition lag (distance between committed offset and partition head). |

Patterns

Kafka's logical model, faithfully miniaturized. The code mirrors Kafka's core abstractions almost 1:1: topics → partitions → offsets, consumer groups with rebalancing, committed vs. current offsets, key-based partitioning, round-robin for null keys, retention-based trimming.

Two-tier offset tracking. currentoffset and committedoffset are separate dictionaries, enabling the three delivery semantics:

Dead-letter queue as a regular topic. sendtodlq creates a topic named dlq{topic} on first use and publishes the failed message into it. This means DLQ messages are consumable with the same API — no special-case code needed.

Round-robin via counter. rrcounters is a per-topic monotonic counter for null-key messages, giving even distribution across partitions without randomness.

Dependencies

Imports: Only stdlib — time (timestamps), uuid (message IDs), defaultdict (failure counters), dataclass (message types). No external dependencies.

Imported by:

Flow

Publish path


publish(topic, Message)
  → key-hash or round-robin → select partition index
  → create StoredMessage (offset = partition.next_offset, timestamp = now, message_id = uuid)
  → partition.append()
  → partition.trim(retention_count)
  → return StoredMessage

Consume path


poll(group_id, consumer_id, max_messages)
  → look up assigned partitions for this consumer
  → for each assigned partition:
      → partition.get(current_offset, remaining_capacity)
      → for each message:
          → [exactly_once] skip if message_id in seen_message_ids
          → append to result, advance current_offset
          → [exactly_once] add message_id to seen_message_ids
      → stop if max_messages reached
  → [at_most_once] auto-commit offsets
  → return messages

Rebalance path


add_consumer / remove_consumer
  → update consumers list
  → rebalance(): for each partition p, assign to consumers[p % len(consumers)]
  → return new assignments

Failure path


acknowledge(group_id, message, success=False)
  → increment failure_counts[message_id]
  → if count >= max_failures:
      → create __dlq_{topic} if needed
      → publish message to DLQ
      → clear failure count

Invariants

1. Offset monotonicity: baseoffset only increases (via trim()), nextoffset only increases (via append()). A partition's offset space is never reused.

2. currentoffset >= committedoffset per partition within a group — poll() advances currentoffset, commit() catches committedoffset up. They never cross.

3. Partition assignment is deterministic: given the same consumer list and partition count, rebalance() always produces the same assignment. Partition p always goes to consumers[p % len(consumers)].

4. Retention is enforced per-publish: every publish() call trims the target partition. Messages are never evicted between publishes.

5. One topic per consumer group: a ConsumerGroup is bound to exactly one topic at creation time. There's no multi-topic subscription.

6. DLQ topics use the _dlq prefix convention and are created lazily on first failure.

Error Handling

All validation uses ValueError with descriptive messages:

Notably, poll() for an unassigned consumer silently returns [] rather than raising — this is intentional, since a consumer may temporarily have no assignments during rebalancing.

The acknowledge() path never raises — it silently counts failures and routes to DLQ. If DLQ topic creation or publish fails, that would propagate up (but in practice can't fail since the broker controls both).

Topics to Explore

Beliefs