jonnify·redis /redis-patterns/coordination/cross-shard-consistency

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.

Prevent or detect — a queue's three keys, with the {q} hashtag and without. Select the with/without control, then hover a key to read its slot.
emq:{orders}:pendingslot — emq:{orders}:activeslot — emq:{orders}:job:<id>slot — @claim → one Lua EVAL, atomic Valkey hashes only the {tag} substring → all 3 keys share one slot
readout · With the {orders} hashtag, all three keys hash to one slot, so @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.

§1

The problem — torn writes

cross-shard-consistency.md.txt · The Problem

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

§2

The detection patterns

cross-shard-consistency.md.txt · Patterns 1–4

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

§3

Recovery strategies, and when to use

cross-shard-consistency.md.txt · Recovery / When

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

§4

Compared with prevention

cross-shard-consistency.md.txt · Comparison

Detection 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:

ApproachGuaranteesOverheadComplexity
Hash tags (same slot)AtomicNoneLow
Transaction stampsDetect onlyPer-value tokenMedium
Commit markersDetect + visibilityExtra keyMedium
Version stampsDetect + orderingPer-value versionMedium
External coordinatorAtomicNetwork + latencyHigh

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.

§5

The pattern, applied — one slot, one EVAL

echo_mq/keyspace.ex:13 · jobs.ex:126,283

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

echo/apps/echo_mq/lib/echo_mq/jobs.ex:126 · the @claim script, verbatimfrozen
@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.

the protocol · the colocation that removes the tear

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.

echo_wire/connector.ex:63 · eval declares every key it touchesfrozen
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
§6

The three dives

detect → order → hide

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

the pattern

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.

its EchoMQ application

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 an Exchange Platform order placement atomic across runtimes. Return to the Coordination chapter to continue.

§7

References

sources · related

Sources

Related in this course