SwadhyAI Notes

All System Design notes

System Design Communication

Message Queues

Payment succeeded; confirmation is already on screen: yet the receipt email, inventory decrement, loyalty points, and fraud score are still running somewhere else. The user should not wait for every side effect to finish on the request thread.

A is the durable handoff that makes that possible. The puts work on a ; a takes it later, retries carefully, and parks poison messages in a .

Checkout journey

1

Checkout API

Commit the order, then publish a job.

2

Durable ack after replication.

3

Email, inventory, loyalty in parallel lanes.

4

Crash before ack → redelivery.

Try it: grow consumer lag, then drain the backlog

One partition behaves like a log lane. Producers append at the rate you choose; a consumer group commits offsets at its processing rate. Lag is log-end minus committed offset. When produce exceeds consume, lag grows and the oldest unprocessed event ages. When consume exceeds produce, spare capacity drains the backlog.

Consumer lag after each secondScale: 1 messages
Bars show end-of-second lag. The chart keeps the latest thirty seconds; totals below cover the whole run.
Log end offset
0
Committed offset
0
Consumer lag
0
Estimated drain time
Not draining

The partition starts empty. Advance time to publish events and watch committed offsets trail the log end.

Surge then stall. Start with 1,200 events/s in and 800 out, advance twenty seconds, and lag reaches roughly eight thousand messages. Set produce to 800/s and advance again: lag stays flat because arrivals no longer exceed capacity. Drop produce to 600/s and run another twenty seconds to watch lag shrink.

Count lag and age together. A million-message lag on fresh click analytics differs from a thousand payment events that are minutes old. Alert on oldest-unprocessed age as well as offset gap.

Model scope: one partition, fluid rates, no rebalance, hot keys, or retries. Real groups sum lag per partition and cap parallelism at partition count.

Naive path, then break it

Naive: checkout API sends email, decrements inventory, and posts loyalty points on the request thread. One region, one DB, wait for every vendor.

Break: payment vendor times out at 8s; client retries; API retries email + inventory again. Or the worker crashes after sending the receipt but before ack: the broker redelivers and a second email goes out. Multi-layer retries (browser + API + broker + handler) amplify load into the sick dependency.

Derive: durable handoff after the order commit, competing workers or partitioned logs, bounded retry budgets with jitter, and idempotent side effects. Delivery may be more than once; processing must not double-charge.

Correctness invariant

What must never become false

After the broker durable-acks a job, the side effect is applied at most once per stable business key(order_id, payment_id, notification_id), even when the message is delivered more than once. Separate two claims: (broker retries, visibility timeouts, rebalances) vs processing ( / upsert / transactional mark-processed). Do not call the pipeline "exactly once" unless you mean exactly-once effects under an explicit failure boundary, not a broker checkbox.

What actually happens

The checkout page feels like one success. Underneath, the API stops owning the side effects after the confirms persistence. Workers claim messages, write to databases or call vendors, then acknowledge. If a worker dies mid-flight, the same job comes back. Which is why matters more than slogans about "exactly once."

Two shapes dominate. A traditional queue deletes or completes after ack and lets competing consumers split work: each message usually has one winner. A keeps ordered records for a retention window so many can replay from their own .

Design around producer rate, , consumer capacity, ordering key, retry budget, , and retention before trusting any throughput claim.

Counterexample to "the broker gives exactly-once." Crash-before-ack, visibility timeout expiry, and partition rebalance all redeliver. That is delivery. Safe money paths need processing idempotency (or a transactional consume-process-produce boundary), not a casual label.

What to watch

Queue depth

How many messages are waiting; it explains user-visible delay better than CPU alone.

Age of oldest message

How long the oldest task has waited; alert on this for SLOs instead of depth only.

Ack / commit failures

Where duplicates are born; track retries, nacks, and offset commit errors.

How to choose queue vs log

1. Is each unit of work claimed once? Background jobs, email sends, and image thumbnails usually want a classic queue with competing consumers.

2. Do many teams need the same history? Analytics, search indexing, and projections often want a with independent groups and replay.

