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

21 KiB
Raw Blame History

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.
# 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.
# 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.
  • 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).
  • 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)