Blocking, timers and the bus · dive 03
The native bus
Every bus operation the Elixir library sends is a Valkey built-in. Most are one command already; the failure count, the batch append and the park each span more than one, with a window between them. The module registers three bus verbs that write in one server turn.
The bus lives in EchoMQ.Bus, its dead-letter helper,
EchoMQ.Stream and EchoMQ.StreamConsumer, and those paths
send Valkey's own commands through the connector, with no script and no MULTI, on
either wire. The module's table holds three bus rows after the job core: EMQ.APPEND
appends one entry or a batch to a stream, EMQ.PUBLISH appends one envelope to a
topic's stream, and EMQ.FAILURES.INCR counts a failure and sets its TTL. Each checks
every argument before its first write, and no Elixir code sends them. Map each operation to the commands it
sends, break the three that take more than one, then follow the three verbs through their checks.
The library sends built-ins
A publish shows the shape. EchoMQ.Bus.publish/4 mints a message id, builds the
envelope and routes it to one of two tiers, and each tier is one command:
# echo_mq — EchoMQ.Bus: the two publish tiers, one Valkey command each defp do_publish(:ephemeral, conn, topic, env) do payload = Envelope.encode_payload(env) case EchoMQ.Connector.command(conn, ["PUBLISH", Topic.channel(topic), payload]) do {:ok, n} when is_integer(n) -> :ok {:ok, {:error_reply, msg}} -> {:error, {:error_reply, msg}} {:error, _} = err -> err end end # … the durable tier: one XADD through the stream writer defp do_publish(:durable, conn, topic, env) do Stream.append(conn, topic, Topic.stream_name(), Envelope.encode_fields(env)) end
The ephemeral tier is one PUBLISH on the topic's
channel; its reply counts the receivers, and a count of zero still answers :ok, so a
publish no subscriber hears is lost. The durable tier is one XADD through
EchoMQ.Stream.append/4, which mints the record id on the host and maps a rejected id
to {:error, :nonmonotonic}. The stream stores whatever fields the
XADD carries: the envelope's shape belongs to the Elixir codec, and decoding a field
map reads each name with no check, so a record written without a msg field reads back
with no message id.
The reading side is built-ins too. An EchoMQ.StreamConsumer pass sends
XAUTOCLAIM once, then one blocking XREADGROUP of up to a beat,
and acknowledges each entry its handler accepts with XACK. Pick an operation to read the commands it sends and
the keys it touches, for a topic named orders.
Bus operations and the commands they send · pick one
:ephemeralone
PUBLISH of the framed envelope on the topic’s channelthe reply: the receiver count; a count of 0 still answers
{:ok, %{msg_id: …}}, so a publish nobody hears is lost: at most oncenatively: no verb;
PUBLISH persists nothing and is one commandThree of the nine operations have a native verb in the module's table: the durable
publish is EMQ.PUBLISH, the batch append EMQ.APPEND and the
failure count EMQ.FAILURES.INCR. The other six have none and stay Valkey built-ins;
the readout's last line names the verb or says there is none. The library sends the built-ins in all
nine.
Most bus operations are one command already, the reclaim among them; the failure count, the batch append and the park are compositions, and the next section breaks them.
Where a composition spans commands
A dead-letter subscription counts each failure verdict per message and parks the message once the count
reaches its bound. The count is two commands, an INCR and then an
EXPIRE, sent one after the other:
# echo_mq — EchoMQ.Bus.DLQ.record_failure/3: the failure count, two commands def record_failure(conn, key, fail_ttl_ms) when is_binary(key) and is_integer(fail_ttl_ms) and fail_ttl_ms > 0 do case Connector.command(conn, ["INCR", key]) do {:ok, n} when is_integer(n) -> # … the TTL in whole seconds, rounded up; the EXPIRE reply is bound to _ and dropped ttl_s = div(fail_ttl_ms + 999, 1000) _ = Connector.command(conn, ["EXPIRE", key, ttl_s]) {:ok, n} {:ok, other} -> {:error, {:unexpected_incr_reply, other}} {:error, _} = err -> err end end
On a first failure the key exists with no TTL between the two commands, and whatever the
EXPIRE answers, the caller receives the count. A batch append is the second
composition: EchoMQ.Stream.append_batch/4 mints one id per record and sends one
XADD per record in a single pipeline:
# echo_mq — EchoMQ.Stream.append_batch/4: N XADDs in one pipeline, the replies matched as _replies
def append_batch(conn, queue, name, records)
when is_binary(queue) and is_binary(name) and is_list(records) do
key = stream_key(queue, name)
{cmds, brandeds} =
Enum.map_reduce(records, [], fn fields, acc ->
branded = EchoData.Snowflake.next_branded(Id.kind())
{:ok, xadd_id} = Id.xadd_id(branded)
{["XADD", key, xadd_id, "id", branded | flatten(fields)], [branded | acc]}
end)
case Connector.pipeline(conn, cmds) do
{:ok, _replies} -> {:ok, Enum.reverse(brandeds)}
{:error, _} = err -> err
end
end
A pipeline is one round trip, not one transaction: each XADD
answers for itself, and an error reply arrives inside the reply list that the match discards. The third
composition is the park. On the failure that reaches the bound, the message is appended to the DLQ topic's
stream, whose name ends in .dlq and so sits under another hash tag; only after that
append succeeds does the consumer's loop acknowledge the source entry:
# echo_mq — EchoMQ.Bus.DLQ.park/6: one XADD on the DLQ topic's stream; :ok lets the loop XACK the source
def park(conn, env, topic, sub_id, failures, reason)
when is_map(env) and is_binary(topic) and is_binary(sub_id) and is_integer(failures) do
dlq_topic = Topic.dlq_topic(topic)
parked = merge_provenance(env, topic, sub_id, failures, reason)
case Stream.append(conn, dlq_topic, Topic.stream_name(), Envelope.encode_fields(parked)) do
{:ok, _evt} -> :ok
{:error, reason} -> {:error, {:park_failed, reason}}
end
end
Pick a composition, then how it ends, to read what the caller receives and what the server holds.
Where a composition breaks · pick a composition and an ending
INCR answers 1; EXPIRE sets 86 400 s, the 24-hour default in whole secondscaller:
{:ok, 1}; server: the count expires a day after its last failurebetween the two commands, the key exists with no TTL
natively:
EMQ.FAILURES.INCR writes INCR and EXPIRE in one turn, so no stop falls between themThe native verbs have no such window. EMQ.FAILURES.INCR
writes the count and its TTL in one turn, and EMQ.APPEND checks every entry of a
batch before its first XADD, so it writes all of them or none. The park has no
native verb: its append and its XACK stay two commands under two hash tags. The
last section reads the three verbs.
Each composition is correct while every command lands; its windows open between commands, where a stop or a dropped reply leaves the server holding something the caller was not told.
Three verbs, one server turn
The module's table holds three bus rows, after EMQ.STALLED.CHECK and before
EMQ.INSTALL. Each declares one key after a numkeys of 1, as the job verbs do, and
carries one capability token, so EMQ.VERSION lists append,
failures.incr and publish after the job core's
fourteen.
// trafikey — registry::TABLE, the three bus rows after EMQ.STALLED.CHECK: one declared key, one token each emq_append => crate::emq::streams::append, Row { name: c"EMQ.APPEND", family: Family::Emq, arity: -5, flags: "write deny-oom", keys: EVAL_KEYS, acl: c"write slow", caps: &["append"], // … emq_failures_incr => crate::emq::bus::failures_incr, Row { name: c"EMQ.FAILURES.INCR", family: Family::Emq, arity: 4, flags: "write deny-oom fast", keys: EVAL_KEYS, acl: c"write fast", caps: &["failures.incr"], // … emq_publish => crate::emq::bus::publish, Row { name: c"EMQ.PUBLISH", family: Family::Emq, arity: -5, flags: "write deny-oom fast", keys: EVAL_KEYS, acl: c"write fast", caps: &["publish"], // …
| verb | command line | flags · ACL | reply |
|---|---|---|---|
EMQ.APPEND | EMQ.APPEND 1 emq:{q}:stream:<name> <n> (<evt> <m> <field> <value> …)×n | write deny-oom · write slow | the <n> stream IDs, in order |
EMQ.PUBLISH | EMQ.PUBLISH 1 emq:{<topic>}:stream:msgs 1 <evt> <m> <field> <value> … | write deny-oom fast · write fast | an array of one stream ID |
EMQ.FAILURES.INCR | EMQ.FAILURES.INCR 1 emq:{<topic>}:fail:<sub>:<msg> <fail_ttl_ms> | write deny-oom fast · write fast | the count after the increment |
Each verb checks in one order, and every check runs before its first write.
EMQ.APPEND checks its shape first: numkeys must be 1, the key
emq:{q}:stream:<name> with a non-empty queue and name,
<n> a positive integer, the arguments must split into exactly
<n> entries, and no field name may be empty. A shared core does the rest. It
runs the fence, then maps each <evt>, which must be an id of namespace
EVT, to the stream ID <unix_ms>-<tail>, the
same ID the Elixir writer computes on the host. The key must be absent or a stream, and the IDs must rise
strictly above the stream's top, which the core reads as XINFO STREAM's
last-generated-id and takes as 0-0 for an absent key. Last come the write guard
and one XADD per entry, with no NOMKSTREAM and no
trim.
// trafikey — emq::streams: the core EMQ.APPEND and EMQ.PUBLISH share, then EMQ.APPEND itself pub(super) fn append_core( ctx: &Context, key: &[u8], entries: &[Entry<'_>], domain: impl Fn(&Entry<'_>) -> Result<(), ValkeyError>, ) -> ValkeyResult { // the fence, then each <evt> mapped to its stream ID: an id of namespace EVT, or a refusal let _caller = host::fence(ctx)?; let ids = entries .iter() .map(|entry| evt_id(entry.evt).ok_or_else(|| errors::emq(errors::KIND_RECORD))) .collect::<Result<Vec<_>, _>>()?; // the caller's own check per entry, then the key's type and the stream's top entries.iter().try_for_each(domain)?; after_top(stream_top(ctx, key)?, &ids)?; // the write guard, then one XADD per entry; the reply is the array of their stream IDs let w = host::begin_writes(ctx)?; apply(&w, &append_plan(key, entries, &ids))?; Ok(ValkeyValue::Array( ids.iter() .map(|id| host::bulk(id.text().as_bytes())) .collect(), )) } // … pub(crate) fn append(ctx: &Context, args: Vec<ValkeyString>) -> ValkeyResult { // numkeys 1, then the shape: the stream layout, n, the split into n entries, no empty field name let s = host::shape("EMQ.APPEND", &args, 1)?; let entries = append_shape(&s)?; append_core(ctx, s.key(1), &entries, |_| Ok(())) }
The top is checked before the guard, so no XADD can
fail after an earlier one wrote: a call writes all of its entries or none, and an ID at or below the top,
or out of order within the call, is refused with EMQSTATE append id not after the stream
top. The Elixir batch writes every record its XADD accepts and still
returns a receipt for each. EMQ.APPEND interprets no field: it stores the names
and values it is given, as the Elixir writer does.
EMQ.PUBLISH is the durable publish over the same core. Its shape half admits only
emq:{<topic>}:stream:msgs, a <n> of 1, and
one envelope: field names among the nine the Elixir codec writes, none repeated, with
msg and body present. After the core's EVT check it adds
one domain check per entry: msg must hold an id of namespace
MSG. An envelope the Elixir codec built passes every check, and the entry it writes
is the one the durable tier's XADD writes; on the Elixir path the server checks
none of these.
// trafikey — emq::bus: EMQ.PUBLISH, the envelope's shape checks before the shared core fn publish_shape<'a, S: Bytes>(s: &Shape<'a, S>) -> Result<Entry<'a>, ValkeyError> { if !matches!( keyspace::split_prefixed(s.key(1), b"stream:"), Some((_, b"msgs")) ) { return Err(s.layout_error()); } if parse_count(s.arg(1)) != Some(1) { return Err(errors::one_entry(s.verb)); } let entry = parse_entries(s, 1)? .into_iter() .next() .ok_or_else(|| errors::entries(s.verb))?; if !is_envelope(&entry) { return Err(errors::not_envelope(s.verb)); } Ok(entry) } // … pub(crate) fn publish(ctx: &Context, args: Vec<ValkeyString>) -> ValkeyResult { let s = host::shape("EMQ.PUBLISH", &args, 1)?; let entry = publish_shape(&s)?; // the core, with one domain check per entry: the msg field holds an id of namespace MSG append_core(ctx, s.key(1), &[entry], msg_branded) }
EMQ.FAILURES.INCR ports the failure count. Its key
must be emq:{<topic>}:fail:<sub>:<msg>, split at the first colon
after fail:, and fail_ttl_ms must be a positive integer
whose EXPIRE seconds, rounded up as the Elixir code rounds them, still fit
Valkey's own bound at the server's time, so EXPIRE cannot refuse after
INCR wrote. After the fence it checks the two brands, the sub part and then the
msg part, each a branded id of any namespace; the key must be absent or a string; and it reads the stored
value to decide what INCR will answer, refusing with
INCR's own texts for a value that is not an integer or would overflow. Then come
the guard, INCR and EXPIRE <seconds>. The module
replicates each write as its effect, and the writes of one command reach the AOF and the replicas inside
one MULTI/EXEC. The reply is the count.
// trafikey — emq::bus: EMQ.FAILURES.INCR, the count and its TTL in one turn pub(crate) fn failures_incr(ctx: &Context, args: Vec<ValkeyString>) -> ValkeyResult { // shape: numkeys 1, the fail:<sub>:<msg> layout, then fail_ttl_ms as EXPIRE seconds let s = host::shape("EMQ.FAILURES.INCR", &args, 1)?; let (sub, msg, seconds) = failures_shape(&s, host::now_ms()?)?; // the fence, the two brands, the key's type, then what INCR will reply, read before any write let _caller = host::fence(ctx)?; brands(sub, msg)?; let key = s.key(1); host::expect_type(ctx, key, b"string")?; let stored = match host::read(ctx, cmd::GET, &[key])? { Reply::Null => None, Reply::Bulk(value) => Some(value), _ => return Err(errors::emq(errors::HOST_REPLY)), }; let count = next_count(stored.as_deref()).map_err(errors::emq)?; // the write guard, then INCR and EXPIRE <seconds>; the reply is the count let w = host::begin_writes(ctx)?; apply(&w, &failures_plan(key, seconds))?; Ok(ValkeyValue::Integer(count)) }
On the ordinary path each verb writes what its Elixir counterpart writes. On the
edges they differ by design: a refused call writes nothing, where the Elixir composition may have written
part of its work; the key layout is checked, where the library sends queue and topic names unvalidated; and
EMQ.PUBLISH checks an envelope the server never sees as one on the Elixir path.
Pick a verb and a call.
Three verbs, step by step · pick a verb and a call
natively: three
XADD emq:{orders}:stream:msgs <ms>-<seq> id <EVT> … in one turn; the reply is the array of the three stream IDs, in orderwire 4:
EchoMQ.Stream.append_batch/4 sends the same three XADDs in one pipeline and returns the three EVT ids as receiptsNo Elixir code sends these three verbs, on either wire.
EchoMQ.Bus, EchoMQ.Stream and
EchoMQ.Bus.DLQ send XADD, INCR
and EXPIRE through the connector's command and pipeline calls, which a
wire: 6 connection passes through as a wire: 4 one does,
and that connection's handshake requires only the fourteen job-core tokens. A client that writes the
command line itself, after finding append, failures.incr
or publish in EMQ.VERSION, sends them.
Natively a failure count and its TTL land in one turn and a batch lands whole or not at all; the park stays two commands, and the library still sends the built-ins.
Pattern & implementation
Pub/sub delivers to whoever listens now; a stream keeps the record, and a consumer group hands each entry to one member, which acknowledges it or leaves it pending for a reclaim. Fire-and-forget vs durable and Consumer groups teach it.
EchoMQ.Bus sends PUBLISH or XADD;
EchoMQ.StreamConsumer sends XAUTOCLAIM,
XREADGROUP and XACK; the dead-letter count and park are
compositions of built-ins. trafikey registers EMQ.PUBLISH,
EMQ.APPEND and EMQ.FAILURES.INCR for the durable
publish, the append and the count, each one server turn; the library does not send them.
This dive closes the module; its hub sets the three dives side by side.
References
Sources
- Valkey — PUBLISH — the ephemeral tier's one command, and its receiver count.
- Valkey — XADD — the durable append, the batch's per-record command and the park.
- Valkey — XREADGROUP — the group read, blocking for up to a beat.
- Valkey — XAUTOCLAIM — the reclaim of entries idle past a threshold, one command.
- Valkey — INCR — the first command of the failure count.
- Valkey — EXPIRE — the second command, whose reply the library drops.
- Valkey — XTRIM — retention by length or by minimum id.
- Valkey — XINFO STREAM — the last-generated-id the native append checks its IDs against.
Related in this course
- Blocking, timers and the bus — the module hub.
- The blocking claim — the previous dive: the queue's own blocking pop and its doorbell.
- Fire-and-forget — the ephemeral tier in the Bus pillar.
- The host-side mint — the record id the stream writer mints before its
XADD. - Recover self, then peers — the reclaim and the group read in the Bus pillar.
- Retention is a policy — the trim and its opt-in driver.
- Fourteen capabilities — the token list, where the three bus tokens follow the job core's fourteen.