queue-jobs-worker 1.0.5 → 2.0.0
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/README.md +754 -572
- package/dist/index.d.ts +546 -29
- package/dist/index.js +1268 -2448
- package/dist/scripts/claim-job.lua +117 -0
- package/dist/scripts/clear-queue.lua +42 -0
- package/dist/scripts/list-jobs.lua +50 -0
- package/dist/scripts/remove-job.lua +41 -0
- package/dist/scripts/save-job.lua +52 -0
- package/dist/scripts/update-job.lua +183 -0
- package/package.json +91 -110
- package/CHANGELOG.md +0 -133
- package/LICENSE +0 -21
- package/assets/queue-jobs-worker-demo.gif +0 -0
- package/assets/queue-jobs-worker-github.png +0 -0
- package/dist/core/backoff.d.ts +0 -24
- package/dist/core/backoff.d.ts.map +0 -1
- package/dist/core/client.d.ts +0 -95
- package/dist/core/client.d.ts.map +0 -1
- package/dist/core/id.d.ts +0 -9
- package/dist/core/id.d.ts.map +0 -1
- package/dist/core/index.d.ts +0 -7
- package/dist/core/index.d.ts.map +0 -1
- package/dist/core/job.d.ts +0 -75
- package/dist/core/job.d.ts.map +0 -1
- package/dist/core/queue.d.ts +0 -72
- package/dist/core/queue.d.ts.map +0 -1
- package/dist/core/worker.d.ts +0 -65
- package/dist/core/worker.d.ts.map +0 -1
- package/dist/events/emitter.d.ts +0 -24
- package/dist/events/emitter.d.ts.map +0 -1
- package/dist/index.cjs +0 -2578
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/lib/scripts/claim.lua +0 -27
- package/dist/lib/scripts/index.d.ts +0 -5
- package/dist/lib/scripts/index.d.ts.map +0 -1
- package/dist/lib/scripts/rate-limit.lua +0 -36
- package/dist/lib/scripts/recover-stalled.lua +0 -40
- package/dist/lib/scripts/renew-lock.lua +0 -16
- package/dist/storage/in-memory.adapter.d.ts +0 -33
- package/dist/storage/in-memory.adapter.d.ts.map +0 -1
- package/dist/storage/index.d.ts +0 -5
- package/dist/storage/index.d.ts.map +0 -1
- package/dist/storage/mysql.adapter.d.ts +0 -38
- package/dist/storage/mysql.adapter.d.ts.map +0 -1
- package/dist/storage/postgres.adapter.d.ts +0 -38
- package/dist/storage/postgres.adapter.d.ts.map +0 -1
- package/dist/storage/redis.adapter.d.ts +0 -45
- package/dist/storage/redis.adapter.d.ts.map +0 -1
- package/dist/types/client.types.d.ts +0 -41
- package/dist/types/client.types.d.ts.map +0 -1
- package/dist/types/events.types.d.ts +0 -22
- package/dist/types/events.types.d.ts.map +0 -1
- package/dist/types/index.d.ts +0 -10
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/job.types.d.ts +0 -97
- package/dist/types/job.types.d.ts.map +0 -1
- package/dist/types/queue.types.d.ts +0 -43
- package/dist/types/queue.types.d.ts.map +0 -1
- package/dist/types/storage.types.d.ts +0 -138
- package/dist/types/storage.types.d.ts.map +0 -1
- package/dist/types/worker.types.d.ts +0 -25
- package/dist/types/worker.types.d.ts.map +0 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
claim-job.lua
|
|
3
|
+
Atomically claim the next eligible job from a queue.
|
|
4
|
+
|
|
5
|
+
This script runs server-side in a single round-trip so no two workers
|
|
6
|
+
can ever pick up the same job, even under high concurrency.
|
|
7
|
+
|
|
8
|
+
KEYS
|
|
9
|
+
[1] index key — sorted set "qjw:{queue}:index" (score = priority)
|
|
10
|
+
[2] waiting key — sorted set "qjw:{queue}:waiting" (score = runAt ms)
|
|
11
|
+
|
|
12
|
+
ARGV
|
|
13
|
+
[1] nowMs — current Unix timestamp in milliseconds (string)
|
|
14
|
+
|
|
15
|
+
Algorithm
|
|
16
|
+
1. Scan the waiting set for members whose score (runAt) <= nowMs.
|
|
17
|
+
ZRANGEBYSCORE returns by runAt ASC — but two jobs with identical runAt
|
|
18
|
+
values would be served in arbitrary order, violating priority ordering.
|
|
19
|
+
2. BUG FIX: re-sort candidates by their priority score from the index set
|
|
20
|
+
(lower score = higher priority) so high-priority jobs are always claimed
|
|
21
|
+
first even when multiple jobs become eligible at the same runAt.
|
|
22
|
+
3. Iterate the priority-sorted list. For each candidate, atomically ZREM
|
|
23
|
+
it from the waiting set — only one worker can succeed (ZREM returns 1).
|
|
24
|
+
4. Fetch the job JSON; skip if missing (external deletion race).
|
|
25
|
+
5. Patch status → "active", bump updatedAt and processedAt in-place.
|
|
26
|
+
6. Persist and return the updated JSON string.
|
|
27
|
+
|
|
28
|
+
Returns
|
|
29
|
+
Bulk string — updated job JSON, or nil if nothing is eligible.
|
|
30
|
+
--]]
|
|
31
|
+
|
|
32
|
+
local indexKey = KEYS[1] -- "qjw:{queue}:index"
|
|
33
|
+
local waitingKey = KEYS[2] -- "qjw:{queue}:waiting"
|
|
34
|
+
local nowMs = tonumber(ARGV[1])
|
|
35
|
+
|
|
36
|
+
-- ── 1. Fetch all eligible job IDs from the waiting set ──────────────────────
|
|
37
|
+
-- ZRANGEBYSCORE returns members ordered by score (runAt) ASC.
|
|
38
|
+
local candidates = redis.call("ZRANGEBYSCORE", waitingKey, "-inf", nowMs)
|
|
39
|
+
|
|
40
|
+
if #candidates == 0 then
|
|
41
|
+
return nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
-- ── 2. Re-sort by priority using scores from the index set ──────────────────
|
|
45
|
+
-- BUG FIX: the waiting set is ordered by runAt, not priority. When multiple
|
|
46
|
+
-- jobs are eligible at the same time (or have identical runAt values) they
|
|
47
|
+
-- should be dispatched in priority ASC order (lower score = higher priority).
|
|
48
|
+
-- We fetch each candidate's priority score and sort locally inside the script.
|
|
49
|
+
--
|
|
50
|
+
-- ZSCORE returns nil for members not in the index (should never happen in a
|
|
51
|
+
-- consistent store, but we default to 0 so such jobs sort at the front rather
|
|
52
|
+
-- than causing a Lua error).
|
|
53
|
+
local scored = {}
|
|
54
|
+
for _, jobId in ipairs(candidates) do
|
|
55
|
+
local score = redis.call("ZSCORE", indexKey, jobId)
|
|
56
|
+
scored[#scored + 1] = { id = jobId, pri = tonumber(score) or 0 }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
-- Stable sort by priority ASC. Lua's table.sort is not guaranteed stable,
|
|
60
|
+
-- but for our purposes (priority is typically a small integer) this is fine —
|
|
61
|
+
-- ties within the same priority are already broken by runAt ASC order from
|
|
62
|
+
-- ZRANGEBYSCORE, and table.sort preserves relative input order for equal keys
|
|
63
|
+
-- in most Lua runtimes (including Redis's embedded LuaJIT).
|
|
64
|
+
table.sort(scored, function(a, b) return a.pri < b.pri end)
|
|
65
|
+
|
|
66
|
+
-- ── 3. Claim the first job we can atomically remove from the waiting set ─────
|
|
67
|
+
for _, entry in ipairs(scored) do
|
|
68
|
+
local jobId = entry.id
|
|
69
|
+
|
|
70
|
+
-- ZREM is atomic: returns 1 if we removed it, 0 if another worker beat us.
|
|
71
|
+
local removed = redis.call("ZREM", waitingKey, jobId)
|
|
72
|
+
if removed == 1 then
|
|
73
|
+
local hashKey = "qjw:job:" .. jobId
|
|
74
|
+
local raw = redis.call("GET", hashKey)
|
|
75
|
+
|
|
76
|
+
if raw then
|
|
77
|
+
-- ── 4. Patch status fields in-place ─────────────────────────────────
|
|
78
|
+
-- We use targeted string substitution rather than a full JSON parse
|
|
79
|
+
-- (Redis has no built-in JSON module by default). The job JSON is a
|
|
80
|
+
-- flat object — no nested status/updatedAt/processedAt fields exist
|
|
81
|
+
-- inside sub-objects, so string replacement is safe and unambiguous.
|
|
82
|
+
local nowStr = tostring(nowMs)
|
|
83
|
+
|
|
84
|
+
-- "status":"<anything>" → "status":"active"
|
|
85
|
+
raw = raw:gsub('"status":"[^"]*"', '"status":"active"', 1)
|
|
86
|
+
|
|
87
|
+
-- "updatedAt":<number> → current timestamp
|
|
88
|
+
raw = raw:gsub('"updatedAt":[%d%.%-]+', '"updatedAt":' .. nowStr, 1)
|
|
89
|
+
|
|
90
|
+
-- "processedAt": <string|number|null|absent> → current timestamp
|
|
91
|
+
-- Two-branch pattern handles both JSON string values and scalar values.
|
|
92
|
+
if raw:find('"processedAt":', 1, true) then
|
|
93
|
+
raw = raw:gsub(
|
|
94
|
+
'"processedAt":("([^"\\]|\\.)*"|[^,}]+)',
|
|
95
|
+
'"processedAt":' .. nowStr,
|
|
96
|
+
1
|
|
97
|
+
)
|
|
98
|
+
else
|
|
99
|
+
-- Field absent — append before the closing brace.
|
|
100
|
+
raw = raw:gsub('}$', ',"processedAt":' .. nowStr .. '}')
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
-- ── 5. Persist and return ────────────────────────────────────────────
|
|
104
|
+
redis.call("SET", hashKey, raw)
|
|
105
|
+
|
|
106
|
+
-- Only remove from the *waiting* set, NOT from the index.
|
|
107
|
+
-- The index tracks ALL jobs regardless of status so listJobs("active")
|
|
108
|
+
-- continues to find this job. Index entries are removed only when the
|
|
109
|
+
-- job reaches a terminal state via update-job.lua or remove-job.lua.
|
|
110
|
+
|
|
111
|
+
return raw
|
|
112
|
+
end
|
|
113
|
+
-- JSON missing — job was deleted externally. Continue to next candidate.
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
return nil
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
clear-queue.lua
|
|
3
|
+
Atomically delete every job in a queue and clean up all sorted sets.
|
|
4
|
+
|
|
5
|
+
Without this script clearQueue requires:
|
|
6
|
+
1 ZRANGE — fetch all IDs
|
|
7
|
+
N DEL — delete each job hash
|
|
8
|
+
1 DEL — delete index
|
|
9
|
+
1 DEL — delete waiting set
|
|
10
|
+
= N + 3 round-trips.
|
|
11
|
+
|
|
12
|
+
With this script it is a single round-trip regardless of queue size.
|
|
13
|
+
|
|
14
|
+
KEYS
|
|
15
|
+
[1] indexKey — "qjw:{queue}:index"
|
|
16
|
+
[2] waitingKey — "qjw:{queue}:waiting"
|
|
17
|
+
|
|
18
|
+
ARGV
|
|
19
|
+
(none)
|
|
20
|
+
|
|
21
|
+
Returns
|
|
22
|
+
Number of job hashes that were deleted.
|
|
23
|
+
--]]
|
|
24
|
+
|
|
25
|
+
local indexKey = KEYS[1]
|
|
26
|
+
local waitingKey = KEYS[2]
|
|
27
|
+
|
|
28
|
+
-- Collect every job ID tracked by the priority index.
|
|
29
|
+
-- ZRANGE 0 -1 returns all members in score order (ASC).
|
|
30
|
+
local ids = redis.call("ZRANGE", indexKey, 0, -1)
|
|
31
|
+
local deleted = 0
|
|
32
|
+
|
|
33
|
+
for _, jobId in ipairs(ids) do
|
|
34
|
+
local n = redis.call("DEL", "qjw:job:" .. jobId)
|
|
35
|
+
deleted = deleted + n
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
-- Drop both sorted sets.
|
|
39
|
+
redis.call("DEL", indexKey)
|
|
40
|
+
redis.call("DEL", waitingKey)
|
|
41
|
+
|
|
42
|
+
return deleted
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
list-jobs.lua
|
|
3
|
+
Fetch all jobs for a queue in a single server-side round-trip.
|
|
4
|
+
|
|
5
|
+
Without this script listJobs requires:
|
|
6
|
+
1 ZRANGE — fetch all IDs
|
|
7
|
+
N GET — fetch each job hash individually ← N+1 problem
|
|
8
|
+
= N + 1 round-trips.
|
|
9
|
+
|
|
10
|
+
With this script it is always exactly 1 round-trip.
|
|
11
|
+
|
|
12
|
+
KEYS
|
|
13
|
+
[1] indexKey — "qjw:{queue}:index" (sorted by priority ASC)
|
|
14
|
+
|
|
15
|
+
ARGV
|
|
16
|
+
[1] statusFilter — status string to filter by, or "" for no filter
|
|
17
|
+
|
|
18
|
+
Algorithm
|
|
19
|
+
1. ZRANGE the index to get all job IDs in priority order.
|
|
20
|
+
2. GET each job hash in a loop (all inside the same script execution,
|
|
21
|
+
so it is pipelined on the Redis side with zero network overhead).
|
|
22
|
+
3. Optionally filter by status using a simple string search.
|
|
23
|
+
4. Return the matching JSON strings as a Redis array (multi-bulk reply).
|
|
24
|
+
|
|
25
|
+
Returns
|
|
26
|
+
Array of job JSON strings, sorted by priority ASC (then insertion order
|
|
27
|
+
within the same priority level, which matches createdAt ASC because jobs
|
|
28
|
+
are added with ZADD NX and the index preserves insertion order for ties).
|
|
29
|
+
--]]
|
|
30
|
+
|
|
31
|
+
local indexKey = KEYS[1]
|
|
32
|
+
local statusFilter = ARGV[1] -- e.g. "waiting", "failed", or "" for all
|
|
33
|
+
|
|
34
|
+
local ids = redis.call("ZRANGE", indexKey, 0, -1)
|
|
35
|
+
local result = {}
|
|
36
|
+
|
|
37
|
+
for _, jobId in ipairs(ids) do
|
|
38
|
+
local raw = redis.call("GET", "qjw:job:" .. jobId)
|
|
39
|
+
if raw then
|
|
40
|
+
-- Apply status filter if requested.
|
|
41
|
+
-- We do a simple substring search: '"status":"<filter>"'
|
|
42
|
+
-- This is safe because status values are simple ASCII words with no
|
|
43
|
+
-- special characters that would need escaping.
|
|
44
|
+
if statusFilter == "" or raw:find('"status":"' .. statusFilter .. '"', 1, true) then
|
|
45
|
+
result[#result + 1] = raw
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
return result
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
remove-job.lua
|
|
3
|
+
Atomically delete a job and remove it from every sorted set.
|
|
4
|
+
|
|
5
|
+
Replaces three separate round-trips (DEL + ZREM + ZREM) with a single
|
|
6
|
+
server-side script execution.
|
|
7
|
+
|
|
8
|
+
KEYS
|
|
9
|
+
[1] hashKey — "qjw:job:{jobId}"
|
|
10
|
+
[2] indexKey — "qjw:{queue}:index"
|
|
11
|
+
[3] waitingKey — "qjw:{queue}:waiting"
|
|
12
|
+
|
|
13
|
+
ARGV
|
|
14
|
+
[1] jobId — the job's id
|
|
15
|
+
|
|
16
|
+
Returns
|
|
17
|
+
1 if the job existed and was deleted, 0 if it was not found.
|
|
18
|
+
--]]
|
|
19
|
+
|
|
20
|
+
local hashKey = KEYS[1]
|
|
21
|
+
local indexKey = KEYS[2]
|
|
22
|
+
local waitingKey = KEYS[3]
|
|
23
|
+
|
|
24
|
+
local jobId = ARGV[1]
|
|
25
|
+
|
|
26
|
+
-- Check existence first so we can return a meaningful result.
|
|
27
|
+
local existed = redis.call("EXISTS", hashKey)
|
|
28
|
+
|
|
29
|
+
if existed == 1 then
|
|
30
|
+
redis.call("DEL", hashKey)
|
|
31
|
+
redis.call("ZREM", indexKey, jobId)
|
|
32
|
+
redis.call("ZREM", waitingKey, jobId)
|
|
33
|
+
return 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
-- Still attempt the ZREM calls in case the hash was deleted externally
|
|
37
|
+
-- but the sorted-set entries were left behind (defensive cleanup).
|
|
38
|
+
redis.call("ZREM", indexKey, jobId)
|
|
39
|
+
redis.call("ZREM", waitingKey, jobId)
|
|
40
|
+
|
|
41
|
+
return 0
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
save-job.lua
|
|
3
|
+
Atomically persist a new job to Redis.
|
|
4
|
+
|
|
5
|
+
Replaces three separate round-trips (SET + ZADD + ZADD) with a single
|
|
6
|
+
server-side script execution.
|
|
7
|
+
|
|
8
|
+
KEYS
|
|
9
|
+
[1] hashKey — "qjw:job:{jobId}"
|
|
10
|
+
[2] indexKey — "qjw:{queue}:index" sorted set, score = priority
|
|
11
|
+
[3] waitingKey — "qjw:{queue}:waiting" sorted set, score = runAt ms
|
|
12
|
+
|
|
13
|
+
ARGV
|
|
14
|
+
[1] jobJson — full JSON string of the Job record
|
|
15
|
+
[2] jobId — the job's id field
|
|
16
|
+
[3] priority — numeric priority (string)
|
|
17
|
+
[4] runAt — runAt timestamp ms (string)
|
|
18
|
+
[5] eligible — "1" if this job should enter the waiting set now
|
|
19
|
+
(status is "waiting" or "retrying"), "0" otherwise
|
|
20
|
+
|
|
21
|
+
Returns
|
|
22
|
+
1 always.
|
|
23
|
+
--]]
|
|
24
|
+
|
|
25
|
+
local hashKey = KEYS[1]
|
|
26
|
+
local indexKey = KEYS[2]
|
|
27
|
+
local waitingKey = KEYS[3]
|
|
28
|
+
|
|
29
|
+
local jobJson = ARGV[1]
|
|
30
|
+
local jobId = ARGV[2]
|
|
31
|
+
local priority = ARGV[3]
|
|
32
|
+
local runAt = ARGV[4]
|
|
33
|
+
local eligible = ARGV[5]
|
|
34
|
+
|
|
35
|
+
-- Store the full job JSON.
|
|
36
|
+
redis.call("SET", hashKey, jobJson)
|
|
37
|
+
|
|
38
|
+
-- Always add to the priority index so listJobs / countJobs work.
|
|
39
|
+
-- BUG FIX: use ZADD NX so re-saving an existing job (e.g. after a crash
|
|
40
|
+
-- recovery re-enqueue) does not silently overwrite the priority score that
|
|
41
|
+
-- update-job.lua may have set. saveJob is a new-job operation; priority
|
|
42
|
+
-- updates go through updateJob / update-job.lua exclusively.
|
|
43
|
+
redis.call("ZADD", indexKey, "NX", priority, jobId)
|
|
44
|
+
|
|
45
|
+
-- Only add to the waiting set if the job is immediately pick-up-eligible.
|
|
46
|
+
-- Use plain ZADD (not NX) so a re-save after a crash can correct the runAt
|
|
47
|
+
-- score; the waiting set score must always reflect the true scheduled time.
|
|
48
|
+
if eligible == "1" then
|
|
49
|
+
redis.call("ZADD", waitingKey, runAt, jobId)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
return 1
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
update-job.lua v6
|
|
3
|
+
Atomically read, patch, and re-persist a job — entirely server-side.
|
|
4
|
+
|
|
5
|
+
One Redis round-trip: GET + targeted string replacement + SET.
|
|
6
|
+
|
|
7
|
+
── KEYS ──────────────────────────────────────────────────────────────────────
|
|
8
|
+
[1] hashKey — "qjw:job:{jobId}"
|
|
9
|
+
[2] indexKey — "qjw:{queue}:index" sorted set, score = priority
|
|
10
|
+
[3] waitingKey — "qjw:{queue}:waiting" sorted set, score = runAt ms
|
|
11
|
+
|
|
12
|
+
── ARGV ──────────────────────────────────────────────────────────────────────
|
|
13
|
+
[1] newStatus — target status string (always patched)
|
|
14
|
+
[2] nowMs — current Unix timestamp ms (always written as updatedAt)
|
|
15
|
+
[3] runAt — runAt ms for "retrying" re-queue, or "-1" as sentinel
|
|
16
|
+
meaning "do not touch runAt / skip ZADD waitingKey".
|
|
17
|
+
[4] priority — job priority integer, or "-1" sentinel ("keep existing").
|
|
18
|
+
[5] strFields — RS-delimited "key\x1evalue" pairs for string fields.
|
|
19
|
+
[6] numFields — RS-delimited "key\x1evalue" pairs for numeric fields.
|
|
20
|
+
[7] nullFields — RS-delimited field names to set to JSON null.
|
|
21
|
+
[8] boolFields — RS-delimited "key\x1evalue" pairs for boolean fields
|
|
22
|
+
("true" / "false" JSON literals).
|
|
23
|
+
|
|
24
|
+
── Returns ───────────────────────────────────────────────────────────────────
|
|
25
|
+
1 on success, 0 if the job does not exist.
|
|
26
|
+
--]]
|
|
27
|
+
|
|
28
|
+
local hashKey = KEYS[1]
|
|
29
|
+
local indexKey = KEYS[2]
|
|
30
|
+
local waitingKey = KEYS[3]
|
|
31
|
+
|
|
32
|
+
local newStatus = ARGV[1]
|
|
33
|
+
local nowMs = ARGV[2]
|
|
34
|
+
local runAt = ARGV[3]
|
|
35
|
+
local priority = ARGV[4]
|
|
36
|
+
local strFields = ARGV[5]
|
|
37
|
+
local numFields = ARGV[6]
|
|
38
|
+
local nullFields = ARGV[7]
|
|
39
|
+
local boolFields = ARGV[8] or ""
|
|
40
|
+
|
|
41
|
+
local RS = "\x1e"
|
|
42
|
+
|
|
43
|
+
-- ── 1. Read current job JSON ──────────────────────────────────────────────────
|
|
44
|
+
local raw = redis.call("GET", hashKey)
|
|
45
|
+
if not raw then return 0 end
|
|
46
|
+
|
|
47
|
+
-- ── helpers ───────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
-- Escape for Lua gsub() PATTERN (not replacement).
|
|
50
|
+
local function escPat(s)
|
|
51
|
+
return s:gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
-- Escape for Lua gsub() REPLACEMENT string.
|
|
55
|
+
local function escRepl(s)
|
|
56
|
+
return s:gsub("%%", "%%%%")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
-- Split RS-delimited string into array.
|
|
60
|
+
local function splitRS(s)
|
|
61
|
+
local parts = {}
|
|
62
|
+
for part in (s .. RS):gmatch("([^" .. RS .. "]*)" .. RS) do
|
|
63
|
+
parts[#parts + 1] = part
|
|
64
|
+
end
|
|
65
|
+
return parts
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
-- BUG FIX (v6): existence check uses plain literal search (4th arg = true).
|
|
69
|
+
-- Previously `find('"' .. escPat(key) .. '":', 1, true)` was used, but the
|
|
70
|
+
-- escPat() output contains % escapes that are meaningless in plain-text mode
|
|
71
|
+
-- and could corrupt the search string if the key contained literal %.
|
|
72
|
+
-- Using the raw key with plain=true is both correct and simpler.
|
|
73
|
+
local function hasField(json, key)
|
|
74
|
+
return json:find('"' .. key .. '":', 1, true) ~= nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
-- ── 2. Patch: status (always) ─────────────────────────────────────────────────
|
|
78
|
+
raw = raw:gsub('"status":"[^"]*"', '"status":"' .. newStatus .. '"', 1)
|
|
79
|
+
|
|
80
|
+
-- ── 3. Patch: updatedAt (always, numeric) ────────────────────────────────────
|
|
81
|
+
raw = raw:gsub('"updatedAt":[%d%.%-]+', '"updatedAt":' .. nowMs, 1)
|
|
82
|
+
|
|
83
|
+
-- ── 4. Patch: string fields ───────────────────────────────────────────────────
|
|
84
|
+
if strFields ~= "" then
|
|
85
|
+
local parts = splitRS(strFields)
|
|
86
|
+
local i = 1
|
|
87
|
+
while i < #parts do
|
|
88
|
+
local key = parts[i]
|
|
89
|
+
local val = parts[i + 1]
|
|
90
|
+
i = i + 2
|
|
91
|
+
|
|
92
|
+
if key ~= "" then
|
|
93
|
+
local pat = '"' .. escPat(key) .. '":"([^"\\\\]|\\\\.)*"'
|
|
94
|
+
local repl = '"' .. key .. '":"' .. escRepl(val) .. '"'
|
|
95
|
+
|
|
96
|
+
if hasField(raw, key) then
|
|
97
|
+
raw = raw:gsub(pat, repl, 1)
|
|
98
|
+
else
|
|
99
|
+
raw = raw:gsub('}$', ',"' .. key .. '":"' .. escRepl(val) .. '"}')
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
-- ── 5. Patch: numeric fields ──────────────────────────────────────────────────
|
|
106
|
+
if numFields ~= "" then
|
|
107
|
+
local parts = splitRS(numFields)
|
|
108
|
+
local i = 1
|
|
109
|
+
while i < #parts do
|
|
110
|
+
local key = parts[i]
|
|
111
|
+
local val = parts[i + 1]
|
|
112
|
+
i = i + 2
|
|
113
|
+
|
|
114
|
+
if key ~= "" then
|
|
115
|
+
local pat = '"' .. escPat(key) .. '":[%d%.%-]+'
|
|
116
|
+
local repl = '"' .. key .. '":' .. val
|
|
117
|
+
|
|
118
|
+
if hasField(raw, key) then
|
|
119
|
+
raw = raw:gsub(pat, repl, 1)
|
|
120
|
+
else
|
|
121
|
+
raw = raw:gsub('}$', ',"' .. key .. '":' .. val .. '}')
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
-- ── 6. Patch: boolean fields ──────────────────────────────────────────────────
|
|
128
|
+
if boolFields ~= "" then
|
|
129
|
+
local parts = splitRS(boolFields)
|
|
130
|
+
local i = 1
|
|
131
|
+
while i < #parts do
|
|
132
|
+
local key = parts[i]
|
|
133
|
+
local val = parts[i + 1] -- "true" or "false"
|
|
134
|
+
i = i + 2
|
|
135
|
+
|
|
136
|
+
if key ~= "" then
|
|
137
|
+
local pat = '"' .. escPat(key) .. '":(true|false)'
|
|
138
|
+
local repl = '"' .. key .. '":' .. val
|
|
139
|
+
|
|
140
|
+
if hasField(raw, key) then
|
|
141
|
+
raw = raw:gsub(pat, repl, 1)
|
|
142
|
+
else
|
|
143
|
+
raw = raw:gsub('}$', ',"' .. key .. '":' .. val .. '}')
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
-- ── 7. Patch: null fields ─────────────────────────────────────────────────────
|
|
150
|
+
if nullFields ~= "" then
|
|
151
|
+
local parts = splitRS(nullFields)
|
|
152
|
+
for _, key in ipairs(parts) do
|
|
153
|
+
if key ~= "" and hasField(raw, key) then
|
|
154
|
+
local pat = '"' .. escPat(key) .. '":("([^"\\\\]|\\\\.)*"|[^,}]+)'
|
|
155
|
+
raw = raw:gsub(pat, '"' .. key .. '":null', 1)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
-- ── 8. Persist updated JSON ───────────────────────────────────────────────────
|
|
161
|
+
redis.call("SET", hashKey, raw)
|
|
162
|
+
|
|
163
|
+
-- ── 9. Manage sorted-set membership ──────────────────────────────────────────
|
|
164
|
+
local jobId = hashKey:match("qjw:job:(.+)")
|
|
165
|
+
|
|
166
|
+
if newStatus == "retrying" then
|
|
167
|
+
if runAt ~= "-1" then
|
|
168
|
+
redis.call("ZADD", waitingKey, runAt, jobId)
|
|
169
|
+
end
|
|
170
|
+
if priority ~= "-1" then
|
|
171
|
+
redis.call("ZADD", indexKey, priority, jobId)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
elseif newStatus == "completed" or newStatus == "failed" then
|
|
175
|
+
redis.call("ZREM", waitingKey, jobId)
|
|
176
|
+
redis.call("ZREM", indexKey, jobId)
|
|
177
|
+
|
|
178
|
+
elseif newStatus == "active" then
|
|
179
|
+
redis.call("ZREM", waitingKey, jobId)
|
|
180
|
+
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
return 1
|