@falcondev-oss/workflow 0.8.7 → 0.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +25 -10
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -7295,15 +7295,15 @@ var require_parser = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
7295
7295
|
}));
|
|
7296
7296
|
|
|
7297
7297
|
//#endregion
|
|
7298
|
-
//#region node_modules/.pnpm/groupmq@1.1.
|
|
7298
|
+
//#region node_modules/.pnpm/groupmq@1.1.1-next.2_ioredis@5.9.2/node_modules/groupmq/dist/index.js
|
|
7299
7299
|
var import_parser = /* @__PURE__ */ __toESM$1(require_parser(), 1);
|
|
7300
7300
|
const __INLINED_LUA_SCRIPTS__ = {
|
|
7301
7301
|
"change-delay": "-- argv: ns, jobId, newDelayUntil, now\nlocal ns = KEYS[1]\nlocal jobId = ARGV[1]\nlocal newDelayUntil = tonumber(ARGV[2])\nlocal now = tonumber(ARGV[3])\n\nlocal jobKey = ns .. \":job:\" .. jobId\nlocal delayedKey = ns .. \":delayed\"\nlocal readyKey = ns .. \":ready\"\n\n-- Check if job exists\nlocal exists = redis.call(\"EXISTS\", jobKey)\nif exists == 0 then\n return 0\nend\n\nlocal groupId = redis.call(\"HGET\", jobKey, \"groupId\")\nif not groupId then\n return 0\nend\n\nlocal gZ = ns .. \":g:\" .. groupId\n\n-- Update job's delayUntil field\nredis.call(\"HSET\", jobKey, \"delayUntil\", tostring(newDelayUntil))\n\n-- Check if job is currently in delayed set\nlocal inDelayed = redis.call(\"ZSCORE\", delayedKey, jobId)\n\nif newDelayUntil > 0 and newDelayUntil > now then\n -- Job should be delayed\n redis.call(\"HSET\", jobKey, \"status\", \"delayed\")\n if inDelayed then\n -- Update existing delay\n redis.call(\"ZADD\", delayedKey, newDelayUntil, jobId)\n else\n -- Move to delayed\n redis.call(\"ZADD\", delayedKey, newDelayUntil, jobId)\n -- If this is the head job, remove group from ready\n local head = redis.call(\"ZRANGE\", gZ, 0, 0)\n if head and #head > 0 and head[1] == jobId then\n redis.call(\"ZREM\", readyKey, groupId)\n end\n end\nelse\n -- Job should be ready immediately\n redis.call(\"HSET\", jobKey, \"status\", \"waiting\")\n if inDelayed then\n -- Remove from delayed\n redis.call(\"ZREM\", delayedKey, jobId)\n -- If this is the head job, ensure group is in ready\n local head = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 and head[1] == jobId then\n local headScore = tonumber(head[2])\n redis.call(\"ZADD\", readyKey, headScore, groupId)\n end\n end\nend\n\nreturn 1\n\n\n",
|
|
7302
7302
|
"check-stalled": "-- Check for stalled jobs and move them back to waiting or fail them\n-- KEYS: namespace, currentTime, gracePeriod, maxStalledCount\n-- Returns: array of [jobId, groupId, action] for each stalled job found\n-- action: \"recovered\" or \"failed\"\n\nlocal ns = KEYS[1]\nlocal now = tonumber(ARGV[1])\nlocal gracePeriod = tonumber(ARGV[2]) or 0\nlocal maxStalledCount = tonumber(ARGV[3]) or 1\n\n-- Circuit breaker for high concurrency: limit stalled job recovery\nlocal circuitBreakerKey = ns .. \":stalled:circuit\"\nlocal lastCheck = redis.call(\"GET\", circuitBreakerKey)\nif lastCheck then\n local lastCheckTime = tonumber(lastCheck)\n local circuitBreakerInterval = 2000\n if lastCheckTime and (now - lastCheckTime) < circuitBreakerInterval then\n return {}\n end\nend\nredis.call(\"SET\", circuitBreakerKey, now, \"PX\", 3000)\n\nlocal processingKey = ns .. \":processing\"\nlocal groupsKey = ns .. \":groups\"\n\n-- Candidates: jobs whose deadlines are past\nlocal candidates = redis.call(\"ZRANGEBYSCORE\", processingKey, 0, now - gracePeriod, \"LIMIT\", 0, 100)\nif not candidates or #candidates == 0 then\n return {}\nend\n\nlocal results = {}\n\nfor _, jobId in ipairs(candidates) do\n local jobKey = ns .. \":job:\" .. jobId\n local h = redis.call(\"HMGET\", jobKey, \"groupId\",\"stalledCount\",\"maxAttempts\",\"attempts\",\"status\",\"finishedOn\",\"score\")\n local groupId = h[1]\n if groupId then\n local stalledCount = tonumber(h[2]) or 0\n local maxAttempts = tonumber(h[3]) or 3\n local status = h[5]\n local finishedOn = tonumber(h[6] or \"0\")\n -- CRITICAL: Don't recover jobs that are completing (prevents race with completion)\n -- \"completing\" is a temporary state set by complete-with-metadata.lua to prevent races\n if status == \"processing\" then\n stalledCount = stalledCount + 1\n redis.call(\"HSET\", jobKey, \"stalledCount\", stalledCount)\n -- BullMQ-style: Remove from per-group active list\n local groupActiveKey = ns .. \":g:\" .. groupId .. \":active\"\n redis.call(\"LREM\", groupActiveKey, 1, jobId)\n \n if stalledCount >= maxStalledCount and maxStalledCount > 0 then\n redis.call(\"ZREM\", processingKey, jobId)\n local groupKey = ns .. \":g:\" .. groupId\n redis.call(\"ZREM\", groupKey, jobId)\n redis.call(\"DEL\", ns .. \":processing:\" .. jobId)\n redis.call(\"HSET\", jobKey, \"status\",\"failed\",\"finishedOn\", now,\n \"failedReason\", \"Job stalled \" .. stalledCount .. \" times (max: \" .. maxStalledCount .. \")\")\n redis.call(\"ZADD\", ns .. \":failed\", now, jobId)\n table.insert(results, jobId); table.insert(results, groupId); table.insert(results, \"failed\")\n else\n local stillInProcessing = redis.call(\"ZSCORE\", processingKey, jobId)\n if stillInProcessing then\n redis.call(\"ZREM\", processingKey, jobId)\n redis.call(\"DEL\", ns .. \":processing:\" .. jobId)\n local score = tonumber(h[7])\n if score then\n local groupKey2 = ns .. \":g:\" .. groupId\n redis.call(\"ZADD\", groupKey2, score, jobId)\n local head = redis.call(\"ZRANGE\", groupKey2, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 then\n local headScore = tonumber(head[2])\n redis.call(\"ZADD\", ns .. \":ready\", headScore, groupId)\n end\n redis.call(\"SADD\", groupsKey, groupId)\n end\n redis.call(\"HSET\", jobKey, \"status\", \"waiting\")\n table.insert(results, jobId); table.insert(results, groupId); table.insert(results, \"recovered\")\n end\n end\n end\n end\nend\n\nreturn results\n\n",
|
|
7303
7303
|
"clean-status": "-- argv: ns, status, graceAtMs, limit\nlocal ns = KEYS[1]\nlocal status = ARGV[1]\nlocal graceAt = tonumber(ARGV[2]) or 0\nlocal limit = tonumber(ARGV[3]) or 1000\n\nlocal setKey = nil\nif status == 'completed' then\n setKey = ns .. ':completed'\nelseif status == 'failed' then\n setKey = ns .. ':failed'\nelseif status == 'delayed' then\n setKey = ns .. ':delayed'\nelse\n -- unsupported status for clean\n return 0\nend\n\n-- Fetch up to 'limit' job ids with score <= graceAt\nlocal ids = redis.call('ZRANGEBYSCORE', setKey, '-inf', graceAt, 'LIMIT', 0, limit)\n\nlocal removed = 0\nfor i = 1, #ids do\n local id = ids[i]\n local jobKey = ns .. ':job:' .. id\n\n -- Remove from the primary set first to avoid reprocessing\n redis.call('ZREM', setKey, id)\n\n -- Remove from group and update ready queue for ALL statuses\n -- This prevents poisoned groups when completed/failed jobs are cleaned\n local groupId = redis.call('HGET', jobKey, 'groupId')\n if groupId then\n local gZ = ns .. ':g:' .. groupId\n local readyKey = ns .. ':ready'\n redis.call('ZREM', gZ, id)\n local jobCount = redis.call('ZCARD', gZ)\n if jobCount == 0 then\n redis.call('ZREM', readyKey, groupId)\n -- Clean up empty group\n redis.call('DEL', gZ)\n redis.call('SREM', ns .. ':groups', groupId)\n elseif status == 'delayed' then\n -- Only update ready queue score for delayed jobs\n -- (completed/failed jobs shouldn't affect ready queue)\n local head = redis.call('ZRANGE', gZ, 0, 0, 'WITHSCORES')\n if head and #head >= 2 then\n local headScore = tonumber(head[2])\n redis.call('ZADD', readyKey, headScore, groupId)\n end\n end\n end\n\n -- Delete job hash and idempotence key\n redis.call('DEL', jobKey)\n redis.call('DEL', ns .. ':unique:' .. id)\n\n removed = removed + 1\nend\n\nreturn removed\n\n\n",
|
|
7304
7304
|
"cleanup-poisoned-group": "-- argv: ns, groupId, now\nlocal ns = KEYS[1]\nlocal groupId = ARGV[1]\nlocal now = tonumber(ARGV[2])\n\nlocal readyKey = ns .. \":ready\"\nlocal gZ = ns .. \":g:\" .. groupId\nlocal lockKey = ns .. \":lock:\" .. groupId\n\n-- Check if group has any jobs at all\nlocal jobCount = redis.call(\"ZCARD\", gZ)\nif jobCount == 0 then\n redis.call(\"ZREM\", readyKey, groupId)\n return \"empty\"\nend\n\n-- Check if group is currently locked by another worker\nlocal lockValue = redis.call(\"GET\", lockKey)\nif lockValue then\n local lockTtl = redis.call(\"PTTL\", lockKey)\n if lockTtl > 0 then\n return \"locked\"\n end\nend\n\n-- Check if all jobs in the group have exceeded max attempts\nlocal jobs = redis.call(\"ZRANGE\", gZ, 0, -1)\nlocal reservableJobs = 0\nfor i = 1, #jobs do\n local jobId = jobs[i]\n local jobKey = ns .. \":job:\" .. jobId\n local attempts = tonumber(redis.call(\"HGET\", jobKey, \"attempts\"))\n local maxAttempts = tonumber(redis.call(\"HGET\", jobKey, \"maxAttempts\"))\n if attempts and maxAttempts and attempts < maxAttempts then\n reservableJobs = reservableJobs + 1\n end\nend\n\nif reservableJobs == 0 then\n redis.call(\"ZREM\", readyKey, groupId)\n return \"poisoned\"\nend\n\nreturn \"ok\"\n\n\n",
|
|
7305
|
-
"cleanup": "-- argv: ns, nowEpochMs\nlocal ns = KEYS[1]\nlocal now = tonumber(ARGV[1])\n\nlocal readyKey = ns .. \":ready\"\nlocal processingKey = ns .. \":processing\"\nlocal cleaned = 0\n\nlocal expiredJobs = redis.call(\"ZRANGEBYSCORE\", processingKey, 0, now)\nfor _, jobId in ipairs(expiredJobs) do\n -- CRITICAL: Verify job is STILL in processing to avoid race conditions\n -- If job was completed between our snapshot and now, don't re-add it\n local stillInProcessing = redis.call(\"ZSCORE\", processingKey, jobId)\n \n if stillInProcessing then\n local procKey = ns .. \":processing:\" .. jobId\n local procData = redis.call(\"HMGET\", procKey, \"groupId\", \"deadlineAt\")\n local gid = procData[1]\n local deadlineAt = tonumber(procData[2])\n if gid and deadlineAt and now > deadlineAt then\n local jobKey = ns .. \":job:\" .. jobId\n local jobScore = redis.call(\"HGET\", jobKey, \"score\")\n if jobScore then\n local gZ = ns .. \":g:\" .. gid\n redis.call(\"ZADD\", gZ, tonumber(jobScore), jobId)\n local head = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 then\n local headScore = tonumber(head[2])\n redis.call(\"ZADD\", readyKey, headScore, gid)\n end\n redis.call(\"DEL\", ns .. \":lock:\" .. gid)\n redis.call(\"DEL\", procKey)\n redis.call(\"ZREM\", processingKey, jobId)\n \n -- No counter operations - use ZCARD for counts\n \n cleaned = cleaned + 1\n end\n end\n end\n -- If not still in processing, it was completed - don't re-add it!\nend\n\nreturn cleaned\n\n\n",
|
|
7306
|
-
"complete-and-reserve-next-with-metadata": "-- Complete a job with metadata and atomically reserve the next job from the same group\n-- argv: ns, completedJobId, groupId, status, timestamp, resultOrError, keepCompleted, keepFailed,\n-- processedOn, finishedOn, attempts, maxAttempts, now, vt\nlocal ns = KEYS[1]\nlocal completedJobId = ARGV[1]\nlocal gid = ARGV[2]\nlocal status = ARGV[3]\nlocal timestamp = tonumber(ARGV[4])\nlocal resultOrError = ARGV[5]\nlocal keepCompleted = tonumber(ARGV[6])\nlocal keepFailed = tonumber(ARGV[7])\nlocal processedOn = ARGV[8]\nlocal finishedOn = ARGV[9]\nlocal attempts = ARGV[10]\nlocal maxAttempts = ARGV[11]\nlocal now = tonumber(ARGV[12])\nlocal vt = tonumber(ARGV[13])\n\n-- Part 1: Atomically verify and mark completion (prevent duplicate processing)\nlocal jobKey = ns .. \":job:\" .. completedJobId\nlocal processingKey = ns .. \":processing\"\n\n-- CRITICAL: Check both status AND processing set membership atomically\n-- This prevents race with stalled job recovery\nlocal jobStatus = redis.call(\"HGET\", jobKey, \"status\")\nlocal stillInProcessing = redis.call(\"ZSCORE\", processingKey, completedJobId)\n\n-- If job is not in \"processing\" state OR not in processing set, this is late/duplicate\nif jobStatus ~= \"processing\" or not stillInProcessing then\n return nil\nend\n\n-- Atomically mark as completed and remove from processing\n-- This prevents stalled checker from racing with us\nredis.call(\"HSET\", jobKey, \"status\", \"completing\") -- Temporary status to block stalled checker\nredis.call(\"DEL\", ns .. \":processing:\" .. completedJobId)\nredis.call(\"ZREM\", processingKey, completedJobId)\n\n-- Part 3: Record job metadata (completed or failed)\n\nif status == \"completed\" then\n local completedKey = ns .. \":completed\"\n \n -- CRITICAL: Always set final status first, even if job will be deleted\n -- This ensures any concurrent reads see \"completed\", not \"completing\"\n redis.call(\"HSET\", jobKey, \"status\", \"completed\")\n \n if keepCompleted > 0 then\n -- Store full job metadata and add to completed set\n redis.call(\"HSET\", jobKey, \n \"processedOn\", processedOn,\n \"finishedOn\", finishedOn,\n \"attempts\", attempts,\n \"maxAttempts\", maxAttempts,\n \"returnvalue\", resultOrError\n )\n redis.call(\"ZADD\", completedKey, timestamp, completedJobId)\n \n -- Trim old entries atomically\n local zcount = redis.call(\"ZCARD\", completedKey)\n local toRemove = zcount - keepCompleted\n if toRemove > 0 then\n local oldIds = redis.call(\"ZRANGE\", completedKey, 0, toRemove - 1)\n if #oldIds > 0 then\n redis.call(\"ZREMRANGEBYRANK\", completedKey, 0, toRemove - 1)\n for i = 1, #oldIds do\n local oldId = oldIds[i]\n redis.call(\"DEL\", ns .. \":job:\" .. oldId)\n redis.call(\"DEL\", ns .. \":unique:\" .. oldId)\n end\n end\n end\n else\n -- keepCompleted == 0: Delete immediately (status already set above)\n redis.call(\"DEL\", jobKey)\n redis.call(\"DEL\", ns .. \":unique:\" .. completedJobId)\n end\n \nelseif status == \"failed\" then\n local failedKey = ns .. \":failed\"\n local errorInfo = cjson.decode(resultOrError)\n \n -- CRITICAL: Always set final status first, even if job will be deleted\n redis.call(\"HSET\", jobKey, \"status\", \"failed\")\n \n if keepFailed > 0 then\n redis.call(\"HSET\", jobKey,\n \"failedReason\", errorInfo.message or \"Error\",\n \"failedName\", errorInfo.name or \"Error\",\n \"stacktrace\", errorInfo.stack or \"\",\n \"processedOn\", processedOn,\n \"finishedOn\", finishedOn,\n \"attempts\", attempts,\n \"maxAttempts\", maxAttempts\n )\n redis.call(\"ZADD\", failedKey, timestamp, completedJobId)\n else\n -- Delete job (status already set above)\n redis.call(\"DEL\", jobKey)\n redis.call(\"DEL\", ns .. \":unique:\" .. completedJobId)\n end\nend\n\n-- Part 3: Handle group active list and get next job (BullMQ-style)\nlocal groupActiveKey = ns .. \":g:\" .. gid .. \":active\"\nlocal activeJobId = redis.call(\"LINDEX\", groupActiveKey, 0)\n\n-- Always clean up this job from active list, even if not at head\n-- This prevents stale active lists from race conditions\nif activeJobId == completedJobId then\n -- Normal case: this job is at the head of active list\n redis.call(\"LPOP\", groupActiveKey)\nelse\n -- Race condition: job is not at head (maybe already removed, or wrong job)\n -- Clean it up anyway to prevent stale entries\n redis.call(\"LREM\", groupActiveKey, 1, completedJobId)\n \n -- If active list had a different job or was empty, don't try to reserve next\n -- Return nil to indicate no chaining\n return nil\nend\n\nlocal gZ = ns .. \":g:\" .. gid\nlocal zpop = redis.call(\"ZPOPMIN\", gZ, 1)\nif not zpop or #zpop == 0 then\n -- Clean up empty group\n local jobCount = redis.call(\"ZCARD\", gZ)\n if jobCount == 0 then\n redis.call(\"DEL\", gZ)\n redis.call(\"SREM\", ns .. \":groups\", gid)\n redis.call(\"ZREM\", ns .. \":ready\", gid)\n end\n -- No next job\n return nil\nend\n\nlocal nextJobId = zpop[1]\nlocal nextJobKey = ns .. \":job:\" .. nextJobId\nlocal job = redis.call(\"HMGET\", nextJobKey, \"id\",\"groupId\",\"data\",\"attempts\",\"maxAttempts\",\"seq\",\"timestamp\",\"orderMs\",\"score\")\nlocal id, groupId, payload, attempts, maxAttempts, seq, enq, orderMs, score = job[1], job[2], job[3], job[4], job[5], job[6], job[7], job[8], job[9]\n\n-- Validate job data exists (handle corrupted/missing job hash)\nif not id or id == false then\n -- Job hash is missing/corrupted, clean up and return completion only\n -- Re-add next job to ready queue if exists\n local nextHead = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if nextHead and #nextHead >= 2 then\n local nextScore = tonumber(nextHead[2])\n local readyKey = ns .. \":ready\"\n redis.call(\"ZADD\", readyKey, nextScore, groupId)\n end\n \n -- Return nil to indicate no next job was reserved\n return nil\nend\n\n-- Push next job to active list (chaining)\nredis.call(\"LPUSH\", groupActiveKey, id)\n\nlocal procKey = ns .. \":processing:\" .. id\nlocal deadline = now + vt\nredis.call(\"HSET\", procKey, \"groupId\", groupId, \"deadlineAt\", tostring(deadline))\n\nlocal processingKey = ns .. \":processing\"\nredis.call(\"ZADD\", processingKey, deadline, id)\n\n-- Mark next job as processing for accurate stalled detection\nredis.call(\"HSET\", nextJobKey, \"status\", \"processing\")\n\nlocal nextHead = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\nif nextHead and #nextHead >= 2 then\n local nextScore = tonumber(nextHead[2])\n local readyKey = ns .. \":ready\"\n redis.call(\"ZADD\", readyKey, nextScore, groupId)\nend\n\nreturn id .. \"
|
|
7305
|
+
"cleanup": "-- argv: ns, nowEpochMs\nlocal ns = KEYS[1]\nlocal now = tonumber(ARGV[1])\n\nlocal readyKey = ns .. \":ready\"\nlocal processingKey = ns .. \":processing\"\nlocal cleaned = 0\n\nlocal expiredJobs = redis.call(\"ZRANGEBYSCORE\", processingKey, 0, now)\nfor _, jobId in ipairs(expiredJobs) do\n -- CRITICAL: Verify job is STILL in processing to avoid race conditions\n -- If job was completed between our snapshot and now, don't re-add it\n local stillInProcessing = redis.call(\"ZSCORE\", processingKey, jobId)\n \n if stillInProcessing then\n local procKey = ns .. \":processing:\" .. jobId\n local procData = redis.call(\"HMGET\", procKey, \"groupId\", \"deadlineAt\")\n local gid = procData[1]\n local deadlineAt = tonumber(procData[2])\n if gid and deadlineAt and now > deadlineAt then\n local jobKey = ns .. \":job:\" .. jobId\n local jobScore = redis.call(\"HGET\", jobKey, \"score\")\n if jobScore then\n local gZ = ns .. \":g:\" .. gid\n -- CRITICAL: Check if job is already in group set before re-adding\n -- This prevents duplicate entries and ensures we only re-add if truly needed\n local alreadyInGroup = redis.call(\"ZSCORE\", gZ, jobId)\n if not alreadyInGroup then\n redis.call(\"ZADD\", gZ, tonumber(jobScore), jobId)\n end\n -- CRITICAL: Reset status from \"processing\" to \"waiting\" when re-adding expired job\n -- This prevents jobs from being stuck with \"processing\" status in the group set\n redis.call(\"HSET\", jobKey, \"status\", \"waiting\")\n local head = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 then\n local headScore = tonumber(head[2])\n redis.call(\"ZADD\", readyKey, headScore, gid)\n end\n -- Remove from group active list (BullMQ-style)\n local groupActiveKey = ns .. \":g:\" .. gid .. \":active\"\n redis.call(\"LREM\", groupActiveKey, 1, jobId)\n redis.call(\"DEL\", ns .. \":lock:\" .. gid)\n redis.call(\"DEL\", procKey)\n redis.call(\"ZREM\", processingKey, jobId)\n \n -- No counter operations - use ZCARD for counts\n \n cleaned = cleaned + 1\n end\n end\n end\n -- If not still in processing, it was completed - don't re-add it!\nend\n\nreturn cleaned\n\n\n",
|
|
7306
|
+
"complete-and-reserve-next-with-metadata": "-- Complete a job with metadata and atomically reserve the next job from the same group\n-- argv: ns, completedJobId, groupId, status, timestamp, resultOrError, keepCompleted, keepFailed,\n-- processedOn, finishedOn, attempts, maxAttempts, now, vt\nlocal ns = KEYS[1]\nlocal completedJobId = ARGV[1]\nlocal gid = ARGV[2]\nlocal status = ARGV[3]\nlocal timestamp = tonumber(ARGV[4])\nlocal resultOrError = ARGV[5]\nlocal keepCompleted = tonumber(ARGV[6])\nlocal keepFailed = tonumber(ARGV[7])\nlocal processedOn = ARGV[8]\nlocal finishedOn = ARGV[9]\nlocal attempts = ARGV[10]\nlocal maxAttempts = ARGV[11]\nlocal now = tonumber(ARGV[12])\nlocal vt = tonumber(ARGV[13])\n\n-- Part 1: Atomically verify and mark completion (prevent duplicate processing)\nlocal jobKey = ns .. \":job:\" .. completedJobId\nlocal processingKey = ns .. \":processing\"\n\n-- CRITICAL: Check both status AND processing set membership atomically\n-- This prevents race with stalled job recovery\nlocal jobStatus = redis.call(\"HGET\", jobKey, \"status\")\nlocal stillInProcessing = redis.call(\"ZSCORE\", processingKey, completedJobId)\n\n-- If job is not in \"processing\" state OR not in processing set, this is late/duplicate\nif jobStatus ~= \"processing\" or not stillInProcessing then\n return nil\nend\n\n-- Atomically mark as completed and remove from processing\n-- This prevents stalled checker from racing with us\nredis.call(\"HSET\", jobKey, \"status\", \"completing\") -- Temporary status to block stalled checker\nredis.call(\"DEL\", ns .. \":processing:\" .. completedJobId)\nredis.call(\"ZREM\", processingKey, completedJobId)\n\n-- Part 3: Record job metadata (completed or failed)\n\nif status == \"completed\" then\n local completedKey = ns .. \":completed\"\n \n -- CRITICAL: Always set final status first, even if job will be deleted\n -- This ensures any concurrent reads see \"completed\", not \"completing\"\n redis.call(\"HSET\", jobKey, \"status\", \"completed\")\n \n if keepCompleted > 0 then\n -- Store full job metadata and add to completed set\n redis.call(\"HSET\", jobKey, \n \"processedOn\", processedOn,\n \"finishedOn\", finishedOn,\n \"attempts\", attempts,\n \"maxAttempts\", maxAttempts,\n \"returnvalue\", resultOrError\n )\n redis.call(\"ZADD\", completedKey, timestamp, completedJobId)\n \n -- Trim old entries atomically\n local zcount = redis.call(\"ZCARD\", completedKey)\n local toRemove = zcount - keepCompleted\n if toRemove > 0 then\n local oldIds = redis.call(\"ZRANGE\", completedKey, 0, toRemove - 1)\n if #oldIds > 0 then\n redis.call(\"ZREMRANGEBYRANK\", completedKey, 0, toRemove - 1)\n for i = 1, #oldIds do\n local oldId = oldIds[i]\n redis.call(\"DEL\", ns .. \":job:\" .. oldId)\n redis.call(\"DEL\", ns .. \":unique:\" .. oldId)\n end\n end\n end\n else\n -- keepCompleted == 0: Delete immediately (status already set above)\n redis.call(\"DEL\", jobKey)\n redis.call(\"DEL\", ns .. \":unique:\" .. completedJobId)\n end\n \nelseif status == \"failed\" then\n local failedKey = ns .. \":failed\"\n local errorInfo = cjson.decode(resultOrError)\n \n -- CRITICAL: Always set final status first, even if job will be deleted\n redis.call(\"HSET\", jobKey, \"status\", \"failed\")\n \n if keepFailed > 0 then\n redis.call(\"HSET\", jobKey,\n \"failedReason\", errorInfo.message or \"Error\",\n \"failedName\", errorInfo.name or \"Error\",\n \"stacktrace\", errorInfo.stack or \"\",\n \"processedOn\", processedOn,\n \"finishedOn\", finishedOn,\n \"attempts\", attempts,\n \"maxAttempts\", maxAttempts\n )\n redis.call(\"ZADD\", failedKey, timestamp, completedJobId)\n else\n -- Delete job (status already set above)\n redis.call(\"DEL\", jobKey)\n redis.call(\"DEL\", ns .. \":unique:\" .. completedJobId)\n end\nend\n\n-- Part 3: Handle group active list and get next job (BullMQ-style)\nlocal groupActiveKey = ns .. \":g:\" .. gid .. \":active\"\nlocal activeJobId = redis.call(\"LINDEX\", groupActiveKey, 0)\n\n-- Always clean up this job from active list, even if not at head\n-- This prevents stale active lists from race conditions\nif activeJobId == completedJobId then\n -- Normal case: this job is at the head of active list\n redis.call(\"LPOP\", groupActiveKey)\nelse\n -- Race condition: job is not at head (maybe already removed, or wrong job)\n -- Clean it up anyway to prevent stale entries\n redis.call(\"LREM\", groupActiveKey, 1, completedJobId)\n \n -- If active list had a different job or was empty, don't try to reserve next\n -- Return nil to indicate no chaining\n return nil\nend\n\nlocal gZ = ns .. \":g:\" .. gid\nlocal zpop = redis.call(\"ZPOPMIN\", gZ, 1)\nif not zpop or #zpop == 0 then\n -- Clean up empty group\n local jobCount = redis.call(\"ZCARD\", gZ)\n if jobCount == 0 then\n redis.call(\"DEL\", gZ)\n redis.call(\"SREM\", ns .. \":groups\", gid)\n redis.call(\"ZREM\", ns .. \":ready\", gid)\n end\n -- No next job\n return nil\nend\n\nlocal nextJobId = zpop[1]\nlocal nextJobKey = ns .. \":job:\" .. nextJobId\nlocal job = redis.call(\"HMGET\", nextJobKey, \"id\",\"groupId\",\"data\",\"attempts\",\"maxAttempts\",\"seq\",\"timestamp\",\"orderMs\",\"score\")\nlocal id, groupId, payload, attempts, maxAttempts, seq, enq, orderMs, score = job[1], job[2], job[3], job[4], job[5], job[6], job[7], job[8], job[9]\n\n-- Validate job data exists (handle corrupted/missing job hash)\nif not id or id == false then\n -- Job hash is missing/corrupted, clean up and return completion only\n -- Re-add next job to ready queue if exists\n local nextHead = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if nextHead and #nextHead >= 2 then\n local nextScore = tonumber(nextHead[2])\n local readyKey = ns .. \":ready\"\n redis.call(\"ZADD\", readyKey, nextScore, groupId)\n end\n \n -- Return nil to indicate no next job was reserved\n return nil\nend\n\n-- Push next job to active list (chaining)\nredis.call(\"LPUSH\", groupActiveKey, id)\n\nlocal procKey = ns .. \":processing:\" .. id\nlocal deadline = now + vt\nredis.call(\"HSET\", procKey, \"groupId\", groupId, \"deadlineAt\", tostring(deadline))\n\nlocal processingKey = ns .. \":processing\"\nredis.call(\"ZADD\", processingKey, deadline, id)\n\n-- Mark next job as processing for accurate stalled detection\nredis.call(\"HSET\", nextJobKey, \"status\", \"processing\")\n\nlocal nextHead = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\nif nextHead and #nextHead >= 2 then\n local nextScore = tonumber(nextHead[2])\n local readyKey = ns .. \":ready\"\n redis.call(\"ZADD\", readyKey, nextScore, groupId)\nend\n\nreturn id .. \"||GROUPMQ||\" .. groupId .. \"||GROUPMQ||\" .. payload .. \"||GROUPMQ||\" .. attempts .. \"||GROUPMQ||\" .. maxAttempts .. \"||GROUPMQ||\" .. seq .. \"||GROUPMQ||\" .. enq .. \"||GROUPMQ||\" .. orderMs .. \"||GROUPMQ||\" .. score .. \"||GROUPMQ||\" .. deadline",
|
|
7307
7307
|
"complete-with-metadata": "-- Complete a job: unlock group AND record metadata atomically in one call\n-- argv: ns, jobId, groupId, status, timestamp, resultOrError, keepCompleted, keepFailed,\n-- processedOn, finishedOn, attempts, maxAttempts\nlocal ns = KEYS[1]\nlocal jobId = ARGV[1]\nlocal gid = ARGV[2]\nlocal status = ARGV[3]\nlocal timestamp = tonumber(ARGV[4])\nlocal resultOrError = ARGV[5]\nlocal keepCompleted = tonumber(ARGV[6])\nlocal keepFailed = tonumber(ARGV[7])\nlocal processedOn = ARGV[8]\nlocal finishedOn = ARGV[9]\nlocal attempts = ARGV[10]\nlocal maxAttempts = ARGV[11]\n\n-- Part 1: Atomically verify and mark completion (prevent duplicate processing)\nlocal jobKey = ns .. \":job:\" .. jobId\nlocal processingKey = ns .. \":processing\"\n\n-- CRITICAL: Check both status AND processing set membership atomically\n-- This prevents race with stalled job recovery\nlocal jobStatus = redis.call(\"HGET\", jobKey, \"status\")\nlocal stillInProcessing = redis.call(\"ZSCORE\", processingKey, jobId)\n\n-- If job is not in \"processing\" state OR not in processing set, this is late/duplicate\nif jobStatus ~= \"processing\" or not stillInProcessing then\n -- Job was already handled (recovered, failed, or completed by another worker)\n -- Return 0 to indicate this completion was ignored\n return 0\nend\n\n-- Atomically mark as completed and remove from processing\n-- This prevents stalled checker from racing with us\nredis.call(\"HSET\", jobKey, \"status\", \"completing\") -- Temporary status to block stalled checker\nredis.call(\"DEL\", ns .. \":processing:\" .. jobId)\nredis.call(\"ZREM\", processingKey, jobId)\n\n-- Always remove this job from active list to prevent stale entries\nlocal groupActiveKey = ns .. \":g:\" .. gid .. \":active\"\nlocal activeJobId = redis.call(\"LINDEX\", groupActiveKey, 0)\nlocal wasActive = (activeJobId == jobId)\n\nif wasActive then\n -- Normal case: remove from head of active list\n redis.call(\"LPOP\", groupActiveKey)\nelse\n -- Race condition: not at head, but still remove to prevent stale entries\n redis.call(\"LREM\", groupActiveKey, 1, jobId)\nend\n\n-- Check if there are more jobs in this group\nlocal gZ = ns .. \":g:\" .. gid\nlocal jobCount = redis.call(\"ZCARD\", gZ)\nif jobCount == 0 then\n -- Remove empty group\n redis.call(\"DEL\", gZ)\n redis.call(\"DEL\", groupActiveKey)\n redis.call(\"SREM\", ns .. \":groups\", gid)\n redis.call(\"ZREM\", ns .. \":ready\", gid)\n redis.call(\"DEL\", ns .. \":buffer:\" .. gid)\n redis.call(\"ZREM\", ns .. \":buffering\", gid)\nelse\n -- Group has more jobs, re-add to ready if not buffering\n local groupBufferKey = ns .. \":buffer:\" .. gid\n local isBuffering = redis.call(\"EXISTS\", groupBufferKey)\n \n if isBuffering == 0 then\n local nextHead = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if nextHead and #nextHead >= 2 then\n local nextScore = tonumber(nextHead[2])\n local readyKey = ns .. \":ready\"\n redis.call(\"ZADD\", readyKey, nextScore, gid)\n end\n end\nend\n\n-- Part 2: Record job metadata (completed or failed)\nlocal jobKey = ns .. \":job:\" .. jobId\n\nif status == \"completed\" then\n local completedKey = ns .. \":completed\"\n \n -- CRITICAL: Always set final status first, even if job will be deleted\n -- This ensures any concurrent reads see \"completed\", not \"completing\"\n redis.call(\"HSET\", jobKey, \"status\", \"completed\")\n \n if keepCompleted > 0 then\n -- Store full job metadata and add to completed set\n redis.call(\"HSET\", jobKey, \n \"processedOn\", processedOn,\n \"finishedOn\", finishedOn,\n \"attempts\", attempts,\n \"maxAttempts\", maxAttempts,\n \"returnvalue\", resultOrError\n )\n redis.call(\"ZADD\", completedKey, timestamp, jobId)\n \n -- Trim old entries atomically\n local zcount = redis.call(\"ZCARD\", completedKey)\n local toRemove = zcount - keepCompleted\n if toRemove > 0 then\n local oldIds = redis.call(\"ZRANGE\", completedKey, 0, toRemove - 1)\n if #oldIds > 0 then\n redis.call(\"ZREMRANGEBYRANK\", completedKey, 0, toRemove - 1)\n for i = 1, #oldIds do\n local oldId = oldIds[i]\n redis.call(\"DEL\", ns .. \":job:\" .. oldId)\n redis.call(\"DEL\", ns .. \":unique:\" .. oldId)\n end\n end\n end\n else\n -- keepCompleted == 0: Delete immediately (status already set above)\n redis.call(\"DEL\", jobKey)\n redis.call(\"DEL\", ns .. \":unique:\" .. jobId)\n end\n \nelseif status == \"failed\" then\n local failedKey = ns .. \":failed\"\n local errorInfo = cjson.decode(resultOrError)\n \n -- CRITICAL: Always set final status first, even if job will be deleted\n redis.call(\"HSET\", jobKey, \"status\", \"failed\")\n \n if keepFailed > 0 then\n redis.call(\"HSET\", jobKey,\n \"failedReason\", errorInfo.message or \"Error\",\n \"failedName\", errorInfo.name or \"Error\",\n \"stacktrace\", errorInfo.stack or \"\",\n \"processedOn\", processedOn,\n \"finishedOn\", finishedOn,\n \"attempts\", attempts,\n \"maxAttempts\", maxAttempts\n )\n redis.call(\"ZADD\", failedKey, timestamp, jobId)\n else\n -- Delete job (status already set above)\n redis.call(\"DEL\", jobKey)\n redis.call(\"DEL\", ns .. \":unique:\" .. jobId)\n end\nend\n\nreturn 1\n\n",
|
|
7308
7308
|
"complete": "-- Complete a job by removing from processing and unlocking the group\n-- Does NOT record job metadata - that's handled separately by record-job-result.lua\n-- argv: ns, jobId, groupId\nlocal ns = KEYS[1]\nlocal jobId = ARGV[1]\nlocal gid = ARGV[2]\n\n-- Remove from processing\nredis.call(\"DEL\", ns .. \":processing:\" .. jobId)\nredis.call(\"ZREM\", ns .. \":processing\", jobId)\n\n-- Check if this job holds the lock\nlocal lockKey = ns .. \":lock:\" .. gid\nlocal val = redis.call(\"GET\", lockKey)\nif val == jobId then\n redis.call(\"DEL\", lockKey)\n \n -- Check if there are more jobs in this group\n local gZ = ns .. \":g:\" .. gid\n local jobCount = redis.call(\"ZCARD\", gZ)\n if jobCount == 0 then\n -- Remove empty group zset and from groups tracking set\n redis.call(\"DEL\", gZ)\n redis.call(\"SREM\", ns .. \":groups\", gid)\n -- Remove from ready queue\n redis.call(\"ZREM\", ns .. \":ready\", gid)\n -- Clean up any buffering state (shouldn't exist but be safe)\n redis.call(\"DEL\", ns .. \":buffer:\" .. gid)\n redis.call(\"ZREM\", ns .. \":buffering\", gid)\n else\n -- Group has more jobs, re-add to ready set\n -- Note: If the group was buffering, it will be handled by the buffering logic\n -- If it's not buffering, add to ready immediately\n local groupBufferKey = ns .. \":buffer:\" .. gid\n local isBuffering = redis.call(\"EXISTS\", groupBufferKey)\n \n if isBuffering == 0 then\n -- Not buffering, add to ready immediately\n local nextHead = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if nextHead and #nextHead >= 2 then\n local nextScore = tonumber(nextHead[2])\n local readyKey = ns .. \":ready\"\n redis.call(\"ZADD\", readyKey, nextScore, gid)\n end\n end\n -- If buffering, the scheduler will promote when ready\n end\n \n return 1\nend\nreturn 0\n",
|
|
7309
7309
|
"dead-letter": "-- argv: ns, jobId, groupId\nlocal ns = KEYS[1]\nlocal jobId = ARGV[1]\nlocal groupId = ARGV[2]\nlocal gZ = ns .. \":g:\" .. groupId\nlocal readyKey = ns .. \":ready\"\n\n-- Remove job from group\nredis.call(\"ZREM\", gZ, jobId)\n\n-- Remove from processing if it's there\nredis.call(\"DEL\", ns .. \":processing:\" .. jobId)\nredis.call(\"ZREM\", ns .. \":processing\", jobId)\n\n-- No counter operations - use ZCARD for counts\n\n-- Remove idempotence mapping to allow reuse\nredis.call(\"DEL\", ns .. \":unique:\" .. jobId)\n\n-- BullMQ-style: Remove from group active list if present\nlocal groupActiveKey = ns .. \":g:\" .. groupId .. \":active\"\nredis.call(\"LREM\", groupActiveKey, 1, jobId)\n\n-- Check if group is now empty or should be removed from ready queue\nlocal jobCount = redis.call(\"ZCARD\", gZ)\nif jobCount == 0 then\n -- Group is empty, remove from ready queue and clean up\n redis.call(\"ZREM\", readyKey, groupId)\n redis.call(\"DEL\", gZ)\n redis.call(\"DEL\", groupActiveKey)\n redis.call(\"SREM\", ns .. \":groups\", groupId)\nelse\n -- Group still has jobs, update ready queue with new head\n local head = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 then\n local headScore = tonumber(head[2])\n redis.call(\"ZADD\", readyKey, headScore, groupId)\n end\nend\n\n-- Optionally store in dead letter queue (uncomment if needed)\n-- redis.call(\"LPUSH\", ns .. \":dead\", jobId)\n\nreturn 1\n\n",
|
|
@@ -7324,9 +7324,9 @@ const __INLINED_LUA_SCRIPTS__ = {
|
|
|
7324
7324
|
"promote-staged": "-- Promote staged jobs that are now ready to be processed\n-- argv: ns, now, limit\nlocal ns = KEYS[1]\nlocal now = tonumber(ARGV[1])\nlocal limit = tonumber(ARGV[2]) or 100\n\nlocal stageKey = ns .. \":stage\"\nlocal readyKey = ns .. \":ready\"\nlocal timerKey = ns .. \":stage:timer\"\n\nlocal promotedCount = 0\n\n-- Get jobs that are ready (score <= now)\nlocal readyJobs = redis.call(\"ZRANGEBYSCORE\", stageKey, 0, now, \"LIMIT\", 0, limit)\n\nfor i = 1, #readyJobs do\n local jobId = readyJobs[i]\n local jobKey = ns .. \":job:\" .. jobId\n \n -- Get job metadata\n local jobData = redis.call(\"HMGET\", jobKey, \"groupId\", \"score\", \"status\")\n local groupId = jobData[1]\n local score = jobData[2]\n local status = jobData[3]\n \n if groupId and score and status == \"staged\" then\n local gZ = ns .. \":g:\" .. groupId\n \n -- Remove from staging set\n redis.call(\"ZREM\", stageKey, jobId)\n \n -- Add to group ZSET with original score\n redis.call(\"ZADD\", gZ, tonumber(score), jobId)\n \n -- Update job status from \"staged\" to \"waiting\"\n redis.call(\"HSET\", jobKey, \"status\", \"waiting\")\n \n -- Check if group should be added to ready queue\n -- Add group to ready if the head job is now waiting (not delayed or staged)\n local head = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 then\n local headJobId = head[1]\n local headScore = tonumber(head[2])\n local headJobKey = ns .. \":job:\" .. headJobId\n local headStatus = redis.call(\"HGET\", headJobKey, \"status\")\n \n -- Only add to ready if head is waiting (not delayed/staged)\n if headStatus == \"waiting\" then\n redis.call(\"ZADD\", readyKey, headScore, groupId)\n end\n end\n \n promotedCount = promotedCount + 1\n end\nend\n\n-- Recompute timer: set to the next earliest staged job\nlocal nextHead = redis.call(\"ZRANGE\", stageKey, 0, 0, \"WITHSCORES\")\nif nextHead and #nextHead >= 2 then\n local nextReleaseAt = tonumber(nextHead[2])\n -- Set timer to expire when the next earliest job is ready\n local ttlMs = math.max(1, nextReleaseAt - now)\n redis.call(\"SET\", timerKey, \"1\", \"PX\", ttlMs)\nelse\n -- No more staged jobs, delete the timer\n redis.call(\"DEL\", timerKey)\nend\n\nreturn promotedCount\n\n",
|
|
7325
7325
|
"record-job-result": "-- Record job completion or failure with retention management\n-- argv: ns, jobId, status ('completed' | 'failed'), timestamp, result/error (JSON), \n-- keepCompleted, keepFailed, processedOn, finishedOn, attempts, maxAttempts\nlocal ns = KEYS[1]\nlocal jobId = ARGV[1]\nlocal status = ARGV[2]\nlocal timestamp = tonumber(ARGV[3])\nlocal resultOrError = ARGV[4]\nlocal keepCompleted = tonumber(ARGV[5])\nlocal keepFailed = tonumber(ARGV[6])\nlocal processedOn = ARGV[7]\nlocal finishedOn = ARGV[8]\nlocal attempts = ARGV[9]\nlocal maxAttempts = ARGV[10]\n\nlocal jobKey = ns .. \":job:\" .. jobId\n\n-- Verify job exists and check current status to prevent race conditions\nlocal currentStatus = redis.call(\"HGET\", jobKey, \"status\")\nif not currentStatus then\n -- Job doesn't exist, likely already cleaned up\n return 0\nend\n\n-- If job is in \"waiting\" state, this might be a late completion after stalled recovery\n-- In this case, we should not overwrite the status or delete the job\nif currentStatus == \"waiting\" then\n -- Job was recovered by stalled check and possibly being processed by another worker\n -- Ignore this late completion to prevent corruption\n return 0\nend\n\nif status == \"completed\" then\n local completedKey = ns .. \":completed\"\n \n if keepCompleted > 0 then\n -- Store job metadata and add to completed set\n redis.call(\"HSET\", jobKey, \n \"status\", \"completed\",\n \"processedOn\", processedOn,\n \"finishedOn\", finishedOn,\n \"attempts\", attempts,\n \"maxAttempts\", maxAttempts,\n \"returnvalue\", resultOrError\n )\n redis.call(\"ZADD\", completedKey, timestamp, jobId)\n -- Ensure idempotence mapping exists\n redis.call(\"SET\", ns .. \":unique:\" .. jobId, jobId)\n \n -- Trim old entries atomically\n local zcount = redis.call(\"ZCARD\", completedKey)\n local toRemove = zcount - keepCompleted\n if toRemove > 0 then\n local oldIds = redis.call(\"ZRANGE\", completedKey, 0, toRemove - 1)\n if #oldIds > 0 then\n redis.call(\"ZREMRANGEBYRANK\", completedKey, 0, toRemove - 1)\n -- Batch delete old jobs and unique keys\n local keysToDelete = {}\n for i = 1, #oldIds do\n local oldId = oldIds[i]\n table.insert(keysToDelete, ns .. \":job:\" .. oldId)\n table.insert(keysToDelete, ns .. \":unique:\" .. oldId)\n end\n if #keysToDelete > 0 then\n redis.call(\"DEL\", unpack(keysToDelete))\n end\n end\n end\n else\n -- keepCompleted == 0: Delete immediately (batch operation)\n redis.call(\"DEL\", jobKey, ns .. \":unique:\" .. jobId)\n end\n \nelseif status == \"failed\" then\n local failedKey = ns .. \":failed\"\n \n -- Parse error info from resultOrError JSON\n -- Expected format: {\"message\":\"...\", \"name\":\"...\", \"stack\":\"...\"}\n local errorInfo = cjson.decode(resultOrError)\n \n if keepFailed > 0 then\n -- Store failure metadata\n redis.call(\"HSET\", jobKey,\n \"status\", \"failed\",\n \"failedReason\", errorInfo.message or \"Error\",\n \"failedName\", errorInfo.name or \"Error\",\n \"stacktrace\", errorInfo.stack or \"\",\n \"processedOn\", processedOn,\n \"finishedOn\", finishedOn,\n \"attempts\", attempts,\n \"maxAttempts\", maxAttempts\n )\n redis.call(\"ZADD\", failedKey, timestamp, jobId)\n \n -- Note: No retention trimming for failed jobs (let clean() handle it)\n else\n -- keepFailed == 0: Delete immediately (batch operation)\n redis.call(\"DEL\", jobKey, ns .. \":unique:\" .. jobId)\n end\nend\n\nreturn 1\n\n",
|
|
7326
7326
|
"remove": "-- argv: ns, jobId\nlocal ns = KEYS[1]\nlocal jobId = ARGV[1]\n\nlocal jobKey = ns .. \":job:\" .. jobId\nlocal delayedKey = ns .. \":delayed\"\nlocal readyKey = ns .. \":ready\"\nlocal processingKey = ns .. \":processing\"\n\n-- If job does not exist, return 0\nif redis.call(\"EXISTS\", jobKey) == 0 then\n return 0\nend\n\nlocal groupId = redis.call(\"HGET\", jobKey, \"groupId\")\n\n-- Remove from delayed and processing structures\nredis.call(\"ZREM\", delayedKey, jobId)\nredis.call(\"DEL\", ns .. \":processing:\" .. jobId)\nredis.call(\"ZREM\", processingKey, jobId)\n\n-- Remove from completed/failed retention sets if present\nredis.call(\"ZREM\", ns .. \":completed\", jobId)\nredis.call(\"ZREM\", ns .. \":failed\", jobId)\n\n-- Delete idempotence mapping\nredis.call(\"DEL\", ns .. \":unique:\" .. jobId)\n\n-- If we have a group, update group zset and ready queue accordingly\nif groupId then\n local gZ = ns .. \":g:\" .. groupId\n redis.call(\"ZREM\", gZ, jobId)\n\n local jobCount = redis.call(\"ZCARD\", gZ)\n if jobCount == 0 then\n redis.call(\"ZREM\", readyKey, groupId)\n -- Clean up empty group\n redis.call(\"DEL\", gZ)\n redis.call(\"SREM\", ns .. \":groups\", groupId)\n else\n local head = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 then\n local headScore = tonumber(head[2])\n redis.call(\"ZADD\", readyKey, headScore, groupId)\n end\n end\nend\n\n-- Finally, delete the job hash\nredis.call(\"DEL\", jobKey)\n\nreturn 1\n\n\n",
|
|
7327
|
-
"reserve-atomic": "-- Atomic reserve operation that checks lock and reserves in one operation\n-- argv: ns, nowEpochMs, vtMs, targetGroupId, allowedJobId (optional)\nlocal ns = KEYS[1]\nlocal now = tonumber(ARGV[1])\nlocal vt = tonumber(ARGV[2])\nlocal targetGroupId = ARGV[3]\nlocal allowedJobId = ARGV[4] -- If provided, allow reserve if lock matches this job ID\n\nlocal readyKey = ns .. \":ready\"\nlocal gZ = ns .. \":g:\" .. targetGroupId\nlocal groupActiveKey = ns .. \":g:\" .. targetGroupId .. \":active\"\n\n-- Respect paused state\nif redis.call(\"GET\", ns .. \":paused\") then\n return nil\nend\n\n-- BullMQ-style: Check if group has active jobs\nlocal activeCount = redis.call(\"LLEN\", groupActiveKey)\n\nif activeCount > 0 then\n -- If allowedJobId is provided, check if it matches the active job (grace collection)\n if allowedJobId then\n local activeJobId = redis.call(\"LINDEX\", groupActiveKey, 0)\n if activeJobId == allowedJobId then\n -- This is grace collection - we're chaining from the same job\n -- Continue to reserve next job\n else\n -- Different job is active, re-add to ready and return\n local head = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 then\n local headScore = tonumber(head[2])\n redis.call(\"ZADD\", readyKey, headScore, targetGroupId)\n end\n return nil\n end\n else\n -- Group has active job and this isn't grace collection, can't proceed\n local head = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 then\n local headScore = tonumber(head[2])\n redis.call(\"ZADD\", readyKey, headScore, targetGroupId)\n end\n return nil\n end\nend\n\n-- Try to get a job from the group\n-- First check if head job is delayed\nlocal head = redis.call(\"ZRANGE\", gZ, 0, 0)\nif not head or #head == 0 then\n return nil\nend\nlocal headJobId = head[1]\nlocal jobKey = ns .. \":job:\" .. headJobId\n\n-- Skip if head job is delayed (will be promoted later)\nlocal jobStatus = redis.call(\"HGET\", jobKey, \"status\")\nif jobStatus == \"delayed\" then\n return nil\nend\n\n-- Pop the job\nlocal zpop = redis.call(\"ZPOPMIN\", gZ, 1)\nif not zpop or #zpop == 0 then\n -- No job available, return\n return nil\nend\nheadJobId = zpop[1]\n\nlocal job = redis.call(\"HMGET\", jobKey, \"id\",\"groupId\",\"data\",\"attempts\",\"maxAttempts\",\"seq\",\"timestamp\",\"orderMs\",\"score\")\nlocal id, groupId, payload, attempts, maxAttempts, seq, enq, orderMs, score = job[1], job[2], job[3], job[4], job[5], job[6], job[7], job[8], job[9]\n\n-- Validate job data exists (handle corrupted/missing job hash)\nif not id or id == false then\n -- Job hash is missing/corrupted, clean up if needed\n if not allowedJobId or activeCount == 0 then\n redis.call(\"LREM\", groupActiveKey, 1, headJobId)\n end\n \n -- Re-add next job to ready queue if exists\n local nextHead = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if nextHead and #nextHead >= 2 then\n local nextScore = tonumber(nextHead[2])\n redis.call(\"ZADD\", readyKey, nextScore, targetGroupId)\n end\n \n return nil\nend\n\n-- BullMQ-style: Push to group active list if not already there (not grace collection)\nif not allowedJobId or activeCount == 0 then\n -- Normal reserve: add this job to active list\n redis.call(\"LPUSH\", groupActiveKey, id)\nend\n-- If this is grace collection and activeCount > 0, the active list already has the job\n\nlocal procKey = ns .. \":processing:\" .. id\nlocal deadline = now + vt\nredis.call(\"HSET\", procKey, \"groupId\", groupId, \"deadlineAt\", tostring(deadline))\n\nlocal processingKey = ns .. \":processing\"\nredis.call(\"ZADD\", processingKey, deadline, id)\n\n-- Mark job as processing for accurate stalled detection and idempotency\nredis.call(\"HSET\", jobKey, \"status\", \"processing\")\n\nlocal nextHead = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\nif nextHead and #nextHead >= 2 then\n local nextScore = tonumber(nextHead[2])\n redis.call(\"ZADD\", readyKey, nextScore, groupId)\nend\n\nreturn id .. \"
|
|
7328
|
-
"reserve-batch": "-- argv: ns, nowEpochMs, vtMs, maxBatch\nlocal ns = KEYS[1]\nlocal now = tonumber(ARGV[1])\nlocal vt = tonumber(ARGV[2])\nlocal maxBatch = tonumber(ARGV[3]) or 16\n\nlocal readyKey = ns .. \":ready\"\nlocal processingKey = ns .. \":processing\"\n\n-- Early exit if paused\nif redis.call(\"GET\", ns .. \":paused\") then\n return {}\nend\n\nlocal out = {}\n\n-- STALLED JOB RECOVERY WITH THROTTLING\n-- Check for stalled jobs periodically to avoid overhead in hot path\n-- This ensures stalled jobs are recovered even in high-load systems where ready queue is never empty\n-- Check interval is adaptive: 1/4 of jobTimeout (to check 4x during visibility window), max 5s\nlocal stalledCheckKey = ns .. \":stalled:lastcheck\"\nlocal lastCheck = tonumber(redis.call(\"GET\", stalledCheckKey)) or 0\nlocal stalledCheckInterval = math.min(math.floor(vt / 4), 5000)\n\nif (now - lastCheck) >= stalledCheckInterval then\n -- Update last check timestamp\n redis.call(\"SET\", stalledCheckKey, tostring(now))\n \n -- Check for expired jobs and recover them\n local expiredJobs = redis.call(\"ZRANGEBYSCORE\", processingKey, 0, now)\n if #expiredJobs > 0 then\n for _, jobId in ipairs(expiredJobs) do\n local procKey = ns .. \":processing:\" .. jobId\n local procData = redis.call(\"HMGET\", procKey, \"groupId\", \"deadlineAt\")\n local gid = procData[1]\n local deadlineAt = tonumber(procData[2])\n if gid and deadlineAt and now > deadlineAt then\n local jobKey = ns .. \":job:\" .. jobId\n local jobScore = redis.call(\"HGET\", jobKey, \"score\")\n if jobScore then\n local gZ = ns .. \":g:\" .. gid\n redis.call(\"ZADD\", gZ, tonumber(jobScore), jobId)\n local head = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 then\n local headScore = tonumber(head[2])\n redis.call(\"ZADD\", readyKey, headScore, gid)\n end\n redis.call(\"DEL\", ns .. \":lock:\" .. gid)\n redis.call(\"DEL\", procKey)\n redis.call(\"ZREM\", processingKey, jobId)\n end\n end\n end\n end\nend\n\n-- Pop up to maxBatch groups from ready set (lowest score first)\nlocal groups = redis.call(\"ZRANGE\", readyKey, 0, maxBatch - 1, \"WITHSCORES\")\nif not groups or #groups == 0 then\n return {}\nend\n\nlocal processedGroups = {}\n-- BullMQ-style: use per-group active list instead of group locks\nfor i = 1, #groups, 2 do\n local gid = groups[i]\n local gZ = ns .. \":g:\" .. gid\n local groupActiveKey = ns .. \":g:\" .. gid .. \":active\"\n\n -- Check if group has no active jobs (BullMQ-style gating)\n local activeCount = redis.call(\"LLEN\", groupActiveKey)\n if activeCount == 0 then\n local head = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 then\n local headJobId = head[1]\n local headScore = tonumber(head[2])\n local headJobKey = ns .. \":job:\" .. headJobId\n \n -- Skip if head job is delayed (will be promoted later)\n local jobStatus = redis.call(\"HGET\", headJobKey, \"status\")\n if jobStatus ~= \"delayed\" then\n -- Pop the job and push to active list atomically\n local zpop = redis.call(\"ZPOPMIN\", gZ, 1)\n if zpop and #zpop > 0 then\n local jobId = zpop[1]\n \n local jobKey = ns .. \":job:\" .. jobId\n local job = redis.call(\"HMGET\", jobKey, \"id\",\"groupId\",\"data\",\"attempts\",\"maxAttempts\",\"seq\",\"timestamp\",\"orderMs\",\"score\")\n local id, groupId, payload, attempts, maxAttempts, seq, enq, orderMs, score = job[1], job[2], job[3], job[4], job[5], job[6], job[7], job[8], job[9]\n\n -- Validate job data exists (handle corrupted/missing job hash)\n if not id or id == false then\n -- Job hash is missing/corrupted, skip this job and continue\n -- Re-add next job to ready queue if exists\n local nextHead = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if nextHead and #nextHead >= 2 then\n local nextScore = tonumber(nextHead[2])\n redis.call(\"ZADD\", readyKey, nextScore, gid)\n end\n else\n -- Push to group active list (enforces 1-per-group)\n redis.call(\"LPUSH\", groupActiveKey, jobId)\n \n -- Mark job as processing\n redis.call(\"HSET\", jobKey, \"status\", \"processing\")\n \n local procKey = ns .. \":processing:\" .. id\n local deadline = now + vt\n redis.call(\"HSET\", procKey, \"groupId\", gid, \"deadlineAt\", tostring(deadline))\n redis.call(\"ZADD\", processingKey, deadline, id)\n\n -- Re-add group if there is a new head job (next oldest)\n local nextHead = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if nextHead and #nextHead >= 2 then\n local nextScore = tonumber(nextHead[2])\n redis.call(\"ZADD\", readyKey, nextScore, gid)\n end\n\n table.insert(out, id .. \"
|
|
7329
|
-
"reserve": "-- argv: ns, nowEpochMs, vtMs, scanLimit\nlocal ns = KEYS[1]\nlocal now = tonumber(ARGV[1])\nlocal vt = tonumber(ARGV[2])\nlocal scanLimit = tonumber(ARGV[3]) or 20\n\nlocal readyKey = ns .. \":ready\"\n\n-- Respect paused state\nif redis.call(\"GET\", ns .. \":paused\") then\n return nil\nend\n\n-- STALLED JOB RECOVERY WITH THROTTLING\n-- Check for stalled jobs periodically to avoid overhead in hot path\n-- This ensures stalled jobs are recovered even in high-load systems\n-- Check interval is adaptive: 1/4 of jobTimeout (to check 4x during visibility window), max 5s\nlocal processingKey = ns .. \":processing\"\nlocal stalledCheckKey = ns .. \":stalled:lastcheck\"\nlocal lastCheck = tonumber(redis.call(\"GET\", stalledCheckKey)) or 0\nlocal stalledCheckInterval = math.min(math.floor(vt / 4), 5000)\n\nlocal shouldCheckStalled = (now - lastCheck) >= stalledCheckInterval\n\n-- Get available groups\nlocal groups = redis.call(\"ZRANGE\", readyKey, 0, scanLimit - 1, \"WITHSCORES\")\n\n-- Check for stalled jobs if: queue is empty OR it's time for periodic check\nif (not groups or #groups == 0) or shouldCheckStalled then\n if shouldCheckStalled then\n redis.call(\"SET\", stalledCheckKey, tostring(now))\n end\n \n local expiredJobs = redis.call(\"ZRANGEBYSCORE\", processingKey, 0, now)\n for _, jobId in ipairs(expiredJobs) do\n local procKey = ns .. \":processing:\" .. jobId\n local procData = redis.call(\"HMGET\", procKey, \"groupId\", \"deadlineAt\")\n local gid = procData[1]\n local deadlineAt = tonumber(procData[2])\n if gid and deadlineAt and now > deadlineAt then\n local jobKey = ns .. \":job:\" .. jobId\n local jobScore = redis.call(\"HGET\", jobKey, \"score\")\n if jobScore then\n local gZ = ns .. \":g:\" .. gid\n redis.call(\"ZADD\", gZ, tonumber(jobScore), jobId)\n local head = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 then\n local headScore = tonumber(head[2])\n redis.call(\"ZADD\", readyKey, headScore, gid)\n end\n redis.call(\"DEL\", ns .. \":lock:\" .. gid)\n redis.call(\"DEL\", procKey)\n redis.call(\"ZREM\", processingKey, jobId)\n end\n end\n end\n \n -- Refresh groups after recovery (only if we didn't have any before)\n if not groups or #groups == 0 then\n groups = redis.call(\"ZRANGE\", readyKey, 0, scanLimit - 1, \"WITHSCORES\")\n end\nend\n\nif not groups or #groups == 0 then\n return nil\nend\n\nlocal chosenGid = nil\nlocal chosenIndex = nil\nlocal headJobId = nil\nlocal job = nil\n\n-- Try to atomically acquire a group and its head job\n-- BullMQ-style: use per-group active list instead of group locks\nfor i = 1, #groups, 2 do\n local gid = groups[i]\n local gZ = ns .. \":g:\" .. gid\n local groupActiveKey = ns .. \":g:\" .. gid .. \":active\"\n \n -- Check if group has no active jobs (BullMQ-style gating)\n local activeCount = redis.call(\"LLEN\", groupActiveKey)\n if activeCount == 0 then\n -- Check if group has jobs\n local head = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 then\n local headJobId = head[1]\n local headJobKey = ns .. \":job:\" .. headJobId\n \n -- Skip if head job is delayed (will be promoted later)\n local jobStatus = redis.call(\"HGET\", headJobKey, \"status\")\n if jobStatus ~= \"delayed\" then\n -- Pop the job and push to active list atomically\n local zpop = redis.call(\"ZPOPMIN\", gZ, 1)\n if zpop and #zpop > 0 then\n headJobId = zpop[1]\n -- Read the popped job (use headJobId to avoid races)\n headJobKey = ns .. \":job:\" .. headJobId\n job = redis.call(\"HMGET\", headJobKey, \"id\",\"groupId\",\"data\",\"attempts\",\"maxAttempts\",\"seq\",\"timestamp\",\"orderMs\",\"score\")\n \n -- Push to group active list (enforces 1-per-group)\n redis.call(\"LPUSH\", groupActiveKey, headJobId)\n \n chosenGid = gid\n chosenIndex = (i + 1) / 2 - 1\n -- Mark job as processing for accurate stalled detection and idempotency\n redis.call(\"HSET\", headJobKey, \"status\", \"processing\")\n break\n end\n end\n end\n end\nend\n\nif not chosenGid or not job then\n return nil\nend\n\nlocal id, groupId, payload, attempts, maxAttempts, seq, enq, orderMs, score = job[1], job[2], job[3], job[4], job[5], job[6], job[7], job[8], job[9]\n\n-- Validate job data exists (handle corrupted/missing job hash)\nif not id or id == false then\n -- Job hash is missing/corrupted, clean up group active list\n local groupActiveKey = ns .. \":g:\" .. chosenGid .. \":active\"\n redis.call(\"LREM\", groupActiveKey, 1, headJobId)\n \n -- Re-add next job to ready queue if exists\n local gZ = ns .. \":g:\" .. chosenGid\n local nextHead = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if nextHead and #nextHead >= 2 then\n local nextScore = tonumber(nextHead[2])\n redis.call(\"ZADD\", readyKey, nextScore, chosenGid)\n end\n \n return nil\nend\n\n-- Remove the group from ready queue\nredis.call(\"ZREMRANGEBYRANK\", readyKey, chosenIndex, chosenIndex)\n\nlocal procKey = ns .. \":processing:\" .. id\nlocal deadline = now + vt\nredis.call(\"HSET\", procKey, \"groupId\", chosenGid, \"deadlineAt\", tostring(deadline))\n\nlocal processingKey2 = ns .. \":processing\"\nredis.call(\"ZADD\", processingKey2, deadline, id)\n\nlocal gZ = ns .. \":g:\" .. chosenGid\nlocal nextHead = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\nif nextHead and #nextHead >= 2 then\n local nextScore = tonumber(nextHead[2])\n redis.call(\"ZADD\", readyKey, nextScore, chosenGid)\nend\n\nreturn id .. \"
|
|
7327
|
+
"reserve-atomic": "-- Atomic reserve operation that checks lock and reserves in one operation\n-- argv: ns, nowEpochMs, vtMs, targetGroupId, allowedJobId (optional)\nlocal ns = KEYS[1]\nlocal now = tonumber(ARGV[1])\nlocal vt = tonumber(ARGV[2])\nlocal targetGroupId = ARGV[3]\nlocal allowedJobId = ARGV[4] -- If provided, allow reserve if lock matches this job ID\n\nlocal readyKey = ns .. \":ready\"\nlocal gZ = ns .. \":g:\" .. targetGroupId\nlocal groupActiveKey = ns .. \":g:\" .. targetGroupId .. \":active\"\n\n-- Respect paused state\nif redis.call(\"GET\", ns .. \":paused\") then\n return nil\nend\n\n-- BullMQ-style: Check if group has active jobs\nlocal activeCount = redis.call(\"LLEN\", groupActiveKey)\n\nif activeCount > 0 then\n -- If allowedJobId is provided, check if it matches the active job (grace collection)\n if allowedJobId then\n local activeJobId = redis.call(\"LINDEX\", groupActiveKey, 0)\n if activeJobId == allowedJobId then\n -- This is grace collection - we're chaining from the same job\n -- Continue to reserve next job\n else\n -- Different job is active, re-add to ready and return\n local head = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 then\n local headScore = tonumber(head[2])\n redis.call(\"ZADD\", readyKey, headScore, targetGroupId)\n end\n return nil\n end\n else\n -- Group has active job and this isn't grace collection, can't proceed\n local head = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 then\n local headScore = tonumber(head[2])\n redis.call(\"ZADD\", readyKey, headScore, targetGroupId)\n end\n return nil\n end\nend\n\n-- Try to get a job from the group\n-- First check if head job is delayed\nlocal head = redis.call(\"ZRANGE\", gZ, 0, 0)\nif not head or #head == 0 then\n return nil\nend\nlocal headJobId = head[1]\nlocal jobKey = ns .. \":job:\" .. headJobId\n\n-- Skip if head job is delayed (will be promoted later)\nlocal jobStatus = redis.call(\"HGET\", jobKey, \"status\")\nif jobStatus == \"delayed\" then\n return nil\nend\n\n-- Pop the job\nlocal zpop = redis.call(\"ZPOPMIN\", gZ, 1)\nif not zpop or #zpop == 0 then\n -- No job available, return\n return nil\nend\nheadJobId = zpop[1]\n\nlocal job = redis.call(\"HMGET\", jobKey, \"id\",\"groupId\",\"data\",\"attempts\",\"maxAttempts\",\"seq\",\"timestamp\",\"orderMs\",\"score\")\nlocal id, groupId, payload, attempts, maxAttempts, seq, enq, orderMs, score = job[1], job[2], job[3], job[4], job[5], job[6], job[7], job[8], job[9]\n\n-- Validate job data exists (handle corrupted/missing job hash)\nif not id or id == false then\n -- Job hash is missing/corrupted, clean up if needed\n if not allowedJobId or activeCount == 0 then\n redis.call(\"LREM\", groupActiveKey, 1, headJobId)\n end\n \n -- Re-add next job to ready queue if exists\n local nextHead = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if nextHead and #nextHead >= 2 then\n local nextScore = tonumber(nextHead[2])\n redis.call(\"ZADD\", readyKey, nextScore, targetGroupId)\n end\n \n return nil\nend\n\n-- BullMQ-style: Push to group active list if not already there (not grace collection)\nif not allowedJobId or activeCount == 0 then\n -- Normal reserve: add this job to active list\n redis.call(\"LPUSH\", groupActiveKey, id)\nend\n-- If this is grace collection and activeCount > 0, the active list already has the job\n\nlocal procKey = ns .. \":processing:\" .. id\nlocal deadline = now + vt\nredis.call(\"HSET\", procKey, \"groupId\", groupId, \"deadlineAt\", tostring(deadline))\n\nlocal processingKey = ns .. \":processing\"\nredis.call(\"ZADD\", processingKey, deadline, id)\n\n-- Mark job as processing for accurate stalled detection and idempotency\nredis.call(\"HSET\", jobKey, \"status\", \"processing\")\n\nlocal nextHead = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\nif nextHead and #nextHead >= 2 then\n local nextScore = tonumber(nextHead[2])\n redis.call(\"ZADD\", readyKey, nextScore, groupId)\nend\n\nreturn id .. \"||GROUPMQ||\" .. groupId .. \"||GROUPMQ||\" .. payload .. \"||GROUPMQ||\" .. attempts .. \"||GROUPMQ||\" .. maxAttempts .. \"||GROUPMQ||\" .. seq .. \"||GROUPMQ||\" .. enq .. \"||GROUPMQ||\" .. orderMs .. \"||GROUPMQ||\" .. score .. \"||GROUPMQ||\" .. deadline\n",
|
|
7328
|
+
"reserve-batch": "-- argv: ns, nowEpochMs, vtMs, maxBatch\nlocal ns = KEYS[1]\nlocal now = tonumber(ARGV[1])\nlocal vt = tonumber(ARGV[2])\nlocal maxBatch = tonumber(ARGV[3]) or 16\n\nlocal readyKey = ns .. \":ready\"\nlocal processingKey = ns .. \":processing\"\n\n-- Early exit if paused\nif redis.call(\"GET\", ns .. \":paused\") then\n return {}\nend\n\nlocal out = {}\n\n-- STALLED JOB RECOVERY WITH THROTTLING\n-- Check for stalled jobs periodically to avoid overhead in hot path\n-- This ensures stalled jobs are recovered even in high-load systems where ready queue is never empty\n-- Check interval is adaptive: 1/4 of jobTimeout (to check 4x during visibility window), max 5s\nlocal stalledCheckKey = ns .. \":stalled:lastcheck\"\nlocal lastCheck = tonumber(redis.call(\"GET\", stalledCheckKey)) or 0\nlocal stalledCheckInterval = math.min(math.floor(vt / 4), 5000)\n\nif (now - lastCheck) >= stalledCheckInterval then\n -- Update last check timestamp\n redis.call(\"SET\", stalledCheckKey, tostring(now))\n \n -- Check for expired jobs and recover them\n local expiredJobs = redis.call(\"ZRANGEBYSCORE\", processingKey, 0, now)\n if #expiredJobs > 0 then\n for _, jobId in ipairs(expiredJobs) do\n local procKey = ns .. \":processing:\" .. jobId\n local procData = redis.call(\"HMGET\", procKey, \"groupId\", \"deadlineAt\")\n local gid = procData[1]\n local deadlineAt = tonumber(procData[2])\n if gid and deadlineAt and now > deadlineAt then\n local jobKey = ns .. \":job:\" .. jobId\n local jobScore = redis.call(\"HGET\", jobKey, \"score\")\n if jobScore then\n local gZ = ns .. \":g:\" .. gid\n redis.call(\"ZADD\", gZ, tonumber(jobScore), jobId)\n local head = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 then\n local headScore = tonumber(head[2])\n redis.call(\"ZADD\", readyKey, headScore, gid)\n end\n redis.call(\"DEL\", ns .. \":lock:\" .. gid)\n redis.call(\"DEL\", procKey)\n redis.call(\"ZREM\", processingKey, jobId)\n end\n end\n end\n end\nend\n\n-- Pop up to maxBatch groups from ready set (lowest score first)\nlocal groups = redis.call(\"ZRANGE\", readyKey, 0, maxBatch - 1, \"WITHSCORES\")\nif not groups or #groups == 0 then\n return {}\nend\n\nlocal processedGroups = {}\n-- BullMQ-style: use per-group active list instead of group locks\nfor i = 1, #groups, 2 do\n local gid = groups[i]\n local gZ = ns .. \":g:\" .. gid\n local groupActiveKey = ns .. \":g:\" .. gid .. \":active\"\n\n -- Check if group has no active jobs (BullMQ-style gating)\n local activeCount = redis.call(\"LLEN\", groupActiveKey)\n if activeCount == 0 then\n local head = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 then\n local headJobId = head[1]\n local headScore = tonumber(head[2])\n local headJobKey = ns .. \":job:\" .. headJobId\n \n -- Skip if head job is delayed (will be promoted later)\n local jobStatus = redis.call(\"HGET\", headJobKey, \"status\")\n if jobStatus ~= \"delayed\" then\n -- Pop the job and push to active list atomically\n local zpop = redis.call(\"ZPOPMIN\", gZ, 1)\n if zpop and #zpop > 0 then\n local jobId = zpop[1]\n \n local jobKey = ns .. \":job:\" .. jobId\n local job = redis.call(\"HMGET\", jobKey, \"id\",\"groupId\",\"data\",\"attempts\",\"maxAttempts\",\"seq\",\"timestamp\",\"orderMs\",\"score\")\n local id, groupId, payload, attempts, maxAttempts, seq, enq, orderMs, score = job[1], job[2], job[3], job[4], job[5], job[6], job[7], job[8], job[9]\n\n -- Validate job data exists (handle corrupted/missing job hash)\n if not id or id == false then\n -- Job hash is missing/corrupted, skip this job and continue\n -- Re-add next job to ready queue if exists\n local nextHead = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if nextHead and #nextHead >= 2 then\n local nextScore = tonumber(nextHead[2])\n redis.call(\"ZADD\", readyKey, nextScore, gid)\n end\n else\n -- Push to group active list (enforces 1-per-group)\n redis.call(\"LPUSH\", groupActiveKey, jobId)\n \n -- Mark job as processing\n redis.call(\"HSET\", jobKey, \"status\", \"processing\")\n \n local procKey = ns .. \":processing:\" .. id\n local deadline = now + vt\n redis.call(\"HSET\", procKey, \"groupId\", gid, \"deadlineAt\", tostring(deadline))\n redis.call(\"ZADD\", processingKey, deadline, id)\n\n -- Re-add group if there is a new head job (next oldest)\n local nextHead = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if nextHead and #nextHead >= 2 then\n local nextScore = tonumber(nextHead[2])\n redis.call(\"ZADD\", readyKey, nextScore, gid)\n end\n\n table.insert(out, id .. \"||GROUPMQ||\" .. groupId .. \"||GROUPMQ||\" .. payload .. \"||GROUPMQ||\" .. attempts .. \"||GROUPMQ||\" .. maxAttempts .. \"||GROUPMQ||\" .. seq .. \"||GROUPMQ||\" .. enq .. \"||GROUPMQ||\" .. orderMs .. \"||GROUPMQ||\" .. score .. \"||GROUPMQ||\" .. deadline)\n table.insert(processedGroups, gid)\n end\n end\n end\n end\n end\n -- Note: Groups with active jobs will be skipped\nend\n\n-- Remove only the groups that were actually processed from ready queue\nfor _, gid in ipairs(processedGroups) do\n redis.call(\"ZREM\", readyKey, gid)\nend\n\nreturn out\n\n\n",
|
|
7329
|
+
"reserve": "-- argv: ns, nowEpochMs, vtMs, scanLimit\nlocal ns = KEYS[1]\nlocal now = tonumber(ARGV[1])\nlocal vt = tonumber(ARGV[2])\nlocal scanLimit = tonumber(ARGV[3]) or 20\n\nlocal readyKey = ns .. \":ready\"\n\n-- Respect paused state\nif redis.call(\"GET\", ns .. \":paused\") then\n return nil\nend\n\n-- STALLED JOB RECOVERY WITH THROTTLING\n-- Check for stalled jobs periodically to avoid overhead in hot path\n-- This ensures stalled jobs are recovered even in high-load systems\n-- Check interval is adaptive: 1/4 of jobTimeout (to check 4x during visibility window), max 5s\nlocal processingKey = ns .. \":processing\"\nlocal stalledCheckKey = ns .. \":stalled:lastcheck\"\nlocal lastCheck = tonumber(redis.call(\"GET\", stalledCheckKey)) or 0\nlocal stalledCheckInterval = math.min(math.floor(vt / 4), 5000)\n\nlocal shouldCheckStalled = (now - lastCheck) >= stalledCheckInterval\n\n-- Get available groups\nlocal groups = redis.call(\"ZRANGE\", readyKey, 0, scanLimit - 1, \"WITHSCORES\")\n\n-- Check for stalled jobs if: queue is empty OR it's time for periodic check\nif (not groups or #groups == 0) or shouldCheckStalled then\n if shouldCheckStalled then\n redis.call(\"SET\", stalledCheckKey, tostring(now))\n end\n \n local expiredJobs = redis.call(\"ZRANGEBYSCORE\", processingKey, 0, now)\n for _, jobId in ipairs(expiredJobs) do\n local procKey = ns .. \":processing:\" .. jobId\n local procData = redis.call(\"HMGET\", procKey, \"groupId\", \"deadlineAt\")\n local gid = procData[1]\n local deadlineAt = tonumber(procData[2])\n if gid and deadlineAt and now > deadlineAt then\n local jobKey = ns .. \":job:\" .. jobId\n local jobScore = redis.call(\"HGET\", jobKey, \"score\")\n if jobScore then\n local gZ = ns .. \":g:\" .. gid\n -- CRITICAL: Check if job is already in group set before re-adding\n -- This prevents duplicate entries and ensures we only re-add if truly needed\n local alreadyInGroup = redis.call(\"ZSCORE\", gZ, jobId)\n if not alreadyInGroup then\n redis.call(\"ZADD\", gZ, tonumber(jobScore), jobId)\n end\n -- CRITICAL: Reset status from \"processing\" to \"waiting\" when re-adding expired job\n -- This prevents jobs from being stuck with \"processing\" status in the group set\n redis.call(\"HSET\", jobKey, \"status\", \"waiting\")\n local head = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 then\n local headScore = tonumber(head[2])\n redis.call(\"ZADD\", readyKey, headScore, gid)\n end\n -- Remove from group active list (BullMQ-style) - CRITICAL to unblock the group\n local groupActiveKey = ns .. \":g:\" .. gid .. \":active\"\n redis.call(\"LREM\", groupActiveKey, 1, jobId)\n redis.call(\"DEL\", ns .. \":lock:\" .. gid)\n redis.call(\"DEL\", procKey)\n redis.call(\"ZREM\", processingKey, jobId)\n end\n end\n end\n \n -- Refresh groups after recovery (only if we didn't have any before)\n if not groups or #groups == 0 then\n groups = redis.call(\"ZRANGE\", readyKey, 0, scanLimit - 1, \"WITHSCORES\")\n end\nend\n\nif not groups or #groups == 0 then\n return nil\nend\n\nlocal chosenGid = nil\nlocal chosenIndex = nil\nlocal headJobId = nil\nlocal job = nil\n\n-- Try to atomically acquire a group and its head job\n-- BullMQ-style: use per-group active list instead of group locks\nfor i = 1, #groups, 2 do\n local gid = groups[i]\n local gZ = ns .. \":g:\" .. gid\n local groupActiveKey = ns .. \":g:\" .. gid .. \":active\"\n \n -- Check if group has no active jobs (BullMQ-style gating)\n local activeCount = redis.call(\"LLEN\", groupActiveKey)\n if activeCount == 0 then\n -- Check if group has jobs\n local head = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 then\n local headJobId = head[1]\n local headJobKey = ns .. \":job:\" .. headJobId\n \n -- Skip if head job is delayed (will be promoted later)\n local jobStatus = redis.call(\"HGET\", headJobKey, \"status\")\n if jobStatus ~= \"delayed\" then\n -- Pop the job and push to active list atomically\n local zpop = redis.call(\"ZPOPMIN\", gZ, 1)\n if zpop and #zpop > 0 then\n headJobId = zpop[1]\n -- Read the popped job (use headJobId to avoid races)\n headJobKey = ns .. \":job:\" .. headJobId\n job = redis.call(\"HMGET\", headJobKey, \"id\",\"groupId\",\"data\",\"attempts\",\"maxAttempts\",\"seq\",\"timestamp\",\"orderMs\",\"score\")\n \n -- Push to group active list (enforces 1-per-group)\n redis.call(\"LPUSH\", groupActiveKey, headJobId)\n \n chosenGid = gid\n chosenIndex = (i + 1) / 2 - 1\n -- Mark job as processing for accurate stalled detection and idempotency\n redis.call(\"HSET\", headJobKey, \"status\", \"processing\")\n \n -- CRITICAL: Ensure job is removed from group set (defensive check)\n -- This handles edge cases where job might still be in group set due to race conditions\n -- or if ZPOPMIN didn't fully remove it (shouldn't happen, but be safe)\n redis.call(\"ZREM\", gZ, headJobId)\n \n break\n end\n end\n end\n end\nend\n\nif not chosenGid or not job then\n return nil\nend\n\nlocal id, groupId, payload, attempts, maxAttempts, seq, enq, orderMs, score = job[1], job[2], job[3], job[4], job[5], job[6], job[7], job[8], job[9]\n\n-- Validate job data exists (handle corrupted/missing job hash)\nif not id or id == false then\n -- Job hash is missing/corrupted, clean up group active list\n local groupActiveKey = ns .. \":g:\" .. chosenGid .. \":active\"\n redis.call(\"LREM\", groupActiveKey, 1, headJobId)\n \n -- Re-add next job to ready queue if exists\n local gZ = ns .. \":g:\" .. chosenGid\n local nextHead = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if nextHead and #nextHead >= 2 then\n local nextScore = tonumber(nextHead[2])\n redis.call(\"ZADD\", readyKey, nextScore, chosenGid)\n end\n \n return nil\nend\n\n-- Remove the group from ready queue\nredis.call(\"ZREMRANGEBYRANK\", readyKey, chosenIndex, chosenIndex)\n\nlocal procKey = ns .. \":processing:\" .. id\nlocal deadline = now + vt\nredis.call(\"HSET\", procKey, \"groupId\", chosenGid, \"deadlineAt\", tostring(deadline))\n\nlocal processingKey2 = ns .. \":processing\"\nredis.call(\"ZADD\", processingKey2, deadline, id)\n\nlocal gZ = ns .. \":g:\" .. chosenGid\nlocal nextHead = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\nif nextHead and #nextHead >= 2 then\n local nextScore = tonumber(nextHead[2])\n redis.call(\"ZADD\", readyKey, nextScore, chosenGid)\nend\n\nreturn id .. \"||GROUPMQ||\" .. groupId .. \"||GROUPMQ||\" .. payload .. \"||GROUPMQ||\" .. attempts .. \"||GROUPMQ||\" .. maxAttempts .. \"||GROUPMQ||\" .. seq .. \"||GROUPMQ||\" .. enq .. \"||GROUPMQ||\" .. orderMs .. \"||GROUPMQ||\" .. score .. \"||GROUPMQ||\" .. deadline\n\n\n",
|
|
7330
7330
|
"retry": "-- argv: ns, jobId, backoffMs\nlocal ns = KEYS[1]\nlocal jobId = ARGV[1]\nlocal backoffMs = tonumber(ARGV[2]) or 0\n\nlocal jobKey = ns .. \":job:\" .. jobId\nlocal gid = redis.call(\"HGET\", jobKey, \"groupId\")\nlocal attempts = tonumber(redis.call(\"HINCRBY\", jobKey, \"attempts\", 1))\nlocal maxAttempts = tonumber(redis.call(\"HGET\", jobKey, \"maxAttempts\"))\n\nredis.call(\"DEL\", ns .. \":processing:\" .. jobId)\nredis.call(\"ZREM\", ns .. \":processing\", jobId)\n\n-- BullMQ-style: Remove from group active list\nlocal groupActiveKey = ns .. \":g:\" .. gid .. \":active\"\nredis.call(\"LREM\", groupActiveKey, 1, jobId)\n\nif attempts > maxAttempts then\n return -1\nend\n\nlocal score = tonumber(redis.call(\"HGET\", jobKey, \"score\"))\nlocal gZ = ns .. \":g:\" .. gid\n\n-- Re-add job to group\nredis.call(\"ZADD\", gZ, score, jobId)\n\n-- If backoffMs > 0, delay the retry\nif backoffMs > 0 then\n local now = tonumber(redis.call(\"TIME\")[1]) * 1000\n local delayUntil = now + backoffMs\n \n -- Move to delayed set\n local delayedKey = ns .. \":delayed\"\n redis.call(\"ZADD\", delayedKey, delayUntil, jobId)\n redis.call(\"HSET\", jobKey, \"runAt\", tostring(delayUntil), \"status\", \"delayed\")\n \n -- Don't add to ready yet - will be added when promoted\n -- (delayed jobs block their group)\nelse\n -- No backoff - immediate retry\n redis.call(\"HSET\", jobKey, \"status\", \"waiting\")\n \n -- Add group to ready queue\n local head = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 then\n local headScore = tonumber(head[2])\n local readyKey = ns .. \":ready\"\n redis.call(\"ZADD\", readyKey, headScore, gid)\n end\nend\n\nreturn attempts\n"
|
|
7331
7331
|
};
|
|
7332
7332
|
var __create = Object.create;
|
|
@@ -7807,7 +7807,7 @@ var Queue = class {
|
|
|
7807
7807
|
String(this.scanLimit)
|
|
7808
7808
|
], 1);
|
|
7809
7809
|
if (!raw) return null;
|
|
7810
|
-
const parts = raw.split("
|
|
7810
|
+
const parts = raw.split("||GROUPMQ||");
|
|
7811
7811
|
if (parts.length !== 10) return null;
|
|
7812
7812
|
let data;
|
|
7813
7813
|
try {
|
|
@@ -7898,7 +7898,7 @@ var Queue = class {
|
|
|
7898
7898
|
String(this.jobTimeoutMs)
|
|
7899
7899
|
], 1);
|
|
7900
7900
|
if (!result) return null;
|
|
7901
|
-
const parts = result.split("
|
|
7901
|
+
const parts = result.split("||GROUPMQ||");
|
|
7902
7902
|
if (parts.length !== 10) {
|
|
7903
7903
|
this.logger.error("Queue completeAndReserveNextWithMetadata: unexpected result format:", result);
|
|
7904
7904
|
return null;
|
|
@@ -8242,7 +8242,7 @@ var Queue = class {
|
|
|
8242
8242
|
String(groupId)
|
|
8243
8243
|
], 1);
|
|
8244
8244
|
if (!result) return null;
|
|
8245
|
-
const parts = result.split("
|
|
8245
|
+
const parts = result.split("||GROUPMQ||");
|
|
8246
8246
|
if (parts.length < 10) return null;
|
|
8247
8247
|
const [id, groupIdRaw, data, attempts, maxAttempts, seq, timestamp, orderMs, score, deadline] = parts;
|
|
8248
8248
|
const parsedTimestamp = parseInt(timestamp, 10);
|
|
@@ -8274,7 +8274,7 @@ var Queue = class {
|
|
|
8274
8274
|
const out = [];
|
|
8275
8275
|
for (const r of results || []) {
|
|
8276
8276
|
if (!r) continue;
|
|
8277
|
-
const parts = r.split("
|
|
8277
|
+
const parts = r.split("||GROUPMQ||");
|
|
8278
8278
|
if (parts.length !== 10) continue;
|
|
8279
8279
|
out.push({
|
|
8280
8280
|
id: parts[0],
|
|
@@ -8851,6 +8851,21 @@ var Queue = class {
|
|
|
8851
8851
|
function sleep$1(ms) {
|
|
8852
8852
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8853
8853
|
}
|
|
8854
|
+
/**
|
|
8855
|
+
* This file contains code copied from BullMQ (https://github.com/taskforcesh/bullmq)
|
|
8856
|
+
*
|
|
8857
|
+
* BullMQ is a fantastic library and one of the most popular Redis-based job queue
|
|
8858
|
+
* libraries for Node.js. We've copied the AsyncFifoQueue implementation from BullMQ
|
|
8859
|
+
* as it's a well-designed component that fits our needs perfectly.
|
|
8860
|
+
*
|
|
8861
|
+
* Original copyright notice:
|
|
8862
|
+
* Copyright (c) Taskforce.sh and contributors
|
|
8863
|
+
*
|
|
8864
|
+
* This code is used under the MIT License. The original license can be found at:
|
|
8865
|
+
* https://github.com/taskforcesh/bullmq/blob/main/LICENSE
|
|
8866
|
+
*
|
|
8867
|
+
* Modifications may have been made to adapt this code for use in GroupMQ.
|
|
8868
|
+
*/
|
|
8854
8869
|
var Node = class {
|
|
8855
8870
|
constructor(value) {
|
|
8856
8871
|
this.value = void 0;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@falcondev-oss/workflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.8.
|
|
4
|
+
"version": "0.8.8",
|
|
5
5
|
"description": "Simple type-safe queue worker with durable execution using Redis.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": "github:falcondev-oss/workflow",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@standard-schema/spec": "^1.1.0",
|
|
40
40
|
"@types/node": "^25.1.0",
|
|
41
41
|
"exit-hook": "^5.0.1",
|
|
42
|
-
"groupmq": "^1.1.
|
|
42
|
+
"groupmq": "^1.1.1-next.2",
|
|
43
43
|
"ioredis": "^5.9.2",
|
|
44
44
|
"p-mutex": "^1.0.0",
|
|
45
45
|
"p-retry": "^7.1.1",
|