EVAL + Lua #
EVAL script numkeys key1 ... keyN arg1 ... argM runs script server-side as one atomic operation. the script sees:
KEYS[1..N]— the named keys (1-indexed)ARGV[1..M]— the named argsredis.call(cmd, ...)— invokes another Redis command from inside the script; raises on errorredis.pcall(cmd, ...)— same but returns{err = "..."}instead of raising
a script either runs to completion or errors; either way, no other client sees any intermediate state.
why it's lua 5.1 forever #
redis embeds Lua 5.1 and uses the included sandbox restrictions. 5.1 has been frozen for ~17 years. that's a feature, not a bug — every redis 5.1 script you've ever written keeps working unchanged forever. it also means:
unpack(table)is global (in 5.2+ it moved totable.unpack)#tblreturns array length but has nil-hole edge cases- no
goto, no integer type (everything's a double until 5.3) pcall/xpcallexist;error()works the obvious way
if you're writing a redis-compatible engine, embed Lua 5.1 specifically. mlua (rust) and ziglua (zig) both let you pick the version explicitly — lang = .lua51 in build.zig.zon.
script return values #
redis maps Lua return values back to RESP this way:
| lua type | RESP reply |
|---|---|
nil |
nil |
true |
integer 1 |
false |
nil (!) |
| number | integer (truncated) |
| string | bulk string |
{ok = "OK"} |
simple string "+OK" |
{err = "msg"} |
error "-msg" |
| table (sequential) | array (recursive) |
{ok = ...} and {err = ...} are the only way for a script to return a status or error reply. otherwise you can only return strings, integers, arrays, or nil.
composability via redis.call #
redis.call lets the script call any other command. typical pattern: a one-shot "do these N things atomically":
-- schedule.lua
if is_immediate == '1' then
redis.call('XADD', stream_key, '*', unpack(fields))
else
redis.call('HSET', parked_key, unpack(fields))
redis.call('ZADD', queue_key, when, task_key)
end
redis.call('HSET', runs_key, 'state', 'parked', 'task_key', task_key)
return 'SCHEDULED'
inside EVAL, redis is still single-threaded. no other client can observe state between any two of those redis.calls. that's the entire reason EVAL exists.
error semantics #
redis.call('NOPE')— raises a Lua error → script terminates → redis returns error reply.redis.pcall('NOPE')— returns{err = "..."}→ script continues, can decide what to do.
real redis does not roll back side effects on error. if your script did XADD and then errored, the XADD stays. matters because anyone parked on that stream (XREADGROUP BLOCK) needs to be woken up regardless of whether the script succeeded — pydocket/burner-redis track this as the had_xadd / had_list_mutation flags on EvalOutcome.
SCRIPT LOAD + EVALSHA #
SCRIPT LOAD <body> returns a SHA1; EVALSHA <sha> ... re-runs it. the redis instance caches scripts in memory and returns NOSCRIPT if asked for one it doesn't know — clients are expected to handle NOSCRIPT by re-LOADing. EVALSHA saves only bandwidth: for a handful of small scripts, embedding them at build time and sending the body with EVAL every call is fine, and there is no NOSCRIPT path to handle.
embedded engines #
burner-redis (rust, the original) and burner-redis-zig (the port) both embed a real Lua 5.1 interpreter and expose redis.call as a C function backed by the in-process store. EVAL scripts run unchanged against either backend — that's what makes "swap redis for an in-process engine in tests" work cleanly.
caveats:
- atomicity is not free. a mutex around the store makes each command atomic, and a script is many commands: if every
redis.calltakes and releases the lock on its own, another thread's command lands between two of them. a reader sawINCR; INCRhalf done in about one read in five, and a task queue lost work when a task was added between a script's range read and its range delete. hold the lock across the whole script, make it re-entrant for the thread that holds it, and build the VM before taking it. single-threaded tests cannot see the difference either way. - script-level blocking (XREADGROUP BLOCK inside a script) isn't a thing in real redis either — BLOCK arguments are silently ignored when inside an EVAL.
sources #
- burner-redis (zig port) —
src/scripting.zig(embedded Lua 5.1 engine) - docket —
src/scripts/(the three atomic scripts) - burner-redis (rust, upstream)