3. What ordering boundary is real? Global FIFO is expensive. Prefer per-order or per-user keys that map to one .

Simple rule of thumb

Match broker type to access pattern: task queues for competing workers, logs for fan-out and replay. Assume , implement , wire early, and chart per partition.

Do not add a queue only to hide a slow synchronous dependency when the user still needs immediate success or failure.

Queue vs log

A task queue assigns each job to one active worker at a time and tracks whether processing finished. A retained log records events in order within partitions and lets independent consumer groups track positions. Queue acknowledgment usually advances or removes the delivery obligation; a log offset records consumer progress while the event remains until retention removes it. Choose according to whether the product needs one worker to perform a task, several independent reactions, or later replay.

Pick queues when each unit of work should be claimed once. Pick logs when many subscribers need the same history or you want replay and retention by policy.

Traditional queue

Broker routes and often deletes after ack. Competing consumers split work: each message to one worker. Push or pull depending on product.

Sketch: broker-routed task queues, in-memory backed job queues, or managed worker queues.

Distributed log

Append-only ; consumers maintain offsets. Same record can feed analytics, search indexing, and projections via different groups.

Sketch: partitioned append-only logs with retention, replay, and independent consumer groups.

Partitions, groups, and offsets

Ordering is per-partition. Scale partitions for parallelism; use stable keys when one entity's events must stay ordered end-to-end.

IdeaBehavior
Topic → partitionsEach partition is an ordered append-only segment; replication configured per topic.
Consumer groupInstances split partitions so each partition is consumed by at most one member of the group at a time.
Key routinghash(order_id) % N keeps all events for one entity in one partition → per-order ordering.
  • Offsets commit after processing: crash recovery resumes from last committed position.
  • Different consumer groups on the same topic maintain independent offsets (fan-out reads).

Partition sizing and hot keys

Partitions are your real parallel lanes. More consumers help only until every active partition is assigned; one still runs serially if ordering for that key is required.

Producer

Sizing checklist

  • peak events/sec = daily events / 86,400 * peak factor
  • minimum partitions = ceil(peak events/sec / safe partition events/sec)
  • effective workers = min(worker instances, partition count)
  • hot-key risk = largest key events/sec / safe per-key serial rate

Trade-off

Too few partitions cap throughput and slow backlog drain. Too many partitions increase metadata, rebalances, small batches, dashboards, and operational noise. Choose enough lanes for peak plus headroom, then test hot keys separately.

How to choose

If one key overloads partition 3, should we add workers?

  • Limit: Adding workers does not fix a single ordered hot key because one consumer must process that key serially.
  • Options: Change the partition key, split the entity into smaller ordered streams, isolate the hot customer, or relax ordering for safe sub-operations.
  • Monitor: Watch lag by partition, oldest message age, handler latency, and downstream throttling before changing partition count.

Capacity math: queues and logs

Backlog math turns "async scales" into a concrete plan: estimate incoming events, partition for parallelism, and prove consumers can drain spikes before users notice.

Drain rate > arrival rate?

Yes

No

QuestionFormulaExample
Events/secdaily events ÷ 86,400 × peak factor1M users × 8 notifications/day = 93 avg/s; 10× peak ≈ 930/s
Partitionsceil(peak events/sec ÷ safe partition throughput)930/s ÷ 250/s ≈ 4 partitions, then add headroom for hot keys
Consumer laglog end offset - committed offset120k lag at 2k/s consumer capacity means about 60s behind
Backlog drainbacklog ÷ (consume rate - produce rate)900k backlog ÷ (5k/s - 2k/s) = 300s to catch up
DLQ volumeevents/day × permanent failure rate × payload size8M/day × 0.05% × 2 KB ≈ 8 MB/day plus metadata
Retention storageevents/sec × payload bytes × retention seconds × replicas1k/s × 1 KB × 7 days × 3 ≈ 1.8 TB raw

Million-user notification queue

If 1M users each create 8 notification jobs/day, use peak events/sec to size producers, then verify workers can clear a promotion burst before the oldest-message SLO is breached.

