R2.04 · Coordination — module hub
Cross-shard consistency.
Detect and handle torn writes across multiple Valkey instances with transaction stamps, version tokens, and commit markers when an atomic multi-key operation is not possible. The better answer, when the keys can share a slot, is prevention — EchoMQ removes the tear before it can happen.
Grounding: echo/apps/echo_mq · EchoMQ.Keyspace.queue_key (keyspace.ex:13) · EchoMQ.Jobs.claim → @claim (jobs.ex:126) · EchoMQ.Connector.eval (echo_wire/connector.ex:63) — one slot, one Lua EVAL over co-located keys; every key declared in KEYS.
@claim runs as one atomic EVAL.Static verdicts — with {orders} → EchoMQ.Keyspace.slot/1 hashes
only orders, all three keys share one slot, @claim is one atomic EVAL · without
the braces → the whole flat key is hashed, the three scatter, a multi-key EVAL raises
CROSSSLOT.
The problem — torn writes
cross-shard-consistency.md.txt · The ProblemWhen two related keys A and B live on different shards, the pair of writes is not one atomic step. The client writes A to shard 1 and it succeeds; it writes B to shard 2 and it fails on a network error or a timeout. A now holds new data, B holds old data, and there is no transaction to roll back. A read that fetches both returns inconsistent state.
With a single primary a multi-key transaction or a Lua script would make the pair atomic; across shards that guarantee is gone, so the application has to detect the tear after the fact. These patterns work with any multi-primary Valkey setup — cluster shards, client-side sharding across independent primaries, or even separate deployments — because detection runs at the application layer through shared tokens embedded in the values themselves, with no coordination between instances required.
Two keys on two shards are two independent writes. One can succeed while the other fails, and the pair is left inconsistent with nothing to undo it.
The detection patterns
cross-shard-consistency.md.txt · Patterns 1–4Pattern 1 — transaction stamp (shared token). Generate one unique token per logical transaction and include it in every related write. A read parses both tokens and compares; equal proves the pair is whole, unequal flags a tear:
token = generate_random_token() # UUID, ULID, or random bytes SET user:123:profile "{token}:{profile_json}" SET user:123:settings "{token}:{settings_json}" # read: parse both tokens, compare; equal == whole, unequal == torn
Pattern 2 — version-stamped values. A bare token catches a tear but cannot order the
sides. A version field both detects and orders: stamp each value {version}:{token}:{payload} and
on a mismatch accept the higher version. A wall-clock timestamp orders cheaply but breaks under clock skew; a
monotonic INCR user:123:version orders by one source and resolves correctly always.
Pattern 3 — commit marker. Write the data first, then one final
SET txn:id:committed 1 EX 3600; a reader accepts the data only when
EXISTS txn:id:committed returns the marker. A crash before the marker leaves the data present but
invisible — a two-phase-commit shape with the marker as the decision record.
Pattern 4 — append-only logs with shared tokens. For event-sourced state,
RPUSH per-key logs stamped with one token, then find the newest token present in both
logs — that token names the latest complete transaction. A sorted-set variant scores by timestamp.
A shared token detects, a version also orders, a commit marker hides the incomplete, an append-only log keeps every stamp — four ways to handle a tear the shards cannot prevent.
Recovery strategies, and when to use
cross-shard-consistency.md.txt · Recovery / WhenDetection is half the policy; the other half is the recovery rule. When a tear is detected, the response depends on what guarantees the values carry:
- Re-read with backoff — the write may still be in progress; retry a few times with increasing delays before declaring a tear.
- Accept newest — if values carry versions, regenerate the older side from the newer.
- Consult the source of truth — re-fetch from the authoritative database, mint a fresh token, and rewrite both values together.
- Serve stale with background repair — return the most recent data immediately and queue an async repair job.
Use transaction stamps when keys must live on different shards and detection plus repair is an acceptable substitute for prevention. Use commit markers when incomplete transactions must be invisible to readers. Use version stamps when you need to determine which write is newest and last-write-wins is acceptable — and all three only when the keys genuinely cannot share a slot.
Compared with prevention
cross-shard-consistency.md.txt · ComparisonDetection has a real cost, and a prevention option usually beats it when the keys can be made to share a slot. The five approaches side by side — the top row wins on every column:
| Approach | Guarantees | Overhead | Complexity |
|---|---|---|---|
| Hash tags (same slot) | Atomic | None | Low |
| Transaction stamps | Detect only | Per-value token | Medium |
| Commit markers | Detect + visibility | Extra key | Medium |
| Version stamps | Detect + ordering | Per-value version | Medium |
| External coordinator | Atomic | Network + latency | High |
When the keys can share a slot, a hash tag co-locates them and a multi-key Lua script stays atomic, so there is no tear to detect. The hero figure above is that choice made concrete — the real EchoMQ keyspace, the braced form versus the flat form, and the slot each lands on.
Notes on ValkeyValkey Cluster hashes only the substring inside the
first non-empty {...}, modulo 16384, so co-located keys share a slot and a multi-key command stays
legal — valkey.io/topics/cluster-spec.
The pattern, applied — one slot, one EVAL
echo_mq/keyspace.ex:13 · jobs.ex:126,283EchoMQ does not run any of these detectors on its own queue keys, because it removes the tear before it can
happen. Every per-queue key is built by EchoMQ.Keyspace.queue_key(q, type), which is literally
"emq:{" <> q <> "}:" <> type — the queue name always lands inside the
braces. So emq:{orders}:pending and emq:{orders}:active (and the row key
emq:{orders}:job:<id>) all hash to one slot. The client computes the slot itself:
Keyspace.slot/1 runs CRC16-XMODEM over Keyspace.hashtag/1 modulo 16384, the cluster
specification's own algorithm, so the connector can route without a server round trip.
Because the keys co-locate, the whole transition is one inline Lua script. EchoMQ.Jobs.claim/3
(jobs.ex:283) calls Connector.eval/5 with two declared keys, and the @claim script
(jobs.ex:126) touches the row key built from ARGV[1] — all three on the {orders}
slot, so the move applies in full or not at all:
@claim Script.new(:claim, """
local popped = redis.call('ZPOPMIN', KEYS[1]) ← emq:{orders}:pending
if #popped == 0 then return {} end
local id = popped[1]
local jk = ARGV[1] .. id ← emq:{orders}:job:<id>
local att = redis.call('HINCRBY', jk, 'attempts', 1)
redis.call('HSET', jk, 'state', 'active')
redis.call('ZADD', KEYS[2], now + tonumber(ARGV[2]), id) ← emq:{orders}:active
return {id, redis.call('HGET', jk, 'payload'), att}
""")
The v2 law that makes this safe is in Connector.eval/5 (echo_wire/connector.ex:63):
parts = ["EVALSHA", s.sha, Integer.to_string(length(keys))] ++ keys ++ argv — every key a
script touches is declared in KEYS; ARGV carries values only. Hand that script a key
on a different slot and the engine raises CROSSSLOT. The braced keyspace is precisely what keeps
it from happening.
One slot, no torn write — the next module, in depth
The colocation mechanic itself — the {tag} hash, CRC16 mod 16384, and the
CROSSSLOT prevention — is the chapter's next module,
R2.05 hash-tag colocation. The full v2 script bundle, the
EVALSHA/NOSCRIPT dispatch, and the version fence ({emq}:version → the wire string in the
figure) are taught in depth in the dedicated EchoMQ course.
def eval(conn, %Script{} = s, keys, argv, timeout \\ 5_000) do
parts = ["EVALSHA", s.sha, Integer.to_string(length(keys))] ++ keys ++ argv
← the key count is the declared key count; ARGV carries values only
The three dives
detect → order → hideEach dive takes one detection pattern in the source's order: the problem and the base token detector, then the version that also orders, then the commit marker that hides an incomplete write — and each closes on the real EchoMQ surface that makes the detector unnecessary on a co-located queue. Read them in order.
Torn writes
The problem and the base detector: a multi-key write off one slot can tear; stamp both values
with one shared token and compare on read. Why EchoMQ cannot tear: one slot, one Lua EVAL.
Version tokens
Detect and order: a monotonic INCR version resolves last-write-wins.
EchoMQ's applied version token is attempts (HINCRBY); a stale writer is caught by
EMQSTALE.
Commit markers
Make an incomplete transaction invisible: write data, then
SET txn:id:committed; read only what EXISTS confirms. EchoMQ needs no marker
— the single Lua EVAL is the serialization.
A cross-shard detection pattern — a shared token, a version stamp, or a commit marker — catches a torn write at the application layer, because the shards offer no atomic multi-key write to prevent it.
EchoMQ prevents the tear instead. Keyspace.queue_key braces the queue name, so a queue's
keys share one slot, and Jobs.claim runs @claim as one Lua EVAL over
those co-located keys — the attempts fence on the row. A cross-slot EVAL raises
CROSSSLOT.
Detection is the substitute for one-slot atomicity; EchoMQ keeps the atomicity by colocation, so it never has to detect a tear — the detectors are for keys that genuinely cannot share a slot.
After R2.04, the Coordination chapter continues with hash-tag colocation — the prevention mechanic these detectors substitute for — closing with a workshop that makes a codemojex guess submission atomic across runtimes. Return to the Coordination chapter to continue.
References
sources · relatedSources
- Redis — Cluster specification — hash tags, the 16384 slots, and
CROSSSLOTfor multi-key commands across slots. - Valkey — Cluster specification — the engine's own slot model: hash only the substring inside
{...}, CRC16 mod 16384. - Redis — SET — the stamped writes and the commit marker (
SET … EX). - Redis — INCR — the monotonic version counter behind Pattern 2.
- Redis — EXISTS — the read-visibility gate behind the commit marker.
Related in this course
- R2.04.1 · Torn writesthe base detector
- R2.04.2 · Version tokensdetect and order
- R2.04.3 · Commit markershide the incomplete
- R2 · Coordinationthe chapter, home of R2.05
- /echomq/protocolthe EchoMQ protocol in depth
- /elixir · CQRSthe single-writer engine