jonnifyechomq
EchoMQ / The Bus / Workshop

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

a guess scored ScoreWorker emq:{cm}: — one owned wire publish PUBLISH events sub sub sub Events · at-most-once Stream · ordered by mint (EVT) EVT EVT EVT EVT EVT EVT EVT XADD head — oldest tail — append → consumer group XREADGROUP · at-least-once read by instant XRANGE [t0, t1] trim the head XTRIM MAXLEN / MINID the durable Graft floor StreamArchive.fold · CubDB → Tigris

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

EVT ideventpayload

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.

the pattern (Redis Patterns Applied)

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.

the implementation (echo_mq)

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

Related in this course