The Bus · workshop · end to end
The retained event log, end to end
Five surfaces, one wire, one feed — built in front of you.
This page closes the Bus pillar by building one thing out of all five
modules: a retained codemojex activity feed. A guess is published to
live screens (EchoMQ.Events), appended to a durable log
(EchoMQ.Stream), consumed reliably
(EchoMQ.StreamConsumer), replayed by instant
(read_window/6), then bounded and folded to disk
(trim/4 → EchoStore.StreamArchive). The thread
through all five is one branded EVT id. Step through the pipeline on the right.
The pipeline · step through the five stages
Stage 1 — publish the live signal
The screen wants to react the instant a guess is scored, with no set to poll. That is fire-and-forget
pub/sub: EchoMQ.Events.publish/5 issues PUBLISH emq:{q}:events
host-side, after the scoring verdict — the byte-frozen transition scripts stay
byte-unchanged, so the publish is a host-side message about the verdict, never part of it. This is
exactly what Codemojex.ScoreWorker does the moment it finishes scoring a guess.
# echo/apps/codemojex — Codemojex.ScoreWorker (the scoring authority) # After scoring, publish a `scored` event so the room channel reacts LIVE. The # score is already written; the event is only a signal — no secret, no guess # content crosses the wire, only game / player / pct / eff. Events.publish(conn, @queue, "scored", job_id, game: game, player: name, pct: to_string(s.percentage), eff: to_string(eff) )
publish/5 gates the job id at the key builder
(Keyspace.job_key/2, INV5) before the wire, encodes the flat cjson
{"event":"scored","job":"<id>",…} object, and
PUBLISHes it on channel(queue) =
emq:{q}:events. A subscriber reads the name back by substring scan
(event_name/1) — the bus carries no JSON parser, and an unknown name answers
:unknown, never minting an atom from the wire.
The cost is named: at-most-once. A PUBLISH with no live subscriber
— or one issued in the window between a socket drop and the resubscribe — is lost.
The resubscribe keeps the feed live across a reconnect, but the durable receipt is the next stage.
The screen sees it now; the log remembers it forever.
Events fan a signal out and are gone — perfect for a live screen, useless as a record. So the same fact also goes onto a log.
Stage 2 — append + replay the durable log
The same fact that fans out to live screens is appended to a retained, replayable log.
EchoMQ.Stream.append/4 mints an EVT id host-side, derives
the explicit XADD id, and issues XADD direct — no Lua. The branded id is the
receipt.
# echo/apps/echo_mq — EchoMQ.Stream.append/4 (the writer's whole point) # 1. mint an EVT-branded record id host-side — the writer OWNS the mint, so # there is nothing to spoof; # 2. derive the explicit XADD id by field correspondence (Stream.Id.xadd_id/1); # 3. XADD <key> <xadd_id> id <branded> <fields…> — DIRECT (no Lua); the 14-byte # branded string is the stream `id` FIELD (the claims-only contract: a # polyglot reader gets the canonical id without re-encoding); # 4. return {:ok, branded} — the branded id IS the receipt. def append(conn, queue, name, fields) when is_binary(queue) and is_binary(name) and is_list(fields) do branded = EchoData.Snowflake.next_branded(Id.kind()) append_id(conn, queue, name, branded, fields) end
For the feed, the worker appends a scored record beside the publish — the same
moment, made durable:
# the same scored fact, appended to the durable feed {:ok, evt} = EchoMQ.Stream.append(conn, "cm", "feed", [{"event", "scored"}, {"game", game}, {"player", name}, {"pct", Integer.to_string(s.percentage)}, {"guess", gid}]) # evt :: "EVT…" — the branded receipt; gid :: "GES…" — the written guess id
The order theorem holds the log: stream order == id sort == mint
order, proven by construction in EchoMQ.Stream.Id. The
EVT brand admits one namespace per stream (so base62 byte-order equals
snowflake-order), and a single writer's strictly-monotone :atomics cell means the
next id always exceeds the stream top — no XADD rejection is possible. A
multi-writer violation surfaces honestly as {:error, :nonmonotonic} — never
swallowed, never retried with *. read/6
(XRANGE) folds the whole log back in mint order, the receipt becoming the canonical
id and the rest the payload map.
The stream remembers what the publish forgets — an ordered, replayable record keyed by the branded id it mints.
Stage 3 — consume reliably
The live screen reads off the publish. But the notification worker — the one that pushes a
“you were beaten” message — must read the log reliably: each entry
handled once, resumable after a crash, never re-running the whole feed. That is a consumer group,
EchoMQ.StreamConsumer, whose handler is byte-identical in shape to
the job Consumer's — one portable handler across job and stream.
# echo/apps/echo_mq — the EchoMQ.StreamConsumer handler shape (the exact mirror) # id :: the stored branded record id (the append/4 receipt) — the dedup key # payload :: the entry's remaining fields as a map # attempts :: the XPENDING per-entry DELIVERY-count (NOT a failure count), so a # poison threshold attempts >= N calibrates correctly # group :: the consumer-group name # :ok -> XACK (the entry retires from the PEL) # {:error,_} -> LEFT un-acked (survives in the PEL, re-delivered) — at-least-once fn %{id: evt, payload: fields, attempts: attempts, group: _g} -> case notify_feed(evt, fields) do :ok -> :ok # XACK err -> err # leave un-acked → re-delivered end end
Two mechanisms recover work. PEL-drain-on-(re)start recovers SELF:
XREADGROUP GROUP g <self> … 0 reads the consumer's own un-acked backlog to
exhaustion, then switches to > — a crashed consumer that restarts
with the same name recovers its held work the instant it restarts. The
XAUTOCLAIM beat recovers dead PEERS: entries idle past
:min_idle_ms (server-side idle — no host clock) held by consumers that died
and never restarted are re-assigned, one pass per beat. The blocking
XREADGROUP … BLOCK parks on the consumer's own private lane,
so the single-owner socket of the rest of the system is never stalled.
The cost is the order-theorem PEL exception: new entries stay id-ordered (the writer's
theorem is untouched), but a re-claimed entry returns out of real-time delivery
order — its branded id is older than entries already handled. This is the irreducible cost of
at-least-once (exactly-once is not claimed), so the handler must be
idempotent — the branded EVT id is the dedup key, the BCS newer-wins
discipline. codemojex's scoring is already pure (Codemojex.Scoring.score/2: the same
secret and guess always yield the same score), so re-handling a feed entry is safe by construction.
The screen reacts now; the worker resumes, not replays — safe to re-handle because the branded id is the dedup key.
Stage 4 — replay by instant
“What did the feed look like between 14:30 and 14:32?” — for a backtest, an audit, or a
debug — is a range read, not a scan, because the log is mint-ordered. A wall-clock
%DateTime{} is an exact id position, so
EchoMQ.Stream.read_window/6 computes its XRANGE bounds
host-side and delegates to the byte-frozen read/6 — zero new
Lua.
# echo/apps/echo_mq — EchoMQ.Stream.read_window/6 (the time-travel read) # A CLOSED mint-time window [t0, t1], both edges inclusive. from = minid_floor(t0) # (the smallest id at/after t0), to = maxid_ceil(t1) (the largest id mintable # at/before t1) — exact because the stream is mint-ordered. RAISES before any # wire on an inverted window; delegates to read/6. def read_window(conn, queue, name, %DateTime{} = t0, %DateTime{} = t1, count \\ nil) when is_binary(queue) and is_binary(name) do if DateTime.compare(t1, t0) == :lt do raise ArgumentError, "read_window requires t0 <= t1; …" end read(conn, queue, name, minid_floor(t0), maxid_ceil(t1), count) end
For the feed, replay one round's life over its open→settle interval — every
GES guess and scored / settled
entry in [opened_at, settled_at], in mint order:
# replay the round's feed over its open→settle interval {:ok, slice} = EchoMQ.Stream.read_window(conn, "cm", "feed", opened_at, settled_at) # [{branded, fields_map}] in mint order — no resident memory of the window needed
read_since/5 is the half-open [t0, ∞) analogue. Both
return {:ok, [{branded, fields_map}]} in mint order; both are pure id-math over the
shipped read/6. Never a raw snowflake integer to the wire — the wire wants
ms-seq (the minid_floor/1 discipline).
Because time is an id position, any past window is a bounded range read — the order theorem cashed out for backtest, audit, and debug.
Stage 5 — bound + fold to disk
A log that only grows is a leak. Retention bounds the live log; what is trimmed is
not lost — it folds to the durable Graft floor.
EchoMQ.Stream.trim/4 bounds the log by length
(MAXLEN) or age (MINID), issued direct over
XTRIM — no Lua.
# echo/apps/echo_mq — EchoMQ.Stream.trim/4 (the destructive verb, no Lua) # {:maxlen, count, approx?} -> XTRIM <key> MAXLEN [~|=] <count> (keep newest count) # {:minid, dt, approx?} -> XTRIM <key> MINID [~|=] "<ms>-0" (remove < instant) # approx? true -> `~` the SAFE default: trims in whole macro-nodes, may UNDER-trim # but NEVER OVER-trim, so a trim can never delete an entry INSIDE the window (INV4). {:ok, removed} = EchoMQ.Stream.trim(conn, "cm", "feed", {:maxlen, 10_000, true})
Retention is a policy, not a default — coupling a safety property (bounded
memory) to a liveness fact (a consumer is up) is the silent-no-op class the design refuses, so the
trim cadence lives on its own beat. The named opt-in driver EchoMQ.StreamRetention
— a :transient GenServer beating on :tick_ms —
re-applies a declared BEAM-side :policy of
{queue, name, window} via trim/4, decoupled from consumer
liveness (a stream nobody drains still trims). A manual trim/4 is the
equally-supported cadence; the driver is sugar.
What is trimmed folds to the archive. EchoStore.StreamArchive.fold/3 folds a
mint-ordered {branded, fields} slice into the native
EchoStore.Graft engine's CubDB — one page per record at a reserved high range
(@archive_base = 2^49, disjoint from business pages by construction) — through
the public VolumeServer.commit/3, advancing the watermark
W to the branded EVT id of the highest-folded record.
# echo/apps/echo_store — EchoStore.StreamArchive.fold/3 (the durable floor) # Fold a mint-ordered slice into the Graft engine at @archive_base + n — one page # per record — and advance the watermark W to the highest-folded EVT id (W is a # branded id, NEVER the integer head_lsn). Fold-before-trim is the no-loss # ordering: on a fold error the caller does NOT trim (the safe direction). {:ok, w} = EchoStore.StreamArchive.fold(volume_id, slice, db)
W splits the merge-read: id ≤ W comes
from the archive, id > W from the live Stream.read/6
tail. No-gap / no-overlap is a consequence of fold-before-trim + the order theorem, never a per-read
check. The page axis is branded-id-monotone (records fold in mint order — the order theorem reaching
disk), so a forward scan reads oldest-first with no second index. The deep feed survives without
resident memory.
This is the durability dial — a system turns it: hold nothing · a bounded in-heap window + a checkpoint per K · commit-per-record + replicate off-box (Graft → Tigris). The comparison is Oban, which keeps jobs in the same Postgres as the data (a job + a row commit in one transaction); the bus separates the log from the store and buys an in-memory hot path + the dial, giving up Oban's one-transaction coupling — the trade stated beside the win. The door out is the persistence floor.
Bound the live log, fold what is trimmed — the deep feed outlives memory, served as one mint-ordered stream by the merge-read.
The feed, built — one round through the pipeline
Run a single codemojex round through the pipeline and watch the feed assemble. Each event is
published live and appended as one EVT record in
mint order; a window read selects a slice; retention trims the head and the
archive folds it below the watermark W. The EVT ids are
real branded ids decoded the same way the footer stamp is — ordered by mint, so the log is the
timeline.
The round-to-feed builder · advance the round
The builder runs the real lifecycle: Codemojex.Guesses.submit/3 enqueues a guess,
Codemojex.ScoreWorker scores it with Codemojex.Scoring.score/2
and appends a scored event, and Codemojex.Settle.close/1
(running Codemojex.Rooms.close_game/1) appends a settled
event. Every row is one append/4 — the EVT ids sort as
mint instants, so reading the log top-to-bottom is reading the round in order. Trim keeps the newest few on
the live tail; the rest fold below W, still readable through the merge-read.
One round, five surfaces, one feed — published, appended, consumed, replayed, and folded, with the branded id the single thread through all of it.
Pattern → implementation
An event log that is broadcast, replayed, consumed reliably, and tiered to durable storage is the pattern the Redis Patterns Applied course frames — and its streams-and-events workshop builds this same codemojex feed from that side. Here it is built from the implementation side.
A retained activity feed: publish a signal, append it to a replayable log, consume it reliably with a consumer group, replay a window, and tier the old log to durable storage. The streams workshop builds the same feed from the pattern side.
EchoMQ.Events.publish/5, EchoMQ.Stream.append/4 +
read_window/6, EchoMQ.StreamConsumer,
trim/4 under EchoMQ.StreamRetention, and
EchoStore.StreamArchive.fold/3 — the real surfaces, one branded
EVT id the thread through all of them.
The same retained-feed idea, read two ways: the pattern names the moves; the pillar shows the shipped code that makes them, end to end.
Recap — the pillar, exercised
The Bus pillar taught five surfaces over one wire; this workshop built one retained codemojex feed out of
all five. Publish for the live screen, append for the durable receipt,
consume to resume-not-replay, read by instant to replay any window,
trim + fold so the deep feed outlives memory. Events say it once; the stream remembers. The
thread through all five is one branded EVT id — the append's receipt is the
consumer's dedup key, the time-travel bound, and the archive watermark, minted once and carried unchanged.
The next floor down — the durability dial in full, the page engine and the portable remote — is the persistence floor. And the architecture law these figures realize is the BCS bus chapter.
References
Where the stream primitive, the cluster routing, and the log idea are grounded.
Sources
- Valkey — Introduction to Streams — the append-only log primitive (
XADD/XRANGE/XREADGROUP) the stream tier issues direct. - Valkey — Cluster specification — the hash-slot routing the
{q}hashtag co-locates a queue's keys onto, so a multi-key read stays on one slot. - Valkey — XADD — the append the writer issues; the explicit id is the
ms-seqthe branded receipt maps to. - Valkey — XTRIM — the
MAXLEN/MINIDretentiontrim/4runs;~the safe, never-over-trim default. - Kreps — The Log — the append-only log as the system's unifying backbone.
- Lamport — Time, Clocks, and the Ordering of Events — why a single writer's monotone clock makes order legible.
Related in this course
- The events channel — stage 1: the fire-and-forget pub/sub surface.
- The stream log — stage 2: the append-only writer + the order theorem.
- The consumer group — stage 3: the reliable, resumable read.
- Time-travel — stage 4: reading the log by a mint instant.
- Retention & the archive — stage 5: bound the log, fold what is trimmed.
- The Bus — the pillar landing this workshop closes.
- redis-patterns · the streams workshop — the same feed, built from the pattern side.
- bcs · the bus — the architecture law these figures realize.
- The persistence floor — the durability dial in full.