Files
atelier/domains/messaging/pubsub.md
T
Jon Chery f61dffbb5a docs(P02): complete messaging domain phase — v0.4
---ci---
project: atelier
phase: 2
milestone: v0.4
status: complete
phase_role: execution
phase_tag: v0.3.2
requirements:
  covered: [ATELIER-97, ATELIER-98, ATELIER-99, ATELIER-100, ATELIER-101]
  partial: []
---/ci---
2026-08-05 16:00:31 +00:00

269 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Pub/Sub — Derived Rules
> Derives from `domains/messaging/first-principles.md`. Applies P1
> (Messages are Contracts), P2 (Ordering is a Property, Not an
> Assumption), P3 (Consumers are Idempotent), and P4 (Delivery
> Semantics are Explicit) primarily, with P7 (partitioning), P10
> (per-subscription lag). The `messaging-shared-subscription` chaos
> anti-pattern lives here (pre-specified in P4 ATELIER-110).
> Cross-links `domains/messaging/streams` for the pub/sub-vs-stream
> durability boundary and `domains/observability/metrics` for
> per-subscription lag.
## What Pub/Sub Is (P1 Messages are Contracts)
- Pub/sub is the fan-out primitive: a producer publishes a message
to a topic; N independent subscriptions each receive a copy. The
message has an explicit, versioned schema (P1): the topic's
schema is the contract every subscription agrees to before
subscribing. A schemaless topic is a defect — every subscriber
breaks silently on the next shape change.
- The boundary with queues is the fan-out ratio. A queue is
point-to-point (one producer, one consumer); pub/sub is
one-to-many (one producer, N consumers, each with its own
subscription). The boundary with streams is the durability model
— see the cross-link below. Pub/sub is an async concern because
producer and consumers are separate systems and the failure model
is network, not crash (D-062).
- See `domains/messaging/queues.md` for the point-to-point variant
and `domains/messaging/streams.md` for the durable-log variant.
## Topic / Subscription Model (P1, P3, P4)
- A **topic** is the named stream of messages. A **subscription**
is a durable cursor over the topic: each subscription receives
every message published after it was created (subject to
retention and filtering). The subscription is independent — its
ack, redelivery, and DLQ are per-subscription, not shared.
- Each subscription is a consumer under at-least-once by default
(P4): the broker redelivers until the subscription acks, and the
subscriber must be idempotent (P3). A subscription with no
idempotency dedup duplicates every redelivered message.
- The topic's schema evolves compatibly (P9 — Schemas Evolve
Compatibly): a new field the old subscriber ignores is
backward-compatible; a renamed field the old subscriber parses
as `undefined` is a P1 violation.
```python
# Publish + two independent subscriptions (P1 contract, P3
# idempotency, P4 at-least-once per subscription). Each
# subscription is an independent durable cursor; acking one does
# not affect the other.
import json, uuid
# --- Publisher ---
def publish(topic, event, broker):
# P1: versioned schema on the topic. All subscribers must
# understand this schema (or a compatible superset — P9).
message = {
"schema": "user.signed-up.v1",
"id": str(uuid.uuid4()),
"idempotencyKey": f"user:{event['userId']}:signup",
"payload": event,
}
broker.publish(topic=topic, body=json.dumps(message))
# --- Subscription A: welcome-email service ---
def subscribe_welcome(broker, dedup_store, send_email):
sub = broker.subscribe(topic="users", subscription="welcome-email")
for message in sub.receive():
# P3: idempotent per subscription. A redelivered message is
# a no-op for THIS subscription, not for the others.
if dedup_store.seen(("welcome", message["idempotencyKey"])):
sub.ack(message)
continue
send_email(message["payload"]["email"], "Welcome!")
dedup_store.mark(("welcome", message["idempotencyKey"]))
sub.ack(message)
# --- Subscription B: analytics-ingest service ---
def subscribe_analytics(broker, dedup_store, ingest):
# Independent subscription: its own cursor, its own dedup,
# its own ack. Welcome-email acking does NOT advance this.
sub = broker.subscribe(topic="users", subscription="analytics")
for message in sub.receive():
if dedup_store.seen(("analytics", message["idempotencyKey"])):
sub.ack(message)
continue
ingest(message["payload"])
dedup_store.mark(("analytics", message["idempotencyKey"]))
sub.ack(message)
```
- The dedup key is scoped per subscription: `(subscription,
idempotencyKey)`. A redelivery to subscription A that was already
processed by A is a no-op for A; the same message delivered to
subscription B is processed by B independently. Scoping the dedup
key by subscription prevents one subscription's dedup from
masking another's redelivery.
## Fan-Out Semantics (P4, P7)
- Fan-out means every subscription receives every published message
(subject to filtering — see below). The broker duplicates the
message per subscription; each subscription's delivery is
independent. The fan-out ratio is the number of subscriptions; the
broker's cost scales with fan-out × message size.
- Partitioning (P7) applies to topics that are partitioned for
throughput: a partitioned topic delivers per-partition order, and
each subscription receives from every partition. A subscription
that consumes partitions in parallel must handle per-partition
ordering and cross-partition non-ordering (P2 — document the
property, do not assume global order).
- The delivery semantic is per-subscription (P4): subscription A
may be at-least-once, subscription B may be at-most-once (for a
loss-tolerant analytics feed). The choice is per subscription,
declared, not emergent.
## Shared vs Independent Subscriptions (P2, P3 — the chaos anti-pattern)
- An **independent subscription** is one durable cursor per
consumer group: each subscription receives every message in
topic order (per partition, P2) and acks independently. This is
the correct default: per-consumer ordering and per-consumer
idempotency hold.
- A **shared subscription** is one subscription shared by multiple
consumers: the broker dispatches each message to an arbitrary
consumer in the shared group. This breaks per-consumer ordering
(P2 — consumer A sees message 3 before consumer B sees message
1) and complicates idempotency (P3 — the dedup state must be
shared across consumers, not per-consumer). This is the
`messaging-shared-subscription` chaos anti-pattern
(pre-specified in P4 ATELIER-110): the primary breach is P2
(ordering); P3 (idempotency) is the compounding consequence.
- A shared subscription is correct ONLY when the consumers are
stateless, the per-message processing is order-independent, and
the dedup store is shared (a shared Redis, a shared DB). A shared
subscription for order-dependent or per-consumer-stateful
processing is the chaos anti-pattern: the broker's arbitrary
dispatch breaks the order the consumer assumes.
```python
# The messaging-shared-subscription chaos anti-pattern (P2
# ordering breach, P3 idempotency compounding). Two consumers
# share one subscription; the broker dispatches each message to
# an arbitrary consumer. Per-consumer ordering breaks; dedup
# must be shared (and often is not).
# BAD — shared subscription, per-consumer dedup (chaos):
def shared_subscription_bad(broker, send_email):
# Both consumers call subscribe with the SAME subscription
# name. The broker round-robins; consumer A gets msg 1, msg 3;
# consumer B gets msg 2, msg 4. Per-consumer order is broken.
# If each consumer has its OWN dedup store, a redelivery to
# the OTHER consumer re-processes (P3 breach).
sub = broker.subscribe(topic="users", subscription="shared")
for message in sub.receive():
# Per-consumer dedup — WRONG. A redelivered message may
# land on the other consumer, which has not seen it.
if local_dedup.seen(message["idempotencyKey"]): # per-consumer
sub.ack(message); continue
send_email(message["payload"]["email"], "Welcome!")
local_dedup.mark(message["idempotencyKey"])
sub.ack(message)
# CORRECT — independent subscriptions (per-consumer ordering,
# per-subscription dedup):
def independent_subscriptions_good(broker, send_email):
sub = broker.subscribe(topic="users", subscription="welcome-email")
for message in sub.receive():
if dedup_store.seen(("welcome", message["idempotencyKey"])):
sub.ack(message); continue
send_email(message["payload"]["email"], "Welcome!")
dedup_store.mark(("welcome", message["idempotencyKey"]))
sub.ack(message)
```
- If a shared subscription is genuinely required (stateless,
order-independent, shared dedup), document the choice and the
shared-dedup requirement (P2 — the ordering property is "none
across consumers"; P3 — the dedup is shared). The default is
independent subscriptions; shared is an opt-in for the narrow case.
## Filtering (P4, C8 Economy)
- **Subscription filtering** lets a subscription receive only
messages matching a filter (e.g., `event.type == "order"`).
Filtering at the broker saves bandwidth (C8 — the subscriber
does not receive and discard) and reduces subscriber load.
- **Server-side filtering** (broker evaluates the filter before
delivery) is more efficient than **client-side filtering**
(subscriber receives and discards). Server-side filtering is the
default where the broker supports it (GCP Pub/Sub, SNS filtering,
NATS subject filtering); client-side is the fallback.
- A filter that is too broad wastes bandwidth; a filter that is
too narrow drops messages the subscriber needed. The filter is
a P1 (contract) and P4 (semantic) decision: the subscription's
filter is part of its declared contract.
## Ordering Across Subscriptions (P2)
- A topic with per-partition ordering delivers per-partition order
to each subscription. Across subscriptions, there is no ordering
guarantee: subscription A may ack message 3 while subscription B
is still on message 1. This is correct and expected — each
subscription is independent.
- Within a subscription, ordering holds per partition (P2 — the
documented property). A subscription that processes partitions
in parallel must not assume cross-partition order. A subscription
that needs global order must use a single partition (sacrificing
parallelism, P7) or an external sequencing mechanism.
- The `messaging-shared-subscription` anti-pattern breaks even
per-partition order within a subscription: the broker's arbitrary
dispatch to consumers in the shared group breaks the per-
partition sequence each consumer sees.
## Pub/Sub vs Stream — The Durability Boundary (cross-link messaging/streams)
- Pub/sub and streams are both fan-out or one-to-many primitives,
but their durability model differs. Pub/sub is a
**push-to-subscription** model: each subscription is a cursor,
retention is short (the subscription's unacked window), and
replay is limited to the unacked messages. A subscription that
falls behind beyond the retention window loses messages
permanently.
- A stream is a **durable-log** model: messages are retained by
the log for a configured window (P8 — Replay and Retention are
Configured), and any consumer group can replay from any offset
within the window. A stream consumer that falls behind can
catch up by replaying; a pub/sub subscription that falls behind
beyond retention cannot.
- The choice is the durability requirement: if the consumer must
be able to replay (reprocessing, backfill, new consumer starting
from the beginning), use a stream. If the consumer only needs
the live feed (and can tolerate loss on a long fall-behind),
pub/sub is lighter. See `domains/messaging/streams.md` for the
durable-log model, offsets, and consumer groups.
## Observability — Per-Subscription Lag (P10)
- Per-subscription lag (messages published minus messages acked
for each subscription, or the age of the oldest unacked message
per subscription) is the primary pub/sub health signal. Each
subscription has its own lag — a fast subscription and a slow
subscription on the same topic are independent signals.
- A subscription whose lag grows beyond the retention window is a
silent data-loss risk: the broker will drop the oldest messages,
and the subscription will never see them. Alert on lag relative
to retention — lag approaching retention is the loss threshold.
- Wire per-subscription lag to `domains/observability/metrics` as
an SLI per subscription. A topic with N subscriptions has N lag
metrics; a single aggregate hides the slow one. See
`domains/observability/metrics` for the generic SLI/SLO
discipline.
## What Violates Pub/Sub Discipline
| Violation | Principle |
|-----------|-----------|
| Shared subscription for order-dependent processing (broker dispatch breaks per-consumer order) | P2 Ordering is a Property, Not an Assumption |
| Shared subscription with per-consumer dedup (redelivery to the other consumer re-processes) | P3 Consumers are Idempotent |
| Schemaless topic (no versioned contract; subscribers parse by guess) | P1 Messages are Contracts |
| Subscription with no idempotency dedup (redelivered message duplicates the effect) | P3 Consumers are Idempotent |
| Subscription whose lag approaches retention (silent data loss) | P10, `domains/observability/metrics` |
| Unstated delivery semantic per subscription (at-least-once vs at-most-once guessed) | P4 Delivery Semantics are Explicit |
| Filter that is too narrow (drops messages the subscriber needed) | P1, P4 |
| Partitioned topic with no documented per-partition ordering contract | P2 Ordering is a Property, Not an Assumption |
| No per-subscription lag metric (slow subscription invisible) | P10 Messaging is Observable |
| Cross-partition order assumption within a subscription (no global order guarantee) | P2, P7 |