jonnify·redis /redis-patterns/coordination/atomic-updates/shadow-key-bulk

R2.01.3 · Atomic updates — dive 3

Swap whole, or admit whole.

Build the new value in a temporary key, then one atomic RENAME. A read returns the old complete value or the new complete value — never a half-built one. The batch sibling admits many items in one wire flush.

Grounding: echo_mq/lib/echo_mq/jobs.ex:100 · enqueue_many/3 — SCRIPT LOAD once, then pipelined EVALSHA per item via EchoMQ.Connector.pipeline/3. Committed figure: bcsA.md.

A concurrent read — in-place vs shadow + RENAME. Select a mode to light its path and read whether the poll catches a partial value.
writer3 field writes live key3 of 3 fields tmp:keystaging readerpolls live build in tmp, then RENAME — read is complete HSET on the live key — a poll can catch 2 of 3
readout · Select a mode. Shadow + RENAME keeps the live key complete; an in-place update can leave a poll a two-of-three partial.

Static verdicts — shadow + RENAME → build in tmp, swap whole, the read returns old-complete or new-complete, partial: no · in place → HSET field by field, a poll after the second write returns 2 of 3, partial: yes.

§1

The partial-update window

atomic-updates.md.txt · Pattern 3

A value built field by field is readable between the writes. Update three fields in place — HSET … field1, HSET … field2, HSET … field3 — and a read landing after the second write returns two new fields and one old one. That two-of-three state is never a value the application meant to publish; it is an artifact of the write being three commands instead of one. The more fields, the wider the window.

The shadow key closes it. Build the whole new value in a temporary key, off to the side, which no reader of the live key reads; then make the temporary key the live key in a single atomic step. A read either runs before the swap and returns the old complete value, or after it and returns the new complete value. There is no instant at which the live key holds a mix of the two.

tmp:key
The shadow key. The new value is assembled here; no reader of the live key reads it.
the window
The span between the first and last in-place write, where a read returns a partial value.
RENAME tmp final
The atomic swap. The temporary key becomes the live key in one indivisible step.
complete value
Old-complete before the swap, new-complete after it; the live key never holds a mix.

The single-key conditional alternative — a Lua script that runs the read-branch-write with nothing interleaved — is the sibling dive, R2.01.2 · Lua for logic.

§2

Build aside, then RENAME

atomic-updates.md.txt · Shadow Key

The move is two phases: assemble in tmp:key, then RENAME tmp:key final:key. The build writes touch the temporary key, which no reader of the live key reads, so the in-progress state is never observable. The swap is the only command that touches the live key, and it is indivisible.

# Phase 1 — build the new value in a temporary key, as many writes as it takes
DEL tmp:user:123:cache
HSET tmp:user:123:cache field1 value1
HSET tmp:user:123:cache field2 value2
HSET tmp:user:123:cache field3 value3

# Phase 2 — atomic swap: the temporary key becomes the live key
RENAME tmp:user:123:cache user:123:cache

A read of user:123:cache returns the old complete value or the new complete value, never a partial one. RENAME carries the source key’s TTL to the destination; EXPIRE after the rename sets a fresh one, or COPY … REPLACE (Redis 6.2+) keeps the staging key. Both keys of a RENAME or COPY must live in the same cluster slot — a cross-slot rename is rejected. Hash tags co-locate them, the same colocation requirement multi-key WATCH carries.

Notes on ValkeyRENAME is atomic and carries the source TTL; both keys must hash to one slot, so a cross-slot rename is rejected — valkey.io/commands/rename.

Build the value where no reader of the live key reads, then flip it in with one RENAME: the live key goes from old-complete to new-complete with no partial state between.

§3

Bulk admission: SCRIPT LOAD once, then pipeline EVALSHA

echo_mq/jobs.ex:100 · bcsA.md

The whole-value swap has a batch sibling: apply many independent writes in one wire flush. The classic Redis form is MULTI/EXEC — queue every command, then run the queue with nothing interleaved. EchoMQ takes a sharper form for bulk admission: one SCRIPT LOAD to prime the script cache, then a pipelined EVALSHA per item, so each admission is the same single atomic @enqueue script and the whole list rides one wire flush.

