29ffb42898
---ci--- project: atelier phase: 0 milestone: v0.4 status: complete requirements: covered: [ATELIER-92, ATELIER-93, ATELIER-94, ATELIER-95, ATELIER-96, ATELIER-97, ATELIER-98, ATELIER-99, ATELIER-100, ATELIER-101, ATELIER-102, ATELIER-103, ATELIER-104, ATELIER-105, ATELIER-106, ATELIER-107, ATELIER-108, ATELIER-109, ATELIER-110, ATELIER-111, ATELIER-112, ATELIER-113, ATELIER-114, ATELIER-115, ATELIER-116, ATELIER-117] partial: [] ---/ci---
226 lines
9.5 KiB
Markdown
226 lines
9.5 KiB
Markdown
# Good Example: Idempotent Consumer with Dedup + DLQ
|
|
|
|
> An orders-consumer that achieves exactly-once-via-idempotency:
|
|
> at-least-once delivery plus a TTL-bounded dedup store and a DLQ
|
|
> routing rule. Each aspect cites the messaging principle it
|
|
> satisfies. Exercises the idempotency + DLQ guidance in
|
|
> `domains/messaging/delivery-semantics.md`.
|
|
|
|
## The Code
|
|
|
|
```python
|
|
# The idempotency-key dedup store (P3). TTL-bounded (P6): a dedup
|
|
# store with no TTL is a memory leak. The TTL exceeds the broker's
|
|
# max-redelivery window; beyond it, the key is expired (the broker
|
|
# has given up).
|
|
|
|
import time, json
|
|
|
|
DEDUP_TTL_SECONDS = 24 * 3600 # > broker max-redelivery window
|
|
|
|
class DedupStore:
|
|
"""P3 (idempotent), P6 (TTL-bounded). seen() before process;
|
|
mark() after process; the order gives at-least-once + dedup."""
|
|
|
|
def __init__(self, backend):
|
|
# backend is Redis or a shared DB. MUST be shared across
|
|
# consumer instances (see messaging/pubsub.md on shared vs
|
|
# independent subscriptions).
|
|
self.backend = backend
|
|
|
|
def seen(self, key: str) -> bool:
|
|
ts = self.backend.get(key)
|
|
if ts is None:
|
|
return False
|
|
if time.time() - ts > DEDUP_TTL_SECONDS:
|
|
self.backend.delete(key) # P6: expired; not a redelivery
|
|
return False
|
|
return True
|
|
|
|
def mark(self, key: str):
|
|
self.backend.set(key, time.time(), ttl=DEDUP_TTL_SECONDS)
|
|
```
|
|
|
|
```python
|
|
# The idempotent consumer. Order: dedup BEFORE process, mark AFTER
|
|
# process, ack AFTER mark. A crash before mark re-processes (the
|
|
# dedup store lacks the key); a crash before ack redelivers and the
|
|
# dedup store makes the redelivery a no-op (P3). For a
|
|
# non-idempotent process (a payment that must not double-charge),
|
|
# process+mark are one DB transaction — exactly-once via
|
|
# idempotency (P4).
|
|
|
|
def consume_orders(broker, dedup: DedupStore, process_order):
|
|
for message in broker.receive():
|
|
payload = json.loads(message["body"])
|
|
|
|
# P3: dedup BEFORE process. A redelivered message is a
|
|
# no-op, not a double-apply.
|
|
if dedup.seen(payload["idempotencyKey"]):
|
|
broker.ack(message) # already processed; skip
|
|
continue
|
|
|
|
try:
|
|
# P4: the declared semantic is at-least-once + idempotent
|
|
# dedup = exactly-once-via-idempotency. For a payment,
|
|
# process_order + dedup.mark run in one DB transaction
|
|
# so the mark commits iff the process commits.
|
|
process_order(payload)
|
|
dedup.mark(payload["idempotencyKey"])
|
|
broker.ack(message)
|
|
|
|
except TransientError as exc:
|
|
# P6: bounded retry with backoff. Nack for redelivery;
|
|
# the broker redelivers after exponential backoff.
|
|
broker.nack(message, delay=backoff(payload.get("attempt", 0)))
|
|
|
|
except (ValueError, SchemaError) as exc:
|
|
# P5: poison message — unparseable. Route immediately,
|
|
# do NOT retry (no retry will fix a bad schema).
|
|
route_to_dlq(broker, message, exc, kind="poison")
|
|
broker.ack(message)
|
|
|
|
except PermanentError as exc:
|
|
# P5: permanent failure (e.g., not-found dependency).
|
|
# Retry will not fix it — DLQ now.
|
|
route_to_dlq(broker, message, exc, kind="dlq")
|
|
broker.ack(message)
|
|
```
|
|
|
|
```python
|
|
# The DLQ routing rule (P5 dead-letter handling, P10 DLQ depth
|
|
# alert). Distinguishes poison (unparseable; never retried) from
|
|
# DLQ (exhausted retry budget on a transient). Both carry audit
|
|
# metadata; both emit a depth metric.
|
|
|
|
DLQ = "orders-dlq"
|
|
POISON = "orders-poison"
|
|
MAX_RETRY_TTL_SECONDS = 30 * 60 # 30 min retry window
|
|
|
|
def route_to_dlq(broker, message, reason, kind: str):
|
|
target = POISON if kind == "poison" else DLQ
|
|
broker.send(target, body=json.dumps({
|
|
"original": message["body"],
|
|
"reason": str(reason),
|
|
"kind": kind, # poison vs dlq
|
|
"deadLetteredAt": now_iso(),
|
|
"redeliveryCount": message.get("attempt", 0),
|
|
}))
|
|
# P10: emit a metric so DLQ depth alerts fire. A DLQ that grows
|
|
# with no alert is a silent correctness defect (P5/P10).
|
|
metrics.increment(f"{kind}.depth", tags={"queue": "orders"})
|
|
|
|
|
|
def consume_with_retry_budget(broker, dedup, process_order):
|
|
# Combines TTL-with-backoff for transient failures (P6) with
|
|
# poison-queue + DLQ + alert (P5/P10).
|
|
for message in broker.receive():
|
|
payload = json.loads(message["body"])
|
|
if dedup.seen(payload["idempotencyKey"]):
|
|
broker.ack(message); continue
|
|
|
|
first_attempt_ts = payload.get("firstAttemptTs", time.time())
|
|
attempt = payload.get("attempt", 0)
|
|
|
|
try:
|
|
process_order(payload)
|
|
dedup.mark(payload["idempotencyKey"])
|
|
broker.ack(message)
|
|
|
|
except TransientError as exc:
|
|
# P6: if the retry window is exhausted, route to DLQ;
|
|
# otherwise redeliver with exponential backoff.
|
|
if time.time() - first_attempt_ts > MAX_RETRY_TTL_SECONDS:
|
|
route_to_dlq(broker, message, exc, kind="dlq") # P5
|
|
broker.ack(message)
|
|
else:
|
|
broker.nack(message, delay=backoff(attempt))
|
|
```
|
|
|
|
## The Scenario
|
|
|
|
An orders queue delivers `order.created` events to the consumer at
|
|
**at-least-once** (the declared semantic, P4). The broker redelivers
|
|
on consumer crash or ack-timeout. Three things happen:
|
|
|
|
1. **Normal delivery** — the consumer dedups by `idempotencyKey`,
|
|
processes, marks, acks.
|
|
2. **Redelivery after a crash before ack** — the consumer crashed
|
|
after `mark` but before `ack`. The broker redelivers; `seen()`
|
|
returns true; the consumer acks without re-processing (P3).
|
|
3. **Poison message** — a malformed JSON body. The consumer routes
|
|
it to the poison queue immediately (no retry will fix a parse
|
|
error), acks the origin, and emits a `poison.depth` metric. The
|
|
operator is paged on poison-queue growth (P10).
|
|
|
|
A transient downstream failure (the payments API is briefly 503)
|
|
retries with exponential backoff for 30 minutes (P6); if it exceeds
|
|
the budget, the message routes to the DLQ with `reason`,
|
|
`redeliveryCount`, and `deadLetteredAt` — auditable, drainable,
|
|
observable (P5). The DLQ depth metric alerts the operator; the DLQ
|
|
entry's audit metadata lets the operator replay after the bug is
|
|
fixed (P5 reversibility).
|
|
|
|
## Principles Demonstrated
|
|
|
|
### Consumers are Idempotent (Messaging P3, C1)
|
|
- The consumer dedups by idempotency key before processing. A
|
|
redelivered message is a no-op, not a double-apply. The
|
|
`process → mark → ack` order gives at-least-once + idempotent
|
|
dedup; for a non-idempotent process, `process + mark` are one DB
|
|
transaction (exactly-once via idempotency, P4).
|
|
- See `domains/messaging/delivery-semantics.md` (idempotency-key
|
|
dedup store) and `domains/messaging/first-principles.md` P3.
|
|
|
|
### Delivery Semantics are Explicit (Messaging P4, C1, C2)
|
|
- The channel is declared **at-least-once + idempotent consumer** —
|
|
the engineering practice that collapses to exactly-once under
|
|
correct dedup (P3). The semantic is not emergent; it is the
|
|
declared choice per channel. The tradeoff (dedup-store cost,
|
|
transactional-process complexity) is conscious and documented.
|
|
- See `domains/messaging/queues.md` (the three-semantics comparison
|
|
table) and `domains/messaging/delivery-semantics.md` (exactly-
|
|
once via idempotency).
|
|
|
|
### Dead-Letter Handling is Defined (Messaging P5, C1, C5)
|
|
- Poison messages (unparseable) route immediately to the poison
|
|
queue — no retry will fix them. Transient failures retry with
|
|
backoff until the TTL, then route to the DLQ. Both carry audit
|
|
metadata (`reason`, `redeliveryCount`, `deadLetteredAt`); both are
|
|
drainable and observable. The DLQ is the reversibility mechanism
|
|
— a dead-lettered message can be reprocessed after the bug is
|
|
fixed.
|
|
- See `domains/messaging/delivery-semantics.md` (dead-letter
|
|
strategy comparison table, DLQ routing rule) and
|
|
`domains/messaging/first-principles.md` P5.
|
|
|
|
### Messaging is Observable (Messaging P10, C7, C1)
|
|
- DLQ depth and poison-queue depth are emitted as metrics and wired
|
|
to alerts. A DLQ that grows silently is a correctness defect;
|
|
the alert makes it visible. Silent backlog is a bug, not a
|
|
feature — the operator is paged on growth, not on a customer
|
|
report.
|
|
- See `domains/messaging/delivery-semantics.md` (DLQ depth as an
|
|
alert) and `domains/messaging/first-principles.md` P10.
|
|
|
|
## Cross-Domain Links
|
|
|
|
- `domains/messaging/delivery-semantics.md` — the idempotency-key
|
|
dedup-store pattern and the dead-letter strategy comparison table
|
|
exercised here (TTL-with-backoff + poison-queue + DLQ + alert).
|
|
- `domains/messaging/queues.md` — the three-semantics comparison
|
|
table; the ack/nack/redelivery model this consumer uses.
|
|
- `domains/messaging/first-principles.md` — P3, P4, P5, P10 are the
|
|
principles demonstrated.
|
|
- `domains/concurrency/patterns` — the in-process retry/backoff
|
|
analog (Pattern 6, Timeout on Every Block); messaging owns the
|
|
broker-backed instance where redelivery comes across a network.
|
|
- `domains/errors/patterns` — errors as data: a DLQ entry is the
|
|
async-messaging instance of an error log (observable, auditable,
|
|
drainable).
|
|
- `domains/observability/metrics` — the generic SLI/SLO discipline
|
|
the DLQ-depth alert builds on.
|
|
- `review/anti-patterns.md` — the `messaging-unbounded-retry` and
|
|
`messaging-shared-subscription` chaos anti-patterns are the
|
|
inverse of this example's bounded retry + independent-consumer
|
|
discipline. |