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---
188 lines
8.6 KiB
Markdown
188 lines
8.6 KiB
Markdown
# Bad Example: Messaging Shared Subscription
|
|
|
|
> Two consumers share one subscription; the broker dispatches each
|
|
> message to an arbitrary consumer. Per-consumer ordering breaks;
|
|
> per-consumer dedup is wrong. This is the pre-specified
|
|
> `messaging-shared-subscription` chaos anti-pattern (ATELIER-110).
|
|
> **Single-breach per D-068:** the primary principle breached is
|
|
> **Messaging P2 (Ordering is a Property, Not an Assumption)**. P3
|
|
> (Consumers are Idempotent) is noted as the compounding
|
|
> consequence — the example remains single-breach in its named
|
|
> violation.
|
|
|
|
## The Code
|
|
|
|
```python
|
|
# Two email workers share ONE subscription on the "users" topic.
|
|
# The broker round-robins: worker A gets msg 1, msg 3; worker B
|
|
# gets msg 2, msg 4. Per-consumer order is broken (P2 breach).
|
|
# Each worker has its OWN dedup store — a redelivery to the other
|
|
# worker re-processes (P3 compounding).
|
|
|
|
import json
|
|
|
|
def shared_subscription_bad(broker, send_email, worker_id: str):
|
|
# P2 VIOLATION: both workers call subscribe with the SAME
|
|
# subscription name. The broker dispatches each message to an
|
|
# arbitrary worker in the shared group. Worker A sees msg 3
|
|
# before worker B sees msg 1; per-worker order is broken.
|
|
sub = broker.subscribe(topic="users", subscription="welcome-shared")
|
|
|
|
# P3 compounding: per-worker dedup. A redelivered message may
|
|
# land on the OTHER worker, which has not seen it, so the
|
|
# per-worker dedup store does not catch it — the message is
|
|
# processed twice across the two workers.
|
|
local_dedup = DedupStore(backend=redis_for(worker_id))
|
|
|
|
for message in sub.receive():
|
|
payload = json.loads(message["body"])
|
|
if local_dedup.seen(payload["idempotencyKey"]):
|
|
sub.ack(message); continue
|
|
send_email(payload["email"], "Welcome!")
|
|
local_dedup.mark(payload["idempotencyKey"])
|
|
sub.ack(message)
|
|
```
|
|
|
|
```python
|
|
# The two workers are launched with the same subscription name.
|
|
# Worker A and Worker B both call subscribe("users", "welcome-shared").
|
|
# The broker sees one shared subscription; it round-robins.
|
|
|
|
def start_workers(broker):
|
|
# Worker A
|
|
spawn(shared_subscription_bad, broker, send_email, worker_id="A")
|
|
# Worker B — same subscription name, same topic
|
|
spawn(shared_subscription_bad, broker, send_email, worker_id="B")
|
|
# The broker dispatches: A gets msg 1, B gets msg 2, A gets msg 3,
|
|
# B gets msg 4. If worker A is slow, B may get msg 2 and msg 4
|
|
# before A acks msg 1. Per-worker order is broken (P2).
|
|
```
|
|
|
|
The scenario: a user signs up, then immediately updates their
|
|
email preference (two messages in topic order: `user.signed-up.v1`,
|
|
`user.preference-updated.v1`). The broker dispatches `signed-up` to
|
|
worker A and `preference-updated` to worker B. Worker B sends the
|
|
preference email before worker A sends the welcome email — the
|
|
user sees the preference confirmation before the welcome. Then the
|
|
broker redelivers `signed-up` (worker A's ack was slow); this time
|
|
it dispatches to worker B. Worker B's local dedup store has never
|
|
seen `signed-up` (it was processed by A), so B sends the welcome
|
|
email again. The user receives two welcome emails. Per-consumer
|
|
ordering broke (P2); per-consumer dedup did not catch the
|
|
cross-worker redelivery (P3 compounding).
|
|
|
|
## Why It Violates
|
|
|
|
### Ordering is a Property, Not an Assumption (Messaging P2, C1, C2)
|
|
|
|
- **The breach (primary):** the two workers share one subscription,
|
|
and the broker dispatches each message to an arbitrary worker in
|
|
the shared group. Per-consumer ordering is broken: worker A sees
|
|
`preference-updated` (msg 2) before it sees `signed-up` (msg 1)
|
|
if the broker round-robins them to different workers. The
|
|
workers' code assumes topic order, but the shared subscription
|
|
provides no per-consumer order guarantee — the broker's dispatch
|
|
is arbitrary. P2 requires that the ordering property be explicit
|
|
and documented; here it is assumed (topic order) but not provided
|
|
(arbitrary dispatch). The assumption is wrong.
|
|
- The P2 contract is that "it's FIFO" is a claim backed by the
|
|
broker's partitioning contract, not an assumption the consumer
|
|
makes. A shared subscription's contract is "no per-consumer
|
|
order"; the workers' code assumes the opposite. See
|
|
`domains/messaging/pubsub.md` (Shared vs Independent
|
|
Subscriptions) and `domains/messaging/first-principles.md` P2.
|
|
|
|
### Consumers are Idempotent — compounding consequence (Messaging P3, C1)
|
|
|
|
- **The compounding consequence (not the named breach):** each
|
|
worker has its OWN dedup store. A redelivered message that lands
|
|
on the *other* worker is not in that worker's dedup store, so it
|
|
is processed again. The dedup is per-worker, but the subscription
|
|
is shared — the dedup must be shared across workers to be
|
|
correct under a shared subscription. The per-worker dedup store
|
|
is wrong for a shared subscription; a shared dedup store (a
|
|
shared Redis, a shared DB) is required.
|
|
- Per D-068, the example remains single-breach in its named
|
|
violation: P2 is the primary breach (the shared subscription
|
|
breaks ordering); P3 is the compounding consequence (the
|
|
per-worker dedup is wrong *because* the subscription is shared).
|
|
If the subscription were independent, per-worker dedup would be
|
|
correct. The shared subscription is the root cause; P3 is the
|
|
downstream effect.
|
|
|
|
## The Fix
|
|
|
|
```python
|
|
# Fix 1 (default): independent subscriptions. Each consumer gets
|
|
# its own durable cursor; per-consumer order holds (P2); per-
|
|
# consumer dedup is correct (P3). This is the default per
|
|
# domains/messaging/pubsub.md.
|
|
|
|
def independent_subscriptions_good(broker, send_email, worker_id: str):
|
|
# Each worker has its OWN subscription name. The broker
|
|
# delivers every message to every subscription in topic order.
|
|
sub = broker.subscribe(
|
|
topic="users",
|
|
subscription=f"welcome-{worker_id}", # per-consumer
|
|
)
|
|
# Per-consumer dedup is now correct: a redelivery to THIS
|
|
# worker is caught by THIS worker's dedup store.
|
|
dedup = DedupStore(backend=redis_for(worker_id))
|
|
for message in sub.receive():
|
|
payload = json.loads(message["body"])
|
|
if dedup.seen(payload["idempotencyKey"]):
|
|
sub.ack(message); continue
|
|
send_email(payload["email"], "Welcome!")
|
|
dedup.mark(payload["idempotencyKey"])
|
|
sub.ack(message)
|
|
```
|
|
|
|
```python
|
|
# Fix 2 (if a shared subscription is genuinely required): the
|
|
# consumers must be stateless, the processing order-independent,
|
|
# AND the dedup store must be SHARED across workers. Document the
|
|
# ordering property as "none across consumers" (P2 — the property
|
|
# is explicit, not assumed) and use a shared dedup backend (P3).
|
|
|
|
def shared_subscription_stateless(broker, send_email):
|
|
# P2: document the ordering property. A shared subscription
|
|
# provides NO per-consumer order; processing must be order-
|
|
# independent. Do not assume topic order.
|
|
sub = broker.subscribe(topic="users", subscription="welcome-shared")
|
|
# P3: SHARED dedup. A redelivery to any worker is caught by the
|
|
# shared store.
|
|
shared_dedup = DedupStore(backend=shared_redis)
|
|
for message in sub.receive():
|
|
payload = json.loads(message["body"])
|
|
if shared_dedup.seen(payload["idempotencyKey"]):
|
|
sub.ack(message); continue
|
|
send_email(payload["email"], "Welcome!") # order-independent
|
|
shared_dedup.mark(payload["idempotencyKey"])
|
|
sub.ack(message)
|
|
```
|
|
|
|
The default is Fix 1 (independent subscriptions): per-consumer
|
|
ordering holds, per-consumer dedup is correct, and the code is
|
|
simpler. Fix 2 is the narrow opt-in for genuinely stateless,
|
|
order-independent processing — and even then, the dedup must be
|
|
shared. See `domains/messaging/pubsub.md` for the full
|
|
shared-vs-independent discussion.
|
|
|
|
## Cross-Domain Links
|
|
|
|
- `domains/messaging/pubsub.md` — the shared-vs-independent
|
|
subscriptions section; the `messaging-shared-subscription`
|
|
anti-pattern lives here (pre-specified in P4 ATELIER-110).
|
|
- `domains/messaging/first-principles.md` — P2 (Ordering is a
|
|
Property, Not an Assumption) is the primary breach; P3
|
|
(Consumers are Idempotent) is the compounding consequence.
|
|
- `domains/messaging/delivery-semantics.md` — the idempotency-key
|
|
dedup store; the per-subscription dedup key scoping
|
|
(`(subscription, idempotencyKey)`) that prevents one
|
|
subscription's dedup from masking another's redelivery.
|
|
- `review/anti-patterns.md` — the `messaging-shared-subscription`
|
|
chaos anti-pattern (messaging P2, P3, C1 — shared subscription
|
|
breaks ordering and dedup).
|
|
- `examples/good/messaging-idempotent-consumer.md` — the good-
|
|
example version of the consumer pattern: an independent consumer
|
|
with a dedup store and a DLQ routing rule. |