jonnifyechomq

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

HASH TAG {orders} HASH TAG {orders.dlq} channelpubsub streamstream:msgs failure countfail:SUB:MSG DLQ streamstream:msgs PUBLISH emq:{orders}:pubsub <frame> one command
one command · sent by EchoMQ.Bus.publish/4, tier :ephemeral
one PUBLISH of the framed envelope on the topic’s channel
the reply: the receiver count; a count of 0 still answers {:ok, %{msg_id: …}}, so a publish nobody hears is lost: at most once
natively: no verb; PUBLISH persists nothing and is one command

Three 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

INCRn = 1 EXPIRETTL 86 400 s the keyexpires caller{:ok, 1} EchoMQ.Bus.DLQ.record_failure/3
failure count, runs through · a first failure, two commands, two round trips
INCR answers 1; EXPIRE sets 86 400 s, the 24-hour default in whole seconds
caller: {:ok, 1}; server: the count expires a day after its last failure
between 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 them

The 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"],
    // …
The three bus verbs
verbcommand lineflags · ACLreply
EMQ.APPENDEMQ.APPEND 1 emq:{q}:stream:<name> <n> (<evt> <m> <field> <value> …)×nwrite deny-oom · write slowthe <n> stream IDs, in order
EMQ.PUBLISHEMQ.PUBLISH 1 emq:{<topic>}:stream:msgs 1 <evt> <m> <field> <value> …write deny-oom fast · write fastan array of one stream ID
EMQ.FAILURES.INCREMQ.FAILURES.INCR 1 emq:{<topic>}:fail:<sub>:<msg> <fail_ttl_ms>write deny-oom fast · write fastthe 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

1 · shape3 entries 2 · fenceechomq:6.0.0 3 · domainEVT, above top 4 · guardallowed 5 · writes3 × XADD EMQ.APPEND 1 emq:{orders}:stream:msgs 3 (<EVT> <m> <field> <value> …)×3
EMQ.APPEND, a batch of three · every check passes before the first write
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 order
wire 4: EchoMQ.Stream.append_batch/4 sends the same three XADDs in one pipeline and returns the three EVT ids as receipts

No 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

the pattern (Redis Patterns Applied)

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.

the implementation (EchoMQ and trafikey)

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

Related in this course