jonnify·redis /redis-patterns/streams-events/pubsub/the-choosing-rule

R5.03.2 · Pub/Sub vs Streams — dive

The choosing rule — one question decides it.

Choosing between the two channels stops being a matter of taste once the promises are clear. It follows from a single question: does a reader who was not present need to read this later? If yes, the answer is the Stream, every time. If the value of the message is genuinely now-or-never, Events is enough and cheaper.

Grounding: echo/apps/echo_mq/lib/echo_mq/cancel.ex · EchoMQ.Cancel (new/0, cancel/3, check/1) — the worked fire-and-forget control signal: a make_ref() token, an {:emq_cancel, token, reason} mailbox message, no record.

The one question, and where each answer leads. Select a property to read which channel its promise calls for. Every property reduces to the same fork: must an absent reader see this later?
absent readermust see it later? YES → Stream NO → Events replay it resume crash audit it a live tick a stop nudge
readout · Select a property. Replay, resume, and audit answer yes — an absent reader must read them — so the Stream. A live tick and a stop nudge answer no — their value expires — so Events.
§1

One question

the fork beneath every property

Every property that distinguishes the two channels reduces to the same fork: must an absent reader see this later? Replay needs it — a reader rebuilding state was not there when the events happened. Resume-after-crash needs it — the reader that comes back was absent while it was down. An audit trail needs it — the auditor reads long after the fact. Time-travel needs it — a window of past entries is exactly the absent reader's case. All four are the Stream.

The opposite branch is a message whose value expires the instant it is sent: a live progress percentage, a presence heartbeat, a "stop what you are doing" nudge to a worker that is running right now. A reader who was not present has nothing to gain from seeing it late — the next tick supersedes it, or the moment has passed. That is the Events branch, and its reward is no storage and no retention decision.

One fork sits beneath every property: must an absent reader read it later? Yes is the Stream; now-or-never is Events. The rest of the rule is a listing of the cases.

§2

The decision table

the rule, written out

Written out, the rule is a short table. Read top to bottom it has a shape — durability is the heavy side and the table leans onto it. Most events a system cares about are records, things that happened which someone will eventually read, so most rows are the Stream; only the genuinely ephemeral rows are Events.

You needChannelWhy
Replay from the startStreamthe log is retained; XRANGE reads from -
A reader that resumes after a crashStreamthe consumer-group cursor / PEL survives the reader
An audit trail or event sourcingStreamevery entry is stored with an ordered id
Time-travel to a past windowStreama mint instant becomes a range bound
Fan-out to many independent readersStreameach group keeps its own cursor over one log
Live "react now, no replay"Eventsat-most-once is enough; no storage cost
An ephemeral signal a present worker readsEventsa control nudge, not a record
A presence ping / live progress tickEventsthe next message supersedes a missed one

Five rows are the Stream and three are Events, because most events worth sending are records. The table is the one question, applied case by case.

§3

The default is the Stream

the asymmetry

When the requirement is unclear, choose the Stream. The reasoning is asymmetric. A Stream can always feed a live reaction too — append the entry, and a reader tailing the log with a blocking read reacts within a round trip, so picking the Stream does not cost you liveness. But Events cannot become durable after the fact: a message published with no subscriber is gone, and no later decision can recover it. Choosing the Stream keeps the durable option open and still serves the live case; choosing Events forecloses durability.

EchoMQ made that choice for its own lifecycle. The system of record is the durable Stream; the fire-and-forget EchoMQ.Events exists for live reaction on top of it. A consumer that must not miss a transition reads the Stream; a dashboard that wants to feel live subscribes to Events and accepts that a dropped frame is a dropped frame. The default is durable, and the exception is named.

The choice is asymmetric: a Stream can also react live, but Events can never become durable after the fact. When unsure, choose the Stream — it keeps both options open.

§4

The worked fire-and-forget case — the cancel token

cancel.ex · new/0 · cancel/3 · check/1

Not every fire-and-forget signal goes over the wire. The clearest worked case in EchoMQ is EchoMQ.Cancel, a cooperative cancellation token — and it is fire-and-forget in the same shape Events is, one level in. It is a control nudge to a worker that is running now, with no durability and no record, which is exactly why it is not a Stream entry.

echo/apps/echo_mq/lib/echo_mq/cancel.ex · the cooperative token: mint, send, non-blocking checkverbatim
def new, do: make_ref()                      ← a token, no wire identity

def cancel(pid, token, reason \\ nil) when is_pid(pid) do
  send(pid, {:emq_cancel, token, reason})    ← fire-and-forget: a mailbox message
  :ok
end

def check(token) do
  receive do
    {:emq_cancel, ^token, reason} -> {:cancelled, reason}   ← only THIS token's cancel
  after
    0 -> :ok                                 ← non-blocking: nothing waiting
  end
end

The token is a plain make_ref() — no branded id, nothing on the wire. cancel/3 sends {:emq_cancel, token, reason} to the handler's mailbox and answers :ok; the handler picks it up at its next check/1, a non-blocking receive after 0. It is cooperative: a handler that never checks completes normally, and the ^token match ensures a handler only catches its own cancellation. A cancel sent to a handler that has already finished is never read — lost, harmlessly, exactly like a publish with no live subscriber. There is no record that a cancel was requested because no record is wanted: the question is only "is a stop waiting for me right now," and the answer expires the moment the work ends.

Read against the table, the cancel token is the bottom rows made concrete: an ephemeral signal a present worker reads, where a missed message means the work had already finished. If cancellation needed to be durable, audited, or replayed — issued from another node and coordinated across the cluster — it would stop being this token and become a recorded, distributed surface. The local token stays fire-and-forget because that is the promise the job needs.

The token's life — and where it can be lost. Select a step to read what happens. The send is fire-and-forget: if the handler has finished, the message is never read, and that is fine.
caller handler new() = ref send :emq_cancel check → abort done: not read
readout · Select a step. new/0 mints a ref; cancel/3 sends an :emq_cancel message; check/1 reads it only if the handler is still running — a finished handler never reads it, and that loss is harmless.
the pattern

A fire-and-forget signal is a control nudge with no record: delivered if a present reader catches it, harmlessly lost otherwise. Reach for it only when the message's value is now-or-never.

its EchoMQ application

EchoMQ.Cancel is that nudge worker-side — a make_ref() and an {:emq_cancel, token, reason} message a handler reads at its next check/1. No branded id, no Stream entry: a record is exactly what a cancel does not want.

Notes on ValkeyThe over-the-wire fire-and-forget twin of this token is PUBLISH on the events channel — both deliver to a present reader and store nothing — so a control signal that must survive an absent reader belongs on a stream instead — valkey.io/topics/pubsub.

§5

The pattern, applied

echo/apps/codemojex

In codemojex (echo/apps/codemojex) the two branches of the table run side by side in one round. A round-scored event is a record — the score has to be replayable and auditable — so it is appended to the durable Stream, and a feed reader replays or resumes over it. A "round closing, stop accepting guesses" nudge to the worker handling the round is the other branch: an ephemeral control signal a present worker reads, with no value to a reader who arrives later, so it travels as a cooperative cancel-style message rather than a Stream entry. The score is remembered; the stop-signal is consumed and gone. Each event takes the channel its promise calls for.

§6

References

sources · related

Sources

Related in this course