jonnify·redis /redis-patterns/coordination/workshop

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.

The admission transition — run @enqueue over two co-located keys and read the before→after state. Select an action to run the script's branches and read its return.
emq:{cm}:* — two co-located keys, one atomic script emq:{cm}:job:<id> (HASH) — emq:{cm}:pending (ZSET) { } · 0 members action: EVALSHA @enqueue(KEYS, id, payload) return — effect: press an action to run @enqueue over the two keys
readout · Start: no row, the pending set empty. Admit a guess to run @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.

§1

Two steps — validate-and-mint, then admit-as-one-script

codemojex/game.ex:21

A 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.

§2

The keys that must move together

echo_mq/keyspace.ex

Admission 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.

§3

One Lua script — the whole admission, all-or-nothing

echo_mq/jobs.ex:14

EchoMQ.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.

echo/apps/echo_mq/lib/echo_mq/jobs.ex:14 · @enqueue, the atomic admissionverbatim
-- @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.

§4

Why the heavier machinery is unnecessary here

R2.02 · R2.03 · R2.04

The 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.

the chapter

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.

this workshop

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.

§5

The hash tag keeps the multi-key script legal

echo_mq/keyspace.ex:43

On 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.

Co-location check · the tag on or off, and the real slot each admission key resolves to. The slot is the live EchoMQ.Keyspace.slot/1 computation (CRC16-XMODEM mod 16384; vector slot("123456789") == 12739).
tag on → Valkey hashes "cm" for every key job:<id> → CRC16("cm") % 16384 = 0000 pending → CRC16("cm") % 16384 = 0000 one slot → the multi-key script is legal
readout · tag on: both keys hash "cm" → one slot. The multi-key script over the row and the pending set is legal.

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.

a door, not a depth · → the EchoMQ course

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.

§6

References

sources · related

Sources

Related in this course