Module: Wurk::Lua
- Defined in:
- lib/wurk/lua.rb,
lib/wurk/lua/loader.rb
Overview
EVALSHA-cached Lua scripts. Loaded once per pool, never re-uploaded. Bulk enqueue, 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 is reproduced verbatim from sidekiq-free.md §1.8 and
MUST NOT diverge — parity tests will fail on a single byte change.
Defined Under Namespace
Classes: Loader
Constant Summary collapse
- ZPOPBYSCORE =
rubocop:disable Metrics/ModuleLength
<<~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
- BULK_PUSH =
Bulk enqueue to a single queue. KEYS = [queue_list, queues_set] ARGV = [queue_name, job_json, ...] Returns the number of jobs pushed.
<<~LUA redis.call("sadd", KEYS[2], ARGV[1]) for i = 2, #ARGV do redis.call("lpush", KEYS[1], ARGV[i]) end return #ARGV - 1 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.) KEYS = [sorted_set, queues_set] ARGV = [now, queue_prefix, now_ms] Returns the number of jobs promoted. 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. <<~LUA 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 decoded = cjson.decode(job) local q = decoded["queue"] local stamped if decoded["enqueued_at"] == nil then stamped = string.gsub(job, "^{", '{"enqueued_at":' .. ARGV[3] .. ",", 1) else stamped = string.gsub(job, '"enqueued_at":%-?%d[%d.eE+-]*', '"enqueued_at":' .. ARGV[3], 1) end redis.call("sadd", KEYS[2], q) redis.call("lpush", ARGV[2] .. q, stamped) redis.call("zrem", KEYS[1], job) 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 LREM guard is the whole point — RPUSH runs only when the job was still in the private list (LREM removed exactly 1). A job the Processor ACKed in the window between hard_shutdown's cross-thread
jobread and this move (LREM removes 0) 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. 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("lrem", KEYS[1], 1, ARGV[1]) == 1 then redis.call("rpush", KEYS[2], ARGV[1]) return 1 end return 0 LUA
- BATCH_PUSH =
Pro Batch: register a job into a batch and push it to its queue atomically. Keeps total/pending in sync with the jids set.
SADD into the live jids set is the registration guard: total/pending increment only when the jid is genuinely new (SADD == 1). A jid already live is a re-push — a retry or scheduled promotion re-enqueueing a job that never left the batch — so it re-LPUSHes the payload but must NOT recount, or pending inflates past the acks and
:successnever fires (spec §2.3/§2.5: total = distinct jobs added, pending = not-yet-succeeded).A jid found in
b-<bid>-diedis a manual retry of a dead job (morgue "retry" / "add to queue") — it rejoins the live set without recounting: total and pending already include it, because a death never decrements pending. When that drains the died set the batch is no longer dead, so the durabledeathsuccess-suppression flag clears and the bid leavesdead-batches— a later full drain can then fire:success(spec §2.4: success after the dead job is manually retried to success). Theb-<bid>-deathnotify dedup key is untouched, so:deathcannot re-fire.The two EXPIRE NX calls are what keep the batch bounded:
b-<bid>-jidsis born here, and onlyBatch#ensure_first_flush!ever stamped a TTL — on the hash alone — so an abandoned or invalidated batch leaked its live-jid set forever. A late re-push (retry, scheduled promotion) can also resurrect an already-expiredb-<bid>through the HINCRBYs above, again TTL-less. NX stamps only keys that currently have no TTL, so a running batch's clock and the shorter post-successlingerwindow (Callbacks#apply_linger) both win. KEYS = [b-, b- -jids, queue_list, queues_set, b- -died, dead-batches] ARGV = [queue_name, jid, job_json, bid, expiry_seconds] Returns 1. <<~LUA if redis.call("srem", KEYS[5], ARGV[2]) == 1 then redis.call("sadd", KEYS[2], ARGV[2]) if redis.call("scard", KEYS[5]) == 0 then redis.call("hdel", KEYS[1], "death") redis.call("zrem", KEYS[6], ARGV[4]) end else if redis.call("sadd", KEYS[2], ARGV[2]) == 1 then redis.call("hincrby", KEYS[1], "total", 1) redis.call("hincrby", KEYS[1], "pending", 1) end end redis.call("expire", KEYS[1], ARGV[5], "NX") redis.call("expire", KEYS[2], ARGV[5], "NX") redis.call("sadd", KEYS[4], ARGV[1]) redis.call("lpush", KEYS[3], ARGV[3]) return 1 LUA
- BATCH_SCHEDULE =
Pro Batch: register a scheduled (
at) job into a batch AND ZADD it onto thescheduleset, atomically. This is the deferred sibling of BATCH_PUSH: same SADD-guarded total/pending counting, but the enqueue action is a ZADD (schedule for later) instead of an LPUSH (queue now). Aperform_ininsidebatch.jobsmust movetotal/pendingat creation — otherwise the empty-marker check (batch.rb) sees no counter movement and fires:complete/:successwhile real jobs still sit inschedule.No died / dead-batches handling (unlike BATCH_PUSH): a job scheduled at creation time is always new to the batch. When the scheduler later promotes it, the re-push routes through BATCH_PUSH, whose guard finds the jid already live (SADD == 0) → pure LPUSH, no recount. So registration happens exactly once, here, at enqueue.
The other path that can create
b-<bid>-jids, so it carries the same NX expiry stamp as BATCH_PUSH. KEYS = [schedule, b-, b- -jids] ARGV = [at_score, job_json, jid, expiry_seconds] Returns 1. <<~LUA if redis.call("sadd", KEYS[3], ARGV[3]) == 1 then redis.call("hincrby", KEYS[2], "total", 1) redis.call("hincrby", KEYS[2], "pending", 1) end redis.call("expire", KEYS[2], ARGV[4], "NX") redis.call("expire", KEYS[3], ARGV[4], "NX") redis.call("zadd", KEYS[1], ARGV[1], ARGV[2]) return 1 LUA
- BATCH_ACK_SUCCESS =
Pro Batch: ACK a job that completed successfully. SREM from the live jids set and decrement pending iff the jid was a member (idempotent against double-success on a flaky retry). A success also clears any outstanding "currently failing" record for the jid (a retry that finally passed), decrementing
failuresso it converges to the count of jobs still failing — Sidekiq Pro semantics, spec §2.5. The failed-set clear runs before the live-jids check so an invalidated batch (BATCH_INVALIDATE deletes the jids set) still converges failures to 0 on its short-circuited success ack, instead of stranding the jid in failed forever. KEYS = [b-, b- -jids, b- -failed] ARGV = [jid] Returns [new_pending, live_jids_remaining], or [-1, -1] when the jid was not a member (treat as already acked). <<~LUA if redis.call("srem", KEYS[3], ARGV[1]) == 1 then redis.call("hincrby", KEYS[1], "failures", -1) end local removed = redis.call("srem", KEYS[2], ARGV[1]) if removed == 1 then local pending = redis.call("hincrby", KEYS[1], "pending", -1) return { pending, redis.call("scard", KEYS[2]) } end return { -1, -1 } LUA
- BATCH_ACK_FAILED =
Pro Batch: record a job that failed and will retry (transient failure). SADDs the jid to the
failedset and bumpsfailuresonly on the first add, sofailures== SCARD(b--failed) == the number of jobs currently in a failing/retrying state. Re-failures of the same jid are idempotent. Cleared by BATCH_ACK_SUCCESS (retry passed) or BATCH_ACK_COMPLETE (job died). Spec §2.5, §2.8. b-<bid>-failedis born here, so it carries the same NX expiry stamp as BATCH_PUSH. KEYS = [b-, b- -failed] ARGV = [jid, expiry_seconds] Returns 1. <<~LUA if redis.call("sadd", KEYS[2], ARGV[1]) == 1 then redis.call("hincrby", KEYS[1], "failures", 1) end redis.call("expire", KEYS[1], ARGV[2], "NX") redis.call("expire", KEYS[2], ARGV[2], "NX") return 1 LUA
- BATCH_ACK_COMPLETE =
Pro Batch: ACK a job that exhausted retries and died. Moves the jid from "currently failing" to "died": SREMs from the failed set (decrementing
failuresif it was recorded as failing), SADDs to died, and SREMs from live jids so the batch can fire:completeeven with terminally failed jobs.b-<bid>-failedholds only currently-retrying jids;b-<bid>-diedholds terminally-dead ones (spec §2.8 — the two sets are distinct).b-<bid>-diedis born here, so it carries the same NX expiry stamp as BATCH_PUSH. KEYS = [b-, b- -jids, b- -died, b- -failed] ARGV = [jid, expiry_seconds] Returns [live_jids_remaining, died_count, first_death]. first_deathis 1 the first time any jid is SADDed into the died set, 0 thereafter — caller uses it to fire:deathexactly once per batch. <<~LUA local was_pre_existing_death = redis.call("scard", KEYS[3]) redis.call("srem", KEYS[2], ARGV[1]) if redis.call("srem", KEYS[4], ARGV[1]) == 1 then redis.call("hincrby", KEYS[1], "failures", -1) end local died_added = redis.call("sadd", KEYS[3], ARGV[1]) redis.call("expire", KEYS[1], ARGV[2], "NX") redis.call("expire", KEYS[3], ARGV[2], "NX") local first_death = 0 if was_pre_existing_death == 0 and died_added == 1 then first_death = 1 end return { redis.call("scard", KEYS[2]), redis.call("scard", KEYS[3]), first_death } LUA
- BATCH_INVALIDATE =
Pro Batch: invalidate all pending jobs. The jobs themselves stay in their queues — the server middleware short-circuits when it sees the invalidated flag — but the jids set is cleared so the batch can no longer accept completion callbacks. KEYS = [b-
, b- -jids] ARGV = [] Returns 1. <<~LUA redis.call("del", KEYS[2]) redis.call("hset", KEYS[1], "invalidated", "1") return 1 LUA
- BATCH_APPEND_CALLBACK =
Pro Batch (§2.4): atomically append one callback triple to the
callbacksJSON array on the batch hash. Server-side append (vs a Ruby read-modify-write) so two processes registering callbacks on the same reopened batch cannot lose each other's writes. Refuses to write when the batch hash is gone — resurrecting a bare hash would create a batch that can never fire anything.Appends are deduped and capped. Reopening the same batch inside every job and re-registering its callback is a common shape, and each append pays a decode + encode of the whole array: unbounded that is O(N^2) Lua work on the way in and N identical callback jobs at fire time. An identical triple is therefore a no-op — the registration is already satisfied by the entry that is there — and past the cap the append is refused rather than allowed to grow the hash field without limit. Both sides of the comparison are normalised through cjson so an entry written by Ruby's
to_jsonat first flush matches one appended here. KEYS = [b-] ARGV = [callback triple JSON, event name, max callbacks] Returns -1 when the batch hash does not exist and -2 when the cap refused the append; otherwise the event's fired flag ("1", or nil when it has not fired yet). <<~LUA if redis.call("exists", KEYS[1]) == 0 then return -1 end local raw = redis.call("hget", KEYS[1], "callbacks") local list if raw and raw ~= "" then list = cjson.decode(raw) else list = {} end local entry = cjson.decode(ARGV[1]) local encoded = cjson.encode(entry) for i = 1, #list do if cjson.encode(list[i]) == encoded then return redis.call("hget", KEYS[1], ARGV[2]) end end if #list >= tonumber(ARGV[3]) then return -2 end list[#list + 1] = entry redis.call("hset", KEYS[1], "callbacks", cjson.encode(list)) return redis.call("hget", KEYS[1], ARGV[2]) 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
- FAST_DELETE_JOB =
Pro Fast API (§11): server-side LRANGE+LREM to delete a single job by jid from a queue list. Pure-Ruby Queue#find_job + JobRecord#delete is O(N) round-trips; this is O(1) round-trip with O(N) Lua work. KEYS = [queue:
] ARGV = [jid] Returns the number of payloads removed (0 or 1; can be >1 in pathological duplicate-jid corruption — caller doesn't rely on the value). <<~LUA local items = redis.call("lrange", KEYS[1], 0, -1) local removed = 0 for i = 1, #items do if string.find(items[i], '"jid":"' .. ARGV[1] .. '"', 1, true) then removed = removed + redis.call("lrem", KEYS[1], 1, items[i]) end end return removed LUA
- FAST_DELETE_BY_CLASS =
Pro Fast API (§11): server-side LRANGE+LREM removing every payload whose
"class":"<klass>"field matches. Plain-text scan (no JSON parse) so it tolerates partial corruption — caller drops only well-formed matches. KEYS = [queue:] ARGV = [klass] Returns the number of payloads removed. <<~LUA local items = redis.call("lrange", KEYS[1], 0, -1) local removed = 0 local needle = '"class":"' .. ARGV[1] .. '"' for i = 1, #items do if string.find(items[i], needle, 1, true) then removed = removed + redis.call("lrem", KEYS[1], 1, items[i]) end end return removed 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 =
Limiter scripts live in
lib/wurk/lua/limiter_*.lua— one file per type. Loaded at boot, the file's basename (minus.lua) becomes the SCRIPTS key as a symbol. Keeping them as separate files makes diffing individual rate-limiter changes painless and keeps each script self- contained for theredis-cli --evaldebug workflow. File.('lua', __dir__)
- FILE_SCRIPTS =
Dir.glob(File.join(LUA_DIR, '*.lua')).each_with_object({}) do |path, h| h[File.basename(path, '.lua').to_sym] = File.read(path) end.freeze
- SCRIPTS =
{ zpopbyscore: ZPOPBYSCORE, bulk_push: BULK_PUSH, reliable_schedule_promote: RELIABLE_SCHEDULE_PROMOTE, reliable_requeue: RELIABLE_REQUEUE, batch_push: BATCH_PUSH, batch_schedule: BATCH_SCHEDULE, batch_ack_success: BATCH_ACK_SUCCESS, batch_ack_failed: BATCH_ACK_FAILED, batch_ack_complete: BATCH_ACK_COMPLETE, batch_invalidate: BATCH_INVALIDATE, batch_append_callback: BATCH_APPEND_CALLBACK, fast_delete_job: FAST_DELETE_JOB, fast_delete_by_class: FAST_DELETE_BY_CLASS, 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