// Shared helpers for the atomic scripts. Each script is a Lua source paired // with its synchronous JS twin (run by MemoryRedis); edit them together. // Deterministic by construction: time and random draws ride in as ARGV, never // TIME or math.random. The HELPERS_LUA string and the JS functions below are // twins. export namespace Script { export interface SyncKV { get(key: string): string | null; set(key: string, value: string, pxMs?: number): void; del(key: string): number; pexpire(key: string, ms: number): void; hget(key: string, field: string): string | null; hset(key: string, field: string, value: string): void; hdel(key: string, field: string): void; hgetall(key: string): Record; hscan( key: string, cursor: string, count: number, ): { cursor: string; entries: [string, string][] }; } export interface Def { lua: string; js(kv: SyncKV, keys: string[], argv: string[]): unknown; } } export const HELPERS_LUA = ` local function parseTable(raw) if not raw then return nil end local ok, value = pcall(cjson.decode, raw) if ok and type(value) == 'table' then return value end return nil end local function liveOwner(workersKey, sessionId, now) if type(sessionId) ~= 'string' then return nil end local raw = redis.call('HGET', workersKey, sessionId) local rec = parseTable(raw) if not rec or type(rec.expiry) ~= 'number' or rec.expiry <= now then return nil end return raw, rec end local function rewriteNs(workersKey, nsWorkersKey, ns, ensureSid, removeSid, now) local out = {} local list = parseTable(redis.call('HGET', nsWorkersKey, ns)) if list then for _, sid in ipairs(list) do if sid ~= ensureSid and sid ~= removeSid and liveOwner(workersKey, sid, now) then out[#out + 1] = sid end end end if ensureSid then out[#out + 1] = ensureSid end if #out == 0 then redis.call('HDEL', nsWorkersKey, ns) else redis.call('HSET', nsWorkersKey, ns, cjson.encode(out)) end return #out end local function bindEpoch(epochsKey, field) local rec = parseTable(redis.call('HGET', epochsKey, field)) if rec and type(rec.epoch) == 'number' then return rec end return { epoch = 0 } end local function pruneWorkers(workersKey, bindingsKey, epochsKey, now, lease) local live = {} local all = redis.call('HGETALL', workersKey) for i = 1, #all, 2 do local rec = parseTable(all[i + 1]) if not rec or type(rec.expiry) ~= 'number' or rec.expiry + lease < now then redis.call('HDEL', workersKey, all[i]) elseif rec.expiry > now and type(rec.envs) == 'table' then for ns, env in pairs(rec.envs) do live['h:' .. tostring(rec.hostname) .. ns] = true live['e:' .. tostring(env)] = true end end end local bindings = redis.call('HGETALL', bindingsKey) for i = 1, #bindings, 2 do if not live[bindings[i]] then redis.call('HDEL', bindingsKey, bindings[i]) end end local epochs = redis.call('HGETALL', epochsKey) for i = 1, #epochs, 2 do if not live[epochs[i]] then redis.call('HDEL', epochsKey, epochs[i]) end end end local function drainBase(clientRaw, now) local cfg = parseTable(clientRaw) local deadline = cfg and cfg.drainDeadline if type(deadline) == 'number' then return true, math.min(now, deadline) end if deadline == cjson.null then return true, now end return false, now end `; export const parseObj = ( raw: string | null, ): Record | null => { if (raw === null) return null; try { const value: unknown = JSON.parse(raw); return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : null; } catch { return null; } }; export const parseList = (raw: string | null): unknown[] | null => { if (raw === null) return null; try { const value: unknown = JSON.parse(raw); return Array.isArray(value) ? value : null; } catch { return null; } }; export const liveOwner = ( kv: Script.SyncKV, workersKey: string, sessionId: unknown, now: number, ): { raw: string; rec: Record } | null => { if (typeof sessionId !== "string") return null; const raw = kv.hget(workersKey, sessionId); const rec = raw === null ? null : parseObj(raw); if (rec === null) return null; const expiry = rec["expiry"]; if (typeof expiry !== "number" || expiry <= now) return null; return { raw: raw!, rec }; }; export const rewriteNs = ( kv: Script.SyncKV, workersKey: string, nsWorkersKey: string, ns: string, ensureSid: string | null, removeSid: string | null, now: number, ): number => { const out: string[] = []; const list = parseList(kv.hget(nsWorkersKey, ns)); if (list !== null) { for (const sid of list) { if (sid === ensureSid || sid === removeSid) continue; if (liveOwner(kv, workersKey, sid, now) !== null) { out.push(sid as string); } } } if (ensureSid !== null) out.push(ensureSid); if (out.length === 0) kv.hdel(nsWorkersKey, ns); else kv.hset(nsWorkersKey, ns, JSON.stringify(out)); return out.length; }; export const bindEpoch = ( kv: Script.SyncKV, epochsKey: string, field: string, ): Record => { const rec = parseObj(kv.hget(epochsKey, field)); if (rec !== null && typeof rec["epoch"] === "number") return rec; return { epoch: 0 }; }; export const pruneWorkers = ( kv: Script.SyncKV, workersKey: string, bindingsKey: string, epochsKey: string, now: number, lease: number, ): void => { const live = new Set(); for (const [sessionId, raw] of Object.entries(kv.hgetall(workersKey))) { const rec = parseObj(raw); const expiry = rec?.["expiry"]; if (typeof expiry !== "number" || expiry + lease < now) { kv.hdel(workersKey, sessionId); } else if ( expiry > now && typeof rec!["envs"] === "object" && rec!["envs"] !== null ) { for (const [ns, env] of Object.entries( rec!["envs"] as Record, )) { live.add(`h:${String(rec!["hostname"])}${ns}`); live.add(`e:${String(env)}`); } } } for (const field of Object.keys(kv.hgetall(bindingsKey))) { if (!live.has(field)) kv.hdel(bindingsKey, field); } for (const field of Object.keys(kv.hgetall(epochsKey))) { if (!live.has(field)) kv.hdel(epochsKey, field); } }; export const drainBase = ( clientRaw: string | null, now: number, ): { draining: boolean; base: number } => { const cfg = parseObj(clientRaw); const deadline = cfg?.["drainDeadline"]; if (typeof deadline === "number") { return { draining: true, base: Math.min(now, deadline) }; } if (cfg !== null && deadline === null) return { draining: true, base: now }; return { draining: false, base: now }; };