jonnify·redis /redis-patterns/streams-events/streams-consumer-patterns

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.

Four moves over one log. Select a move to read what it does and the EchoMQ surface it becomes. Reading the log is non-destructive — the four moves are how a reader stays reliable while many readers share the log.
emq:{q}:streamappend-only log block · BLOCK resume · PEL trim · MAXLEN archive · fold
readout · Select a move. The log is read non-destructively — block waits for the next entry, resume recovers an un-acked backlog, trim bounds the log, and archive keeps what is trimmed.
§1

The mental model problem

streams-consumer-patterns.md.txt

Most 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.

List versus stream, one behaviour at a time. Select a behaviour to read how a list and a stream differ. The differences compound: non-destructive reads are what make crash recovery and explicit retention possible.
list · LPUSH/RPOP stream · XADD/XREADGROUP read failure cleanup destructivemoves a cursor lost on crashstays until ACK automaticexplicit · XTRIM
readout · Select a behaviour. A stream read moves a cursor instead of removing the entry — so a crash before the work finishes does not lose it, and cleanup is an explicit policy rather than a side effect of reading.

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.

§2

The pending entries list

stream_consumer.ex · settle

When 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.

echo/apps/echo_mq/lib/echo_mq/stream_consumer.ex · settle/2, the ack-or-leave branchverbatim
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.

§3

Recover self, then recover peers

stream_consumer.ex · drain_pel / reclaim_peers

A 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 0 to 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 the 0 read returns nothing and one code path covers both.
  • The XAUTOCLAIM beat — 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 (default 30_000) via XAUTOCLAIM — 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.

§4

Retention is a policy

stream.ex · trim/4 · stream_retention.ex

A 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.

the pattern

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.

its EchoMQ application

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.

§5

The three dives

block → group → trim

The 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.

built ready to read

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.

§6

The pattern, applied

echo/apps/codemojex · EchoStore

codemojex (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.

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.

§7

References

sources · related

Sources

Related in this course