jonnify·redis /redis-patterns/queues/atomic-state-machine/evalsha-and-noscript

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.

The cached-script call — hit, or NOSCRIPT then load. Select a path to light it and read the round trips.
connectorholds the SHA Valkeyscript cache EVALSHA <sha> result NOSCRIPT SCRIPT LOAD body → EVALSHA again
readout · Select a path. A warm cache runs the SHA in one round trip; a flushed cache returns 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.

§1

Load once, call by SHA

script.ex · Script.new/2

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

§2

The NOSCRIPT fallback

connector.ex · eval/5

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

§3

The wire cost

EVAL every time vs EVALSHA after one load

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

Bytes on the wire — EVAL every time vs EVALSHA after one load. The SHA is forty bytes; pick the counts.
1000
800

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 application · one load per script per connection

The connector loads, then runs by SHA

The connector's moduledoc names the discipline — EVALSHA-first, one load on NOSCRIPT:

echo/apps/echo_wire/lib/echo_mq/connector.ex · the script disciplinefrozen
replies route in order off an active: :once socket; EVALSHA-first script
execution with one load-on-NOSCRIPT per script per connection
the pattern

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.

its EchoMQ application

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.

§4

Recap — the SHA and the fallback

back → R3.04

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

§5

References

sources · related

Sources

Related in this course