Module: Wurk::Lua
- Defined in:
- lib/wurk/lua.rb,
lib/wurk/lua/loader.rb,
lib/wurk/lua/fast_delete.rb
Overview
EVALSHA-cached Lua scripts. Loaded once per pool, never re-uploaded. Multi-pop, atomic schedule promotion, batch ops.
Source strings are intentionally bare — the SHA1 of each is computed
at load time and is the same value Redis reports from SCRIPT LOAD.
Whitespace edits change the SHA, which forces a re-upload at runtime.
:zpopbyscore implements the pop-one-due-entry step in sidekiq-free.md
§1.8. It is the minimal expression of that step — range by score, take one,
remove it, return it — and there is no room to write it differently without
changing what it does.
Defined Under Namespace
Modules: FastDelete Classes: Loader
Constant Summary collapse
- ZPOPBYSCORE =
<<~LUA local key, now = KEYS[1], ARGV[1] local jobs = redis.call("zrange", key, "-inf", now, "byscore", "limit", 0, 1) if jobs[1] then redis.call("zrem", key, jobs[1]) return jobs[1] end LUA
- RELIABLE_SCHEDULE_PROMOTE =
Pro reliable scheduler: atomically promote all due jobs in a sorted set to their target queues. Pure-Ruby promotion does ZRANGE → ZREM → LPUSH non-atomically and can lose jobs on a mid-step crash.
Each promoted payload is restamped with a fresh
enqueued_at(ARGV, epoch ms): that field marks arrival on an immediate queue, so a job leavingschedule/retrymust get a new one at promotion rather than keep its stale scheduled-origin value (or none). This matches the default Ruby scheduler, whose push path restampsenqueued_attoo (Client#push_plain / #push_batched) — so both schedulers emit wire-identical promoted payloads (spec §7.1).The restamp is a surgical string patch, NOT a cjson.decode -> cjson.encode round-trip: cjson maps every JSON number to a Lua double, so re-encoding would silently corrupt integer args past 2^53 (snowflake IDs, 64-bit counters) and reformat 15+ digit numbers into scientific notation -- breaking wire-compat AND diverging from the loss-free Ruby scheduler, whose Ruby-side JSON keeps big integers exact. So the stored member is preserved byte-for-byte and only its top-level
enqueued_atis rewritten: replaced in place when present (retry members keep theirs), inserted after the opening brace when absent (schedule members are stored stripped of it, via Client#push_scheduled). cjson.decode is still called -- but only to read thequeuestring and test for a top-levelenqueued_at, never to re-serialize, so a lossy decode never reaches the payload. (Residual: a retry member whose user args embed a numeric key literally namedenqueued_atahead of the top-level one patches the arg instead -- vanishingly rare, and still strictly safer than round-tripping every arg.) A member that is not a JSON object naming a stringqueue(garbage bytes, a bare scalar, a nil/numeric queue) can never be promoted. Raising on it would abort the script at the same lowest-scored member on every sweep — and since one Ruby call drainsretrybeforeschedule, a single poison retry would starve the whole cluster's scheduled jobs. It is moved to the dead set (scorednow, like a kill) so it stays inspectable, and the sweep continues. pcall keeps a failed decode from raising out of the script. KEYS = [sorted_set, queues_set, dead_set] ARGV = [now, queue_prefix, now_ms, batch] Returns the number of members handled (promoted or moved to dead). Order matters: decode + push BEFORE zrem. Redis Lua has no rollback, so a failed cjson.decode after a zrem would lose the job. Decode first; push first; only then remove from the sorted set — and zrem the ORIGINAL member, not the restamped copy. Worst case is a crash between lpush and zrem → at-least-once redelivery, never loss. ARGV caps members per call: Lua is atomic and single-threaded in Redis, so an unbatched promote of a post-outage backlog (100k+ due members) would block every client for the whole sweep. The Ruby caller loops until a short batch comes back. The first line is a Lua comment to Redis and Valkey. Dragonfly reads it as a per-script grant, because this script alone derives its target key from the payload (ARGV[2] .. q) and that server refuses undeclared keys by default. It spares operators the server-wide allow-undeclared-keys flag, which serializes every script and cost about 3x script throughput. <<~LUA --!df flags=allow-undeclared-keys local jobs = redis.call("zrangebyscore", KEYS[1], "-inf", ARGV[1], "LIMIT", 0, tonumber(ARGV[4])) for i = 1, #jobs do local job = jobs[i] local ok, decoded = pcall(cjson.decode, job) local q = ok and type(decoded) == "table" and decoded["queue"] if type(q) == "string" then local pat, rep = '"enqueued_at":%-?%d[%d.eE+-]*', '"enqueued_at":' .. ARGV[3] if decoded["enqueued_at"] == nil then pat, rep = "^{", "{" .. rep .. "," end redis.call("sadd", KEYS[2], q) redis.call("lpush", ARGV[2] .. q, (string.gsub(job, pat, rep, 1))) else redis.call("zadd", KEYS[3], ARGV[1], job) end redis.call("zrem", KEYS[1], job) end return #jobs LUA
- SCHEDULED_CLAIM =
Default scheduler claim: lease up to ARGV due members instead of popping them. Each stays in its set, rescored past any due window, so a lost reply, a failed push or a crash costs a later re-promotion and never the job. The caller ZREMs a member only once it has been pushed.
Redis's clock decides both ends, so a host with a skewed clock can neither cut a lease short nor see another host's lease as due: the window is the earlier of the caller's and the server's time, and the expiry counts from the server's. tostring() would round a 10-digit epoch, hence the format. KEYS = [sorted_set] ARGV = [now, lease_seconds, limit] Returns the claimed members, lowest score first.
<<~LUA local t = redis.call("TIME") local server_now = tonumber(t[1]) + (tonumber(t[2]) / 1000000) local window = math.min(tonumber(ARGV[1]), server_now) local jobs = redis.call("zrange", KEYS[1], "-inf", string.format("%.6f", window), "byscore", "limit", 0, tonumber(ARGV[3])) local expiry = string.format("%.6f", server_now + tonumber(ARGV[2])) for i = 1, #jobs do redis.call("zadd", KEYS[1], "XX", expiry, jobs[i]) end return jobs LUA
- RELIABLE_REQUEUE =
Reliable fetch (Pro super_fetch §3) shutdown requeue: atomically move one in-flight job from a per-process private list back to its public queue. The LPOS guard is the whole point — RPUSH runs only when the job is still in the private list. A job the Processor ACKed in the window between hard_shutdown's cross-thread
jobread and this move is NOT re-pushed, so the job lands in exactly one place and can't double-execute. RPUSH (public tail) not LPUSH so the reclaimed job is fetched next — LMOVE pops the tail — ahead of fresh LPUSH'd enqueues.Push before remove, in this script, the one below and
lua/reclaim_orphan.lua. Nothing rolls a script back, and a server can refuse the write mid-script (Dragonfly at maxmemory refuses the RPUSH after applying an LREM): the raise has to leave the job where it was, never in neither place. KEYS = [private_list, public_queue] ARGV = [job_json] Returns 1 when the job was moved, 0 when it was already acked. <<~LUA if redis.call("lpos", KEYS[1], ARGV[1]) then redis.call("rpush", KEYS[2], ARGV[1]) redis.call("lrem", KEYS[1], 1, ARGV[1]) return 1 end return 0 LUA
- REQUEUE_UNIT =
UnitOfWork#requeue: unguarded, as the caller owns the unit and a job whose ACK already went out must still go back. Not replay-safe, so it is only ever sent once (Loader.eval_once). KEYS = [private_list, public_queue] ARGV = [job_json]
<<~LUA redis.call("rpush", KEYS[2], ARGV[1]) return redis.call("lrem", KEYS[1], 1, ARGV[1]) LUA
- RELEASE_IF_OWNER =
Ent Unique (§3): atomic compare-and-delete of a lock key. Replaces the two-command GET-then-DEL — between those calls the key can expire and a fresh owner can grab it, and the bare DEL would then drop the new owner's lock. Shared by
Unique::ServerMiddleware#release(normal success/start release),Unique::DEATH_HANDLER(automatic-death release) andLeader#release(stepping down from the cluster lock) so those paths cannot drift. KEYS = [the lock key — unique:| dear-leader] ARGV = [the owner that must still hold it — jid | : : ] Returns 1 when the key was deleted, 0 otherwise. <<~LUA if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) end return 0 LUA
- CRON_CLAIM_FIRE =
Ent Periodic (§2): compare-and-swap the fire marks of
loops:<lid>. The cron poller's leader gate is a cached read (Component#leader?, LEADER_CACHE_TTL_MS), so for a few seconds after a handover two processes both believe they lead and both reach the same due loop — HMGET → decide → enqueue → HSET then fires it twice. Claiming the slot atomically is what makes a tick fire exactly once; the loser gets 0 and enqueues nothing. Values written are the same decimal-epoch strings the Ruby path wrote, byte for byte — the dashboard reads these fields.nfpresent: the caller must still be looking at the mark it read (byte compare, so no parse can drift the token).nfabsent: the caller derived the slot fromlf, so refuse oncelfhas reached it — that is the exhausted-schedule case, where the winner clearednfand a loser would otherwise re-fire the very same slot. KEYS = [loops:] ARGV = [expected nf(or the derived slot), newlf, newnf('' → HDEL)] Returns 1 when this caller claimed the slot, 0 otherwise. <<~LUA local key = KEYS[1] local cur = redis.call("hget", key, "nf") if cur and cur ~= "" then if cur ~= ARGV[1] then return 0 end else local lf = tonumber(redis.call("hget", key, "lf") or "") if lf and lf >= tonumber(ARGV[1]) then return 0 end end redis.call("hset", key, "lf", ARGV[2]) if ARGV[3] == "" then redis.call("hdel", key, "nf") else redis.call("hset", key, "nf", ARGV[3]) end return 1 LUA
- LUA_DIR =
The rest — batch (
batch_*), limiter (limiter_*), flow and the other per-feature scripts — live inlib/wurk/lua/*.lua, one script per file. Loaded at boot, the file's basename (minus.lua) becomes the SCRIPTS key as a symbol. Separate files keep each script diffable on its own and self-contained for theredis-cli --evaldebug workflow. File.('lua', __dir__)
- FILE_SCRIPTS =
Dir.glob(File.join(LUA_DIR, '*.lua')).to_h do |path| [File.basename(path, '.lua').to_sym, File.read(path)] end.freeze
- SCRIPTS =
{ zpopbyscore: ZPOPBYSCORE, reliable_schedule_promote: RELIABLE_SCHEDULE_PROMOTE, scheduled_claim: SCHEDULED_CLAIM, reliable_requeue: RELIABLE_REQUEUE, requeue_unit: REQUEUE_UNIT, release_if_owner: RELEASE_IF_OWNER, cron_claim_fire: CRON_CLAIM_FIRE }.merge(FILE_SCRIPTS).freeze
- SHAS =
SHA1 of each script source — matches what
SCRIPT LOADreturns. Precomputing keepseval_cachedallocation-free in the hot path. SCRIPTS.transform_values { |src| Digest::SHA1.hexdigest(src) }.freeze