Lab 01 — Messaging Broker (Service Bus peek-lock + Event Grid retry)
Phase: 10 — Event-Driven: Event Grid, Service Bus & Logic Apps Difficulty: ⭐⭐⭐☆☆ | Time: 4–6 hours
Every "we lost a message" and every "we processed it twice" incident is one of two mechanisms misunderstood: the peek-lock state machine (lock → process → complete/abandon/dead-letter, with lock-expiry redelivery and a max-delivery poison guard) or at-least-once push delivery with backoff retry (Event Grid). This lab builds both engines — deterministically, offline, with time injected — so the redelivery off-by-one, the FIFO-session lock, the dedup window, and the dead-letter transition stop being folklore and become code you can defend in an interview and reason about at 2 a.m.
What you build
Message(body, message_id, session_id, scheduled_for)— the unit the broker locks, counts, and dead-letters; carries mutabledelivery_countanddead_letter_reason.Broker— a single Service Bus queue (or one topic-subscription) with the full peek-lock machine:send(msg, now)— honors duplicate detection (a repeatmessage_idinsidededup_windowis silently dropped) and scheduled messages (scheduled_forin the future is not receivable yet).receive(now) -> (msg, lock_token) | None— peek-lock: the message goes invisible untilnow + lock_duration; FIFO, and FIFO-per-session with a single active consumer when sessions are enabled.complete/abandon/dead_letter— settle (remove), release the lock now (redeliverable,delivery_countalready climbed), or move to the DLQ with a reason.tick(now)— when a lock expires (now >= lock_until), redeliver and incrementdelivery_count; once a message has been deliveredmax_delivery_counttimes it goes to the dead-letter queue instead of being redelivered.dlq()— the dead-letter queue accessor.
deliver_with_retry(handler, event, *, schedule, max_retries, now) -> Delivery— the Event Grid push path: callhandler(event), and on failure (falsy return or a raised exception) retry after fixed exponential-backoff intervals; aftermax_retriesare exhausted, dead-letter. Returns the timestamps of every attempt and the outcome.classify_delivery(retries, idempotent_consumer)—at-most-once/at-least-once/exactly-once-effect.
Key concepts
| Concept | What to understand |
|---|---|
| Peek-lock | receive locks a message invisible; you must settle it or the lock expires |
| Lock expiry → redelivery | now >= lock_until ⇒ redelivered, DeliveryCount++ (the off-by-one) |
| Max-delivery → DLQ | delivered at most max_delivery_count times, then dead-lettered |
| Abandon vs lock-expiry | abandon redelivers now; expiry redelivers after the lock — both ++ |
| Duplicate detection | a repeat MessageId inside the window is dropped (idempotent producer) |
| Scheduled messages | enqueued but not receivable until scheduled_for |
| Sessions | FIFO per session_id, one active consumer per session |
| Event Grid retry | at-least-once + fixed exponential backoff, then dead-letter to storage |
| Delivery semantics | at-most / at-least / exactly-once-effect (the only one that exists) |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers and full signatures/docstrings |
solution.py | complete reference; python solution.py runs a six-part worked example |
test_lab.py | the proof — run it red, make it green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # the worked example
Success criteria
- All tests pass against your
lab.pyand againstsolution.py. - You can explain why
test_lock_expiry_at_exact_boundary_is_expiredtreatsnow == lock_untilas expired (the lock has elapsed, so the message is back). - You can explain why, with
max_delivery_count=2, the poison message intest_max_delivery_count_moves_to_dlq_via_abandonreaches the DLQ atdelivery_count == 2— "delivered at most twice," not "twice then one more." - You can explain why
test_session_single_active_consumerskips to session B instead of draining the rest of session A (one active consumer per session = the FIFO guarantee). - You can explain why
deliver_with_retrytreats a raised exception identically to aFalsereturn, and why a duplicate at-least-once delivery is harmless iff the consumer is idempotent (classify_delivery).
How this maps to real Azure
| In the lab | In Azure Service Bus / Event Grid |
|---|---|
Broker(lock_duration=…) | a queue/subscription's LockDuration (max 5 min); the receiver renews or loses the lock |
receive returning a lock token | PeekLock receive mode (vs ReceiveAndDelete = at-most-once) |
complete / abandon / dead_letter | CompleteMessageAsync / AbandonMessageAsync / DeadLetterMessageAsync |
tick redelivery on lock expiry | the broker's lock timeout → automatic redelivery, DeliveryCount++ |
max_delivery_count → DLQ | MaxDeliveryCount (default 10) → the entity's $DeadLetterQueue |
dedup_window | RequiresDuplicateDetection + DuplicateDetectionHistoryTimeWindow |
scheduled_for | scheduled messages (ScheduledEnqueueTimeUtc / ScheduleMessageAsync) |
sessions_enabled / session_id | RequiresSession + SessionId (FIFO, single accepted-session consumer) |
deliver_with_retry schedule + dead-letter | Event Grid at-least-once push, exponential-backoff retry policy (to ~24 h / max attempts), then dead-letter to a storage account |
classify_delivery | the guarantee you actually engineer: at-least-once + an idempotent consumer = "exactly-once" |
Limits worth memorizing (Service Bus Standard/Premium): LockDuration ≤ 5 min;
MaxDeliveryCount default 10; dedup window up to 7 days; message TTL default 14 days;
Standard message size 256 KB, Premium up to 100 MB; sessions and transactions are
Premium-friendly features. Event Grid: at-least-once, retry with backoff up to 24 h,
dead-letter to a blob container. (The lab models the mechanism; the exact numbers
move with tier and are in the phase HITCHHIKERS-GUIDE.md.)
What the lab deliberately simplifies
- No lock renewal — real receivers call
RenewMessageLockAsyncto extend a lock for a long-running handler. The lab models the consequence of not renewing (expiry → redeliver). - No deferral / transactions —
Defer/ReceiveDeferredMessageand atomic send-and-complete transactions are described inWARMUP.mdbut not implemented (an extension below). - Logical time only — Azure uses UTC wall-clock with lock-duration arithmetic; the lab
injects integer
nowso every test is deterministic. The arithmetic is identical.
Extensions
- Lock renewal: add
renew_lock(token, now, extend_by)that pusheslock_untilforward (and rejects renewal of an already-expired lock) — model a long-running handler. - Deferred messages: add
defer(token)+receive_deferred(sequence_number, now)so a consumer can set a message aside and fetch it by number later (out-of-order processing). - Topics & subscriptions with filters: wrap N
Brokersubscriptions behind aTopicthat fans a published message to every subscription whose SQL/correlation filter matches — the Event GrideventType/subjectfilter, but pull-based. - Transactions: a
transaction()context that makescomplete(in) + send(out)atomic (the classic "process and forward exactly once within the broker" pattern). - Real Azure (your subscription): provision a namespace, queue, and Event Grid topic
with
az servicebus/az eventgrid(one-liners in the phase guide), publish a CloudEvent, and watchDeliveryCount, the DLQ, and the dead-letter blob fill up — then map each back to a function in this file.
Interview / resume bullets
- "Implemented a Service Bus-style broker with peek-lock receive, lock-expiry redelivery
and
DeliveryCounttracking, duplicate detection, scheduled messages, FIFO sessions with a single active consumer, and a max-delivery dead-letter queue." - "Built an Event Grid-style at-least-once push-delivery engine with deterministic exponential-backoff retry and dead-lettering, and a delivery-semantics classifier (at-most / at-least / exactly-once-effect)."