R3.04.3 · Atomic state machine — dive 3
EVALSHA and NOSCRIPT.
The transition script runs by its SHA, not its body. The connector calls
EVALSHA; a flushed cache returns NOSCRIPT, and the connector runs
SCRIPT LOAD once and re-runs the EVALSHA.
Grounding: echo_wire/script.ex (EchoMQ.Script.new/2 precomputes the SHA1) · echo_wire/connector.ex (EchoMQ.Connector.eval/5: EVALSHA-first, load once on NOSCRIPT) — one load per script per connection. Engine: Valkey.
NOSCRIPT, the
connector loads the body once and re-runs the EVALSHA.Static verdicts — hit → one EVALSHA round trip, the SHA on
the wire · NOSCRIPT → the body loaded once (the returned SHA must equal the precomputed one),
then the SHA again — once per script per connection.
Load once, call by SHA
script.ex · Script.new/2A Lua script can be sent on every call with EVAL script numkeys …, but that puts the
whole body on the wire each time. Valkey offers a cheaper path.
EchoMQ.Script.new(name, lua) precomputes the SHA-1 of the body when the script is
declared:
# EchoMQ.Script.new/2 (script.ex) — precompute the SHA so the connector can run EVALSHA-first
def new(name, source) when is_atom(name) and is_binary(source) do
sha = :crypto.hash(:sha, source) |> Base.encode16(case: :lower)
%__MODULE__{name: name, source: source, sha: sha}
end
So the connector already holds the SHA before it ever talks to the server. It calls
EVALSHA sha numkeys … — the forty-character digest stands in for the body, and
Valkey runs the cached script. The transition is identical; only the bytes on the wire change.
The body is sent once; thereafter the transition rides a forty-character SHA, not the script.
The NOSCRIPT fallback
connector.ex · eval/5The server's script cache is not permanent. A restart clears it; SCRIPT FLUSH clears
it. After that, an EVALSHA for a SHA the server no longer holds returns a
NOSCRIPT error — the SHA is unknown, the body is needed.
EchoMQ.Connector.eval/5 handles this without giving up the transition: on the
NOSCRIPT reply it runs SCRIPT LOAD with the body once and re-runs the same
EVALSHA:
# EchoMQ.Connector.eval/5 (connector.ex), trimmed — EVALSHA-first, load once on NOSCRIPT parts = ["EVALSHA", s.sha, Integer.to_string(length(keys))] ++ keys ++ argv case command(conn, parts, timeout) do {:ok, {:error_reply, "NOSCRIPT" <> _}} -> case command(conn, ["SCRIPT", "LOAD", s.source], timeout) do {:ok, sha} when sha == s.sha -> command(conn, parts, timeout) # re-run the same EVALSHA {:ok, other} -> {:error, {:sha_mismatch, other}} end ... end
The load returns a SHA that must equal the precomputed s.sha — a mismatch is
refused as :sha_mismatch, proof the loaded body is the one the connector expected. The
transition succeeds either way; the only cost of a flushed cache is one load-and-retry round trip,
taken once per script per connection.
Notes on ValkeyThe engine returns the NOSCRIPT
error when an EVALSHA names a SHA its cache no longer holds; SCRIPT LOAD caches
the body and returns its SHA —
valkey.io/commands/evalsha.
The wire cost
EVAL every time vs EVALSHA after one loadThe contrast is concrete. EVAL every time sends the whole body on every call: N
transitions cost N script bodies on the wire. EVALSHA after one load sends the body once
and a SHA thereafter: N transitions cost one body plus N short SHAs. For the lifecycle scripts under a
busy worker pool, that is the difference between re-sending the protocol on every move and sending a
digest. The fallback re-sends the body only when the cache is actually gone — once per flush, not
once per call.
Set a transition count and a script-body size below. The readout computes the bytes on the wire for
EVAL every time against EVALSHA after one SCRIPT LOAD.
Static verdict — EVAL sends the body N times; EVALSHA
sends it once (the SCRIPT LOAD) and a forty-byte SHA the other N times.
The connector loads, then runs by SHA
The connector's moduledoc names the discipline — EVALSHA-first, one load on NOSCRIPT:
replies route in order off an active: :once socket; EVALSHA-first script execution with one load-on-NOSCRIPT per script per connection
Run the read-modify-write as one server-side script, and reach it cheaply by its SHA — loading the body only when the server's cache no longer holds it.
EchoMQ.Script.new/2 precomputes the SHA and EchoMQ.Connector.eval/5
calls EVALSHA first, falling back to SCRIPT LOAD + a re-run
EVALSHA on NOSCRIPT — cheap on the wire, resilient to a flushed
cache.
The cached script reaches the server by SHA, and the NOSCRIPT path reloads it
once when the cache is gone — the transition is both cheap and resilient.
Recap — the SHA and the fallback
back → R3.04EchoMQ.Script.new/2 precomputes the SHA; EchoMQ.Connector.eval/5 runs the
transition with EVALSHA, and on a flushed cache it runs SCRIPT LOAD once and
re-runs the call. The body crosses the wire once per script per connection; every later transition
rides the digest. That closes the module: a job's states are emq:{q}: locations, each
transition is one EVALSHA, and the cached script reaches the server by SHA.
References
sources · relatedSources
- Redis — EVALSHA — run a cached script by its SHA; the
NOSCRIPTerror when the SHA is unknown. - Redis — SCRIPT LOAD — cache the body once and get back the SHA the client calls by.
- Redis — EVAL / scripting — the full-body call that backs the load step.
- Valkey — EVALSHA — the cached-script call and the
NOSCRIPTreply on the engine the connector is gated against.
Related in this course
- R3.04 · Atomic state machinethe module hub
- R3.04.2 · Read-decide-write in one EVALSHAthe transition this dive runs by SHA
- R3.04.1 · States as locationsthe locations the transition travels between
- R3.03 · Stalled recoverythe reaper whose script also runs by SHA
- R2.01 · Atomic updatesthe one-script read-modify-write pattern
- /echomq/queuethe dedicated EchoMQ course: the script dispatch and SHA cache