Files
atelier/domains/messaging/streams.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

329 lines
21 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.
# Streams — Derived Rules
> Derives from `domains/messaging/first-principles.md`. Applies P8
> (Replay and Retention are Configured) primarily, with P2
> (per-partition ordering), P3 (idempotent consumers), P4 (exactly-
> once via transactions), P7 (partitioning), and P10 (observable
> consumer-group health). For the Kafka / Kinesis / Pulsar /
> NATS JetStream decision, see the stream-platform comparison
> table below. For the consumer-group rebalance strategy choice,
> see the rebalance enumeration below (IDEATE-41). Cross-links
> `domains/messaging/delivery-semantics` for exactly-once via
> transactions, `domains/data/schema-design` for stream schema,
> and `domains/observability/tracing` for cross-partition traces.
## What a Stream Is (P8 Replay and Retention are Configured)
- A stream is a durable-log messaging primitive. Messages are
appended to a partitioned, replicated log; consumers read from an
offset and advance at their own pace. The log is retained for a
configured window (P8) — a stream is a durable log, not a pipe.
A consumer that falls behind can catch up by replaying from an
earlier offset; a consumer that starts fresh can replay from the
beginning (within retention).
- The boundary with queues and pub/sub is the durability model. A
queue deletes on ack; a pub/sub subscription retains only its
unacked window; a stream retains the whole log for the configured
retention. This makes a stream replayable (P8 — the
reversibility mechanism) and observable as a record (P10 — the
log is itself an audit of what happened). See
`domains/messaging/pubsub.md` for the pub/sub-vs-stream
durability boundary discussion.
- The boundary with concurrency is per D-062: messaging owns the
cross-process/network-failure-model angle. A stream is a
messaging concern because the log spans brokers and consumers
across a network, and the failure model is partition, broker
restart, or consumer crash-and-retry — not in-process OOM or
thread crash.
## Partitioned Log Model (P2, P7)
- A stream is partitioned for throughput and parallelism. Each
partition is an ordered, append-only log; messages within a
partition are strictly ordered (P2 — per-partition strict order
is the documented property). Across partitions, there is no
ordering guarantee: partition 0 and partition 1 are independent
logs.
- The partition key (P7 — Partitioning is Intentional) determines
which partition a message lands on. A key that hashes evenly
spreads load; a key that matches the per-entity ordering need
(e.g., `userId` for user events) keeps a user's events on one
partition in order; a key that is too coarse (one partition for
the whole topic) serializes all traffic.
- The partition count is a capacity bound: it caps the parallelism
(one consumer per partition per consumer group) and the
throughput (each partition has a write-throughput limit). Too
few partitions cap parallelism; too many partition overhead the
broker (file handles, replication, rebalance cost). The choice
is documented (P7), not defaulted.
## Offsets (P2, P8)
- An offset is a consumer's position in a partition. The consumer
reads from its last committed offset; acking (committing the
offset) advances it. A consumer that crashes before committing
re-reads from the last committed offset (at-least-once by
default, P4) — the consumer must be idempotent (P3).
- The offset is per-partition (P2): each partition has its own
position, and the consumer commits them independently (or
atomically across partitions in a transaction — see below).
- Replay (P8) is resetting the offset backward: a consumer can
replay from the beginning of retention, from a timestamp, or
from a specific offset. This is the durable-log property that
distinguishes a stream from a queue.
## Consumer Groups (P3, P7, P10)
- A consumer group is a set of consumers sharing the stream's
partitions: each partition is assigned to exactly one consumer
in the group. The group is the unit of parallelism and the unit
of offset tracking. Within a group, each consumer handles its
assigned partitions; across groups, each group independently
reads the whole stream (the pub/sub fan-out property, per
subscription/group).
- A consumer in the group is idempotent (P3): under at-least-once
(the default), a redelivery after a crash-and-retry re-processes
messages. The consumer dedups by idempotency key, or processes
deterministically (e.g., a stateful aggregation that overwrites
with the latest value).
- The consumer group's health is observable (P10): per-partition
lag (offset of the consumer vs the log's head), the group's
consumption rate, and rebalance events are first-class signals.
A group whose lag grows without bound is a P6 (backpressure)
smell and a P10 (observability) violation if not alerted.
```python
# Consumer-group reading from offsets (P2 per-partition order,
# P3 idempotent under at-least-once, P7 partition assignment,
# P8 replay from offset). Each consumer in the group handles its
# assigned partitions; the group commits offsets atomically or
# per-partition.
def consume_stream(stream, group, dedup_store, process_event):
# Assign partitions to this consumer by the group's
# rebalance strategy (see the enumeration below).
for partition in stream.assigned_partitions(group, consumer=ME):
# Read from the last committed offset (P8 — replay by
# resetting this offset).
offset = stream.committed_offset(group, partition)
for message in stream.read(partition, from_offset=offset):
# P3: idempotent under at-least-once. A redelivery
# after a crash-and-retry re-processes; dedup by key.
if dedup_store.seen(message["idempotencyKey"]):
stream.commit(group, partition, message["offset"])
continue
process_event(message["payload"])
dedup_store.mark(message["idempotencyKey"])
# Commit the offset to advance (P8 — the position is
# the replay pointer).
stream.commit(group, partition, message["offset"])
```
- The commit-after-process order gives at-least-once (a crash
before commit re-reads); the commit-before-process order gives
at-most-once (a crash after commit loses the unprocessed
message). The default is at-least-once with idempotent consumers
(P3, P4).
## Consumer-Group Rebalance Strategies (IDEATE-41, ATELIER-100 refinement)
When a consumer joins or leaves the group, the broker must
reassign partitions. The rebalance strategy determines the cost
and the use-case fit. This enumeration parallels the v0.3
IDEATE-30 drift-type enumeration (each strategy with its
stop-the-world cost and use-case fit).
| Strategy | Mechanism | Partition Stop-the-World Cost | Use-Case Fit |
|----------|-----------|-------------------------------|--------------|
| **Eager rebalance** (stop-the-world) | Every consumer in the group revokes ALL its partitions, the broker reassigns the full partition set, then consumers resume. Every rebalance pauses the whole group. | High — every partition pauses for every rebalance; the whole group stops processing during the revocation+reassignment window. Throughput drops to zero during rebalance. | Simple brokers, small groups, or rarely-rebalancing groups where the simplicity of full revocation outweighs the pause cost. Kafka's legacy protocol (pre-2.4). Avoid for large groups or frequent scale events. |
| **Sticky (incremental cooperative) rebalance** | The broker reassigns only the partitions that must move (the joining/leaving consumer's share); existing partitions stay assigned. The rebalance is incremental and cooperative — no full revocation. | Low — only the moving partitions pause; the rest of the group continues processing. The pause is proportional to the changed partition count, not the total. | The default for large groups, frequent scale events, and rolling deploys. Kafka's CooperativeStickyAssignor (2.4+), Pulsar, NATS JetStream. Prefer for any group where a full stop-the-world on every deploy is unacceptable. |
| **Cooperative (no-revoke) rebalance** | A subset of sticky where no partition is revoked unless the consumer leaves; only additions are incremental. The strictest minimization of stop-the-world. | Lowest — only added partitions pause; existing assignments are untouched. | Groups where partition assignment is append-only (consumers join but rarely leave). Useful for long-lived consumers with incremental scaling. |
- The default for any non-trivial group is **sticky/cooperative**:
a rolling deploy that triggers an eager rebalance pauses the
whole group on every pod restart, which is unacceptable at
scale. The eager strategy is a legacy default that survives
because it is simple; prefer sticky where the broker supports
it.
- The "partition stop-the-world cost" column is the P6
(backpressure) angle: a full stop-the-world during rebalance
causes lag to spike (the consumer is paused, the producer is
not). Sticky rebalance bounds the spike to the moving partitions.
- A rebalance that pauses without a lag alert is a P10 violation:
the operator cannot see the rebalance-induced lag. Wire rebalance
events to `domains/observability/metrics` as an event signal.
## Replay and Retention Windows (P8, C5 Reversibility)
- Retention is the configured window the log keeps messages: time-
based (e.g., 7 days), size-based (e.g., 10 GB per partition), or
compacted (keep the latest value per key — a changelog). A
stream with no retention is a pipe, not a log (P8 violation); a
stream with infinite retention grows until disk exhaustion (P6
violation — backpressure on the broker).
- Replay (P8) is resetting a consumer's offset to re-read from
within the retention window. Use cases: reprocessing after a
consumer bug (replay from the timestamp of the buggy deploy),
backfilling a new consumer (replay from the beginning), or
reindexing (replay to rebuild a derived store).
- Compacted topics (Kafka log-compaction, Pulsar compaction) keep
the latest value per key and discard older values for the same
key. This turns the log into a changelog — a durable
materialized view that replays to the current state. Compaction
is a P8 (retention) and C5 (reversibility) mechanism: the log
retains the current state per key and is replayable to it.
## Stream Processing (P2, P3, P4)
- Stream processing is computing over the stream as it arrives:
windowing (tumbling, sliding, session windows), joins (stream-
stream, stream-table), aggregations (count, sum, per-key
windows), and stateful transformations. The processing is
per-partition ordered (P2 — a window over a partition is
deterministic; a window across partitions is not unless the
window is global).
- Stream processing consumers are idempotent (P3): a redelivery
after a crash re-processes a window; the aggregation must
tolerate re-application (e.g., a sum is idempotent under replay
if the window is keyed by offset range, not by wall time).
- Exactly-once stream processing (P4) requires transactions: the
consume-process-produce loop is one transaction — the input
offset commit and the output produce are atomic. See the
transactional exactly-once producer below.
## Exactly-Once via Transactions (P4, P3)
- Exactly-once stream processing is at-least-once plus a
transaction: the consumer commits the input offset and produces
the output in one transactional operation. If the consumer
crashes mid-transaction, neither the offset commit nor the
output produce happens — the consumer re-reads from the last
committed offset and re-processes (at-least-once), but the
transaction ensures the output is produced exactly once.
- This is NOT a broker guarantee of exactly-once delivery; it is
at-least-once delivery plus idempotent/transactional processing
(P3, P4). Jepsen analyses of Kafka transactions confirm the
boundaries: the transaction is atomic within the broker, but
the downstream sink must be transactional or idempotent too.
- See `domains/messaging/delivery-semantics.md` for the full
exactly-once-via-idempotency discussion.
```python
# Transactional exactly-once producer (P4 exactly-once via
# transactions, P3 idempotent produce). The consume-process-
# produce loop is one transaction: the input offset commit and
# the output produce are atomic. A crash mid-transaction rolls
# both back; the consumer re-reads and re-processes.
def consume_transform_produce(stream, group, txn_producer):
# Begin a transaction. All produces and the offset commit in
# this block are atomic (P4).
with txn_producer.transaction() as txn:
for partition in stream.assigned_partitions(group, ME):
offset = stream.committed_offset(group, partition)
for message in stream.read(partition, from_offset=offset):
output = transform(message["payload"])
# P3: idempotent produce. The txn producer
# dedups by an epoch+sequence so a retried
# transaction does not double-produce.
txn.produce(
topic="enriched-events",
key=message["key"],
value=output,
idempotencyKey=message["idempotencyKey"],
)
# Commit the input offset within the same
# transaction (P4 atomicity). A crash before
# txn.commit() rolls this back; the consumer
# re-reads from the prior offset.
txn.commit_offset(group, partition, message["offset"])
# txn.commit() makes the produces and the offset commit
# visible atomically. A crash before this point aborts
# both; a crash after is safe (idempotent produce — P3).
```
- The transactional producer's idempotency (P3) is the defense
against a retried transaction: the broker dedups the output by
the producer's epoch and sequence so a re-commit does not
double-produce. The transaction (P4) is the defense against a
partial failure: the offset and the output commit together.
## Stream Schema (P1, P9, cross-link data/schema-design)
- A stream's messages carry a versioned schema (P1). The schema
evolves compatibly (P9): a new field the old consumer ignores is
backward-compatible; a renamed field the old consumer parses as
`undefined` is a P1 violation compounded as an evolution defect.
- Stream schemas are often registered in a schema registry
(Confluent, Apicurio) that enforces compatibility on produce.
A producer that tries to publish an incompatible schema is
rejected; the registry is the P1/P9 enforcement point.
- See `domains/data/schema-design` for the generic schema-design
discipline (Avro, Protobuf, JSON Schema); messaging owns the
stream-specific instance — the registry, the per-topic
compatibility mode, the consumer-side routing by schema
version.
## Stream-Platform Comparison (D-069)
| Axis | Apache Kafka | AWS Kinesis | Apache Pulsar | NATS JetStream |
|------|--------------|-------------|---------------|----------------|
| **Ordering** | Per-partition strict (P2); global only via single-partition topic | Per-shard strict; global only via single shard | Per-partition strict; global via single partition; also supports shared (out-of-order) subscriptions | Per-stream strict; per-subject ordering; global via single stream |
| **Partitioning model** | Partitions (immutable count post-creation; increase requires recreate); key→partition by hash | Shards (reshardable: split/merge at runtime); key→shard by hash | Partitions (resizable; Pulsar's layered architecture separates compute from storage); key→partition by hash | Streams (subject-based; republish to resize); key→stream by subject |
| **Replay / retention** | Time- or size-based retention; compaction (latest-per-key); replay from offset or timestamp | Time-based retention (24h365d); replay from sequence number or timestamp; no compaction | Time- or size-based; compaction; replay from offset or timestamp; tiered storage (hot/warm/cold) | Time- or size-based; per-stream max-age; replay from sequence; no native compaction |
| **Consumer groups** | Group-coordinated; offsets stored in an internal topic; eager (legacy) or sticky/cooperative (2.4+) rebalance | Enhanced fan-out consumers (per-shard HTTP/2 push); KCL for group coordination; no native group rebalance (shard is the unit) | Group-coordinated; shared or failover subscription modes; cooperative rebalance | Per-stream consumers; durable cursors; no native group rebalance (stream is the unit) |
| **Exactly-once** | Transactions (KIP-98): consume-process-produce atomic; idempotent producer (KIP-516) | No native exactly-once; at-least-once with consumer-side dedup (P3) | Transactions: produce-ack atomic; idempotent producer | At-least-once by default; dedup window per stream (P3 idempotency) |
| **Use-case fit** | High-throughput durable logs, stream processing (Kafka Streams, Flink), event sourcing, multi-consumer replay | AWS-native streaming, log ingestion, simple ETL within AWS; low operational burden | Cloud-native, geo-replication, tiered storage, mixed pub/sub + streaming; multi-tenant | Lightweight, low-latency, edge-friendly; NATS ecosystem; simpler ops than Kafka |
| **Watch out for** | Partition count is fixed at creation (resize requires recreate + republish); rebalance cost on large groups; operational complexity | Shard limits per account; no compaction; retention cap at 365 days; AWS lock-in | Two-arch (BookKeeper + Brokers) operational complexity; smaller ecosystem | Smaller ecosystem; no native compaction; fewer stream-processing libraries |
- The default for high-throughput durable logs with multi-consumer
replay is **Kafka**; for AWS-native streaming, **Kinesis**; for
geo-replicated multi-tenant or mixed pub/sub + streaming,
**Pulsar**; for lightweight low-latency edge-friendly streaming,
**NATS JetStream** (which also cross-links `domains/edge/iot`
via MQTT parallels — see the edge↔messaging bidirectionality in
`domains/messaging/first-principles.md` §4).
- The ordering column is the P2 check: every platform provides
per-partition/per-shard strict order; none provides global order
across partitions except by single-partition. The replay/
retention column is the P8 check: every platform retains for a
configured window; replay is from offset or timestamp. The
exactly-once column is the P4 check: Kafka and Pulsar provide
transactions; Kinesis and JetStream rely on at-least-once plus
consumer-side idempotency (P3).
## Cross-Partition Traces (P10, cross-link observability/tracing)
- A stream-processing pipeline that fans out across partitions
must propagate a trace context per event: the trace ID follows
the event from source to processed output, even as the event
crosses partition boundaries. Without cross-partion traces, a
downstream error cannot be traced back to its source event.
- See `domains/observability/tracing` for the generic distributed-
tracing discipline (trace context propagation, span
correlation). Messaging owns the stream-specific instance: the
trace context is a message header, the span boundary is the
consume-process-produce edge, and the cross-partition
correlation is by trace ID (not by partition — partitions are
independent logs, P2).
- A stream processor with no trace propagation is a P10
violation: the operator cannot trace a processed event back to
its source. Wire the trace context into every produce and every
consume.
## What Violates Stream Discipline
| Violation | Principle |
|-----------|-----------|
| Stream with no retention (pipe, not log; no replay) | P8 Replay and Retention are Configured |
| Infinite retention (grows until disk exhaustion) | P8, P6 |
| Consumer group with eager rebalance at scale (full stop-the-world per deploy) | P6, IDEATE-41 (use sticky/cooperative) |
| Non-idempotent stream consumer under at-least-once (redelivery re-processes the window) | P3 Consumers are Idempotent |
| Default partition key (no rationale; hotspot or wrong-order) | P7 Partitioning is Intentional |
| Partition count too low (caps parallelism) or too high (overhead) | P7 |
| Cross-partition order assumption (no global order guarantee) | P2 Ordering is a Property, Not an Assumption |
| Exactly-once claimed without transactional consume-process-produce (P4 violation) | P4 Delivery Semantics are Explicit |
| Stream schema with no registry / no compatibility enforcement (silent shape break) | P1, P9, `domains/data/schema-design` |
| No per-partition lag metric (consumer falls behind invisibly) | P10, `domains/observability/metrics` |
| No cross-partition trace propagation (downstream error untraceable) | P10, `domains/observability/tracing` |
| Compacted topic treated as a full log (old values already discarded) | P8, C1 (compaction is a retention mode, not a full log) |