Chat delivery backlog

If 1M users average 20 chat events/day, a 20× evening peak is about 4.6k events/sec; order by conversation id so one room stays consistent without forcing global FIFO.

Ordering

Global FIFO across a whole topic is usually unnecessary and expensive. Design per-entity or per-partition order and accept interleaving elsewhere.

System flavorOrderingDesign note
Partitioned logStrict FIFO inside one partitionPick partition key = consistency boundary.
Classic FIFO queueOften FIFO per queueMany competing consumers can interleave unless vendor says otherwise.
Strict FIFO (managed)Per message group / dedup scopeRead quotas and throughput caps: verify current docs.

Delivery vs processing (no casual "exactly once")

Producer acknowledgment, consumer receipt, and completed business processing are separate events. A broker can durably accept a job while the worker has not begun. A worker can finish a side effect and then lose its acknowledgment, causing another delivery. This is why at-least-once transport requires processing that tolerates duplicates. The transport guarantee cannot decide whether two emails or two charges represent an acceptable outcome.

Most production paths are delivery unless you invest in idempotent producers, transactional offset+sink commits, or broker dedup windows, and you still verify failure modes on your stack version. Processing safety is a separate promise.

Delivery

What the broker does after publish ack, visibility timeout, nack, and rebalance: the same message id or offset can appear again. At-most-once may drop; at-least-once retries until ack.

Processing

What your handler does to side effects: upsert by business key, processed-message row in the same transaction, or transactional consume-process-produce. Duplicate delivery + idempotent processing ≈ exactly-once effects.

At-most-once

May drop under crash paths; cheapest. Occasionally acceptable for telemetry where gaps matter less than tail latency.

At-least-once

Retries until ack: duplicates possible. Pair with idempotent handlers or natural keys in the datastore.

Exactly-once effects

End-to-end is a system property (producer + broker + consumer + sink). Idempotent producers, dedup keys, and transactional offset commits are building blocks, not free magic on the wire.

How to choose

If an interviewer asks, “Can we make this exactly once?”

  • Caveat: Split delivery vs processing. Exactly-once is not a broker checkbox; it needs one failure boundary across source, broker, consumer state, and sink.
  • Practical answer: Design for at-least-once delivery plus idempotent writes first, then add transactions or dedup windows only where the business value justifies the cost.
  • Example: Use order_id or payment_id as a natural dedupe key so retried messages update the same record instead of creating a second side effect.

Stronger processing modes typically combine (names vary by client):

yaml
# illustrative producer settings; names vary by client
idempotent_producer: true
transactional_writer_id: order-processor-1

# transactional consume-process-produce:
# read offsets + publish + commit offsets in one transaction boundary

Failure timeline: crash before ack + retry amplification

T0  Checkout API durable-acks OrderPaid#991 to broker; responds 200 to user
T1  Worker A sends receipt email; crashes before ack / offset commit
T2  Visibility timeout / rebalance → Worker B redelivers OrderPaid#991
T3  Without idempotency key → second email (delivery ≠ processing)
T4  Meanwhile payment vendor is slow; API + client + worker each retry ×3
T5  Offered load ≈ base × 3×3×3; vendor brownout deepens (retry amplification)
T6  Fix: one retry budget with jitter end-to-end; DLQ after max; upsert by order_id

Counterexample to "async always softens load": unbounded multi-layer retries turn a queue into a retry storm. Cap attempts, add jitter, and treat redelivery as expected, not a rare bug.

DLQ and backpressure

A visibility timeout or processing lease allows a job to be retried if a worker disappears. If legitimate processing exceeds that timeout, another worker may receive the same job while the first still runs. Extend the lease with bounded heartbeats or choose an appropriate timeout, but retain idempotency because lease extension can fail. Backpressure limits accepted work or worker concurrency; a larger queue only postpones overload when arrivals remain above completion capacity.

Poison messages belong in a with metrics; growing main-queue depth should trigger scale-out or before memory pressure or retention loss.

Dead letter queue

After max retries or permanent errors, park payloads for inspection and replay once fixes ship.

