R5.02 · Streams & Events — module hub
Stream consumer patterns — block, resume, trim, archive.
Implement reliable message processing with Valkey Streams consumer groups, handling failure
recovery, poison pills, and memory management — the operational patterns production systems require
beyond basic XREADGROUP usage. A stream is an immutable log, not a work queue: a read moves
a cursor instead of removing the entry, so the entry stays until a reader acknowledges it.
Grounding: echo/apps/echo_mq · EchoMQ.StreamConsumer (the reader law) · EchoMQ.Stream.trim/4 · EchoMQ.StreamRetention — the consumer-group loop, retention as policy, the fold to the durable floor. Consumer: codemojex.
block waits for the next entry, resume recovers an
un-acked backlog, trim bounds the log, and archive keeps what is trimmed.The mental model problem
streams-consumer-patterns.md.txtMost stream adoption failures stem from treating a stream like a list. The difference is not cosmetic: a list read is destructive and a stream read moves a cursor, so the queue state — pending versus handled — lives in consumer-group metadata, not in the data itself. A stream is an immutable log, and that one shift is what the rest of this module is built on.
EchoMQ keeps the two shapes as separate code paths, never a mode on one. The job EchoMQ.Consumer
claims a job and completes it once — the work leaves the queue. The EchoMQ.StreamConsumer
reads a log many readers consume at their own pace: a different claim path (XREADGROUP group read,
not a lease pop) and a different settle (XACK or leave-un-acked, not complete or retry). A queue
is for work claimed and completed once; a stream is the append-only log beside it.
A stream is a log, not a queue. The queue state lives in group metadata, the entry stays until it is acknowledged, and that is the foundation the four moves stand on.
The pending entries list
stream_consumer.ex · settleWhen a consumer reads via XREADGROUP, each delivered entry enters that consumer's pending
entries list — the PEL. It stays there until the consumer acknowledges it with XACK. The PEL
is the per-consumer record of delivered to me, not yet settled: it is what survives a crash, and it is
what XAUTOCLAIM reads to recover a dead peer.
EchoMQ rides the PEL directly. On :ok the entry is XACKed and retires; on
{:error, reason} — or a raising handler, which the loop converts to an error and survives
— it is left un-acked, surviving in the PEL to be re-delivered. The only way an entry
leaves the PEL is a handler that returns :ok.
case verdict do
:ok ->
← the genuinely-pending entry retires from the PEL
_ = Connector.command(s.conn, ["XACK", s.key, s.group, xadd_id])
:ok
{:error, _reason} ->
← left un-acked: survives in the PEL, re-delivered (at-least-once)
:ok
end
Notes on ValkeyA high PEL with low ingestion lag points at a processing
bottleneck or an XACK failure, not a delivery problem — XINFO GROUPS reads the
pending count beside the last-delivered id — valkey.io/commands/xack.
Recover self, then recover peers
stream_consumer.ex · drain_pel / reclaim_peersA consumer that only ever requests > (new entries) is the critical anti-pattern: if it
crashes after a delivery but before the XACK, those entries become zombies, stuck in the PEL and
never re-read. Recovery is two complementary mechanisms, both named, and the loop runs them in order before it
blocks for anything new.
- Drain own PEL — recover self. On the first pass the consumer reads its own pending
list from cursor
0to exhaustion, settling each, then switches to>. A crashed consumer that restarts with the same name recovers its own held work the instant it restarts; a clean cold start has an empty PEL, so the0read returns nothing and one code path covers both. - The
XAUTOCLAIMbeat — recover peers. A consumer that dies and never restarts has a PEL nobody drains. On each beat the loop reclaims entries idle past:min_idle_ms(default30_000) viaXAUTOCLAIM —decentralised work-stealing, no coordinator. The idle threshold is evaluated server-side, never against a host clock.
New entries arrive in mint order, so XREADGROUP ... > preserves the writer's order theorem.
But a re-claimed entry returns with a branded id older than entries already handled — the
irreducible cost of at-least-once. The consequence is a hard rule: the handler must be idempotent,
with the branded id as the dedup key.
XREADGROUP GROUP g worker-1 COUNT 100 STREAMS emq:{cm.feed}:stream:rounds 0 # drain own PEL first
XREADGROUP GROUP g worker-1 BLOCK 1000 COUNT 100 STREAMS emq:{cm.feed}:stream:rounds > # then new entries
XAUTOCLAIM emq:{cm.feed}:stream:rounds g worker-1 30000 0 COUNT 100 # reclaim a dead peer's idle entries
A blocking read gives delivery; the group gives reliability. Drain own backlog, read the new, reclaim the dead — and stay idempotent, because a re-claim arrives out of order.
Retention is a policy
stream.ex · trim/4 · stream_retention.exA log that only grows is a leak, and acknowledgement does not shrink it — XACK marks an
entry handled in group metadata, but the entry stays in the log. The right tool is XTRIM, not
XDEL: XDEL marks an entry deleted but frees memory only when a whole macro-node empties,
leaving fragmentation. EchoMQ.Stream.trim/4 bounds the log by length
({:maxlen, count, approx?}) or age ({:minid, dt, approx?}), and the
approx? flag selects ~ — the safe default: it may under-trim but can never
over-trim, so a trim can never delete inside the declared window.
Retention is decoupled from consumer liveness. EchoMQ.StreamRetention is a named, opt-in driver
that beats on a tick and re-applies a declared policy: a stream nobody drains still trims, because bounded
memory is a safety property and a consumer is up is a liveness fact — coupling the two is a
silent-no-op the design refuses. A manual trim/4 call is the equally-supported cadence.
Keep the log bounded with XTRIM MAXLEN ~ N (or MINID by time), and keep
acknowledgement separate from retention. ~ trims whole macro-nodes — cheap, and it never
deletes inside the window.
EchoMQ.Stream.trim/4 is that verb; EchoMQ.StreamRetention re-applies a declared
policy on its own beat. What is trimmed folds via EchoStore.StreamArchive.fold/3 into the durable
Graft floor — bounded memory, history preserved.
Notes on ValkeyThe ~ in MAXLEN ~ N trims in
whole macro-nodes rather than to an exact count, so the removed-count a trim returns can be 0 even
when entries are old — cheaper, and never a delete inside the window — valkey.io/commands/xtrim.
The three dives
block → group → trimThe arc is wait cheaply → read reliably → bound the log. Read them in order — the blocking read is how a reader waits, the consumer group is how it stays reliable, and trimming is how the log stays bounded without losing history.
The blocking read
XREAD BLOCK long-poll versus a busy-poll, and why the blocking verb rides a
dedicated connection so it never stalls the shared socket.
Consumer groups
XREADGROUP / XACK, at-least-once with resume, and
EchoMQ.StreamConsumer as the reader law: drain own PEL, read >, reclaim peers.
MAXLEN trimming
MAXLEN ~ / MINID retention as a policy, and how the archive catches
what is trimmed and folds it to the durable floor.
Three dives, one reliable reader: the blocking read waits without spinning, the consumer group recovers self and peers, and trimming bounds the log while the archive keeps what it removes.
The pattern, applied
echo/apps/codemojex · EchoStorecodemojex (echo/apps/codemojex) is the worked consumer — a Telegram emoji-guessing game on
the same stack. A game round appends an entry to its activity stream on the {q} queue — a
guess scored, a round opened — and a feed reader joins the consumer group to render the live timeline.
When the reader restarts, it drains its own PEL first, so the feed never double-renders an event it already
showed. When the stream grows past the declared window, EchoMQ.StreamRetention trims it on its
beat, and EchoStore.StreamArchive folds the trimmed segments into the Graft floor — the full
round history stays readable for an audit without holding it all in memory.
The reader law, in depth
The Bus pillar teaches the retained, replayable log end to end: the consumer-group loop, the
XAUTOCLAIM beat, the polyglot seam, and time-travel over the stream.
Where a trimmed segment lands
EchoStore.StreamArchive folds trimmed stream segments into the Graft floor on CubDB and on to
Tigris — deep history without resident memory, the durability dial taught in full.
The Stream Tier in the manuscript
Part B3 builds the bus the reader runs on: the emq:{q}: keyspace, jobs and lanes, and the
Stream Tier whose retention folds into the floor.
Notes on ValkeyEvery stream key carries the per-queue {q}
hash tag, so a queue's stream and its keys hash to one of 16384 slots — co-located, and a multi-key script
over them stays legal — valkey.io/topics/cluster-spec.
References
sources · relatedSources
- Valkey — Introduction to Streams — the append-only log and the consumer groups the tier is built on.
- Valkey — XREADGROUP — the group read that delivers new entries and drains a consumer's PEL from cursor
0. - Valkey — XACK — the acknowledgement that retires an entry from the PEL; no ack means re-delivery.
- Valkey — XTRIM —
MAXLEN ~andMINIDretention; the~trims whole macro-nodes. - Valkey — Cluster specification — the
{q}hash tag forces a queue's stream keys onto one of 16384 hash slots. - Kreps — The Log — the log as the shared abstraction beneath a stream.
Related in this course
- R5 · Streams & Eventsthe chapter
- R5.02.1 · The blocking readthe long-poll on a dedicated lane
- R5.02.2 · Consumer groupsat-least-once with resume
- R5.02.3 · MAXLEN trimmingretention as policy, and the archive
- /echomq/busthe retained replayable log in depth
- /echo-persistencethe durability dial a trimmed segment folds onto