---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---
8.6 KiB
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-subscriptionchaos 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
# 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)
# 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 seessigned-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) anddomains/messaging/first-principles.mdP2.
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
# 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)
# 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; themessaging-shared-subscriptionanti-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— themessaging-shared-subscriptionchaos 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.