EchoMQ.Jobs.enqueue_many/3 (jobs.ex:100) loads the @enqueue source once, builds one EVALSHA command per {id, payload} pair, and runs the list through EchoMQ.Connector.pipeline/3. The per-item verdicts return in input order:

# enqueue_many/3 (echo_mq/lib/echo_mq/jobs.ex:100) — SCRIPT LOAD once, then pipeline EVALSHA per item.
def enqueue_many(conn, queue, pairs) when is_list(pairs) do
  {:ok, _} = Connector.command(conn, ["SCRIPT", "LOAD", @enqueue.source])

  cmds =
    for {id, payload} <- pairs do
      ["EVALSHA", @enqueue.sha, "2",
       Keyspace.job_key(queue, id), Keyspace.queue_key(queue, "pending"),
       id, payload]
    end

  with {:ok, results} <- Connector.pipeline(conn, cmds) do
    {:ok, Enum.map(results, fn
      1 -> :enqueued
      0 -> :duplicate
      {:error_reply, "EMQKIND" <> _} -> {:error, :kind}
    end)}
  end
end

Each item is the same atomic @enqueue script — kind check, EXISTS duplicate refusal, HSET the row, ZADD pending — so the per-item verdicts (:enqueued, :duplicate, {:error, :kind}) line up with the input. Every key shares the queue’s {q} hashtag, so every pipelined EVALSHA is single-slot legal.

Admit a batch in one wire flush — SCRIPT LOAD once, then EVALSHA per item. Select an outcome and set the batch size; the readout names the verdicts.
5
readout · Select an outcome. All fresh ids return :enqueued; a repeated id returns :duplicate in its input position.

Static verdicts — all fresh ids → one SCRIPT LOAD, then N pipelined EVALSHA, all :enqueued · one id already enqueued → that position returns :duplicate, the row untouched; the rest :enqueued; verdicts in input order.

content/bcsA.md · the script path, against Valkey on :6390frozen
script path                   script_loads=1   ← one SCRIPT LOAD, then EVALSHA from cache
pipelined EVALSHA             161192 ops/s   ← the declared-keys law at speed
slot law                      emq:{orders}:* → slot 105 == 105

Swap one whole value with RENAME, or admit a whole batch with SCRIPT LOAD + pipelined EVALSHA — a reader never returns part of a value, a consumer never returns part of a batch.

§4

The pattern, and its application

echo_mq/jobs.ex
the pattern

Commit the whole value, or the whole set of writes, in one step. RENAME swaps one value atomically; the script cache pipelines a batch in one wire flush. Both refuse the partial state.

its EchoMQ application

EchoMQ.Jobs.enqueue_many/3 does SCRIPT LOAD once then pipelines EVALSHA per item (echo_mq/lib/echo_mq/jobs.ex) — each item the same atomic @enqueue script, per-item verdicts in input order.

Swap one whole value, or admit a whole batch; both refuse the partial state, and EchoMQ’s bulk admission proves it.

the door · the EchoMQ course

The full pipeline model is the next course

The full pipeline model — the SCRIPT LOAD preflight, the EVALSHA/NOSCRIPT dispatch, the per-item verdict mapping, and the echomq:3.0.0 version fence — is the dedicated EchoMQ course. This dive cites one bulk admission path as proof the pattern is real; the depth is the next course. The functional-Elixir & OTP craft behind the echo umbrella is the /elixir course.

§5

Recap — swap whole, or admit whole

back → R2.01

A value written field by field is readable mid-update; the shadow key removes the window by assembling the new value in tmp:key and swapping it in with one atomic RENAME — a read returns old-complete or new-complete, never partial. RENAME carries the source TTL. At batch scale, EchoMQ admits many jobs in one wire flush: enqueue_many/3 does SCRIPT LOAD once then pipelines EVALSHA per item, each the same atomic @enqueue script, verdicts in input order. The hub gathers the three tools and the primitives that round them out.

§6

References

sources · related

Sources

  • Valkey — RENAME — atomically rename a key to a destination, carrying the source TTL; the whole-value swap.
  • Redis — Transactions — MULTI/EXEC queue a batch and run it all-or-none with nothing interleaved.
  • Valkey — EVALSHA — run a cached script by its SHA1; the pipelined call the bulk admission flushes per item.
  • Valkey — SET — write a value with options; the per-field write the shadow build replaces with one atomic swap.

Related in this course