python
for attempt in range(max_retries):
    try:
        process(msg); return ack
    except TransientError:
        backoff_sleep(attempt)
send_to_dlq(msg); alert_if_depth_high()

Backpressure

  • Max queue depth or retention policies
  • Autoscale consumers from depth metrics
  • Throttle producers when depth crosses SLO
  • Tune poll batch sizes and prefetch limits per product

Handler succeeds?

Yes

Transient failure

Permanent failure

Retry storm guardrail

Transient error

Retry with exponential backoff, jitter, and a retry budget.

Permanent validation error

Skip retries and send to DLQ with a clear reason.

Downstream throttling

Slow consumers or pause pulls; do not multiply load.

Handler bug

Fail fast, alert, park messages, deploy fix, then replay safely.

How to choose

When should you introduce a queue?

  • Good signals: Long work, bursty producers, flaky downstreams, fan-out side effects, or a need to smooth capacity across time.
  • Bad reason: Do not add a queue just to hide a slow synchronous dependency when users still require immediate success or failure.
  • Backpressure answer: Name the control loop: cap producers, scale consumers, bound retries, and shed low-priority work before retention expires.

Batching and payloads

Amortize RTT with producer-side batching and compression; schema-based binary payloads shrink bandwidth and speed deserialization versus raw JSON at high volume.

Batching

Batch by partition with bounded wait (linger) so throughput jumps without unbounded latency.

Per-message
Higher overhead
Batched
Much higher throughput

Schema-first binaries

FormatNotes
JSONEasy debug; larger on wire
Schema-based binaryCompact; pair with a registry for compatibility rules

Routing sketches

Routing decides which queue or binding receives a message before consumers run: broker-managed filtering trades flexibility for operational clarity of the graph.

Broker routing exchanges

Direct, topic, fanout, headers routing keeps filtering in the broker: great for flexible bindings; ops must understand exchange topology.

Managed FIFO standard

Simple worker queues with huge elasticity; standard tiers emphasize throughput over strict ordering: reach for FIFO variants when order is contractual.

Strict FIFO groups

Ordering scoped to a group id plus dedup metadata where offered: read quotas and partial failures carefully.

Consumer lag

is how far a consumer partition trails the log end: rising lag means you are slower than producers or failing commits; tie alerts to business retention windows.

GroupTopicPartitionCommittedLog endLag
inventory-svcorders0158471590255
inventory-svcorders21833419201867

Alert thresholds depend on retention: unbounded lag risks reading past deleted segments. Track both raw lag count and lag age because 10k old payment events can be more urgent than 1M fresh analytics events.

Lag incident calculation

A producer surge creates 2M waiting messages. Producers continue at 8k events/sec and consumers process 12k events/sec, so net drain is 4k events/sec. Backlog drain time = 2,000,000 / (12,000 - 8,000) = 500 seconds. If the oldest message is already 23 hours old on 24-hour retention, this is a retention incident, not only a scaling task.

Idempotent consumers

For a database side effect, write the stable message identifier into a deduplication table in the same transaction as the business update. A unique constraint makes concurrent attempts converge on one committed result. A marker written before the side effect can lose work after a crash; one written afterward outside the transaction can allow duplicates. External providers need their own idempotency key or a reconciliation procedure because they do not share the local database transaction.

At-least-once delivery means duplicates will arrive: make handling safe with a dedupe key or natural in your datastore before chasing exactly-once brokers alone.

python
def handle(msg):
    key = msg.business_id
    if store.already_processed(key):
        return ack
    with store.transaction():
        apply_side_effects(msg)
        store.mark_processed(key)
    return ack
Natural business key

Insert or upsert by order_id, payment_id, or notification_id so duplicates hit the same row.

Processed-message table

Store message id in the same transaction as the side effect.

Version check

Apply only if event_version is newer than the stored entity version.

External side-effect wrapper

Write an outbox command first, then call the external system with a stable idempotency key.

Deepening: ops contracts for queues

Producer acknowledgements and in-flight limits

