R2.06 · Coordination — capstone workshop
A guess submission, made atomic.
Fold five coordination patterns into one codemojex guess submission that either lands
completely or not at all — a validated guess admitted onto the cm lane by a single Lua
script. It needs no lock and no Redlock, because the script is the serialization.
Grounding: echo/apps/codemojex · Codemojex.Guesses.submit/3 · echo/apps/echo_mq · EchoMQ.Lanes.enqueue · EchoMQ.Jobs.enqueue → @enqueue — the validate-then-admit door and the real atomic admission, every figure verbatim from the as-built code.
@enqueue; retry it to see the EXISTS refusal.One script checks the kind, refuses a duplicate by id, writes the row, and inserts the pending
entry — the whole admission lands together or not at all, with no WATCH loop and no lock.
Two steps — validate-and-mint, then admit-as-one-script
codemojex/game.ex:21A guess submission is two moves, and the chapter’s patterns govern the second.
First, the door. Codemojex.Guesses.submit/3 turns an untrusted six-emoji
guess into either an admitted job or one member of a closed error set. No exception escapes; nothing is half
done. It validates against the round (open? not expired? valid emojis?), charges the wallet atomically, mints
a branded JOB id at the instant of acceptance, and admits the guess onto the player’s lane:
# Codemojex.Guesses.submit/3 — the success branch (game.ex:21) true -> guess = Locks.merge(round, player, emojis) # overlay the player's locked positions case Wallet.charge_guess(player, r, round) do # Repo.transaction + SELECT … FOR UPDATE {:ok, _balance} -> job = EchoData.BrandedId.generate!("JOB") # minted at acceptance, success branch only payload = :erlang.term_to_binary({:guess, round, player, guess}) Lanes.enqueue(Bus.conn(), "cm", player, job, payload) # one atomic admission onto the cm lane {:error, reason} -> {:error, reason} end
The closed errors are the whole failure channel — :no_round, :closed,
:expired, :bad_guess, and a wallet :insufficient — and a
rejection mints nothing.
Second, admission. The validated guess is recorded onto the cm lane as one
atomic Lua script. The branded JOB id is the job key and the idempotency key
(echo/apps/codemojex/game.ex), so admitting the guess and refusing a duplicate are the same
indivisible step.
submit/3 validates, charges, and mints; the lane admits. The chapter’s five
patterns are all about that second step — making the admission one atomic, co-located, idempotent move.
The keys that must move together
echo_mq/keyspace.exAdmission touches two co-located keys, both declared in the script’s KEYS — the v2
law: every key a script touches is named in KEYS, and ARGV carries values only.
emq:{cm}:job:<branded> # Hash: the guess's row — state, attempts, payload
emq:{cm}:pending # Sorted set: the pending queue, member = the id, score 0
EchoMQ.Keyspace.queue_key(q, type) always braces the queue name —
"emq:{" <> q <> "}:" <> type — and job_key(q, branded) returns
emq:{q}:job:<branded> after gating the id with EchoData.BrandedId.valid?/1. The
row and the pending entry are one fact: a guess on the pending set with no row, or a row with no pending entry,
is a half-written guess. They must change as one.
Two keys, one fact: the guess’s row and its place in the pending queue. Atomicity is the requirement they impose, not a nicety.
One Lua script — the whole admission, all-or-nothing
echo_mq/jobs.ex:14EchoMQ.Jobs.enqueue/4 (jobs.ex:27) runs the admission as a single declared-keys
script, @enqueue (jobs.ex:14), through EchoMQ.Connector.eval/5
(connector.ex:63), EVALSHA-first with a load-on-NOSCRIPT fallback. There is no
WATCH/retry loop (R2.01): a Lua script
is the serialization point — Valkey runs it to completion before any other command, so nothing
interleaves.
-- @enqueue KEYS[1] = emq:{cm}:job:<id> KEYS[2] = emq:{cm}:pending -- ARGV[1] = the branded id ARGV[2] = the payload if string.sub(ARGV[1], 1, 3) ~= 'JOB' then return redis.error_reply('EMQKIND job id must be JOB-namespaced') -- admit by kind end if redis.call('EXISTS', KEYS[1]) == 1 then return 0 -- refuse duplicates end redis.call('HSET', KEYS[1], 'state', 'pending', 'attempts', '0', 'payload', ARGV[2]) redis.call('ZADD', KEYS[2], 0, ARGV[1]) return 1
enqueue/4 reads the script’s integer back into a verdict in input order —
{:ok, 1} is :enqueued, {:ok, 0} is :duplicate, and the
EMQKIND server error is {:error, :kind}. Three properties make this correct, and the
interactive in the hero is each of them made concrete.
Admit by kind. The first line checks the id’s three-byte namespace; the queue admits
jobs, so a non-JOB id is refused with EMQKIND before any write.
Idempotent on retry. The EXISTS check returns 0 for an id already
on the lane, writing nothing. A dropped reply that triggers a client retry never double-admits the guess
— the branded id is the idempotency key (R2.01’s idempotency-key technique).
Never a torn write. The HSET row and the ZADD pending entry run
inside one script, so the guess’s row and its pending position land together or not at all — the
atomic precondition R2.01 is built to hold.
Notes on ValkeyEVAL runs a Lua script as one atomic step:
the script executes to completion before the next command, so a multi-key transition needs no transaction retry
loop — valkey.io/commands/eval.
Why the heavier machinery is unnecessary here
R2.02 · R2.03 · R2.04The script is the serialization. Because Valkey runs one Lua script to completion before the next command, the admission is already mutually exclusive without a separate lock. Each of the chapter’s heavier tools is unnecessary for this admission — not because they are wrong, but because the co-located atomic script already does their work:
No per-guess distributed lock
(R2.02). A SET NX PX lock with a
fencing token exists to serialize a read-modify-write that spans several round trips. Admission is one round
trip; the script already serializes it, so a lock would only add a lease to renew and a failure mode to handle.
EchoMQ’s lease and attempts fence belong to the claim of a pending job, not its
admission.
No multi-master Redlock
(R2.03). Redlock buys mutual exclusion across N independent
Redis masters. EchoMQ reads one server clock inside one atomic script over one EchoMQ.Connector;
its atomicity comes from the single server running the script, not from a majority of masters. Reaching for
Redlock here would trade a real guarantee for a weaker, more expensive one.
No torn-write detection
(R2.04). Cross-shard detection —
version tokens, commit markers — is the fallback when keys cannot share a slot. The {cm}
hash tag co-locates the row and the pending set, so the torn write the detection guards against cannot occur.
Five coordination patterns: atomicity (R2.01), locking (R2.02), the Redlock contrast (R2.03), cross-shard detection (R2.04), and co-location (R2.05) — each the right tool for a different shape of multi-key change.
One co-located, idempotent, atomic admission — EchoMQ.Jobs.enqueue’s
@enqueue over emq:{cm}:* — that needs none of the heavier machinery,
because the script is the serialization.
The chapter’s lightest pattern is the one that holds: co-locate the keys, run one script, and the three heavier coordination tools are not needed for this admission.
The hash tag keeps the multi-key script legal
echo_mq/keyspace.ex:43On a cluster the two keys must share one slot, or a script touching both raises CROSSSLOT
(R2.05). The {cm} hash tag
arranges that: Valkey hashes only the substring inside the first non-empty {...}, so
emq:{cm}:job:<id> and emq:{cm}:pending both hash cm and
land on one slot. EchoMQ.Keyspace.slot/1 computes this client-side — CRC16-XMODEM over the
hash tag modulo 16384 — so the connector routes without a server round trip. Toggle the tag and read the
real slots the two keys resolve to.
EchoMQ.Keyspace.slot/1 computation (CRC16-XMODEM mod 16384;
vector slot("123456789") == 12739).The tag is the precondition for the atomic admission. Without it the two keys scatter and the admission is not one script — it is two commands on two nodes, with nothing to make them atomic.
The full script bundle behind this admission
The admission model — one Lua script per state transition, the full script bundle (enqueue, browse,
pending_size, claim, complete, retry, promote, reap), the EVALSHA / NOSCRIPT dispatch in
EchoMQ.Connector.eval/5, and the lease-and-fence claim plane — is the subject of the
dedicated EchoMQ course, the companion depth course that teaches the
protocol in full. The next verb, claim, leases a pending job on the server clock and takes
attempts as its fencing token — R2.02’s worked form, the move beyond this admission.
Return to the Coordination chapter to continue, and see the
Overview for where Valkey sits under codemojex.
Notes on ValkeyA cluster hashes a key by the substring inside the first
non-empty {...} when present, else the whole key, then takes CRC16 modulo 16384 to pick one of the
16384 slots — valkey.io/topics/cluster-spec.
References
sources · relatedSources
- Redis — Programmability (EVAL intro) — one Lua script as a single atomic step;
EVALSHA/NOSCRIPTdispatch. - Valkey — EVAL — the command that runs the admission script as one atomic step on the engine.
- Valkey — Cluster specification — hash tags, the 16384 slots, and the
CROSSSLOTthat co-location prevents. - Redis — Documentation — the sorted set, hash, and scripting commands the admission is built from.
Related in this course
- R2 · Coordination & Consistencythe chapter this workshop closes
- R2.01 · Atomic updatesthe single-script atomic transition this admission is
- R2.02 · Distributed lockingthe claim lease and attempts fence this admission does not need
- R2.04 · Cross-shard consistencythe torn write the one-slot script cannot produce
- R2.05 · Hash-tag co-locationthe {cm} tag that keeps the multi-key EVAL legal
- /echomq/protocolthe EchoMQ protocol that teaches the full script bundle