Something went wrong. Try again.
in-memory redis
redis in-memory testing
Something went wrong. Try again.
48 kB · 1224 lines
Zig
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225//! Lua scripting engine — translation of upstream src/scripting.rs.//!//! Owns a thin wrapper around ziglua (Lua 5.1, the same version Redis uses)//! and exposes the `redis.call` / `redis.pcall` API that real Redis EVAL//! scripts rely on. Inside the engine we dispatch on uppercase command//! names and call into `Store` methods.//!//! Like upstream, every `eval` call spins up a fresh Lua VM — no script//! state leaks across invocations. The Store is shared (locked at the//! per-method level) so concurrent EVAL on different VMs is safe.//!//! Command coverage: the docket-critical subset (DEL, EXISTS, HSET, HGET,//! HGETALL, HDEL, HEXISTS, ZADD, ZRANGEBYSCORE, ZREM, ZCARD, ZSCORE, XADD,//! XLEN, XDEL, XGROUP CREATE, XREADGROUP, XACK). Remaining commands land//! in follow-on translations.
const std = @import("std");const Allocator = std.mem.Allocator;const zlua = @import("zlua");const Lua = zlua.Lua;
const store_mod = @import("store.zig");const Store = store_mod.Store;const StoreError = store_mod.StoreError;const StreamId = store_mod.StreamId;const ScoredMember = store_mod.ScoredMember;
// ----------------------------------------------------------------------------// RedisValue — return type from `redis.call` / `eval`// ----------------------------------------------------------------------------
pub const RedisValue = union(enum) { bulk_string: []u8, integer: i64, array: []RedisValue, nil, err: []u8, status: []u8,
pub fn deinit(self: RedisValue, alloc: Allocator) void { switch (self) { .bulk_string, .err, .status => |s| alloc.free(s), .array => |items| { for (items) |it| it.deinit(alloc); alloc.free(items); }, .integer, .nil => {}, } }};
// ----------------------------------------------------------------------------// LuaEngine// ----------------------------------------------------------------------------
pub const LuaEngine = struct { allocator: Allocator, store: *Store,
pub fn init(allocator: Allocator, store: *Store) LuaEngine { return .{ .allocator = allocator, .store = store }; }
pub fn deinit(_: *LuaEngine) void {}
/// Compute SHA1 hex digest of a script body. Matches upstream /// `LuaEngine::sha1_hex`. Returns the lowercased hex into `out_buf` /// (must be at least 40 bytes); returns a slice of the prefix used. pub fn sha1Hex(out_buf: *[40]u8, script: []const u8) []u8 { var hasher = std.crypto.hash.Sha1.init(.{}); hasher.update(script); var raw: [20]u8 = undefined; hasher.final(&raw);
const hex = "0123456789abcdef"; for (raw, 0..) |b, i| { out_buf[i * 2] = hex[b >> 4]; out_buf[i * 2 + 1] = hex[b & 0x0f]; } return out_buf[0..40]; }
/// EVAL a script with KEYS + ARGV. Returns a heap-allocated RedisValue; /// caller frees with `value.deinit(out_alloc)`. Errors raised inside Lua /// turn into `RedisValue.err`. pub fn eval( self: *LuaEngine, out_alloc: Allocator, script: []const u8, keys: []const []const u8, args: []const []const u8, ) !RedisValue { const lua = try Lua.init(self.allocator); defer lua.deinit();
// Open just the libs Redis scripts can rely on. lua.openBase(); lua.openString(); lua.openTable(); lua.openMath();
// Globals: KEYS, ARGV. try pushStringSliceTable(lua, keys); lua.setGlobal("KEYS"); try pushStringSliceTable(lua, args); lua.setGlobal("ARGV");
// Per-call DispatchCtx — outlives the Lua VM, captured by the // closure as a lightuserdata upvalue. var ctx: DispatchCtx = .{ .engine = self, .scratch = out_alloc };
lua.newTable(); lua.pushLightUserdata(&ctx); lua.pushClosure(luaCall, 1); lua.setField(-2, "call"); lua.pushLightUserdata(&ctx); lua.pushClosure(luaPcall, 1); lua.setField(-2, "pcall"); lua.setGlobal("redis");
// ziglua's loadString demands `[:0]const u8` — stash a NUL-terminated copy. const z = try self.allocator.allocSentinel(u8, script.len, 0); defer self.allocator.free(z); @memcpy(z, script);
lua.loadString(z) catch { const msg = lua.toString(-1) catch "load failed"; const dup = try out_alloc.dupe(u8, msg); return .{ .err = dup }; }; // Held for the whole script: no other thread's command lands between // two of its calls. Taken only now, with the VM built and the script // compiled, to keep it short. self.store.mutex.lock(); defer self.store.mutex.unlock(); lua.protectedCall(.{ .results = 1 }) catch { const msg = lua.toString(-1) catch "script error"; const dup = try out_alloc.dupe(u8, msg); return .{ .err = dup }; };
return luaToRedis(out_alloc, lua, -1); }};
// ----------------------------------------------------------------------------// Dispatch context + Lua callbacks// ----------------------------------------------------------------------------
const DispatchCtx = struct { engine: *LuaEngine, scratch: Allocator,};
fn pushStringSliceTable(lua: *Lua, items: []const []const u8) anyerror!void { lua.newTable(); for (items, 0..) |s, i| { _ = lua.pushString(s); lua.setIndexRaw(-2, @intCast(i + 1)); }}
fn readArgs(alloc: Allocator, lua: *Lua) ![]const []const u8 { const top: i32 = lua.getTop(); if (top <= 0) return &.{};
var out = try alloc.alloc([]const u8, @intCast(top)); var i: i32 = 1; var n: usize = 0; while (i <= top) : (i += 1) { const s = lua.toString(i) catch { if (lua.isNumber(i)) { const num = lua.toNumber(i) catch 0; var buf: [64]u8 = undefined; const txt = std.fmt.bufPrint(&buf, "{d}", .{num}) catch "0"; const dup = try alloc.dupe(u8, txt); out[n] = dup; n += 1; continue; } out[n] = ""; n += 1; continue; }; out[n] = s; n += 1; } return out[0..n];}
/// `redis.call`: raises a Lua error on failure.fn luaCall(state: ?*zlua.LuaState) callconv(.c) c_int { const lua: *Lua = @ptrCast(state.?); const ctx = lua.toUserdata(DispatchCtx, Lua.upvalueIndex(1)) catch { lua.raiseErrorStr("redis.call: internal context missing", .{}); }; return dispatchCall(ctx, lua, false);}
/// `redis.pcall`: on failure pushes `{err="..."}` instead of raising.fn luaPcall(state: ?*zlua.LuaState) callconv(.c) c_int { const lua: *Lua = @ptrCast(state.?); const ctx = lua.toUserdata(DispatchCtx, Lua.upvalueIndex(1)) catch { lua.newTable(); _ = lua.pushString("redis.pcall: internal context missing"); lua.setField(-2, "err"); return 1; }; return dispatchCall(ctx, lua, true);}
fn dispatchCall(ctx: *DispatchCtx, lua: *Lua, pcall: bool) c_int { var arena = std.heap.ArenaAllocator.init(ctx.scratch); defer arena.deinit(); const a = arena.allocator();
const args = readArgs(a, lua) catch { return errorReply(lua, pcall, "ERR redis.call: read args failed"); }; if (args.len == 0) return errorReply(lua, pcall, "ERR wrong number of arguments for 'redis.call'");
const cmd_in = args[0]; var ucmd_buf: [32]u8 = undefined; if (cmd_in.len > ucmd_buf.len) return errorReply(lua, pcall, "ERR unknown command"); var i: usize = 0; while (i < cmd_in.len) : (i += 1) { ucmd_buf[i] = std.ascii.toUpper(cmd_in[i]); } const cmd = ucmd_buf[0..cmd_in.len];
return dispatchInner(ctx, lua, pcall, cmd, args[1..]) catch |err| { return errorReply(lua, pcall, @errorName(err)); };}
fn errorReply(lua: *Lua, pcall: bool, msg: []const u8) c_int { if (pcall) { lua.newTable(); _ = lua.pushString(msg); lua.setField(-2, "err"); return 1; } else { _ = lua.pushString(msg); lua.raiseError(); }}
fn redisErrorMsg(err: StoreError) []const u8 { return switch (err) { StoreError.WrongType => "WRONGTYPE Operation against a key holding the wrong kind of value", StoreError.NoGroup => "NOGROUP No such key or consumer group", StoreError.BusyGroup => "BUSYGROUP Consumer Group name already exists", StoreError.KeyNotFound => "ERR The XGROUP subcommand requires the key to exist", StoreError.IntegerOverflow => "ERR increment or decrement would overflow", StoreError.Syntax => "ERR syntax error", StoreError.NoSuchKey => "ERR no such key", StoreError.OutOfMemory => "ERR out of memory", };}
// ----------------------------------------------------------------------------// Per-command dispatch// ----------------------------------------------------------------------------
fn dispatchInner( ctx: *DispatchCtx, lua: *Lua, pcall: bool, cmd: []const u8, args: []const []const u8,) !c_int { const store = ctx.engine.store; const scratch = ctx.scratch;
if (std.mem.eql(u8, cmd, "DEL")) { if (args.len == 0) return errorReply(lua, pcall, "ERR wrong number of arguments"); lua.pushInteger(store.delete(args)); return 1; }
// --- Strings ----------------------------------------------------------- if (std.mem.eql(u8, cmd, "SET")) { if (args.len < 2) return errorReply(lua, pcall, "ERR wrong number of arguments for 'set'"); var flags: Store.SetFlags = .{}; var i: usize = 2; while (i < args.len) { if (std.ascii.eqlIgnoreCase(args[i], "NX")) { flags.nx = true; i += 1; } else if (std.ascii.eqlIgnoreCase(args[i], "XX")) { flags.xx = true; i += 1; } else if (std.ascii.eqlIgnoreCase(args[i], "EX")) { if (i + 1 >= args.len) return errorReply(lua, pcall, "ERR syntax error"); const sec = std.fmt.parseInt(i64, args[i + 1], 10) catch return errorReply(lua, pcall, "ERR value is not an integer"); flags.ttl_ns = @as(i128, sec) * std.time.ns_per_s; i += 2; } else if (std.ascii.eqlIgnoreCase(args[i], "PX")) { if (i + 1 >= args.len) return errorReply(lua, pcall, "ERR syntax error"); const ms = std.fmt.parseInt(i64, args[i + 1], 10) catch return errorReply(lua, pcall, "ERR value is not an integer"); flags.ttl_ns = @as(i128, ms) * std.time.ns_per_ms; i += 2; } else break; } const ok = store.set(args[0], args[1], flags) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); if (ok) { lua.newTable(); _ = lua.pushString("OK"); lua.setField(-2, "ok"); } else { lua.pushBoolean(false); } return 1; }
if (std.mem.eql(u8, cmd, "GET")) { if (args.len != 1) return errorReply(lua, pcall, "ERR wrong number of arguments for 'get'"); const v = store.get(scratch, args[0]) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); if (v) |b| { _ = lua.pushString(b); scratch.free(b); } else lua.pushBoolean(false); return 1; }
if (std.mem.eql(u8, cmd, "MGET")) { if (args.len == 0) return errorReply(lua, pcall, "ERR wrong number of arguments for 'mget'"); const res = store.mget(scratch, args) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); defer Store.freeMGetResult(scratch, res); lua.newTable(); for (res, 0..) |opt, i| { if (opt) |b| _ = lua.pushString(b) else lua.pushBoolean(false); lua.setIndexRaw(-2, @intCast(i + 1)); } return 1; }
if (std.mem.eql(u8, cmd, "KEYS")) { if (args.len != 1) return errorReply(lua, pcall, "ERR wrong number of arguments for 'keys'"); const out = store.keysGlob(scratch, args[0]) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); defer Store.freeKeys(scratch, out); lua.newTable(); for (out, 0..) |k, i| { _ = lua.pushString(k); lua.setIndexRaw(-2, @intCast(i + 1)); } return 1; }
if (std.mem.eql(u8, cmd, "TTL")) { if (args.len != 1) return errorReply(lua, pcall, "ERR wrong number of arguments for 'ttl'"); lua.pushInteger(store.ttl(args[0])); return 1; }
if (std.mem.eql(u8, cmd, "EXPIRE")) { if (args.len != 2) return errorReply(lua, pcall, "ERR wrong number of arguments for 'expire'"); const sec = std.fmt.parseInt(u64, args[1], 10) catch return errorReply(lua, pcall, "ERR value is not an integer"); lua.pushInteger(if (store.expire(args[0], sec)) @as(i64, 1) else 0); return 1; }
if (std.mem.eql(u8, cmd, "INCR") or std.mem.eql(u8, cmd, "INCRBY") or std.mem.eql(u8, cmd, "DECR") or std.mem.eql(u8, cmd, "DECRBY")) { const is_decr = std.ascii.startsWithIgnoreCase(cmd, "DECR"); const has_arg = std.mem.eql(u8, cmd, "INCRBY") or std.mem.eql(u8, cmd, "DECRBY"); const arg_count: usize = if (has_arg) 2 else 1; if (args.len != arg_count) return errorReply(lua, pcall, "ERR wrong number of arguments"); const delta: i64 = if (has_arg) (std.fmt.parseInt(i64, args[1], 10) catch return errorReply(lua, pcall, "ERR value is not an integer")) else 1; const n = (if (is_decr) store.decrby(args[0], delta) else store.incrby(args[0], delta)) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); lua.pushInteger(n); return 1; }
// --- Sets -------------------------------------------------------------- if (std.mem.eql(u8, cmd, "SADD")) { if (args.len < 2) return errorReply(lua, pcall, "ERR wrong number of arguments for 'sadd'"); const n = store.sadd(args[0], args[1..]) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); lua.pushInteger(n); return 1; }
if (std.mem.eql(u8, cmd, "SMEMBERS")) { if (args.len != 1) return errorReply(lua, pcall, "ERR wrong number of arguments for 'smembers'"); const out = store.smembers(scratch, args[0]) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); defer Store.freeKeys(scratch, out); lua.newTable(); for (out, 0..) |m, i| { _ = lua.pushString(m); lua.setIndexRaw(-2, @intCast(i + 1)); } return 1; }
if (std.mem.eql(u8, cmd, "SISMEMBER")) { if (args.len != 2) return errorReply(lua, pcall, "ERR wrong number of arguments for 'sismember'"); const r = store.sismember(args[0], args[1]) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); lua.pushInteger(r); return 1; }
if (std.mem.eql(u8, cmd, "SREM")) { if (args.len < 2) return errorReply(lua, pcall, "ERR wrong number of arguments for 'srem'"); const n = store.srem(args[0], args[1..]) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); lua.pushInteger(n); return 1; }
if (std.mem.eql(u8, cmd, "SCARD")) { if (args.len != 1) return errorReply(lua, pcall, "ERR wrong number of arguments for 'scard'"); const n = store.scard(args[0]) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); lua.pushInteger(n); return 1; }
// --- Lists ------------------------------------------------------------- if (std.mem.eql(u8, cmd, "LPUSH") or std.mem.eql(u8, cmd, "RPUSH")) { if (args.len < 2) return errorReply(lua, pcall, "ERR wrong number of arguments"); const n = if (std.mem.eql(u8, cmd, "LPUSH")) (store.lpush(args[0], args[1..]) catch |err| return errorReply(lua, pcall, redisErrorMsg(err))) else (store.rpush(args[0], args[1..]) catch |err| return errorReply(lua, pcall, redisErrorMsg(err))); lua.pushInteger(n); return 1; }
if (std.mem.eql(u8, cmd, "LLEN")) { if (args.len != 1) return errorReply(lua, pcall, "ERR wrong number of arguments for 'llen'"); const n = store.llen(args[0]) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); lua.pushInteger(n); return 1; }
if (std.mem.eql(u8, cmd, "LINDEX")) { if (args.len != 2) return errorReply(lua, pcall, "ERR wrong number of arguments for 'lindex'"); const idx = std.fmt.parseInt(i64, args[1], 10) catch return errorReply(lua, pcall, "ERR value is not an integer"); const v = store.lindex(scratch, args[0], idx) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); if (v) |b| { _ = lua.pushString(b); scratch.free(b); } else lua.pushBoolean(false); return 1; }
if (std.mem.eql(u8, cmd, "LRANGE")) { if (args.len != 3) return errorReply(lua, pcall, "ERR wrong number of arguments for 'lrange'"); const start = std.fmt.parseInt(i64, args[1], 10) catch return errorReply(lua, pcall, "ERR not integer"); const stop = std.fmt.parseInt(i64, args[2], 10) catch return errorReply(lua, pcall, "ERR not integer"); const out = store.lrange(scratch, args[0], start, stop) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); defer Store.freeKeys(scratch, out); lua.newTable(); for (out, 0..) |v, i| { _ = lua.pushString(v); lua.setIndexRaw(-2, @intCast(i + 1)); } return 1; }
if (std.mem.eql(u8, cmd, "LPOP") or std.mem.eql(u8, cmd, "RPOP")) { if (args.len < 1 or args.len > 2) return errorReply(lua, pcall, "ERR wrong number of arguments"); const count: ?usize = if (args.len == 2) (std.fmt.parseInt(usize, args[1], 10) catch return errorReply(lua, pcall, "ERR not integer")) else null; const result = if (std.mem.eql(u8, cmd, "LPOP")) (store.lpop(scratch, args[0], count) catch |err| return errorReply(lua, pcall, redisErrorMsg(err))) else (store.rpop(scratch, args[0], count) catch |err| return errorReply(lua, pcall, redisErrorMsg(err))); defer Store.freeLPopResult(scratch, result); switch (result) { .nil => lua.pushBoolean(false), .single => |s| _ = lua.pushString(s), .array => |arr| { lua.newTable(); for (arr, 0..) |v, i| { _ = lua.pushString(v); lua.setIndexRaw(-2, @intCast(i + 1)); } }, } return 1; }
if (std.mem.eql(u8, cmd, "ZRANGE")) { if (args.len < 3) return errorReply(lua, pcall, "ERR wrong number of arguments for 'zrange'"); const start = std.fmt.parseInt(i64, args[1], 10) catch return errorReply(lua, pcall, "ERR not integer"); const stop = std.fmt.parseInt(i64, args[2], 10) catch return errorReply(lua, pcall, "ERR not integer"); var with_scores = false; var i: usize = 3; while (i < args.len) : (i += 1) { if (std.ascii.eqlIgnoreCase(args[i], "WITHSCORES")) with_scores = true; } const sms = store.zrange(scratch, args[0], start, stop, with_scores) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); defer Store.freeScoredMembers(scratch, sms); lua.newTable(); var idx: i32 = 1; for (sms) |sm| { _ = lua.pushString(sm.member); lua.setIndexRaw(-2, idx); idx += 1; if (with_scores) { var buf: [64]u8 = undefined; const txt = std.fmt.bufPrint(&buf, "{d}", .{sm.score}) catch "0"; _ = lua.pushString(txt); lua.setIndexRaw(-2, idx); idx += 1; } } return 1; }
if (std.mem.eql(u8, cmd, "ZCOUNT")) { if (args.len != 3) return errorReply(lua, pcall, "ERR wrong number of arguments for 'zcount'"); const min = parseScoreBound(args[1]) catch return errorReply(lua, pcall, "ERR min is not a valid float"); const max = parseScoreBound(args[2]) catch return errorReply(lua, pcall, "ERR max is not a valid float"); const n = store.zcount(args[0], min, max) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); lua.pushInteger(n); return 1; }
if (std.mem.eql(u8, cmd, "EXISTS")) { if (args.len == 0) return errorReply(lua, pcall, "ERR wrong number of arguments"); lua.pushInteger(store.exists(args)); return 1; }
if (std.mem.eql(u8, cmd, "HSET")) { if (args.len < 3 or (args.len - 1) % 2 != 0) return errorReply(lua, pcall, "ERR wrong number of arguments for 'hset'"); const n = store.hset(args[0], args[1..]) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); lua.pushInteger(n); return 1; }
if (std.mem.eql(u8, cmd, "HGET")) { if (args.len != 2) return errorReply(lua, pcall, "ERR wrong number of arguments for 'hget'"); const v = store.hget(scratch, args[0], args[1]) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); if (v) |bytes| { _ = lua.pushString(bytes); scratch.free(bytes); } else { lua.pushBoolean(false); } return 1; }
if (std.mem.eql(u8, cmd, "HGETALL")) { if (args.len != 1) return errorReply(lua, pcall, "ERR wrong number of arguments for 'hgetall'"); const pairs = store.hgetall(scratch, args[0]) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); defer Store.freeHashPairs(scratch, pairs);
lua.newTable(); var idx: i32 = 1; for (pairs) |p| { _ = lua.pushString(p.field); lua.setIndexRaw(-2, idx); idx += 1; _ = lua.pushString(p.value); lua.setIndexRaw(-2, idx); idx += 1; } return 1; }
if (std.mem.eql(u8, cmd, "HDEL")) { if (args.len < 2) return errorReply(lua, pcall, "ERR wrong number of arguments for 'hdel'"); const n = store.hdel(args[0], args[1..]) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); lua.pushInteger(n); return 1; }
if (std.mem.eql(u8, cmd, "HEXISTS")) { if (args.len != 2) return errorReply(lua, pcall, "ERR wrong number of arguments for 'hexists'"); const r = store.hexists(args[0], args[1]) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); lua.pushInteger(r); return 1; }
if (std.mem.eql(u8, cmd, "ZADD")) return try doZadd(ctx, lua, pcall, args); if (std.mem.eql(u8, cmd, "ZRANGEBYSCORE")) return try doZrangebyscore(ctx, lua, pcall, args);
if (std.mem.eql(u8, cmd, "ZREM")) { if (args.len < 2) return errorReply(lua, pcall, "ERR wrong number of arguments for 'zrem'"); const n = store.zrem(args[0], args[1..]) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); lua.pushInteger(n); return 1; }
if (std.mem.eql(u8, cmd, "ZREMRANGEBYSCORE")) { if (args.len != 3) return errorReply(lua, pcall, "ERR wrong number of arguments for 'zremrangebyscore'"); const min = parseScoreBound(args[1]) catch return errorReply(lua, pcall, "ERR min is not a valid float"); const max = parseScoreBound(args[2]) catch return errorReply(lua, pcall, "ERR max is not a valid float"); const n = store.zremrangebyscore(args[0], min, max) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); lua.pushInteger(n); return 1; }
if (std.mem.eql(u8, cmd, "ZCARD")) { if (args.len != 1) return errorReply(lua, pcall, "ERR wrong number of arguments for 'zcard'"); const n = store.zcard(args[0]) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); lua.pushInteger(n); return 1; }
if (std.mem.eql(u8, cmd, "ZSCORE")) { if (args.len != 2) return errorReply(lua, pcall, "ERR wrong number of arguments for 'zscore'"); const sc = store.zscore(args[0], args[1]) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); if (sc) |s| { var buf: [64]u8 = undefined; const txt = std.fmt.bufPrint(&buf, "{d}", .{s}) catch return errorReply(lua, pcall, "ERR format zscore"); _ = lua.pushString(txt); } else { lua.pushBoolean(false); } return 1; }
if (std.mem.eql(u8, cmd, "XADD")) return try doXadd(ctx, lua, pcall, args);
if (std.mem.eql(u8, cmd, "XLEN")) { if (args.len != 1) return errorReply(lua, pcall, "ERR wrong number of arguments for 'xlen'"); const n = store.xlen(args[0]) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); lua.pushInteger(n); return 1; }
if (std.mem.eql(u8, cmd, "XDEL")) { if (args.len < 2) return errorReply(lua, pcall, "ERR wrong number of arguments for 'xdel'"); var ids = try scratch.alloc(StreamId, args.len - 1); defer scratch.free(ids); for (args[1..], 0..) |arg, i| { ids[i] = StreamId.parse(arg) orelse return errorReply(lua, pcall, "ERR invalid stream ID"); } const n = store.xdel(args[0], ids) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); lua.pushInteger(n); return 1; }
if (std.mem.eql(u8, cmd, "XGROUP")) return try doXgroup(ctx, lua, pcall, args); if (std.mem.eql(u8, cmd, "XREADGROUP")) return try doXreadgroup(ctx, lua, pcall, args);
if (std.mem.eql(u8, cmd, "XACK")) { if (args.len < 3) return errorReply(lua, pcall, "ERR wrong number of arguments for 'xack'"); var ids = try scratch.alloc(StreamId, args.len - 2); defer scratch.free(ids); for (args[2..], 0..) |arg, i| { ids[i] = StreamId.parse(arg) orelse return errorReply(lua, pcall, "ERR invalid stream ID"); } const n = store.xack(args[0], args[1], ids) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); lua.pushInteger(n); return 1; }
std.log.warn("burner-redis: unknown command '{s}' (docket-critical subset only)", .{cmd}); return errorReply(lua, pcall, "ERR unknown command (docket-critical subset only)");}
// ---- per-command helpers ----------------------------------------------------
fn doZadd(ctx: *DispatchCtx, lua: *Lua, pcall: bool, args: []const []const u8) !c_int { if (args.len < 3) return errorReply(lua, pcall, "ERR wrong number of arguments for 'zadd'"); const key = args[0]; var flags: Store.ZAddFlags = .{}; var i: usize = 1; while (i < args.len) { const a = args[i]; if (std.ascii.eqlIgnoreCase(a, "NX")) { flags.nx = true; i += 1; } else if (std.ascii.eqlIgnoreCase(a, "XX")) { flags.xx = true; i += 1; } else if (std.ascii.eqlIgnoreCase(a, "GT")) { flags.gt = true; i += 1; } else if (std.ascii.eqlIgnoreCase(a, "LT")) { flags.lt = true; i += 1; } else if (std.ascii.eqlIgnoreCase(a, "CH")) { flags.ch = true; i += 1; } else break; } if (i >= args.len or (args.len - i) % 2 != 0) { return errorReply(lua, pcall, "ERR wrong number of arguments for 'zadd'"); }
var pairs: std.ArrayList(ScoredMember) = .empty; defer pairs.deinit(ctx.scratch); var j: usize = i; while (j < args.len) : (j += 2) { const score = std.fmt.parseFloat(f64, args[j]) catch return errorReply(lua, pcall, "ERR value is not a valid float"); try pairs.append(ctx.scratch, .{ .score = score, .member = args[j + 1] }); }
const n = ctx.engine.store.zadd(key, pairs.items, flags) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); lua.pushInteger(n); return 1;}
fn doZrangebyscore(ctx: *DispatchCtx, lua: *Lua, pcall: bool, args: []const []const u8) !c_int { if (args.len < 3) return errorReply(lua, pcall, "ERR wrong number of arguments for 'zrangebyscore'"); const key = args[0]; const min = parseScoreBound(args[1]) catch return errorReply(lua, pcall, "ERR min is not a valid float"); const max = parseScoreBound(args[2]) catch return errorReply(lua, pcall, "ERR max is not a valid float");
var with_scores = false; var i: usize = 3; while (i < args.len) : (i += 1) { if (std.ascii.eqlIgnoreCase(args[i], "WITHSCORES")) with_scores = true; }
const sms = ctx.engine.store.zrangebyscore(ctx.scratch, key, min, max, with_scores) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); defer Store.freeScoredMembers(ctx.scratch, sms);
lua.newTable(); var idx: i32 = 1; for (sms) |sm| { _ = lua.pushString(sm.member); lua.setIndexRaw(-2, idx); idx += 1; if (with_scores) { var buf: [64]u8 = undefined; const txt = std.fmt.bufPrint(&buf, "{d}", .{sm.score}) catch "0"; _ = lua.pushString(txt); lua.setIndexRaw(-2, idx); idx += 1; } } return 1;}
fn parseScoreBound(s: []const u8) !f64 { if (std.mem.eql(u8, s, "-inf") or std.mem.eql(u8, s, "-INF")) return -std.math.inf(f64); if (std.mem.eql(u8, s, "+inf") or std.mem.eql(u8, s, "+INF") or std.mem.eql(u8, s, "inf")) return std.math.inf(f64); return std.fmt.parseFloat(f64, s);}
fn doXadd(ctx: *DispatchCtx, lua: *Lua, pcall: bool, args: []const []const u8) !c_int { if (args.len < 4) return errorReply(lua, pcall, "ERR wrong number of arguments for 'xadd'"); const key = args[0]; const id_arg = args[1];
const id: ?StreamId = if (std.mem.eql(u8, id_arg, "*")) null else StreamId.parse(id_arg); if (!std.mem.eql(u8, id_arg, "*") and id == null) { return errorReply(lua, pcall, "ERR invalid stream ID"); }
const fields = args[2..]; const new_id = ctx.engine.store.xadd(key, id, fields) catch |err| return errorReply(lua, pcall, redisErrorMsg(err));
var buf: [48]u8 = undefined; const txt = std.fmt.bufPrint(&buf, "{d}-{d}", .{ new_id.ms, new_id.seq }) catch "0-0"; _ = lua.pushString(txt); return 1;}
fn doXgroup(ctx: *DispatchCtx, lua: *Lua, pcall: bool, args: []const []const u8) !c_int { if (args.len < 1) return errorReply(lua, pcall, "ERR wrong number of arguments for 'xgroup'"); const sub = args[0];
if (std.ascii.eqlIgnoreCase(sub, "CREATE")) { if (args.len < 4) return errorReply(lua, pcall, "ERR wrong number of arguments for 'xgroup'"); const key = args[1]; const group = args[2]; const id_arg = args[3];
var mkstream = false; for (args[4..]) |opt| { if (std.ascii.eqlIgnoreCase(opt, "MKSTREAM")) mkstream = true; }
const id: StreamId = if (std.mem.eql(u8, id_arg, "$") or std.mem.eql(u8, id_arg, "0")) StreamId.zero() else StreamId.parse(id_arg) orelse return errorReply(lua, pcall, "ERR invalid stream ID");
ctx.engine.store.xgroupCreate(key, group, .{ .id = id, .mkstream = mkstream }) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); _ = lua.pushString("OK"); return 1; }
return errorReply(lua, pcall, "ERR unknown XGROUP subcommand (docket-critical subset only)");}
fn doXreadgroup(ctx: *DispatchCtx, lua: *Lua, pcall: bool, args: []const []const u8) !c_int { // XREADGROUP GROUP <group> <consumer> [COUNT n] [BLOCK ms] [NOACK] STREAMS key [key...] id [id...] var i: usize = 0; if (i >= args.len or !std.ascii.eqlIgnoreCase(args[i], "GROUP")) return errorReply(lua, pcall, "ERR syntax error"); i += 1; if (i + 2 > args.len) return errorReply(lua, pcall, "ERR syntax error"); const group = args[i]; i += 1; const consumer = args[i]; i += 1;
var count: ?usize = null;
while (i < args.len) { if (std.ascii.eqlIgnoreCase(args[i], "COUNT")) { if (i + 1 >= args.len) return errorReply(lua, pcall, "ERR syntax error"); count = std.fmt.parseInt(usize, args[i + 1], 10) catch return errorReply(lua, pcall, "ERR not an integer"); i += 2; } else if (std.ascii.eqlIgnoreCase(args[i], "BLOCK")) { // skip BLOCK ms — no in-engine blocking yet i += 2; } else if (std.ascii.eqlIgnoreCase(args[i], "NOACK")) { i += 1; } else break; }
if (i >= args.len or !std.ascii.eqlIgnoreCase(args[i], "STREAMS")) return errorReply(lua, pcall, "ERR syntax error"); i += 1;
const rest = args[i..]; if (rest.len == 0 or rest.len % 2 != 0) return errorReply(lua, pcall, "ERR syntax error"); const half = rest.len / 2; const keys = rest[0..half]; const ids = rest[half..];
lua.newTable(); var top_idx: i32 = 1;
for (keys, ids) |key, id_str| { const id_form: Store.ReadIdForm = if (std.mem.eql(u8, id_str, ">")) .new_messages else if (StreamId.parse(id_str)) |id| .{ .from_pel = id } else return errorReply(lua, pcall, "ERR invalid stream ID");
const delivered = ctx.engine.store.xreadgroupOne(ctx.scratch, key, group, consumer, id_form, count) catch |err| return errorReply(lua, pcall, redisErrorMsg(err)); defer store_mod.freeDeliveredEntries(ctx.scratch, delivered);
if (delivered.len == 0) continue;
// Per-stream pair: [name, entries-table] lua.newTable(); _ = lua.pushString(key); lua.setIndexRaw(-2, 1);
lua.newTable(); for (delivered, 0..) |de, j| { // entry pair: [id_str, [field, value, ...]] lua.newTable(); var id_buf: [48]u8 = undefined; const id_str_out = std.fmt.bufPrint(&id_buf, "{d}-{d}", .{ de.id.ms, de.id.seq }) catch "0-0"; _ = lua.pushString(id_str_out); lua.setIndexRaw(-2, 1);
lua.newTable(); var fld_idx: i32 = 1; for (de.fields) |p| { _ = lua.pushString(p.field); lua.setIndexRaw(-2, fld_idx); fld_idx += 1; _ = lua.pushString(p.value); lua.setIndexRaw(-2, fld_idx); fld_idx += 1; } lua.setIndexRaw(-2, 2); lua.setIndexRaw(-2, @intCast(j + 1)); } lua.setIndexRaw(-2, 2);
lua.setIndexRaw(-2, top_idx); top_idx += 1; }
if (top_idx == 1) { // No streams produced — Redis returns nil for XREADGROUP with no data. lua.pop(1); lua.pushBoolean(false); } return 1;}
// ---- Lua → RedisValue conversion -------------------------------------------
fn luaToRedis(out_alloc: Allocator, lua: *Lua, index: i32) anyerror!RedisValue { return switch (lua.typeOf(index)) { .nil => .nil, .boolean => if (lua.toBoolean(index)) RedisValue{ .integer = 1 } else RedisValue.nil, .number => blk: { const n = lua.toNumber(index) catch break :blk RedisValue.nil; const as_i: i64 = @intFromFloat(n); break :blk .{ .integer = as_i }; }, .string => blk: { const s = lua.toString(index) catch break :blk RedisValue.nil; break :blk .{ .bulk_string = try out_alloc.dupe(u8, s) }; }, .table => try luaTableToRedis(out_alloc, lua, index), else => .nil, };}
fn luaTableToRedis(out_alloc: Allocator, lua: *Lua, index: i32) anyerror!RedisValue { const abs = if (index < 0) lua.getTop() + index + 1 else index;
// {err = "..."} → error _ = lua.getField(abs, "err"); if (lua.typeOf(-1) == .string) { const msg = lua.toString(-1) catch unreachable; const dup = try out_alloc.dupe(u8, msg); lua.pop(1); return .{ .err = dup }; } lua.pop(1);
// {ok = "..."} → status _ = lua.getField(abs, "ok"); if (lua.typeOf(-1) == .string) { const msg = lua.toString(-1) catch unreachable; const dup = try out_alloc.dupe(u8, msg); lua.pop(1); return .{ .status = dup }; } lua.pop(1);
var items: std.ArrayList(RedisValue) = .empty; errdefer { for (items.items) |it| it.deinit(out_alloc); items.deinit(out_alloc); }
var i: i32 = 1; while (true) : (i += 1) { lua.pushInteger(@intCast(i)); _ = lua.getTable(abs); if (lua.typeOf(-1) == .nil) { lua.pop(1); break; } const v = try luaToRedis(out_alloc, lua, -1); lua.pop(1); try items.append(out_alloc, v); }
return .{ .array = try items.toOwnedSlice(out_alloc) };}
// ----------------------------------------------------------------------------// Tests// ----------------------------------------------------------------------------
test "ziglua boots and runs return-string" { var lua = try Lua.init(std.testing.allocator); defer lua.deinit(); try lua.doString("return 'hello from lua'"); const got = try lua.toString(-1); try std.testing.expectEqualStrings("hello from lua", got);}
test "sha1Hex matches a known vector" { var buf: [40]u8 = undefined; const h = LuaEngine.sha1Hex(&buf, ""); try std.testing.expectEqualStrings("da39a3ee5e6b4b0d3255bfef95601890afd80709", h);}
test "EVAL returning a string" { var threaded = std.Io.Threaded.init(std.testing.allocator, .{}); defer threaded.deinit(); var store = Store.init(std.testing.allocator, threaded.io()); defer store.deinit();
var engine = LuaEngine.init(std.testing.allocator, &store); defer engine.deinit();
const v = try engine.eval(std.testing.allocator, "return 'hello'", &.{}, &.{}); defer v.deinit(std.testing.allocator); try std.testing.expect(v == .bulk_string); try std.testing.expectEqualStrings("hello", v.bulk_string);}
test "EVAL: redis.call HSET + HGET roundtrips" { var threaded = std.Io.Threaded.init(std.testing.allocator, .{}); defer threaded.deinit(); var store = Store.init(std.testing.allocator, threaded.io()); defer store.deinit();
var engine = LuaEngine.init(std.testing.allocator, &store); defer engine.deinit();
const script1 = \\redis.call('HSET', KEYS[1], 'f1', 'v1', 'f2', 'v2') \\return redis.call('HGET', KEYS[1], 'f1') ; const v = try engine.eval(std.testing.allocator, script1, &.{"h"}, &.{}); defer v.deinit(std.testing.allocator); try std.testing.expect(v == .bulk_string); try std.testing.expectEqualStrings("v1", v.bulk_string);}
test "EVAL: SET/GET via redis.call" { var threaded = std.Io.Threaded.init(std.testing.allocator, .{}); defer threaded.deinit(); var store = Store.init(std.testing.allocator, threaded.io()); defer store.deinit(); var engine = LuaEngine.init(std.testing.allocator, &store); defer engine.deinit();
const script = \\redis.call('SET', KEYS[1], 'hello') \\return redis.call('GET', KEYS[1]) ; const v = try engine.eval(std.testing.allocator, script, &.{"k"}, &.{}); defer v.deinit(std.testing.allocator); try std.testing.expect(v == .bulk_string); try std.testing.expectEqualStrings("hello", v.bulk_string);}
test "EVAL: list LPUSH/RPUSH/LRANGE" { var threaded = std.Io.Threaded.init(std.testing.allocator, .{}); defer threaded.deinit(); var store = Store.init(std.testing.allocator, threaded.io()); defer store.deinit(); var engine = LuaEngine.init(std.testing.allocator, &store); defer engine.deinit();
const script = \\redis.call('RPUSH', KEYS[1], 'a', 'b', 'c') \\local r = redis.call('LRANGE', KEYS[1], '0', '-1') \\return #r ; const v = try engine.eval(std.testing.allocator, script, &.{"L"}, &.{}); defer v.deinit(std.testing.allocator); try std.testing.expect(v == .integer); try std.testing.expectEqual(@as(i64, 3), v.integer);}
test "EVAL: full XGROUP + XADD + XREADGROUP cycle (docket-critical)" { var threaded = std.Io.Threaded.init(std.testing.allocator, .{}); defer threaded.deinit(); var store = Store.init(std.testing.allocator, threaded.io()); defer store.deinit();
var engine = LuaEngine.init(std.testing.allocator, &store); defer engine.deinit();
const script = \\redis.call('XGROUP', 'CREATE', KEYS[1], 'g1', '0', 'MKSTREAM') \\redis.call('XADD', KEYS[1], '*', 'function', 'tick', 'attempt', '1') \\local r = redis.call('XREADGROUP', 'GROUP', 'g1', 'c1', 'COUNT', '1', 'STREAMS', KEYS[1], '>') \\if r and #r > 0 then return 'got' else return 'empty' end ; const v = try engine.eval(std.testing.allocator, script, &.{"st"}, &.{}); defer v.deinit(std.testing.allocator); try std.testing.expect(v == .bulk_string); try std.testing.expectEqualStrings("got", v.bulk_string);}
const Racer = struct { store: *Store, done: std.atomic.Value(bool) = .init(false),
/// what another thread sees while the scripts run: `n` is only ever even fn readCounter(self: *Racer, odd: *usize) void { var digits: [32]u8 = undefined; while (!self.done.load(.monotonic)) { var buffer: std.heap.FixedBufferAllocator = .init(&digits); const got = (self.store.get(buffer.allocator(), "n") catch unreachable) orelse continue; if ((std.fmt.parseInt(u64, got, 10) catch unreachable) % 2 == 1) odd.* += 1; } }
/// docket's schedule, for a task already due: park the body, then queue the key fn schedule(self: *Racer, tasks: usize) void { var parked: [32]u8 = undefined; for (0..tasks) |n| { const key = std.fmt.bufPrint(&parked, "parked:{d}", .{n}) catch unreachable; _ = self.store.hset(key, &.{ "function", "f" }) catch unreachable; _ = self.store.zadd("queue", &.{.{ .score = 1, .member = key["parked:".len..] }}, .{}) catch unreachable; } self.done.store(true, .monotonic); }};
test "no other thread sees a script half done" { var threaded = std.Io.Threaded.init(std.testing.allocator, .{}); defer threaded.deinit(); var store = Store.init(std.testing.allocator, threaded.io()); defer store.deinit(); // not the testing allocator: it takes a stack trace for each of the VM's allocations var engine = LuaEngine.init(std.heap.smp_allocator, &store);
var racer: Racer = .{ .store = &store }; var odd: usize = 0; const reader = try std.Thread.spawn(.{}, Racer.readCounter, .{ &racer, &odd }); var arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator); defer arena.deinit(); for (0..2000) |_| { _ = try engine.eval(arena.allocator(), "redis.call('INCR', KEYS[1]) redis.call('INCR', KEYS[1]) return 1", &.{"n"}, &.{}); _ = arena.reset(.retain_capacity); } racer.done.store(true, .monotonic); reader.join(); try std.testing.expectEqual(@as(usize, 0), odd);}
test "a task scheduled while a script promotes the due ones is not stranded" { var threaded = std.Io.Threaded.init(std.testing.allocator, .{}); defer threaded.deinit(); // twenty thousand tasks: the testing allocator would take a stack trace for each allocation var store = Store.init(std.heap.smp_allocator, threaded.io()); defer store.deinit(); var engine = LuaEngine.init(std.heap.smp_allocator, &store);
const tasks = 20_000; var racer: Racer = .{ .store = &store }; const scheduler = try std.Thread.spawn(.{}, Racer.schedule, .{ &racer, tasks }); var arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator); defer arena.deinit(); // docket's promote_due: read the due range, move each parked body to the // stream, then remove the range by score. A task queued in between is // removed with the rest, its body still parked. const promote_due = \\local promoted = 0 \\for _, task in ipairs(redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1])) do \\ local fields = redis.call('HGETALL', 'parked:' .. task) \\ if #fields > 0 then \\ redis.call('XADD', KEYS[2], '*', unpack(fields)) \\ redis.call('DEL', 'parked:' .. task) \\ promoted = promoted + 1 \\ end \\end \\if promoted > 0 then redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1]) end \\return promoted ; var promoted: i64 = 0; var scheduling = true; while (scheduling) { // read before the pass, so the last pass starts with every task queued scheduling = !racer.done.load(.monotonic); promoted += (try engine.eval(arena.allocator(), promote_due, &.{ "queue", "stream" }, &.{"2"})).integer; _ = arena.reset(.retain_capacity); } scheduler.join(); try std.testing.expectEqual(@as(i64, tasks), promoted); try std.testing.expectEqual(@as(i64, tasks), try store.xlen("stream"));}
test "EVAL integer overflow returns errors without changing the key" { var threaded = std.Io.Threaded.init(std.testing.allocator, .{}); defer threaded.deinit(); var store = Store.init(std.testing.allocator, threaded.io()); defer store.deinit(); var engine = LuaEngine.init(std.testing.allocator, &store); defer engine.deinit(); const cases = [_]struct { value: []const u8, command: []const u8 }{ .{ .value = "9223372036854775807", .command = "'INCR', 'k'" }, .{ .value = "9223372036854775807", .command = "'INCRBY', 'k', '1'" }, .{ .value = "-9223372036854775808", .command = "'DECR', 'k'" }, .{ .value = "-9223372036854775808", .command = "'DECRBY', 'k', '1'" }, .{ .value = "-1", .command = "'DECRBY', 'k', '-9223372036854775808'" }, }; for (cases) |case| { for ([_][]const u8{ "call", "pcall" }) |mode| { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); _ = try store.set("k", case.value, .{}); const script = try std.fmt.allocPrint(arena.allocator(), "return redis.{s}({s})", .{ mode, case.command }); const result = try engine.eval(arena.allocator(), script, &.{}, &.{}); try std.testing.expect(result == .err); try std.testing.expect(std.mem.indexOf(u8, result.err, "overflow") != null); const value = (try store.get(arena.allocator(), "k")).?; try std.testing.expectEqualStrings(case.value, value); } }}