Require durable producer acks before treating a write as accepted. Cap in-flight messages per consumer so retries cannot explode memory or rebalance storms when workers stall.

Partition rebalancing and processing semantics

State whether delivery is at-most-once, at-least-once, or effectively-once with idempotent sinks. During rebalance, pause processing, commit offsets carefully, and expect duplicate deliveries.

Schema registry

Version every payload, enforce compatibility modes (backward/forward/full), and reject incompatible publishes in CI before they reach the broker.

Poison-message runbook

A poison message fails repeatedly because its data or processing logic is invalid. After a bounded number of attempts, preserve the original payload, failure reason, schema version, and business identifier in a dead-letter path. Fix the cause before replaying, and use the same idempotency rules during replay. Decide whether a failed event blocks later events for its ordering key or whether the business permits skipping it with an explicit record.

Bound retries, route permanent failures to a DLQ with the original headers and failure reason, alert on DLQ depth, and require an owner to replay or quarantine after root-cause fixes.

Break the improved design

Queues fix request-thread coupling, then fail differently: a pins one partition while peers look healthy; poison payloads loop until the fills; dual-write (DB commit then publish) loses events on the crash between steps.

Retention expiry drops unread lag; schema breaks on replay when old retained payloads lack fields new code needs. Consumer scale-out stops at partition count: more workers than partitions idle while lag grows.

Trade-offs

Queue vs log

Competing consumers for one winner vs fan-out + replay. Match access pattern, not fashion.

Stronger “exactly-once”

Transactions and dedup windows cost latency and ops complexity; at-least-once + idempotency is the honest default.

More partitions

Raises parallelism and weakens global order; rebalance and metadata cost climb.

What to remember

Match broker type to access pattern: task queues for competing workers, logs for fan-out and replay. Assume at-least-once delivery, implement processing idempotency, wire DLQs early, and chart lag per partition. Bound retries end-to-end: multi-layer retries amplify load. Batch and compress when producers bottleneck on network. Let schemas evolve with compatibility checks, not ad hoc JSON edits in prod.

Topic-specific references

Exercises

1. Delivery vs processing in review

A design doc says "SQS / Kafka exactly-once so refunds are unique." Write the precise delivery-vs-processing rebuttal and the minimal handler change (key + atomic mark) you would require before merge.

2. Retry amplification budget

Checkout clients retry ×3, the API retries the broker publish ×3, and workers retry the payment vendor ×5 with no jitter. Peak steady load is 2k jobs/sec. Estimate worst-case offered load and propose one end-to-end retry budget that still meets a 30s email SLO.

3. Hot partition under a flash sale

All events for flash-sale-1 hash to one partition at 4k events/sec; safe serial processing is 800/sec. Decide: change the key, isolate the hotspot, relax ordering for safe sub-ops, or shed work, and name the lag/age alerts you page on.

Practice and further reading

Use these exercises to connect the lesson to a design decision. State your assumptions, trace what happens to one request, and explain the rule that must remain true even during a failure. That rule is the design's correctness invariant.

Focus for this lesson: Define producer acknowledgement, durability, ordering, delivery semantics, in-flight bounds, retry, poison handling, rebalancing, and schema evolution.

Explain the decision

Why does at-least-once delivery require idempotent consumers?

Read the reasoning

A broker may redeliver after consumer work commits but acknowledgement is lost; the same message must not apply the business effect twice.

Apply it to a production scenario

One poison message retries forever and blocks an ordered partition. What is the runbook?

Read one approach

Bound retries, capture error/context, park it in a dead-letter workflow, alert an owner, and provide audited replay after remediation.

Work through the numbers

At 25,000 messages/s averaging 2 KiB and 24-hour retention with replication factor 3, estimate raw storage.

Read the worked calculation

25,000 × 2 KiB × 86,400 × 3 ≈ 12.1 TiB before compression and indexes.

Reason through a failure

What must offset commit ordering preserve?

Read the recovery reasoning

A consumer commits an offset only after its durable effect or transactional dedup record, so restart cannot silently skip work.

Sources and further study

Continue with Publish-Subscribe.