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
package/dist/index.js
CHANGED
|
@@ -1,2568 +1,1388 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
var __export = (target, all) => {
|
|
11
|
-
for (var name in all)
|
|
12
|
-
__defProp(target, name, { get: all[name], enumerable: true });
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
// src/lib/scripts/claim.lua
|
|
16
|
-
var claim_default;
|
|
17
|
-
var init_claim = __esm({
|
|
18
|
-
"src/lib/scripts/claim.lua"() {
|
|
19
|
-
claim_default = "local prefix = ARGV[4]\nlocal now_ms = tonumber(ARGV[3])\n\n-- Promote due delayed jobs into the waiting set.\nlocal due = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', now_ms)\nfor _, jid in ipairs(due) do\n local pri_raw = redis.call('HGET', prefix .. 'job:' .. jid, 'priority')\n local pri = tonumber(pri_raw) or 0\n redis.call('ZADD', KEYS[1], -pri, jid)\n redis.call('ZREM', KEYS[2], jid)\n redis.call('HSET', prefix .. 'job:' .. jid, 'status', 'waiting')\nend\n\n-- Pop the top-priority job.\nlocal items = redis.call('ZPOPMIN', KEYS[1], 1)\nif #items == 0 then return '' end\nlocal job_id = items[1]\n\n-- Lock it.\nredis.call('SADD', KEYS[3], job_id)\nredis.call('HSET', prefix .. 'job:' .. job_id,\n 'status', 'active',\n 'lockId', ARGV[1],\n 'lockExpiresAt', ARGV[2],\n 'updatedAt', ARGV[2]\n)\nreturn job_id\n";
|
|
20
|
-
}
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
// src/lib/scripts/recover-stalled.lua
|
|
24
|
-
var recover_stalled_default;
|
|
25
|
-
var init_recover_stalled = __esm({
|
|
26
|
-
"src/lib/scripts/recover-stalled.lua"() {
|
|
27
|
-
recover_stalled_default = "local job_key = KEYS[1]\nlocal active_set = KEYS[2]\nlocal waiting_zset = KEYS[3]\n\nlocal job_id = ARGV[1]\nlocal expected_exp = ARGV[2]\nlocal expected_lock = ARGV[3]\nlocal recovery_ts = ARGV[4]\nlocal priority_score = tonumber(ARGV[5])\n\n-- Re-read the three guard fields in one atomic HMGET.\nlocal fields = redis.call('HMGET', job_key, 'lockExpiresAt', 'lockId', 'status')\nlocal current_exp = fields[1] or ''\nlocal current_lock = fields[2] or ''\nlocal current_status = fields[3] or ''\n\n-- Guard 1: job must still be active.\n-- If the worker already completed, failed, or moved to DLQ, skip.\nif current_status ~= 'active' then return 0 end\n\n-- Guard 2: lockId must not have changed.\n-- A different worker may have claimed the job after the original lock expired\n-- and before this script runs.\nif current_lock ~= expected_lock then return 0 end\n\n-- Guard 3: lockExpiresAt must be exactly what the caller observed.\n-- If the worker renewed its lock the timestamp will be later than observed;\n-- the CAS mismatch catches that without needing ISO\u2192epoch conversion in Lua.\nif current_exp ~= expected_exp then return 0 end\n\n-- All guards passed \u2014 recover atomically.\nredis.call('HSET', job_key,\n 'status', 'waiting',\n 'lockId', '',\n 'lockExpiresAt', '',\n 'updatedAt', recovery_ts\n)\nredis.call('SREM', active_set, job_id)\nredis.call('ZADD', waiting_zset, priority_score, job_id)\nreturn 1\n";
|
|
1
|
+
// src/storage/memory.storage.ts
|
|
2
|
+
var MemoryStorage = class {
|
|
3
|
+
/**
|
|
4
|
+
* Top-level map: queueName → (jobId → Job).
|
|
5
|
+
* Each queue gets its own inner Map so lookups stay O(1).
|
|
6
|
+
*/
|
|
7
|
+
store = /* @__PURE__ */ new Map();
|
|
8
|
+
// ─── IStorage ─────────────────────────────────────────────────────────────
|
|
9
|
+
async connect() {
|
|
28
10
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
// src/lib/scripts/renew-lock.lua
|
|
32
|
-
var renew_lock_default;
|
|
33
|
-
var init_renew_lock = __esm({
|
|
34
|
-
"src/lib/scripts/renew-lock.lua"() {
|
|
35
|
-
renew_lock_default = "local job_key = KEYS[1]\nlocal lock_id = ARGV[1]\nlocal new_exp = ARGV[2]\nlocal now_iso = ARGV[3]\n\nlocal fields = redis.call('HMGET', job_key, 'status', 'lockId', 'lockExpiresAt')\nlocal status = fields[1] or ''\nlocal current_lock = fields[2] or ''\nlocal current_exp = fields[3] or ''\n\nif status ~= 'active' then return 0 end\nif current_lock ~= lock_id then return 0 end\nif current_exp == '' or current_exp <= now_iso then return 0 end\n\nredis.call('HSET', job_key, 'lockExpiresAt', new_exp, 'updatedAt', now_iso)\nreturn 1\n";
|
|
11
|
+
async disconnect() {
|
|
12
|
+
this.store.clear();
|
|
36
13
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
var rate_limit_default;
|
|
41
|
-
var init_rate_limit = __esm({
|
|
42
|
-
"src/lib/scripts/rate-limit.lua"() {
|
|
43
|
-
rate_limit_default = `-- RATE_LIMIT_LUA
|
|
44
|
-
-- KEYS[1]: rate_count_key ("qjw:rate:{queue}")
|
|
45
|
-
-- KEYS[2]: rate_ts_key ("qjw:rate:{queue}:ts")
|
|
46
|
-
-- ARGV[1]: max (number)
|
|
47
|
-
-- ARGV[2]: windowMs (number)
|
|
48
|
-
-- ARGV[3]: nowMs (number)
|
|
49
|
-
-- ARGV[4]: ttlSec (number)
|
|
50
|
-
--
|
|
51
|
-
-- Returns:
|
|
52
|
-
-- 1: Allowed
|
|
53
|
-
-- 0: Rejected
|
|
54
|
-
|
|
55
|
-
local ck = KEYS[1]
|
|
56
|
-
local tk = KEYS[2]
|
|
57
|
-
local max = tonumber(ARGV[1])
|
|
58
|
-
local windowMs = tonumber(ARGV[2])
|
|
59
|
-
local nowMs = tonumber(ARGV[3])
|
|
60
|
-
local ttlSec = tonumber(ARGV[4])
|
|
61
|
-
|
|
62
|
-
local windowStart = redis.call('GET', tk)
|
|
63
|
-
|
|
64
|
-
if not windowStart or (nowMs - tonumber(windowStart)) >= windowMs then
|
|
65
|
-
redis.call('SET', ck, '1', 'EX', ttlSec)
|
|
66
|
-
redis.call('SET', tk, tostring(nowMs), 'EX', ttlSec)
|
|
67
|
-
return 1
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
local current = tonumber(redis.call('GET', ck) or '0')
|
|
71
|
-
if current >= max then
|
|
72
|
-
return 0
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
redis.call('INCR', ck)
|
|
76
|
-
redis.call('EXPIRE', ck, ttlSec)
|
|
77
|
-
redis.call('EXPIRE', tk, ttlSec)
|
|
78
|
-
return 1
|
|
79
|
-
`;
|
|
14
|
+
async saveJob(queueName, job) {
|
|
15
|
+
const queue = this.getOrCreateQueue(queueName);
|
|
16
|
+
queue.set(job.id, job);
|
|
80
17
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
"src/lib/scripts/index.ts"() {
|
|
87
|
-
init_claim();
|
|
88
|
-
init_recover_stalled();
|
|
89
|
-
init_renew_lock();
|
|
90
|
-
init_rate_limit();
|
|
91
|
-
CLAIM_LUA = claim_default;
|
|
92
|
-
RECOVER_STALLED_LUA = recover_stalled_default;
|
|
93
|
-
RENEW_LOCK_LUA = renew_lock_default;
|
|
94
|
-
RATE_LIMIT_LUA = rate_limit_default;
|
|
95
|
-
}
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
// src/storage/redis.adapter.ts
|
|
99
|
-
var redis_adapter_exports = {};
|
|
100
|
-
__export(redis_adapter_exports, {
|
|
101
|
-
RedisStorageAdapter: () => RedisStorageAdapter
|
|
102
|
-
});
|
|
103
|
-
async function loadRedis() {
|
|
104
|
-
try {
|
|
105
|
-
return await import('redis');
|
|
106
|
-
} catch {
|
|
107
|
-
throw new Error(
|
|
108
|
-
'RedisStorageAdapter requires the "redis" package (node-redis v4+).\nInstall it: npm install redis'
|
|
109
|
-
);
|
|
18
|
+
async getJob(queueName, jobId) {
|
|
19
|
+
const queue = this.store.get(queueName);
|
|
20
|
+
if (!queue) return void 0;
|
|
21
|
+
const job = queue.get(jobId);
|
|
22
|
+
return job;
|
|
110
23
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
maxAttempts: String(job.maxAttempts),
|
|
121
|
-
retryDelay: String(job.retryDelay),
|
|
122
|
-
backoff: job.backoff,
|
|
123
|
-
timeout: String(job.timeout),
|
|
124
|
-
priority: String(job.priority),
|
|
125
|
-
runAt: job.runAt,
|
|
126
|
-
...job.cron !== void 0 ? { cron: job.cron } : {},
|
|
127
|
-
attempts: JSON.stringify(job.attempts),
|
|
128
|
-
lockId: job.lockId ?? "",
|
|
129
|
-
lockExpiresAt: job.lockExpiresAt ?? "",
|
|
130
|
-
createdAt: job.createdAt,
|
|
131
|
-
updatedAt: job.updatedAt,
|
|
132
|
-
completedAt: job.completedAt ?? "",
|
|
133
|
-
failedAt: job.failedAt ?? ""
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
function hashToJob(h) {
|
|
137
|
-
const job = {
|
|
138
|
-
id: h["id"] ?? "",
|
|
139
|
-
queue: h["queue"] ?? "",
|
|
140
|
-
type: h["type"] ?? "",
|
|
141
|
-
payload: JSON.parse(h["payload"] ?? "null"),
|
|
142
|
-
status: h["status"] ?? "waiting",
|
|
143
|
-
attemptsMade: Number(h["attemptsMade"] ?? 0),
|
|
144
|
-
maxAttempts: Number(h["maxAttempts"] ?? 1),
|
|
145
|
-
retryDelay: Number(h["retryDelay"] ?? 1e3),
|
|
146
|
-
backoff: h["backoff"] ?? "exponential",
|
|
147
|
-
timeout: Number(h["timeout"] ?? 3e4),
|
|
148
|
-
priority: Number(h["priority"] ?? 0),
|
|
149
|
-
runAt: h["runAt"] ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
150
|
-
attempts: JSON.parse(h["attempts"] ?? "[]"),
|
|
151
|
-
lockId: h["lockId"] || null,
|
|
152
|
-
lockExpiresAt: h["lockExpiresAt"] || null,
|
|
153
|
-
createdAt: h["createdAt"] ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
154
|
-
updatedAt: h["updatedAt"] ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
155
|
-
completedAt: h["completedAt"] || null,
|
|
156
|
-
failedAt: h["failedAt"] || null
|
|
157
|
-
};
|
|
158
|
-
if (h["cron"]) job.cron = h["cron"];
|
|
159
|
-
return job;
|
|
160
|
-
}
|
|
161
|
-
var PREFIX, k, RedisStorageAdapter;
|
|
162
|
-
var init_redis_adapter = __esm({
|
|
163
|
-
"src/storage/redis.adapter.ts"() {
|
|
164
|
-
init_scripts();
|
|
165
|
-
PREFIX = "qjw:";
|
|
166
|
-
k = {
|
|
167
|
-
job: (id) => `${PREFIX}job:${id}`,
|
|
168
|
-
waiting: (q) => `${PREFIX}queue:${q}:waiting`,
|
|
169
|
-
delayed: (q) => `${PREFIX}queue:${q}:delayed`,
|
|
170
|
-
active: (q) => `${PREFIX}queue:${q}:active`,
|
|
171
|
-
completed: (q) => `${PREFIX}queue:${q}:completed`,
|
|
172
|
-
dead: (q) => `${PREFIX}queue:${q}:dead`,
|
|
173
|
-
rateCount: (q) => `${PREFIX}rate:${q}`,
|
|
174
|
-
rateTs: (q) => `${PREFIX}rate:${q}:ts`
|
|
175
|
-
};
|
|
176
|
-
RedisStorageAdapter = class {
|
|
177
|
-
client;
|
|
178
|
-
url;
|
|
179
|
-
constructor(connectionString) {
|
|
180
|
-
this.url = connectionString;
|
|
181
|
-
}
|
|
182
|
-
// -------------------------------------------------------------------------
|
|
183
|
-
// Lifecycle
|
|
184
|
-
// -------------------------------------------------------------------------
|
|
185
|
-
async initialize() {
|
|
186
|
-
const { createClient } = await loadRedis();
|
|
187
|
-
this.client = createClient({ url: this.url });
|
|
188
|
-
this.client.on("error", () => {
|
|
189
|
-
});
|
|
190
|
-
await this.client.connect();
|
|
191
|
-
const pong = await this.client.ping();
|
|
192
|
-
if (pong !== "PONG") {
|
|
193
|
-
throw new Error(
|
|
194
|
-
"RedisStorageAdapter: PING returned unexpected response. Connection may be unhealthy."
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
async close() {
|
|
199
|
-
if (this.client) {
|
|
200
|
-
await this.client.quit();
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
// -------------------------------------------------------------------------
|
|
204
|
-
// Enqueue
|
|
205
|
-
// -------------------------------------------------------------------------
|
|
206
|
-
async enqueue(input) {
|
|
207
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
208
|
-
const runAtMs = new Date(input.runAt).getTime();
|
|
209
|
-
const isDelayed = runAtMs > Date.now();
|
|
210
|
-
const job = {
|
|
211
|
-
id: input.id,
|
|
212
|
-
queue: input.queue,
|
|
213
|
-
type: input.type,
|
|
214
|
-
payload: input.payload,
|
|
215
|
-
status: isDelayed ? "delayed" : "waiting",
|
|
216
|
-
attemptsMade: 0,
|
|
217
|
-
maxAttempts: input.maxAttempts,
|
|
218
|
-
retryDelay: input.retryDelay,
|
|
219
|
-
backoff: input.backoff,
|
|
220
|
-
timeout: input.timeout,
|
|
221
|
-
priority: input.priority,
|
|
222
|
-
runAt: input.runAt,
|
|
223
|
-
...input.cron !== void 0 ? { cron: input.cron } : {},
|
|
224
|
-
attempts: [],
|
|
225
|
-
lockId: null,
|
|
226
|
-
lockExpiresAt: null,
|
|
227
|
-
createdAt: now,
|
|
228
|
-
updatedAt: now,
|
|
229
|
-
completedAt: null,
|
|
230
|
-
failedAt: null
|
|
231
|
-
};
|
|
232
|
-
const key = k.job(input.id);
|
|
233
|
-
const exists = await this.client.exists(key);
|
|
234
|
-
if (exists) {
|
|
235
|
-
const hash = await this.client.hGetAll(key);
|
|
236
|
-
return hashToJob(hash);
|
|
237
|
-
}
|
|
238
|
-
const multi = this.client.multi();
|
|
239
|
-
multi.hSet(key, jobToHash(job));
|
|
240
|
-
if (isDelayed) {
|
|
241
|
-
multi.zAdd(k.delayed(input.queue), { score: runAtMs, value: input.id });
|
|
242
|
-
} else {
|
|
243
|
-
multi.zAdd(k.waiting(input.queue), { score: -input.priority, value: input.id });
|
|
244
|
-
}
|
|
245
|
-
await multi.exec();
|
|
246
|
-
return job;
|
|
247
|
-
}
|
|
248
|
-
// -------------------------------------------------------------------------
|
|
249
|
-
// Claim (atomic Lua)
|
|
250
|
-
// -------------------------------------------------------------------------
|
|
251
|
-
async claim(input) {
|
|
252
|
-
const { queue, lockId, lockDuration, now } = input;
|
|
253
|
-
const nowMs = new Date(now).getTime();
|
|
254
|
-
const lockExpiresAt = new Date(nowMs + lockDuration).toISOString();
|
|
255
|
-
const jobId = await this.client.eval(CLAIM_LUA, {
|
|
256
|
-
keys: [k.waiting(queue), k.delayed(queue), k.active(queue)],
|
|
257
|
-
arguments: [lockId, lockExpiresAt, String(nowMs), PREFIX]
|
|
258
|
-
});
|
|
259
|
-
if (!jobId) return null;
|
|
260
|
-
const hash = await this.client.hGetAll(k.job(jobId));
|
|
261
|
-
if (!hash || Object.keys(hash).length === 0) return null;
|
|
262
|
-
return hashToJob(hash);
|
|
263
|
-
}
|
|
264
|
-
// -------------------------------------------------------------------------
|
|
265
|
-
// Renew lock
|
|
266
|
-
// -------------------------------------------------------------------------
|
|
267
|
-
async renewLock(jobId, lockId, lockDuration) {
|
|
268
|
-
const nowMs = Date.now();
|
|
269
|
-
const nowIso = new Date(nowMs).toISOString();
|
|
270
|
-
const newExpiresAt = new Date(nowMs + lockDuration).toISOString();
|
|
271
|
-
const res = await this.client.eval(RENEW_LOCK_LUA, {
|
|
272
|
-
keys: [k.job(jobId)],
|
|
273
|
-
arguments: [lockId, newExpiresAt, nowIso]
|
|
274
|
-
});
|
|
275
|
-
return Number(res) === 1;
|
|
276
|
-
}
|
|
277
|
-
// -------------------------------------------------------------------------
|
|
278
|
-
// Complete
|
|
279
|
-
// -------------------------------------------------------------------------
|
|
280
|
-
async complete(jobId, lockId) {
|
|
281
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
282
|
-
const hash = await this.client.hGetAll(k.job(jobId));
|
|
283
|
-
if (!hash || Object.keys(hash).length === 0) return;
|
|
284
|
-
if (lockId !== void 0) {
|
|
285
|
-
if (hash["status"] !== "active" || hash["lockId"] !== lockId) return;
|
|
286
|
-
}
|
|
287
|
-
const queue = hash["queue"] ?? "";
|
|
288
|
-
const multi = this.client.multi();
|
|
289
|
-
multi.hSet(k.job(jobId), {
|
|
290
|
-
status: "completed",
|
|
291
|
-
lockId: "",
|
|
292
|
-
lockExpiresAt: "",
|
|
293
|
-
completedAt: now,
|
|
294
|
-
updatedAt: now
|
|
295
|
-
});
|
|
296
|
-
multi.sRem(k.active(queue), jobId);
|
|
297
|
-
multi.sAdd(k.completed(queue), jobId);
|
|
298
|
-
await multi.exec();
|
|
299
|
-
}
|
|
300
|
-
// -------------------------------------------------------------------------
|
|
301
|
-
// Requeue
|
|
302
|
-
// -------------------------------------------------------------------------
|
|
303
|
-
async requeue(input) {
|
|
304
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
305
|
-
const hash = await this.client.hGetAll(k.job(input.jobId));
|
|
306
|
-
if (!hash || Object.keys(hash).length === 0) return;
|
|
307
|
-
if (input.lockId !== void 0) {
|
|
308
|
-
if (hash["status"] !== "active" || hash["lockId"] !== input.lockId) return;
|
|
309
|
-
}
|
|
310
|
-
const queue = hash["queue"] ?? "";
|
|
311
|
-
const attempts = JSON.parse(hash["attempts"] ?? "[]");
|
|
312
|
-
attempts.push({
|
|
313
|
-
attempt: input.attemptNumber,
|
|
314
|
-
startedAt: hash["updatedAt"] ?? now,
|
|
315
|
-
finishedAt: now,
|
|
316
|
-
error: input.error,
|
|
317
|
-
...input.stack !== void 0 ? { stack: input.stack } : {}
|
|
318
|
-
});
|
|
319
|
-
const multi = this.client.multi();
|
|
320
|
-
multi.hSet(k.job(input.jobId), {
|
|
321
|
-
status: "waiting",
|
|
322
|
-
attemptsMade: String(input.attemptNumber),
|
|
323
|
-
attempts: JSON.stringify(attempts),
|
|
324
|
-
runAt: input.runAt,
|
|
325
|
-
lockId: "",
|
|
326
|
-
lockExpiresAt: "",
|
|
327
|
-
updatedAt: now
|
|
328
|
-
});
|
|
329
|
-
multi.sRem(k.active(queue), input.jobId);
|
|
330
|
-
multi.zAdd(k.delayed(queue), {
|
|
331
|
-
score: new Date(input.runAt).getTime(),
|
|
332
|
-
value: input.jobId
|
|
333
|
-
});
|
|
334
|
-
await multi.exec();
|
|
335
|
-
}
|
|
336
|
-
// -------------------------------------------------------------------------
|
|
337
|
-
// Move to DLQ
|
|
338
|
-
// -------------------------------------------------------------------------
|
|
339
|
-
async moveToDlq(input) {
|
|
340
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
341
|
-
const hash = await this.client.hGetAll(k.job(input.jobId));
|
|
342
|
-
if (!hash || Object.keys(hash).length === 0) return;
|
|
343
|
-
if (input.lockId !== void 0) {
|
|
344
|
-
if (hash["status"] !== "active" || hash["lockId"] !== input.lockId) return;
|
|
345
|
-
}
|
|
346
|
-
const queue = hash["queue"] ?? "";
|
|
347
|
-
const attempts = JSON.parse(hash["attempts"] ?? "[]");
|
|
348
|
-
attempts.push({
|
|
349
|
-
attempt: input.attemptNumber,
|
|
350
|
-
startedAt: hash["updatedAt"] ?? now,
|
|
351
|
-
finishedAt: now,
|
|
352
|
-
error: input.error,
|
|
353
|
-
...input.stack !== void 0 ? { stack: input.stack } : {}
|
|
354
|
-
});
|
|
355
|
-
const multi = this.client.multi();
|
|
356
|
-
multi.hSet(k.job(input.jobId), {
|
|
357
|
-
status: "dead",
|
|
358
|
-
attemptsMade: String(input.attemptNumber),
|
|
359
|
-
attempts: JSON.stringify(attempts),
|
|
360
|
-
lockId: "",
|
|
361
|
-
lockExpiresAt: "",
|
|
362
|
-
failedAt: now,
|
|
363
|
-
updatedAt: now
|
|
364
|
-
});
|
|
365
|
-
multi.sRem(k.active(queue), input.jobId);
|
|
366
|
-
multi.sAdd(k.dead(queue), input.jobId);
|
|
367
|
-
await multi.exec();
|
|
368
|
-
}
|
|
369
|
-
// -------------------------------------------------------------------------
|
|
370
|
-
// Release lock
|
|
371
|
-
// -------------------------------------------------------------------------
|
|
372
|
-
async releaseLock(jobId, lockId) {
|
|
373
|
-
const hash = await this.client.hGetAll(k.job(jobId));
|
|
374
|
-
if (!hash || Object.keys(hash).length === 0) return;
|
|
375
|
-
if (lockId !== void 0) {
|
|
376
|
-
if (hash["status"] !== "active" || hash["lockId"] !== lockId) return;
|
|
377
|
-
}
|
|
378
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
379
|
-
await this.client.hSet(k.job(jobId), {
|
|
380
|
-
lockId: "",
|
|
381
|
-
lockExpiresAt: now,
|
|
382
|
-
updatedAt: now
|
|
383
|
-
});
|
|
384
|
-
}
|
|
385
|
-
// -------------------------------------------------------------------------
|
|
386
|
-
// Recover stalled jobs (atomic compare-and-swap per job via Lua)
|
|
387
|
-
//
|
|
388
|
-
// Previous implementation: two-phase read pipeline → write pipeline.
|
|
389
|
-
// Race condition: a worker could complete or renew its lock between the two
|
|
390
|
-
// phases, causing recovery to overwrite a legitimately-active job.
|
|
391
|
-
//
|
|
392
|
-
// Fix (issue #6): for each candidate job the RECOVER_STALLED_LUA script
|
|
393
|
-
// re-reads lockExpiresAt, lockId, and status atomically and only applies
|
|
394
|
-
// the recovery if all three still match what was observed in the read phase
|
|
395
|
-
// (compare-and-swap). If the worker renewed or completed the job in the
|
|
396
|
-
// window between the read and the Lua call, the CAS mismatch causes the
|
|
397
|
-
// script to return 0 and the job is left untouched.
|
|
398
|
-
// -------------------------------------------------------------------------
|
|
399
|
-
async recoverStalledJobs(queue, now) {
|
|
400
|
-
const nowMs = new Date(now).getTime();
|
|
401
|
-
const activeIds = await this.client.sMembers(k.active(queue));
|
|
402
|
-
if (activeIds.length === 0) return [];
|
|
403
|
-
const fetchPipeline = this.client.multi();
|
|
404
|
-
for (const jobId of activeIds) {
|
|
405
|
-
fetchPipeline.hmGet(k.job(jobId), ["lockExpiresAt", "lockId", "priority"]);
|
|
406
|
-
}
|
|
407
|
-
const fetchResults = await fetchPipeline.exec();
|
|
408
|
-
const recovered = [];
|
|
409
|
-
const evalPromises = [];
|
|
410
|
-
for (let i = 0; i < activeIds.length; i++) {
|
|
411
|
-
const jobId = activeIds[i];
|
|
412
|
-
const fields = fetchResults[i];
|
|
413
|
-
if (!fields) continue;
|
|
414
|
-
const [lockExpiresAt, lockId, priorityStr] = fields;
|
|
415
|
-
if (!lockExpiresAt) continue;
|
|
416
|
-
if (new Date(lockExpiresAt).getTime() > nowMs) continue;
|
|
417
|
-
const priority = Number(priorityStr ?? "0");
|
|
418
|
-
const priorityScore = String(-priority);
|
|
419
|
-
const p = this.client.eval(RECOVER_STALLED_LUA, {
|
|
420
|
-
keys: [k.job(jobId), k.active(queue), k.waiting(queue)],
|
|
421
|
-
arguments: [jobId, lockExpiresAt, lockId ?? "", now, priorityScore]
|
|
422
|
-
}).then((result) => {
|
|
423
|
-
if (result === 1) recovered.push(jobId);
|
|
424
|
-
});
|
|
425
|
-
evalPromises.push(p);
|
|
426
|
-
}
|
|
427
|
-
await Promise.all(evalPromises);
|
|
428
|
-
return recovered;
|
|
429
|
-
}
|
|
430
|
-
// -------------------------------------------------------------------------
|
|
431
|
-
// Reads
|
|
432
|
-
// -------------------------------------------------------------------------
|
|
433
|
-
async getJob(jobId) {
|
|
434
|
-
const hash = await this.client.hGetAll(k.job(jobId));
|
|
435
|
-
if (!hash || Object.keys(hash).length === 0) return null;
|
|
436
|
-
return hashToJob(hash);
|
|
437
|
-
}
|
|
438
|
-
async getJobs(filter) {
|
|
439
|
-
const { queue, status, limit = 100, offset = 0 } = filter;
|
|
440
|
-
let ids = [];
|
|
441
|
-
if (queue && status) {
|
|
442
|
-
switch (status) {
|
|
443
|
-
case "waiting":
|
|
444
|
-
ids = await this.client.zRange(k.waiting(queue), 0, -1);
|
|
445
|
-
break;
|
|
446
|
-
case "delayed":
|
|
447
|
-
ids = await this.client.zRange(k.delayed(queue), 0, -1);
|
|
448
|
-
break;
|
|
449
|
-
case "active":
|
|
450
|
-
ids = await this.client.sMembers(k.active(queue));
|
|
451
|
-
break;
|
|
452
|
-
case "completed":
|
|
453
|
-
ids = await this.client.sMembers(k.completed(queue));
|
|
454
|
-
break;
|
|
455
|
-
case "dead":
|
|
456
|
-
ids = await this.client.sMembers(k.dead(queue));
|
|
457
|
-
break;
|
|
458
|
-
default:
|
|
459
|
-
ids = [];
|
|
460
|
-
}
|
|
461
|
-
} else {
|
|
462
|
-
for await (const key of this.client.scanIterator({
|
|
463
|
-
MATCH: `${PREFIX}job:*`,
|
|
464
|
-
COUNT: 500
|
|
465
|
-
})) {
|
|
466
|
-
const keyStr = Array.isArray(key) ? String(key[0]) : String(key);
|
|
467
|
-
ids.push(keyStr.replace(`${PREFIX}job:`, ""));
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
const results = [];
|
|
471
|
-
const page = ids.slice(offset, offset + limit);
|
|
472
|
-
for (const id of page) {
|
|
473
|
-
const job = await this.getJob(id);
|
|
474
|
-
if (!job) continue;
|
|
475
|
-
if (queue && job.queue !== queue) continue;
|
|
476
|
-
if (status && job.status !== status) continue;
|
|
477
|
-
results.push(job);
|
|
478
|
-
}
|
|
479
|
-
return results;
|
|
480
|
-
}
|
|
481
|
-
async getJobCounts(queue) {
|
|
482
|
-
const [waiting, delayed, active, completed, dead] = await Promise.all([
|
|
483
|
-
this.client.zCard(k.waiting(queue)),
|
|
484
|
-
this.client.zCard(k.delayed(queue)),
|
|
485
|
-
this.client.sCard(k.active(queue)),
|
|
486
|
-
this.client.sCard(k.completed(queue)),
|
|
487
|
-
this.client.sCard(k.dead(queue))
|
|
488
|
-
]);
|
|
489
|
-
return { waiting, delayed, active, completed, dead };
|
|
490
|
-
}
|
|
491
|
-
// -------------------------------------------------------------------------
|
|
492
|
-
// Rate limiting
|
|
493
|
-
// -------------------------------------------------------------------------
|
|
494
|
-
async checkAndIncrementRateLimit(queue, max, windowMs, now) {
|
|
495
|
-
const nowMs = new Date(now).getTime();
|
|
496
|
-
const ck = k.rateCount(queue);
|
|
497
|
-
const tk = k.rateTs(queue);
|
|
498
|
-
const ttlSec = Math.max(1, Math.ceil(windowMs / 1e3));
|
|
499
|
-
const res = await this.client.eval(RATE_LIMIT_LUA, {
|
|
500
|
-
keys: [ck, tk],
|
|
501
|
-
arguments: [String(max), String(windowMs), String(nowMs), String(ttlSec)]
|
|
502
|
-
});
|
|
503
|
-
return Number(res) === 1;
|
|
504
|
-
}
|
|
24
|
+
async updateJob(queueName, jobId, patch) {
|
|
25
|
+
const queue = this.store.get(queueName);
|
|
26
|
+
if (!queue) return;
|
|
27
|
+
const existing = queue.get(jobId);
|
|
28
|
+
if (!existing) return;
|
|
29
|
+
const updated = {
|
|
30
|
+
...existing,
|
|
31
|
+
...patch,
|
|
32
|
+
updatedAt: Date.now()
|
|
505
33
|
};
|
|
34
|
+
queue.set(jobId, updated);
|
|
506
35
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
// src/storage/postgres.adapter.ts
|
|
510
|
-
var postgres_adapter_exports = {};
|
|
511
|
-
__export(postgres_adapter_exports, {
|
|
512
|
-
PostgreSQLStorageAdapter: () => PostgreSQLStorageAdapter
|
|
513
|
-
});
|
|
514
|
-
async function loadPg() {
|
|
515
|
-
try {
|
|
516
|
-
return await import('pg');
|
|
517
|
-
} catch {
|
|
518
|
-
throw new Error(
|
|
519
|
-
'PostgreSQLStorageAdapter requires the "pg" package (node-postgres).\nInstall it: npm install pg'
|
|
520
|
-
);
|
|
36
|
+
async removeJob(queueName, jobId) {
|
|
37
|
+
this.store.get(queueName)?.delete(jobId);
|
|
521
38
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
if (row.cron) job.cron = row.cron;
|
|
546
|
-
return job;
|
|
547
|
-
}
|
|
548
|
-
var CREATE_JOBS_TABLE, CREATE_RATE_TABLE, PostgreSQLStorageAdapter;
|
|
549
|
-
var init_postgres_adapter = __esm({
|
|
550
|
-
"src/storage/postgres.adapter.ts"() {
|
|
551
|
-
CREATE_JOBS_TABLE = `
|
|
552
|
-
CREATE TABLE IF NOT EXISTS qjw_jobs (
|
|
553
|
-
id TEXT NOT NULL PRIMARY KEY,
|
|
554
|
-
queue TEXT NOT NULL,
|
|
555
|
-
type TEXT NOT NULL,
|
|
556
|
-
payload JSONB NOT NULL DEFAULT '{}',
|
|
557
|
-
status TEXT NOT NULL DEFAULT 'waiting',
|
|
558
|
-
attempts_made INTEGER NOT NULL DEFAULT 0,
|
|
559
|
-
max_attempts INTEGER NOT NULL DEFAULT 3,
|
|
560
|
-
retry_delay INTEGER NOT NULL DEFAULT 1000,
|
|
561
|
-
backoff TEXT NOT NULL DEFAULT 'exponential',
|
|
562
|
-
timeout INTEGER NOT NULL DEFAULT 30000,
|
|
563
|
-
priority INTEGER NOT NULL DEFAULT 0,
|
|
564
|
-
run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
565
|
-
cron TEXT,
|
|
566
|
-
attempts JSONB NOT NULL DEFAULT '[]',
|
|
567
|
-
lock_id TEXT,
|
|
568
|
-
lock_expires_at TIMESTAMPTZ,
|
|
569
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
570
|
-
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
571
|
-
completed_at TIMESTAMPTZ,
|
|
572
|
-
failed_at TIMESTAMPTZ
|
|
573
|
-
);
|
|
574
|
-
CREATE INDEX IF NOT EXISTS qjw_jobs_claim_idx
|
|
575
|
-
ON qjw_jobs (queue, status, run_at, priority DESC)
|
|
576
|
-
WHERE status IN ('waiting', 'delayed');
|
|
577
|
-
`;
|
|
578
|
-
CREATE_RATE_TABLE = `
|
|
579
|
-
CREATE TABLE IF NOT EXISTS qjw_rate_limits (
|
|
580
|
-
queue TEXT NOT NULL PRIMARY KEY,
|
|
581
|
-
count INTEGER NOT NULL DEFAULT 0,
|
|
582
|
-
window_start BIGINT NOT NULL DEFAULT 0
|
|
583
|
-
);
|
|
584
|
-
`;
|
|
585
|
-
PostgreSQLStorageAdapter = class {
|
|
586
|
-
pool;
|
|
587
|
-
connectionString;
|
|
588
|
-
constructor(connectionString) {
|
|
589
|
-
this.connectionString = connectionString;
|
|
590
|
-
}
|
|
591
|
-
// -------------------------------------------------------------------------
|
|
592
|
-
// Lifecycle
|
|
593
|
-
// -------------------------------------------------------------------------
|
|
594
|
-
async initialize() {
|
|
595
|
-
const { Pool } = await loadPg();
|
|
596
|
-
this.pool = new Pool({ connectionString: this.connectionString });
|
|
597
|
-
const client = await this.pool.connect();
|
|
598
|
-
try {
|
|
599
|
-
await client.query("SELECT 1");
|
|
600
|
-
await client.query(CREATE_JOBS_TABLE);
|
|
601
|
-
await client.query(CREATE_RATE_TABLE);
|
|
602
|
-
} finally {
|
|
603
|
-
client.release();
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
async close() {
|
|
607
|
-
if (this.pool) {
|
|
608
|
-
await this.pool.end();
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
// -------------------------------------------------------------------------
|
|
612
|
-
// Enqueue
|
|
613
|
-
// -------------------------------------------------------------------------
|
|
614
|
-
async enqueue(input) {
|
|
615
|
-
const res = await this.pool.query(
|
|
616
|
-
`INSERT INTO qjw_jobs
|
|
617
|
-
(id, queue, type, payload, status, attempts_made, max_attempts,
|
|
618
|
-
retry_delay, backoff, timeout, priority, run_at, cron,
|
|
619
|
-
attempts, lock_id, lock_expires_at, created_at, updated_at,
|
|
620
|
-
completed_at, failed_at)
|
|
621
|
-
VALUES ($1,$2,$3,$4,
|
|
622
|
-
CASE WHEN $5::timestamptz > NOW() THEN 'delayed' ELSE 'waiting' END,
|
|
623
|
-
0,$6,$7,$8,$9,$10,$5,$11,
|
|
624
|
-
'[]'::jsonb, NULL, NULL, NOW(), NOW(), NULL, NULL)
|
|
625
|
-
ON CONFLICT (id) DO NOTHING
|
|
626
|
-
RETURNING *`,
|
|
627
|
-
[
|
|
628
|
-
input.id,
|
|
629
|
-
input.queue,
|
|
630
|
-
input.type,
|
|
631
|
-
JSON.stringify(input.payload),
|
|
632
|
-
input.runAt,
|
|
633
|
-
input.maxAttempts,
|
|
634
|
-
input.retryDelay,
|
|
635
|
-
input.backoff,
|
|
636
|
-
input.timeout,
|
|
637
|
-
input.priority,
|
|
638
|
-
input.cron ?? null
|
|
639
|
-
]
|
|
640
|
-
);
|
|
641
|
-
if (res.rows.length > 0) {
|
|
642
|
-
return rowToJob(res.rows[0]);
|
|
643
|
-
}
|
|
644
|
-
const existing = await this.pool.query("SELECT * FROM qjw_jobs WHERE id = $1", [
|
|
645
|
-
input.id
|
|
646
|
-
]);
|
|
647
|
-
return rowToJob(existing.rows[0]);
|
|
648
|
-
}
|
|
649
|
-
// -------------------------------------------------------------------------
|
|
650
|
-
// Claim (SELECT … FOR UPDATE SKIP LOCKED)
|
|
651
|
-
// -------------------------------------------------------------------------
|
|
652
|
-
async claim(input) {
|
|
653
|
-
const { queue, lockId, lockDuration, now } = input;
|
|
654
|
-
const lockExpiresAt = new Date(new Date(now).getTime() + lockDuration).toISOString();
|
|
655
|
-
const client = await this.pool.connect();
|
|
656
|
-
try {
|
|
657
|
-
await client.query("BEGIN");
|
|
658
|
-
const res = await client.query(
|
|
659
|
-
`SELECT * FROM qjw_jobs
|
|
660
|
-
WHERE queue = $1
|
|
661
|
-
AND status IN ('waiting', 'delayed')
|
|
662
|
-
AND run_at <= $2::timestamptz
|
|
663
|
-
AND (lock_expires_at IS NULL OR lock_expires_at <= $2::timestamptz)
|
|
664
|
-
ORDER BY priority DESC, run_at ASC, created_at ASC
|
|
665
|
-
LIMIT 1
|
|
666
|
-
FOR UPDATE SKIP LOCKED`,
|
|
667
|
-
[queue, now]
|
|
668
|
-
);
|
|
669
|
-
if (res.rows.length === 0) {
|
|
670
|
-
await client.query("ROLLBACK");
|
|
671
|
-
return null;
|
|
672
|
-
}
|
|
673
|
-
const row = res.rows[0];
|
|
674
|
-
await client.query(
|
|
675
|
-
`UPDATE qjw_jobs
|
|
676
|
-
SET status = 'active', lock_id = $1, lock_expires_at = $2, updated_at = NOW()
|
|
677
|
-
WHERE id = $3`,
|
|
678
|
-
[lockId, lockExpiresAt, row.id]
|
|
679
|
-
);
|
|
680
|
-
await client.query("COMMIT");
|
|
681
|
-
const updated = await this.pool.query("SELECT * FROM qjw_jobs WHERE id = $1", [
|
|
682
|
-
row.id
|
|
683
|
-
]);
|
|
684
|
-
return rowToJob(updated.rows[0]);
|
|
685
|
-
} catch (err) {
|
|
686
|
-
await client.query("ROLLBACK");
|
|
687
|
-
throw err;
|
|
688
|
-
} finally {
|
|
689
|
-
client.release();
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
// -------------------------------------------------------------------------
|
|
693
|
-
// Renew lock
|
|
694
|
-
// -------------------------------------------------------------------------
|
|
695
|
-
async renewLock(jobId, lockId, lockDuration) {
|
|
696
|
-
const newLockExpiresAt = new Date(Date.now() + lockDuration).toISOString();
|
|
697
|
-
const res = await this.pool.query(
|
|
698
|
-
`UPDATE qjw_jobs
|
|
699
|
-
SET lock_expires_at = $1::timestamptz,
|
|
700
|
-
updated_at = NOW()
|
|
701
|
-
WHERE id = $2 AND status = 'active' AND lock_id = $3 AND lock_expires_at > NOW()`,
|
|
702
|
-
[newLockExpiresAt, jobId, lockId]
|
|
703
|
-
);
|
|
704
|
-
return (res.rowCount ?? 0) > 0;
|
|
705
|
-
}
|
|
706
|
-
// -------------------------------------------------------------------------
|
|
707
|
-
// Complete
|
|
708
|
-
// -------------------------------------------------------------------------
|
|
709
|
-
async complete(jobId, lockId) {
|
|
710
|
-
if (lockId !== void 0) {
|
|
711
|
-
await this.pool.query(
|
|
712
|
-
`UPDATE qjw_jobs
|
|
713
|
-
SET status = 'completed', lock_id = NULL, lock_expires_at = NULL,
|
|
714
|
-
completed_at = NOW(), updated_at = NOW()
|
|
715
|
-
WHERE id = $1 AND status = 'active' AND lock_id = $2`,
|
|
716
|
-
[jobId, lockId]
|
|
717
|
-
);
|
|
718
|
-
} else {
|
|
719
|
-
await this.pool.query(
|
|
720
|
-
`UPDATE qjw_jobs
|
|
721
|
-
SET status = 'completed', lock_id = NULL, lock_expires_at = NULL,
|
|
722
|
-
completed_at = NOW(), updated_at = NOW()
|
|
723
|
-
WHERE id = $1`,
|
|
724
|
-
[jobId]
|
|
725
|
-
);
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
// -------------------------------------------------------------------------
|
|
729
|
-
// Requeue
|
|
730
|
-
// -------------------------------------------------------------------------
|
|
731
|
-
async requeue(input) {
|
|
732
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
733
|
-
const attempt = {
|
|
734
|
-
attempt: input.attemptNumber,
|
|
735
|
-
startedAt: now,
|
|
736
|
-
finishedAt: now,
|
|
737
|
-
error: input.error,
|
|
738
|
-
...input.stack !== void 0 ? { stack: input.stack } : {}
|
|
739
|
-
};
|
|
740
|
-
if (input.lockId !== void 0) {
|
|
741
|
-
await this.pool.query(
|
|
742
|
-
`UPDATE qjw_jobs
|
|
743
|
-
SET status = 'waiting',
|
|
744
|
-
attempts_made = $1,
|
|
745
|
-
attempts = attempts || $2::jsonb,
|
|
746
|
-
run_at = $3::timestamptz,
|
|
747
|
-
lock_id = NULL,
|
|
748
|
-
lock_expires_at = NULL,
|
|
749
|
-
updated_at = NOW()
|
|
750
|
-
WHERE id = $4 AND status = 'active' AND lock_id = $5`,
|
|
751
|
-
[input.attemptNumber, JSON.stringify([attempt]), input.runAt, input.jobId, input.lockId]
|
|
752
|
-
);
|
|
753
|
-
} else {
|
|
754
|
-
await this.pool.query(
|
|
755
|
-
`UPDATE qjw_jobs
|
|
756
|
-
SET status = 'waiting',
|
|
757
|
-
attempts_made = $1,
|
|
758
|
-
attempts = attempts || $2::jsonb,
|
|
759
|
-
run_at = $3::timestamptz,
|
|
760
|
-
lock_id = NULL,
|
|
761
|
-
lock_expires_at = NULL,
|
|
762
|
-
updated_at = NOW()
|
|
763
|
-
WHERE id = $4`,
|
|
764
|
-
[input.attemptNumber, JSON.stringify([attempt]), input.runAt, input.jobId]
|
|
765
|
-
);
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
// -------------------------------------------------------------------------
|
|
769
|
-
// Move to DLQ
|
|
770
|
-
// -------------------------------------------------------------------------
|
|
771
|
-
async moveToDlq(input) {
|
|
772
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
773
|
-
const attempt = {
|
|
774
|
-
attempt: input.attemptNumber,
|
|
775
|
-
startedAt: now,
|
|
776
|
-
finishedAt: now,
|
|
777
|
-
error: input.error,
|
|
778
|
-
...input.stack !== void 0 ? { stack: input.stack } : {}
|
|
779
|
-
};
|
|
780
|
-
if (input.lockId !== void 0) {
|
|
781
|
-
await this.pool.query(
|
|
782
|
-
`UPDATE qjw_jobs
|
|
783
|
-
SET status = 'dead',
|
|
784
|
-
attempts_made = $1,
|
|
785
|
-
attempts = attempts || $2::jsonb,
|
|
786
|
-
lock_id = NULL,
|
|
787
|
-
lock_expires_at = NULL,
|
|
788
|
-
failed_at = NOW(),
|
|
789
|
-
updated_at = NOW()
|
|
790
|
-
WHERE id = $3 AND status = 'active' AND lock_id = $4`,
|
|
791
|
-
[input.attemptNumber, JSON.stringify([attempt]), input.jobId, input.lockId]
|
|
792
|
-
);
|
|
793
|
-
} else {
|
|
794
|
-
await this.pool.query(
|
|
795
|
-
`UPDATE qjw_jobs
|
|
796
|
-
SET status = 'dead',
|
|
797
|
-
attempts_made = $1,
|
|
798
|
-
attempts = attempts || $2::jsonb,
|
|
799
|
-
lock_id = NULL,
|
|
800
|
-
lock_expires_at = NULL,
|
|
801
|
-
failed_at = NOW(),
|
|
802
|
-
updated_at = NOW()
|
|
803
|
-
WHERE id = $3`,
|
|
804
|
-
[input.attemptNumber, JSON.stringify([attempt]), input.jobId]
|
|
805
|
-
);
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
// -------------------------------------------------------------------------
|
|
809
|
-
// Release lock
|
|
810
|
-
// -------------------------------------------------------------------------
|
|
811
|
-
async releaseLock(jobId, lockId) {
|
|
812
|
-
if (lockId !== void 0) {
|
|
813
|
-
await this.pool.query(
|
|
814
|
-
`UPDATE qjw_jobs
|
|
815
|
-
SET lock_id = NULL, lock_expires_at = NOW(), updated_at = NOW()
|
|
816
|
-
WHERE id = $1 AND status = 'active' AND lock_id = $2`,
|
|
817
|
-
[jobId, lockId]
|
|
818
|
-
);
|
|
819
|
-
} else {
|
|
820
|
-
await this.pool.query(
|
|
821
|
-
`UPDATE qjw_jobs
|
|
822
|
-
SET lock_id = NULL, lock_expires_at = NOW(), updated_at = NOW()
|
|
823
|
-
WHERE id = $1`,
|
|
824
|
-
[jobId]
|
|
825
|
-
);
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
// -------------------------------------------------------------------------
|
|
829
|
-
// Recover stalled jobs
|
|
830
|
-
// -------------------------------------------------------------------------
|
|
831
|
-
async recoverStalledJobs(queue, now) {
|
|
832
|
-
const res = await this.pool.query(
|
|
833
|
-
`UPDATE qjw_jobs
|
|
834
|
-
SET status = 'waiting', lock_id = NULL, lock_expires_at = NULL, updated_at = NOW()
|
|
835
|
-
WHERE queue = $1
|
|
836
|
-
AND status = 'active'
|
|
837
|
-
AND lock_expires_at <= $2::timestamptz
|
|
838
|
-
RETURNING id`,
|
|
839
|
-
[queue, now]
|
|
840
|
-
);
|
|
841
|
-
return res.rows.map((r) => r.id);
|
|
842
|
-
}
|
|
843
|
-
// -------------------------------------------------------------------------
|
|
844
|
-
// Reads
|
|
845
|
-
// -------------------------------------------------------------------------
|
|
846
|
-
async getJob(jobId) {
|
|
847
|
-
const res = await this.pool.query("SELECT * FROM qjw_jobs WHERE id = $1", [jobId]);
|
|
848
|
-
if (res.rows.length === 0) return null;
|
|
849
|
-
return rowToJob(res.rows[0]);
|
|
850
|
-
}
|
|
851
|
-
async getJobs(filter) {
|
|
852
|
-
const { queue, status, limit = 100, offset = 0 } = filter;
|
|
853
|
-
const conditions = [];
|
|
854
|
-
const params = [];
|
|
855
|
-
let idx = 1;
|
|
856
|
-
if (queue !== void 0) {
|
|
857
|
-
conditions.push(`queue = $${idx++}`);
|
|
858
|
-
params.push(queue);
|
|
859
|
-
}
|
|
860
|
-
if (status !== void 0) {
|
|
861
|
-
conditions.push(`status = $${idx++}`);
|
|
862
|
-
params.push(status);
|
|
863
|
-
}
|
|
864
|
-
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
865
|
-
params.push(limit, offset);
|
|
866
|
-
const res = await this.pool.query(
|
|
867
|
-
`SELECT * FROM qjw_jobs ${where}
|
|
868
|
-
ORDER BY priority DESC, run_at ASC, created_at ASC
|
|
869
|
-
LIMIT $${idx++} OFFSET $${idx}`,
|
|
870
|
-
params
|
|
871
|
-
);
|
|
872
|
-
return res.rows.map((r) => rowToJob(r));
|
|
873
|
-
}
|
|
874
|
-
async getJobCounts(queue) {
|
|
875
|
-
const res = await this.pool.query(
|
|
876
|
-
"SELECT status, COUNT(*)::int AS count FROM qjw_jobs WHERE queue = $1 GROUP BY status",
|
|
877
|
-
[queue]
|
|
878
|
-
);
|
|
879
|
-
const counts = {
|
|
880
|
-
waiting: 0,
|
|
881
|
-
active: 0,
|
|
882
|
-
completed: 0,
|
|
883
|
-
delayed: 0,
|
|
884
|
-
dead: 0
|
|
885
|
-
};
|
|
886
|
-
for (const row of res.rows) {
|
|
887
|
-
if (row.status in counts) {
|
|
888
|
-
counts[row.status] = Number(row.count);
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
return counts;
|
|
892
|
-
}
|
|
893
|
-
// -------------------------------------------------------------------------
|
|
894
|
-
// Rate limiting
|
|
895
|
-
// -------------------------------------------------------------------------
|
|
896
|
-
async checkAndIncrementRateLimit(queue, max, windowMs, now) {
|
|
897
|
-
const nowMs = new Date(now).getTime();
|
|
898
|
-
const client = await this.pool.connect();
|
|
899
|
-
try {
|
|
900
|
-
await client.query("BEGIN");
|
|
901
|
-
await client.query(
|
|
902
|
-
`INSERT INTO qjw_rate_limits (queue, count, window_start) VALUES ($1, 0, 0)
|
|
903
|
-
ON CONFLICT (queue) DO NOTHING`,
|
|
904
|
-
[queue]
|
|
905
|
-
);
|
|
906
|
-
const res = await client.query(
|
|
907
|
-
"SELECT count, window_start FROM qjw_rate_limits WHERE queue = $1 FOR UPDATE",
|
|
908
|
-
[queue]
|
|
909
|
-
);
|
|
910
|
-
const row = res.rows[0];
|
|
911
|
-
const windowStart = Number(row?.window_start ?? 0);
|
|
912
|
-
const count = Number(row?.count ?? 0);
|
|
913
|
-
if (!row || nowMs - windowStart >= windowMs) {
|
|
914
|
-
await client.query(
|
|
915
|
-
"UPDATE qjw_rate_limits SET count = 1, window_start = $2 WHERE queue = $1",
|
|
916
|
-
[queue, nowMs]
|
|
917
|
-
);
|
|
918
|
-
await client.query("COMMIT");
|
|
919
|
-
return true;
|
|
920
|
-
}
|
|
921
|
-
if (count >= max) {
|
|
922
|
-
await client.query("COMMIT");
|
|
923
|
-
return false;
|
|
924
|
-
}
|
|
925
|
-
await client.query("UPDATE qjw_rate_limits SET count = count + 1 WHERE queue = $1", [queue]);
|
|
926
|
-
await client.query("COMMIT");
|
|
927
|
-
return true;
|
|
928
|
-
} catch (err) {
|
|
929
|
-
await client.query("ROLLBACK");
|
|
930
|
-
throw err;
|
|
931
|
-
} finally {
|
|
932
|
-
client.release();
|
|
933
|
-
}
|
|
934
|
-
}
|
|
39
|
+
async listJobs(queueName, status) {
|
|
40
|
+
const queue = this.store.get(queueName);
|
|
41
|
+
if (!queue) return [];
|
|
42
|
+
let jobs = Array.from(queue.values());
|
|
43
|
+
if (status !== void 0) {
|
|
44
|
+
jobs = jobs.filter((j) => j.status === status);
|
|
45
|
+
}
|
|
46
|
+
return this.sortJobs(jobs);
|
|
47
|
+
}
|
|
48
|
+
async getNextJob(queueName) {
|
|
49
|
+
const queue = this.store.get(queueName);
|
|
50
|
+
if (!queue) return void 0;
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const candidates = Array.from(queue.values()).filter(
|
|
53
|
+
(j) => (j.status === "waiting" || j.status === "retrying") && j.runAt <= now
|
|
54
|
+
);
|
|
55
|
+
if (candidates.length === 0) return void 0;
|
|
56
|
+
const next = this.sortJobs(candidates)[0];
|
|
57
|
+
const claimed = {
|
|
58
|
+
...next,
|
|
59
|
+
status: "active",
|
|
60
|
+
processedAt: now,
|
|
61
|
+
updatedAt: now
|
|
935
62
|
};
|
|
63
|
+
queue.set(next.id, claimed);
|
|
64
|
+
return claimed;
|
|
936
65
|
}
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
// src/storage/mysql.adapter.ts
|
|
940
|
-
var mysql_adapter_exports = {};
|
|
941
|
-
__export(mysql_adapter_exports, {
|
|
942
|
-
MySQLStorageAdapter: () => MySQLStorageAdapter
|
|
943
|
-
});
|
|
944
|
-
async function loadMySQL2() {
|
|
945
|
-
try {
|
|
946
|
-
return await import('mysql2/promise');
|
|
947
|
-
} catch {
|
|
948
|
-
throw new Error(
|
|
949
|
-
'MySQLStorageAdapter requires the "mysql2" package.\nInstall it: npm install mysql2'
|
|
950
|
-
);
|
|
66
|
+
async clearQueue(queueName) {
|
|
67
|
+
this.store.delete(queueName);
|
|
951
68
|
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
queue
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
maxAttempts: Number(row.max_attempts),
|
|
964
|
-
retryDelay: Number(row.retry_delay),
|
|
965
|
-
backoff: row.backoff,
|
|
966
|
-
timeout: Number(row.timeout),
|
|
967
|
-
priority: Number(row.priority),
|
|
968
|
-
runAt: row.run_at instanceof Date ? row.run_at.toISOString() : String(row.run_at),
|
|
969
|
-
attempts,
|
|
970
|
-
lockId: row.lock_id,
|
|
971
|
-
lockExpiresAt: row.lock_expires_at instanceof Date ? row.lock_expires_at.toISOString() : null,
|
|
972
|
-
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at),
|
|
973
|
-
updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : String(row.updated_at),
|
|
974
|
-
completedAt: row.completed_at instanceof Date ? row.completed_at.toISOString() : null,
|
|
975
|
-
failedAt: row.failed_at instanceof Date ? row.failed_at.toISOString() : null
|
|
976
|
-
};
|
|
977
|
-
if (row.cron) job.cron = row.cron;
|
|
978
|
-
return job;
|
|
979
|
-
}
|
|
980
|
-
var CREATE_JOBS_TABLE2, CREATE_RATE_TABLE2, MySQLStorageAdapter;
|
|
981
|
-
var init_mysql_adapter = __esm({
|
|
982
|
-
"src/storage/mysql.adapter.ts"() {
|
|
983
|
-
CREATE_JOBS_TABLE2 = `
|
|
984
|
-
CREATE TABLE IF NOT EXISTS qjw_jobs (
|
|
985
|
-
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
|
986
|
-
queue VARCHAR(255) NOT NULL,
|
|
987
|
-
type VARCHAR(255) NOT NULL,
|
|
988
|
-
payload JSON NOT NULL,
|
|
989
|
-
status VARCHAR(20) NOT NULL DEFAULT 'waiting',
|
|
990
|
-
attempts_made INT NOT NULL DEFAULT 0,
|
|
991
|
-
max_attempts INT NOT NULL DEFAULT 3,
|
|
992
|
-
retry_delay INT NOT NULL DEFAULT 1000,
|
|
993
|
-
backoff VARCHAR(20) NOT NULL DEFAULT 'exponential',
|
|
994
|
-
timeout INT NOT NULL DEFAULT 30000,
|
|
995
|
-
priority INT NOT NULL DEFAULT 0,
|
|
996
|
-
run_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
997
|
-
cron VARCHAR(255),
|
|
998
|
-
attempts JSON NOT NULL,
|
|
999
|
-
lock_id VARCHAR(255),
|
|
1000
|
-
lock_expires_at DATETIME(3),
|
|
1001
|
-
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
1002
|
-
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
|
1003
|
-
completed_at DATETIME(3),
|
|
1004
|
-
failed_at DATETIME(3),
|
|
1005
|
-
INDEX qjw_jobs_claim_idx (queue, status, run_at, priority)
|
|
1006
|
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
1007
|
-
`;
|
|
1008
|
-
CREATE_RATE_TABLE2 = `
|
|
1009
|
-
CREATE TABLE IF NOT EXISTS qjw_rate_limits (
|
|
1010
|
-
queue VARCHAR(255) NOT NULL PRIMARY KEY,
|
|
1011
|
-
count INT NOT NULL DEFAULT 0,
|
|
1012
|
-
window_start BIGINT NOT NULL DEFAULT 0
|
|
1013
|
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
1014
|
-
`;
|
|
1015
|
-
MySQLStorageAdapter = class {
|
|
1016
|
-
pool;
|
|
1017
|
-
connectionString;
|
|
1018
|
-
constructor(connectionString) {
|
|
1019
|
-
this.connectionString = connectionString;
|
|
1020
|
-
}
|
|
1021
|
-
// -------------------------------------------------------------------------
|
|
1022
|
-
// Lifecycle
|
|
1023
|
-
// -------------------------------------------------------------------------
|
|
1024
|
-
async initialize() {
|
|
1025
|
-
const mysql2 = await loadMySQL2();
|
|
1026
|
-
this.pool = mysql2.createPool({
|
|
1027
|
-
uri: this.connectionString,
|
|
1028
|
-
waitForConnections: true,
|
|
1029
|
-
connectionLimit: 10,
|
|
1030
|
-
queueLimit: 0
|
|
1031
|
-
});
|
|
1032
|
-
const conn = await this.pool.getConnection();
|
|
1033
|
-
try {
|
|
1034
|
-
await conn.query("SELECT 1");
|
|
1035
|
-
await conn.query(CREATE_JOBS_TABLE2);
|
|
1036
|
-
await conn.query(CREATE_RATE_TABLE2);
|
|
1037
|
-
} finally {
|
|
1038
|
-
conn.release();
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
async close() {
|
|
1042
|
-
if (this.pool) {
|
|
1043
|
-
await this.pool.end();
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
// -------------------------------------------------------------------------
|
|
1047
|
-
// Enqueue
|
|
1048
|
-
// -------------------------------------------------------------------------
|
|
1049
|
-
async enqueue(input) {
|
|
1050
|
-
const runAtMs = new Date(input.runAt).getTime();
|
|
1051
|
-
const isDelayed = runAtMs > Date.now();
|
|
1052
|
-
const status = isDelayed ? "delayed" : "waiting";
|
|
1053
|
-
await this.pool.query(
|
|
1054
|
-
`INSERT IGNORE INTO qjw_jobs
|
|
1055
|
-
(id, queue, type, payload, status, attempts_made, max_attempts,
|
|
1056
|
-
retry_delay, backoff, timeout, priority, run_at, cron,
|
|
1057
|
-
attempts, lock_id, lock_expires_at, created_at, updated_at,
|
|
1058
|
-
completed_at, failed_at)
|
|
1059
|
-
VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?,?,NULL,NULL,NOW(3),NOW(3),NULL,NULL)`,
|
|
1060
|
-
[
|
|
1061
|
-
input.id,
|
|
1062
|
-
input.queue,
|
|
1063
|
-
input.type,
|
|
1064
|
-
JSON.stringify(input.payload),
|
|
1065
|
-
status,
|
|
1066
|
-
input.maxAttempts,
|
|
1067
|
-
input.retryDelay,
|
|
1068
|
-
input.backoff,
|
|
1069
|
-
input.timeout,
|
|
1070
|
-
input.priority,
|
|
1071
|
-
input.runAt,
|
|
1072
|
-
input.cron ?? null,
|
|
1073
|
-
JSON.stringify([])
|
|
1074
|
-
]
|
|
1075
|
-
);
|
|
1076
|
-
const rows = await this.pool.query("SELECT * FROM qjw_jobs WHERE id = ?", [input.id]);
|
|
1077
|
-
const row = rows[0][0];
|
|
1078
|
-
return rowToJob2(row);
|
|
1079
|
-
}
|
|
1080
|
-
// -------------------------------------------------------------------------
|
|
1081
|
-
// Claim (transaction + SELECT … FOR UPDATE SKIP LOCKED)
|
|
1082
|
-
// -------------------------------------------------------------------------
|
|
1083
|
-
async claim(input) {
|
|
1084
|
-
const { queue, lockId, lockDuration, now } = input;
|
|
1085
|
-
const lockExpiresAt = new Date(new Date(now).getTime() + lockDuration).toISOString().slice(0, 23).replace("T", " ");
|
|
1086
|
-
const nowMysql = new Date(now).toISOString().slice(0, 23).replace("T", " ");
|
|
1087
|
-
const conn = await this.pool.getConnection();
|
|
1088
|
-
try {
|
|
1089
|
-
await conn.beginTransaction();
|
|
1090
|
-
const [rows] = await conn.query(
|
|
1091
|
-
`SELECT * FROM qjw_jobs
|
|
1092
|
-
WHERE queue = ?
|
|
1093
|
-
AND status IN ('waiting', 'delayed')
|
|
1094
|
-
AND run_at <= ?
|
|
1095
|
-
AND (lock_expires_at IS NULL OR lock_expires_at <= ?)
|
|
1096
|
-
ORDER BY priority DESC, run_at ASC, created_at ASC
|
|
1097
|
-
LIMIT 1
|
|
1098
|
-
FOR UPDATE SKIP LOCKED`,
|
|
1099
|
-
[queue, nowMysql, nowMysql]
|
|
1100
|
-
);
|
|
1101
|
-
if (!rows || rows.length === 0) {
|
|
1102
|
-
await conn.rollback();
|
|
1103
|
-
return null;
|
|
1104
|
-
}
|
|
1105
|
-
const row = rows[0];
|
|
1106
|
-
await conn.query(
|
|
1107
|
-
`UPDATE qjw_jobs
|
|
1108
|
-
SET status = 'active', lock_id = ?, lock_expires_at = ?, updated_at = NOW(3)
|
|
1109
|
-
WHERE id = ?`,
|
|
1110
|
-
[lockId, lockExpiresAt, row.id]
|
|
1111
|
-
);
|
|
1112
|
-
await conn.commit();
|
|
1113
|
-
const [updated] = await this.pool.query("SELECT * FROM qjw_jobs WHERE id = ?", [
|
|
1114
|
-
row.id
|
|
1115
|
-
]);
|
|
1116
|
-
return rowToJob2(updated[0]);
|
|
1117
|
-
} catch (err) {
|
|
1118
|
-
await conn.rollback();
|
|
1119
|
-
throw err;
|
|
1120
|
-
} finally {
|
|
1121
|
-
conn.release();
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1124
|
-
// -------------------------------------------------------------------------
|
|
1125
|
-
// Renew lock
|
|
1126
|
-
// -------------------------------------------------------------------------
|
|
1127
|
-
async renewLock(jobId, lockId, lockDuration) {
|
|
1128
|
-
const newLockExpiresAt = new Date(Date.now() + lockDuration).toISOString().slice(0, 23).replace("T", " ");
|
|
1129
|
-
const [result] = await this.pool.query(
|
|
1130
|
-
`UPDATE qjw_jobs
|
|
1131
|
-
SET lock_expires_at = ?, updated_at = NOW(3)
|
|
1132
|
-
WHERE id = ? AND status = 'active' AND lock_id = ? AND lock_expires_at > NOW(3)`,
|
|
1133
|
-
[newLockExpiresAt, jobId, lockId]
|
|
1134
|
-
);
|
|
1135
|
-
return result.affectedRows > 0;
|
|
1136
|
-
}
|
|
1137
|
-
// -------------------------------------------------------------------------
|
|
1138
|
-
// Complete
|
|
1139
|
-
// -------------------------------------------------------------------------
|
|
1140
|
-
async complete(jobId, lockId) {
|
|
1141
|
-
if (lockId !== void 0) {
|
|
1142
|
-
await this.pool.query(
|
|
1143
|
-
`UPDATE qjw_jobs
|
|
1144
|
-
SET status = 'completed', lock_id = NULL, lock_expires_at = NULL,
|
|
1145
|
-
completed_at = NOW(3), updated_at = NOW(3)
|
|
1146
|
-
WHERE id = ? AND status = 'active' AND lock_id = ?`,
|
|
1147
|
-
[jobId, lockId]
|
|
1148
|
-
);
|
|
1149
|
-
} else {
|
|
1150
|
-
await this.pool.query(
|
|
1151
|
-
`UPDATE qjw_jobs
|
|
1152
|
-
SET status = 'completed', lock_id = NULL, lock_expires_at = NULL,
|
|
1153
|
-
completed_at = NOW(3), updated_at = NOW(3)
|
|
1154
|
-
WHERE id = ?`,
|
|
1155
|
-
[jobId]
|
|
1156
|
-
);
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
// -------------------------------------------------------------------------
|
|
1160
|
-
// Requeue
|
|
1161
|
-
// -------------------------------------------------------------------------
|
|
1162
|
-
async requeue(input) {
|
|
1163
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1164
|
-
const attempt = JSON.stringify({
|
|
1165
|
-
attempt: input.attemptNumber,
|
|
1166
|
-
startedAt: now,
|
|
1167
|
-
finishedAt: now,
|
|
1168
|
-
error: input.error,
|
|
1169
|
-
...input.stack !== void 0 ? { stack: input.stack } : {}
|
|
1170
|
-
});
|
|
1171
|
-
if (input.lockId !== void 0) {
|
|
1172
|
-
await this.pool.query(
|
|
1173
|
-
`UPDATE qjw_jobs
|
|
1174
|
-
SET status = 'waiting',
|
|
1175
|
-
attempts_made = attempts_made + 1,
|
|
1176
|
-
attempts = JSON_ARRAY_APPEND(attempts, '$', CAST(? AS JSON)),
|
|
1177
|
-
run_at = ?,
|
|
1178
|
-
lock_id = NULL,
|
|
1179
|
-
lock_expires_at = NULL,
|
|
1180
|
-
updated_at = NOW(3)
|
|
1181
|
-
WHERE id = ? AND status = 'active' AND lock_id = ?`,
|
|
1182
|
-
[attempt, input.runAt, input.jobId, input.lockId]
|
|
1183
|
-
);
|
|
1184
|
-
} else {
|
|
1185
|
-
await this.pool.query(
|
|
1186
|
-
`UPDATE qjw_jobs
|
|
1187
|
-
SET status = 'waiting',
|
|
1188
|
-
attempts_made = attempts_made + 1,
|
|
1189
|
-
attempts = JSON_ARRAY_APPEND(attempts, '$', CAST(? AS JSON)),
|
|
1190
|
-
run_at = ?,
|
|
1191
|
-
lock_id = NULL,
|
|
1192
|
-
lock_expires_at = NULL,
|
|
1193
|
-
updated_at = NOW(3)
|
|
1194
|
-
WHERE id = ?`,
|
|
1195
|
-
[attempt, input.runAt, input.jobId]
|
|
1196
|
-
);
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
// -------------------------------------------------------------------------
|
|
1200
|
-
// Move to DLQ
|
|
1201
|
-
// -------------------------------------------------------------------------
|
|
1202
|
-
async moveToDlq(input) {
|
|
1203
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1204
|
-
const attempt = JSON.stringify({
|
|
1205
|
-
attempt: input.attemptNumber,
|
|
1206
|
-
startedAt: now,
|
|
1207
|
-
finishedAt: now,
|
|
1208
|
-
error: input.error,
|
|
1209
|
-
...input.stack !== void 0 ? { stack: input.stack } : {}
|
|
1210
|
-
});
|
|
1211
|
-
if (input.lockId !== void 0) {
|
|
1212
|
-
await this.pool.query(
|
|
1213
|
-
`UPDATE qjw_jobs
|
|
1214
|
-
SET status = 'dead',
|
|
1215
|
-
attempts_made = attempts_made + 1,
|
|
1216
|
-
attempts = JSON_ARRAY_APPEND(attempts, '$', CAST(? AS JSON)),
|
|
1217
|
-
lock_id = NULL,
|
|
1218
|
-
lock_expires_at = NULL,
|
|
1219
|
-
failed_at = NOW(3),
|
|
1220
|
-
updated_at = NOW(3)
|
|
1221
|
-
WHERE id = ? AND status = 'active' AND lock_id = ?`,
|
|
1222
|
-
[attempt, input.jobId, input.lockId]
|
|
1223
|
-
);
|
|
1224
|
-
} else {
|
|
1225
|
-
await this.pool.query(
|
|
1226
|
-
`UPDATE qjw_jobs
|
|
1227
|
-
SET status = 'dead',
|
|
1228
|
-
attempts_made = attempts_made + 1,
|
|
1229
|
-
attempts = JSON_ARRAY_APPEND(attempts, '$', CAST(? AS JSON)),
|
|
1230
|
-
lock_id = NULL,
|
|
1231
|
-
lock_expires_at = NULL,
|
|
1232
|
-
failed_at = NOW(3),
|
|
1233
|
-
updated_at = NOW(3)
|
|
1234
|
-
WHERE id = ?`,
|
|
1235
|
-
[attempt, input.jobId]
|
|
1236
|
-
);
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
// -------------------------------------------------------------------------
|
|
1240
|
-
// Release lock
|
|
1241
|
-
// -------------------------------------------------------------------------
|
|
1242
|
-
async releaseLock(jobId, lockId) {
|
|
1243
|
-
if (lockId !== void 0) {
|
|
1244
|
-
await this.pool.query(
|
|
1245
|
-
`UPDATE qjw_jobs
|
|
1246
|
-
SET lock_id = NULL, lock_expires_at = NOW(3), updated_at = NOW(3)
|
|
1247
|
-
WHERE id = ? AND status = 'active' AND lock_id = ?`,
|
|
1248
|
-
[jobId, lockId]
|
|
1249
|
-
);
|
|
1250
|
-
} else {
|
|
1251
|
-
await this.pool.query(
|
|
1252
|
-
`UPDATE qjw_jobs
|
|
1253
|
-
SET lock_id = NULL, lock_expires_at = NOW(3), updated_at = NOW(3)
|
|
1254
|
-
WHERE id = ?`,
|
|
1255
|
-
[jobId]
|
|
1256
|
-
);
|
|
1257
|
-
}
|
|
1258
|
-
}
|
|
1259
|
-
// -------------------------------------------------------------------------
|
|
1260
|
-
// Recover stalled jobs (atomic — SELECT … FOR UPDATE inside transaction)
|
|
1261
|
-
// -------------------------------------------------------------------------
|
|
1262
|
-
async recoverStalledJobs(queue, now) {
|
|
1263
|
-
const nowMysql = new Date(now).toISOString().slice(0, 23).replace("T", " ");
|
|
1264
|
-
const conn = await this.pool.getConnection();
|
|
1265
|
-
try {
|
|
1266
|
-
await conn.beginTransaction();
|
|
1267
|
-
const [rows] = await conn.query(
|
|
1268
|
-
`SELECT id FROM qjw_jobs
|
|
1269
|
-
WHERE queue = ?
|
|
1270
|
-
AND status = 'active'
|
|
1271
|
-
AND lock_expires_at <= ?
|
|
1272
|
-
FOR UPDATE SKIP LOCKED`,
|
|
1273
|
-
[queue, nowMysql]
|
|
1274
|
-
);
|
|
1275
|
-
const ids = rows.map((r) => String(r["id"]));
|
|
1276
|
-
if (ids.length > 0) {
|
|
1277
|
-
await conn.query(
|
|
1278
|
-
`UPDATE qjw_jobs
|
|
1279
|
-
SET status = 'waiting', lock_id = NULL, lock_expires_at = NULL, updated_at = NOW(3)
|
|
1280
|
-
WHERE id IN (?)`,
|
|
1281
|
-
[ids]
|
|
1282
|
-
);
|
|
1283
|
-
}
|
|
1284
|
-
await conn.commit();
|
|
1285
|
-
return ids;
|
|
1286
|
-
} catch (err) {
|
|
1287
|
-
await conn.rollback();
|
|
1288
|
-
throw err;
|
|
1289
|
-
} finally {
|
|
1290
|
-
conn.release();
|
|
1291
|
-
}
|
|
1292
|
-
}
|
|
1293
|
-
// -------------------------------------------------------------------------
|
|
1294
|
-
// Reads
|
|
1295
|
-
// -------------------------------------------------------------------------
|
|
1296
|
-
async getJob(jobId) {
|
|
1297
|
-
const [rows] = await this.pool.query("SELECT * FROM qjw_jobs WHERE id = ?", [jobId]);
|
|
1298
|
-
const list = rows;
|
|
1299
|
-
if (!list || list.length === 0) return null;
|
|
1300
|
-
return rowToJob2(list[0]);
|
|
1301
|
-
}
|
|
1302
|
-
async getJobs(filter) {
|
|
1303
|
-
const { queue, status, limit = 100, offset = 0 } = filter;
|
|
1304
|
-
const conditions = [];
|
|
1305
|
-
const params = [];
|
|
1306
|
-
if (queue !== void 0) {
|
|
1307
|
-
conditions.push("queue = ?");
|
|
1308
|
-
params.push(queue);
|
|
1309
|
-
}
|
|
1310
|
-
if (status !== void 0) {
|
|
1311
|
-
conditions.push("status = ?");
|
|
1312
|
-
params.push(status);
|
|
1313
|
-
}
|
|
1314
|
-
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1315
|
-
params.push(limit, offset);
|
|
1316
|
-
const [rows] = await this.pool.query(
|
|
1317
|
-
`SELECT * FROM qjw_jobs ${where}
|
|
1318
|
-
ORDER BY priority DESC, run_at ASC, created_at ASC
|
|
1319
|
-
LIMIT ? OFFSET ?`,
|
|
1320
|
-
params
|
|
1321
|
-
);
|
|
1322
|
-
return rows.map((r) => rowToJob2(r));
|
|
1323
|
-
}
|
|
1324
|
-
async getJobCounts(queue) {
|
|
1325
|
-
const [rows] = await this.pool.query(
|
|
1326
|
-
"SELECT status, COUNT(*) AS count FROM qjw_jobs WHERE queue = ? GROUP BY status",
|
|
1327
|
-
[queue]
|
|
1328
|
-
);
|
|
1329
|
-
const counts = {
|
|
1330
|
-
waiting: 0,
|
|
1331
|
-
active: 0,
|
|
1332
|
-
completed: 0,
|
|
1333
|
-
delayed: 0,
|
|
1334
|
-
dead: 0
|
|
1335
|
-
};
|
|
1336
|
-
for (const row of rows) {
|
|
1337
|
-
const s = String(row["status"]);
|
|
1338
|
-
if (s in counts) counts[s] = Number(row["count"]);
|
|
1339
|
-
}
|
|
1340
|
-
return counts;
|
|
1341
|
-
}
|
|
1342
|
-
// -------------------------------------------------------------------------
|
|
1343
|
-
// Rate limiting
|
|
1344
|
-
// -------------------------------------------------------------------------
|
|
1345
|
-
async checkAndIncrementRateLimit(queue, max, windowMs, now) {
|
|
1346
|
-
const nowMs = new Date(now).getTime();
|
|
1347
|
-
const conn = await this.pool.getConnection();
|
|
1348
|
-
try {
|
|
1349
|
-
await conn.beginTransaction();
|
|
1350
|
-
await conn.query(
|
|
1351
|
-
`INSERT INTO qjw_rate_limits (queue, count, window_start) VALUES (?, 0, 0)
|
|
1352
|
-
ON DUPLICATE KEY UPDATE queue = queue`,
|
|
1353
|
-
[queue]
|
|
1354
|
-
);
|
|
1355
|
-
const [rows] = await conn.query(
|
|
1356
|
-
"SELECT count, window_start FROM qjw_rate_limits WHERE queue = ? FOR UPDATE",
|
|
1357
|
-
[queue]
|
|
1358
|
-
);
|
|
1359
|
-
const existing = rows[0];
|
|
1360
|
-
const windowStart = Number(existing?.["window_start"] ?? 0);
|
|
1361
|
-
const count = Number(existing?.["count"] ?? 0);
|
|
1362
|
-
if (!existing || nowMs - windowStart >= windowMs) {
|
|
1363
|
-
await conn.query("UPDATE qjw_rate_limits SET count = 1, window_start = ? WHERE queue = ?", [
|
|
1364
|
-
nowMs,
|
|
1365
|
-
queue
|
|
1366
|
-
]);
|
|
1367
|
-
await conn.commit();
|
|
1368
|
-
return true;
|
|
1369
|
-
}
|
|
1370
|
-
if (count >= max) {
|
|
1371
|
-
await conn.commit();
|
|
1372
|
-
return false;
|
|
1373
|
-
}
|
|
1374
|
-
await conn.query("UPDATE qjw_rate_limits SET count = count + 1 WHERE queue = ?", [queue]);
|
|
1375
|
-
await conn.commit();
|
|
1376
|
-
return true;
|
|
1377
|
-
} catch (err) {
|
|
1378
|
-
await conn.rollback();
|
|
1379
|
-
throw err;
|
|
1380
|
-
} finally {
|
|
1381
|
-
conn.release();
|
|
1382
|
-
}
|
|
1383
|
-
}
|
|
1384
|
-
};
|
|
69
|
+
async countJobs(queueName) {
|
|
70
|
+
return this.store.get(queueName)?.size ?? 0;
|
|
71
|
+
}
|
|
72
|
+
// ─── Private helpers ──────────────────────────────────────────────────────
|
|
73
|
+
getOrCreateQueue(queueName) {
|
|
74
|
+
let queue = this.store.get(queueName);
|
|
75
|
+
if (!queue) {
|
|
76
|
+
queue = /* @__PURE__ */ new Map();
|
|
77
|
+
this.store.set(queueName, queue);
|
|
78
|
+
}
|
|
79
|
+
return queue;
|
|
1385
80
|
}
|
|
1386
|
-
});
|
|
1387
|
-
|
|
1388
|
-
// src/core/job.ts
|
|
1389
|
-
var Job = class {
|
|
1390
|
-
/** @internal Raw data record — treat as immutable outside storage layer. */
|
|
1391
|
-
_data;
|
|
1392
|
-
constructor(data) {
|
|
1393
|
-
this._data = data;
|
|
1394
|
-
}
|
|
1395
|
-
// -------------------------------------------------------------------------
|
|
1396
|
-
// Identity
|
|
1397
|
-
// -------------------------------------------------------------------------
|
|
1398
|
-
/** Unique, stable job identifier. */
|
|
1399
|
-
get id() {
|
|
1400
|
-
return this._data.id;
|
|
1401
|
-
}
|
|
1402
|
-
/** Name of the queue this job belongs to. */
|
|
1403
|
-
get queue() {
|
|
1404
|
-
return this._data.queue;
|
|
1405
|
-
}
|
|
1406
|
-
/** Application-defined job type (matches the registered processor). */
|
|
1407
|
-
get type() {
|
|
1408
|
-
return this._data.type;
|
|
1409
|
-
}
|
|
1410
|
-
// -------------------------------------------------------------------------
|
|
1411
|
-
// Payload
|
|
1412
|
-
// -------------------------------------------------------------------------
|
|
1413
81
|
/**
|
|
1414
|
-
*
|
|
1415
|
-
*
|
|
82
|
+
* Sort jobs by priority ascending (lower number = higher priority),
|
|
83
|
+
* then by createdAt ascending (older jobs run first).
|
|
1416
84
|
*/
|
|
1417
|
-
|
|
1418
|
-
return
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
// -------------------------------------------------------------------------
|
|
1423
|
-
/** Current lifecycle status. */
|
|
1424
|
-
get status() {
|
|
1425
|
-
return this._data.status;
|
|
1426
|
-
}
|
|
1427
|
-
/** Number of attempts already executed (0 = not yet started). */
|
|
1428
|
-
get attemptsMade() {
|
|
1429
|
-
return this._data.attemptsMade;
|
|
1430
|
-
}
|
|
1431
|
-
/** Maximum allowed attempts. */
|
|
1432
|
-
get maxAttempts() {
|
|
1433
|
-
return this._data.maxAttempts;
|
|
1434
|
-
}
|
|
1435
|
-
/** How many attempts remain (including the current one). */
|
|
1436
|
-
get attemptsRemaining() {
|
|
1437
|
-
return Math.max(0, this._data.maxAttempts - this._data.attemptsMade);
|
|
1438
|
-
}
|
|
1439
|
-
/** Ordered list of past attempt records. */
|
|
1440
|
-
get attemptHistory() {
|
|
1441
|
-
return this._data.attempts;
|
|
1442
|
-
}
|
|
1443
|
-
// -------------------------------------------------------------------------
|
|
1444
|
-
// Retry configuration
|
|
1445
|
-
// -------------------------------------------------------------------------
|
|
1446
|
-
/** Base delay in ms between retry attempts. */
|
|
1447
|
-
get retryDelay() {
|
|
1448
|
-
return this._data.retryDelay;
|
|
1449
|
-
}
|
|
1450
|
-
/** Backoff strategy applied on retry. */
|
|
1451
|
-
get backoff() {
|
|
1452
|
-
return this._data.backoff;
|
|
1453
|
-
}
|
|
1454
|
-
// -------------------------------------------------------------------------
|
|
1455
|
-
// Execution config
|
|
1456
|
-
// -------------------------------------------------------------------------
|
|
1457
|
-
/** Per-attempt timeout in ms. */
|
|
1458
|
-
get timeout() {
|
|
1459
|
-
return this._data.timeout;
|
|
1460
|
-
}
|
|
1461
|
-
/** Processing priority — higher numbers are processed first. */
|
|
1462
|
-
get priority() {
|
|
1463
|
-
return this._data.priority;
|
|
1464
|
-
}
|
|
1465
|
-
// -------------------------------------------------------------------------
|
|
1466
|
-
// Scheduling
|
|
1467
|
-
// -------------------------------------------------------------------------
|
|
1468
|
-
/** ISO timestamp when this job is eligible to run. */
|
|
1469
|
-
get runAt() {
|
|
1470
|
-
return this._data.runAt;
|
|
1471
|
-
}
|
|
1472
|
-
/** Cron expression for recurring jobs, or undefined. */
|
|
1473
|
-
get cron() {
|
|
1474
|
-
return this._data.cron;
|
|
1475
|
-
}
|
|
1476
|
-
// -------------------------------------------------------------------------
|
|
1477
|
-
// Lock
|
|
1478
|
-
// -------------------------------------------------------------------------
|
|
1479
|
-
/** ID of the worker currently holding the lock, or null. */
|
|
1480
|
-
get lockId() {
|
|
1481
|
-
return this._data.lockId;
|
|
1482
|
-
}
|
|
1483
|
-
/** ISO timestamp when the current lock expires, or null. */
|
|
1484
|
-
get lockExpiresAt() {
|
|
1485
|
-
return this._data.lockExpiresAt;
|
|
1486
|
-
}
|
|
1487
|
-
// -------------------------------------------------------------------------
|
|
1488
|
-
// Timestamps
|
|
1489
|
-
// -------------------------------------------------------------------------
|
|
1490
|
-
/** ISO timestamp when the job was first enqueued. */
|
|
1491
|
-
get createdAt() {
|
|
1492
|
-
return this._data.createdAt;
|
|
1493
|
-
}
|
|
1494
|
-
/** ISO timestamp of the last status change. */
|
|
1495
|
-
get updatedAt() {
|
|
1496
|
-
return this._data.updatedAt;
|
|
1497
|
-
}
|
|
1498
|
-
/** ISO timestamp when the job completed successfully, or null. */
|
|
1499
|
-
get completedAt() {
|
|
1500
|
-
return this._data.completedAt;
|
|
1501
|
-
}
|
|
1502
|
-
/** ISO timestamp when the job was moved to the DLQ, or null. */
|
|
1503
|
-
get failedAt() {
|
|
1504
|
-
return this._data.failedAt;
|
|
1505
|
-
}
|
|
1506
|
-
// -------------------------------------------------------------------------
|
|
1507
|
-
// Derived helpers
|
|
1508
|
-
// -------------------------------------------------------------------------
|
|
1509
|
-
/** Returns true when the job has been successfully processed. */
|
|
1510
|
-
isCompleted() {
|
|
1511
|
-
return this._data.status === "completed";
|
|
1512
|
-
}
|
|
1513
|
-
/** Returns true when the job is in the Dead Letter Queue. */
|
|
1514
|
-
isDead() {
|
|
1515
|
-
return this._data.status === "dead";
|
|
1516
|
-
}
|
|
1517
|
-
/** Returns true when the job is currently being processed by a worker. */
|
|
1518
|
-
isActive() {
|
|
1519
|
-
return this._data.status === "active";
|
|
1520
|
-
}
|
|
1521
|
-
/** Returns true when the job is waiting to be claimed. */
|
|
1522
|
-
isWaiting() {
|
|
1523
|
-
return this._data.status === "waiting";
|
|
1524
|
-
}
|
|
1525
|
-
/** Returns true when the job is scheduled for a future time. */
|
|
1526
|
-
isDelayed() {
|
|
1527
|
-
return this._data.status === "delayed";
|
|
1528
|
-
}
|
|
1529
|
-
// -------------------------------------------------------------------------
|
|
1530
|
-
// Debug representation (intentionally omits payload)
|
|
1531
|
-
// -------------------------------------------------------------------------
|
|
1532
|
-
toJSON() {
|
|
1533
|
-
return {
|
|
1534
|
-
id: this._data.id,
|
|
1535
|
-
queue: this._data.queue,
|
|
1536
|
-
type: this._data.type,
|
|
1537
|
-
status: this._data.status,
|
|
1538
|
-
attemptsMade: this._data.attemptsMade,
|
|
1539
|
-
maxAttempts: this._data.maxAttempts,
|
|
1540
|
-
priority: this._data.priority,
|
|
1541
|
-
runAt: this._data.runAt,
|
|
1542
|
-
createdAt: this._data.createdAt,
|
|
1543
|
-
updatedAt: this._data.updatedAt,
|
|
1544
|
-
completedAt: this._data.completedAt,
|
|
1545
|
-
failedAt: this._data.failedAt
|
|
1546
|
-
// Payload intentionally excluded to prevent accidental logging.
|
|
1547
|
-
};
|
|
1548
|
-
}
|
|
1549
|
-
toString() {
|
|
1550
|
-
return `Job(${this._data.id}, type=${this._data.type}, status=${this._data.status}, attempt=${this._data.attemptsMade}/${this._data.maxAttempts})`;
|
|
85
|
+
sortJobs(jobs) {
|
|
86
|
+
return jobs.slice().sort((a, b) => {
|
|
87
|
+
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
88
|
+
return a.createdAt - b.createdAt;
|
|
89
|
+
});
|
|
1551
90
|
}
|
|
1552
91
|
};
|
|
1553
92
|
|
|
1554
|
-
// src/
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
93
|
+
// src/storage/redis.storage.ts
|
|
94
|
+
import { readFile } from "fs/promises";
|
|
95
|
+
import { fileURLToPath } from "url";
|
|
96
|
+
import { join, dirname } from "path";
|
|
97
|
+
var RedisStorage = class {
|
|
98
|
+
client = null;
|
|
99
|
+
connectionString;
|
|
100
|
+
/** SHA1 digests returned by SCRIPT LOAD, keyed by script name. */
|
|
101
|
+
sha = {};
|
|
102
|
+
constructor(connectionString) {
|
|
103
|
+
this.connectionString = connectionString;
|
|
104
|
+
}
|
|
105
|
+
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
|
106
|
+
async connect() {
|
|
107
|
+
let mod;
|
|
108
|
+
try {
|
|
109
|
+
mod = await import("redis");
|
|
110
|
+
} catch {
|
|
111
|
+
throw new Error(
|
|
112
|
+
"[queue-jobs-worker] Redis dialect requires the `redis` package. Run: npm install redis"
|
|
113
|
+
);
|
|
1566
114
|
}
|
|
1567
|
-
|
|
1568
|
-
|
|
115
|
+
const c = mod.createClient({ url: this.connectionString });
|
|
116
|
+
c.on("error", (err) => console.error("[queue-jobs-worker] Redis error:", err));
|
|
117
|
+
await c.connect();
|
|
118
|
+
this.client = c;
|
|
119
|
+
await this.loadScripts();
|
|
120
|
+
}
|
|
121
|
+
async disconnect() {
|
|
122
|
+
if (this.client) {
|
|
123
|
+
await this.client.quit();
|
|
124
|
+
this.client = null;
|
|
1569
125
|
}
|
|
1570
126
|
}
|
|
1571
|
-
|
|
1572
|
-
function nextRunAt(delayMs, fromDate = /* @__PURE__ */ new Date()) {
|
|
1573
|
-
return new Date(fromDate.getTime() + delayMs).toISOString();
|
|
1574
|
-
}
|
|
1575
|
-
function generateJobId() {
|
|
1576
|
-
return randomUUID();
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
// src/core/worker.ts
|
|
1580
|
-
function resolveConfig(workerOptions, queueOptions, defaults) {
|
|
1581
|
-
return {
|
|
1582
|
-
concurrency: workerOptions.concurrency ?? queueOptions.concurrency ?? defaults.concurrency,
|
|
1583
|
-
shutdownTimeout: workerOptions.shutdownTimeout ?? 3e4,
|
|
1584
|
-
pollInterval: queueOptions.pollInterval ?? defaults.pollInterval,
|
|
1585
|
-
stalledInterval: queueOptions.stalledInterval ?? defaults.stalledInterval,
|
|
1586
|
-
lockDuration: queueOptions.lockDuration ?? defaults.lockDuration,
|
|
1587
|
-
rateLimit: queueOptions.rateLimit ?? defaults.rateLimit
|
|
1588
|
-
};
|
|
1589
|
-
}
|
|
1590
|
-
var Worker = class {
|
|
1591
|
-
/** Unique identifier for this worker instance. */
|
|
1592
|
-
id;
|
|
1593
|
-
queueName;
|
|
1594
|
-
storage;
|
|
1595
|
-
emitter;
|
|
1596
|
-
processors;
|
|
1597
|
-
config;
|
|
1598
|
-
_status = "idle";
|
|
1599
|
-
activeCount = 0;
|
|
127
|
+
// ─── IStorage implementation ──────────────────────────────────────────────
|
|
1600
128
|
/**
|
|
1601
|
-
*
|
|
1602
|
-
*
|
|
129
|
+
* Persist a new job.
|
|
130
|
+
* 1 round-trip (save-job.lua: SET + ZADD index + conditional ZADD waiting).
|
|
1603
131
|
*/
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
this.id = `worker:${queueName}:${randomUUID()}`;
|
|
1611
|
-
this.queueName = queueName;
|
|
1612
|
-
this.storage = storage;
|
|
1613
|
-
this.emitter = emitter;
|
|
1614
|
-
this.processors = processors;
|
|
1615
|
-
this.config = resolveConfig(workerOptions, queueOptions, defaults);
|
|
1616
|
-
}
|
|
1617
|
-
// -------------------------------------------------------------------------
|
|
1618
|
-
// Public API
|
|
1619
|
-
// -------------------------------------------------------------------------
|
|
1620
|
-
get status() {
|
|
1621
|
-
return this._status;
|
|
1622
|
-
}
|
|
1623
|
-
/** Start polling for jobs. */
|
|
1624
|
-
start() {
|
|
1625
|
-
if (this._status !== "idle" && this._status !== "stopped") {
|
|
1626
|
-
return;
|
|
1627
|
-
}
|
|
1628
|
-
this._status = "running";
|
|
1629
|
-
this.emitter.emit("worker:started", this.id);
|
|
1630
|
-
this.emitter.emit("worker:status", this.id, this._status);
|
|
1631
|
-
this.schedulePoll();
|
|
1632
|
-
this.scheduleStallCheck();
|
|
132
|
+
async saveJob(queueName, job) {
|
|
133
|
+
const eligible = job.status === "waiting" || job.status === "retrying" ? "1" : "0";
|
|
134
|
+
await this.evalSha("save-job", {
|
|
135
|
+
keys: [this.hashKey(job.id), this.indexKey(queueName), this.waitingKey(queueName)],
|
|
136
|
+
arguments: [JSON.stringify(job), job.id, String(job.priority), String(job.runAt), eligible]
|
|
137
|
+
});
|
|
1633
138
|
}
|
|
1634
139
|
/**
|
|
1635
|
-
*
|
|
140
|
+
* Fetch a single job by ID.
|
|
141
|
+
* 1 round-trip (GET).
|
|
142
|
+
*/
|
|
143
|
+
async getJob(_queueName, jobId) {
|
|
144
|
+
const raw = await this.assertClient().get(this.hashKey(jobId));
|
|
145
|
+
if (!raw) return void 0;
|
|
146
|
+
return JSON.parse(raw);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Atomically patch a job and update sorted-set membership.
|
|
150
|
+
* 1 round-trip (update-job.lua: GET + patch + SET server-side).
|
|
151
|
+
*
|
|
152
|
+
* Fields are decomposed into three RS-delimited (ASCII 0x1E) lists:
|
|
153
|
+
* strFields — "key\x1evalue\x1ekey\x1evalue…" for string fields
|
|
154
|
+
* numFields — "key\x1evalue\x1ekey\x1evalue…" for numeric fields
|
|
155
|
+
* nullFields — "key\x1ekey\x1e…" fields to set to null
|
|
1636
156
|
*
|
|
1637
|
-
*
|
|
1638
|
-
*
|
|
1639
|
-
* 3. Release locks on any jobs that did not finish in time so another
|
|
1640
|
-
* worker can reclaim them.
|
|
1641
|
-
* 4. Emit stopped event.
|
|
157
|
+
* RS (record separator, 0x1E) is used instead of pipe so that error
|
|
158
|
+
* messages containing "|" characters are never mistaken for delimiters.
|
|
1642
159
|
*/
|
|
1643
|
-
async
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
160
|
+
async updateJob(queueName, jobId, patch) {
|
|
161
|
+
const newStatus = patch.status ?? "";
|
|
162
|
+
const nowMs = String(Date.now());
|
|
163
|
+
const runAt = patch.runAt !== void 0 ? String(patch.runAt) : "-1";
|
|
164
|
+
const priority = patch.priority !== void 0 ? String(patch.priority) : "-1";
|
|
165
|
+
const RS = "";
|
|
166
|
+
const strParts = [];
|
|
167
|
+
const numParts = [];
|
|
168
|
+
const nullParts = [];
|
|
169
|
+
const boolParts = [];
|
|
170
|
+
for (const [key, val] of Object.entries(patch)) {
|
|
171
|
+
if (key === "status" || key === "updatedAt" || key === "runAt") continue;
|
|
172
|
+
if (val === null || val === void 0) {
|
|
173
|
+
nullParts.push(key);
|
|
174
|
+
} else if (typeof val === "number") {
|
|
175
|
+
numParts.push(key, String(val));
|
|
176
|
+
} else if (typeof val === "boolean") {
|
|
177
|
+
boolParts.push(key, val ? "true" : "false");
|
|
178
|
+
} else if (typeof val === "string") {
|
|
179
|
+
strParts.push(key, val);
|
|
180
|
+
}
|
|
1656
181
|
}
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
182
|
+
await this.evalSha("update-job", {
|
|
183
|
+
keys: [this.hashKey(jobId), this.indexKey(queueName), this.waitingKey(queueName)],
|
|
184
|
+
arguments: [
|
|
185
|
+
newStatus,
|
|
186
|
+
nowMs,
|
|
187
|
+
runAt,
|
|
188
|
+
priority,
|
|
189
|
+
strParts.join(RS),
|
|
190
|
+
numParts.join(RS),
|
|
191
|
+
nullParts.join(RS),
|
|
192
|
+
boolParts.join(RS)
|
|
193
|
+
// ARGV[8] — new in update-job.lua v5
|
|
194
|
+
]
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Permanently delete a job and clean up sorted sets.
|
|
199
|
+
* 1 round-trip (remove-job.lua: EXISTS + DEL + ZREM × 2).
|
|
200
|
+
*/
|
|
201
|
+
async removeJob(queueName, jobId) {
|
|
202
|
+
await this.evalSha("remove-job", {
|
|
203
|
+
keys: [this.hashKey(jobId), this.indexKey(queueName), this.waitingKey(queueName)],
|
|
204
|
+
arguments: [jobId]
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* List all jobs, optionally filtered by status.
|
|
209
|
+
* 1 round-trip (list-jobs.lua: ZRANGE + N×GET server-side).
|
|
210
|
+
* Results are already sorted by priority ASC (index set order).
|
|
211
|
+
*/
|
|
212
|
+
async listJobs(queueName, status) {
|
|
213
|
+
const raw = await this.evalSha("list-jobs", {
|
|
214
|
+
keys: [this.indexKey(queueName)],
|
|
215
|
+
arguments: [status ?? ""]
|
|
216
|
+
});
|
|
217
|
+
if (!Array.isArray(raw) || raw.length === 0) return [];
|
|
218
|
+
return raw.map((r) => JSON.parse(r));
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Atomically claim the next eligible job and mark it active.
|
|
222
|
+
* 1 round-trip (claim-job.lua).
|
|
223
|
+
*/
|
|
224
|
+
async getNextJob(queueName) {
|
|
225
|
+
const raw = await this.evalSha("claim-job", {
|
|
226
|
+
keys: [this.indexKey(queueName), this.waitingKey(queueName)],
|
|
227
|
+
arguments: [String(Date.now())]
|
|
228
|
+
});
|
|
229
|
+
if (!raw || typeof raw !== "string") return void 0;
|
|
230
|
+
return JSON.parse(raw);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Delete every job in a queue and drop both sorted sets.
|
|
234
|
+
* 1 round-trip (clear-queue.lua: ZRANGE + N×DEL + 2×DEL server-side).
|
|
235
|
+
*/
|
|
236
|
+
async clearQueue(queueName) {
|
|
237
|
+
await this.evalSha("clear-queue", {
|
|
238
|
+
keys: [this.indexKey(queueName), this.waitingKey(queueName)],
|
|
239
|
+
arguments: []
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Return the total number of jobs in a queue (all statuses).
|
|
244
|
+
* 1 round-trip (ZCARD on the index set).
|
|
245
|
+
*/
|
|
246
|
+
async countJobs(queueName) {
|
|
247
|
+
return this.assertClient().zCard(this.indexKey(queueName));
|
|
248
|
+
}
|
|
249
|
+
// ─── Script loading ───────────────────────────────────────────────────────
|
|
250
|
+
/**
|
|
251
|
+
* Read every Lua script from disk and SCRIPT LOAD it into Redis.
|
|
252
|
+
* Uses async readFile so the event loop is never blocked.
|
|
253
|
+
* Called once on connect, and again automatically on NOSCRIPT errors.
|
|
254
|
+
*/
|
|
255
|
+
async loadScripts() {
|
|
256
|
+
const c = this.assertClient();
|
|
257
|
+
const dir = join(dirname(fileURLToPath(import.meta.url)), "..", "scripts");
|
|
258
|
+
const names = [
|
|
259
|
+
"save-job",
|
|
260
|
+
"update-job",
|
|
261
|
+
"remove-job",
|
|
262
|
+
"clear-queue",
|
|
263
|
+
"list-jobs",
|
|
264
|
+
"claim-job"
|
|
265
|
+
];
|
|
266
|
+
const sources = await Promise.all(
|
|
267
|
+
names.map((name) => readFile(join(dir, `${name}.lua`), "utf8"))
|
|
268
|
+
);
|
|
269
|
+
const shas = await Promise.all(sources.map((src) => c.scriptLoad(src)));
|
|
270
|
+
for (let i = 0; i < names.length; i++) {
|
|
271
|
+
const sha = shas[i];
|
|
272
|
+
if (sha !== void 0) {
|
|
273
|
+
this.sha[names[i]] = sha;
|
|
274
|
+
}
|
|
1664
275
|
}
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
276
|
+
}
|
|
277
|
+
// ─── Private helpers ──────────────────────────────────────────────────────
|
|
278
|
+
/**
|
|
279
|
+
* Call EVALSHA and automatically reload scripts on NOSCRIPT errors
|
|
280
|
+
* (e.g. after a Redis server restart that flushed the script cache).
|
|
281
|
+
*/
|
|
282
|
+
async evalSha(name, opts) {
|
|
283
|
+
const c = this.assertClient();
|
|
284
|
+
const sha = this.sha[name];
|
|
285
|
+
if (!sha) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
`[queue-jobs-worker] Lua script "${name}" has not been loaded. This is a bug \u2014 please open an issue.`
|
|
1671
288
|
);
|
|
1672
289
|
}
|
|
1673
|
-
this._status = "stopped";
|
|
1674
|
-
this.emitter.emit("worker:stopped", this.id);
|
|
1675
|
-
this.emitter.emit("worker:status", this.id, this._status);
|
|
1676
|
-
}
|
|
1677
|
-
// -------------------------------------------------------------------------
|
|
1678
|
-
// Poll loop
|
|
1679
|
-
// -------------------------------------------------------------------------
|
|
1680
|
-
schedulePoll() {
|
|
1681
|
-
if (this._status !== "running") return;
|
|
1682
|
-
this.pollTimer = setTimeout(() => {
|
|
1683
|
-
void this.poll();
|
|
1684
|
-
}, this.config.pollInterval);
|
|
1685
|
-
}
|
|
1686
|
-
async poll() {
|
|
1687
|
-
if (this._status !== "running") return;
|
|
1688
290
|
try {
|
|
1689
|
-
|
|
1690
|
-
const claimed = await this.claimNext();
|
|
1691
|
-
if (!claimed) break;
|
|
1692
|
-
}
|
|
291
|
+
return await c.evalSha(sha, opts);
|
|
1693
292
|
} catch (err) {
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
}
|
|
1700
|
-
// -------------------------------------------------------------------------
|
|
1701
|
-
// Claim & execute
|
|
1702
|
-
// -------------------------------------------------------------------------
|
|
1703
|
-
async claimNext() {
|
|
1704
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1705
|
-
const raw = await this.storage.claim({
|
|
1706
|
-
queue: this.queueName,
|
|
1707
|
-
lockId: this.id,
|
|
1708
|
-
lockDuration: this.config.lockDuration,
|
|
1709
|
-
now
|
|
1710
|
-
});
|
|
1711
|
-
if (!raw) return false;
|
|
1712
|
-
if (this.config.rateLimit) {
|
|
1713
|
-
const allowed = await this.storage.checkAndIncrementRateLimit(
|
|
1714
|
-
this.queueName,
|
|
1715
|
-
this.config.rateLimit.max,
|
|
1716
|
-
this.config.rateLimit.duration,
|
|
1717
|
-
now
|
|
1718
|
-
);
|
|
1719
|
-
if (!allowed) {
|
|
1720
|
-
await this.storage.releaseLock(raw.id, this.id);
|
|
1721
|
-
return false;
|
|
293
|
+
if (isNoscriptError(err)) {
|
|
294
|
+
await this.loadScripts();
|
|
295
|
+
const reloadedSha = this.sha[name];
|
|
296
|
+
if (!reloadedSha) throw err;
|
|
297
|
+
return c.evalSha(reloadedSha, opts);
|
|
1722
298
|
}
|
|
299
|
+
throw err;
|
|
1723
300
|
}
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
this.
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
}
|
|
1730
|
-
async executeJob(job) {
|
|
1731
|
-
this.emitter.emit("job:started", job._data);
|
|
1732
|
-
const processor = this.processors.get(job.type);
|
|
1733
|
-
if (!processor) {
|
|
1734
|
-
const error = new Error(
|
|
1735
|
-
`No processor registered for job type "${job.type}" in queue "${this.queueName}"`
|
|
301
|
+
}
|
|
302
|
+
assertClient() {
|
|
303
|
+
if (!this.client) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
"[queue-jobs-worker] RedisStorage is not connected. Call client.init() first."
|
|
1736
306
|
);
|
|
1737
|
-
await this.handleFailure(job, error);
|
|
1738
|
-
return;
|
|
1739
307
|
}
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
308
|
+
return this.client;
|
|
309
|
+
}
|
|
310
|
+
hashKey(jobId) {
|
|
311
|
+
return `qjw:job:${jobId}`;
|
|
312
|
+
}
|
|
313
|
+
indexKey(queueName) {
|
|
314
|
+
return `qjw:${queueName}:index`;
|
|
315
|
+
}
|
|
316
|
+
waitingKey(queueName) {
|
|
317
|
+
return `qjw:${queueName}:waiting`;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
function isNoscriptError(err) {
|
|
321
|
+
return err instanceof Error && err.message.toUpperCase().includes("NOSCRIPT");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// src/storage/postgres.storage.ts
|
|
325
|
+
var PostgresStorage = class {
|
|
326
|
+
pool = null;
|
|
327
|
+
connectionString;
|
|
328
|
+
constructor(connectionString) {
|
|
329
|
+
this.connectionString = connectionString;
|
|
330
|
+
}
|
|
331
|
+
// ─── IStorage ─────────────────────────────────────────────────────────────
|
|
332
|
+
async connect() {
|
|
333
|
+
let pgModule;
|
|
1754
334
|
try {
|
|
1755
|
-
await
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
await this.storage.complete(job.id, this.id);
|
|
1769
|
-
const completedData = await this.storage.getJob(job.id) ?? job._data;
|
|
1770
|
-
this.emitter.emit("job:completed", completedData);
|
|
1771
|
-
if (job.cron) {
|
|
1772
|
-
await this.enqueueCronNext(job);
|
|
1773
|
-
}
|
|
1774
|
-
} catch (err) {
|
|
1775
|
-
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
1776
|
-
if (lockRenewTimer) {
|
|
1777
|
-
clearInterval(lockRenewTimer);
|
|
1778
|
-
lockRenewTimer = null;
|
|
1779
|
-
}
|
|
1780
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
1781
|
-
await this.handleFailure(job, error);
|
|
1782
|
-
} finally {
|
|
1783
|
-
if (lockRenewTimer) {
|
|
1784
|
-
clearInterval(lockRenewTimer);
|
|
1785
|
-
lockRenewTimer = null;
|
|
1786
|
-
}
|
|
1787
|
-
this.activeJobIds.delete(job.id);
|
|
1788
|
-
this.activeCount -= 1;
|
|
1789
|
-
if (this.activeCount === 0 && this.drainResolve) {
|
|
1790
|
-
this.drainResolve();
|
|
1791
|
-
this.drainResolve = null;
|
|
1792
|
-
}
|
|
335
|
+
pgModule = await import("pg");
|
|
336
|
+
} catch {
|
|
337
|
+
throw new Error(
|
|
338
|
+
"[queue-jobs-worker] Postgres dialect requires the `pg` package. Run: npm install pg"
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
this.pool = new pgModule.Pool({ connectionString: this.connectionString });
|
|
342
|
+
await this.ensureTable();
|
|
343
|
+
}
|
|
344
|
+
async disconnect() {
|
|
345
|
+
if (this.pool) {
|
|
346
|
+
await this.pool.end();
|
|
347
|
+
this.pool = null;
|
|
1793
348
|
}
|
|
1794
349
|
}
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
350
|
+
async saveJob(queueName, job) {
|
|
351
|
+
const p = this.assertPool();
|
|
352
|
+
await p.query(
|
|
353
|
+
`INSERT INTO qjw_jobs (id, queue_name, payload, status, priority, run_at, created_at)
|
|
354
|
+
VALUES ($1, $2, $3::jsonb, $4, $5, $6, $7)
|
|
355
|
+
ON CONFLICT (queue_name, id) DO UPDATE SET payload = EXCLUDED.payload,
|
|
356
|
+
status = EXCLUDED.status, priority = EXCLUDED.priority, run_at = EXCLUDED.run_at`,
|
|
357
|
+
[job.id, queueName, JSON.stringify(job), job.status, job.priority, job.runAt, job.createdAt]
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
async getJob(queueName, jobId) {
|
|
361
|
+
const p = this.assertPool();
|
|
362
|
+
const { rows } = await p.query(
|
|
363
|
+
`SELECT payload FROM qjw_jobs WHERE queue_name = $1 AND id = $2`,
|
|
364
|
+
[queueName, jobId]
|
|
365
|
+
);
|
|
366
|
+
return rows[0]?.payload;
|
|
367
|
+
}
|
|
368
|
+
async updateJob(queueName, jobId, patch) {
|
|
369
|
+
const p = this.assertPool();
|
|
370
|
+
const patchWithTs = { ...patch, updatedAt: Date.now() };
|
|
371
|
+
const newStatus = patch.status !== void 0 ? patch.status : null;
|
|
372
|
+
const newPriority = patch.priority !== void 0 ? patch.priority : null;
|
|
373
|
+
const newRunAt = patch.runAt !== void 0 ? patch.runAt : null;
|
|
374
|
+
await p.query(
|
|
375
|
+
`UPDATE qjw_jobs
|
|
376
|
+
SET payload = payload || $1::jsonb,
|
|
377
|
+
status = COALESCE($2, status),
|
|
378
|
+
priority = COALESCE($3::integer, priority),
|
|
379
|
+
run_at = COALESCE($4::bigint, run_at)
|
|
380
|
+
WHERE queue_name = $5 AND id = $6`,
|
|
381
|
+
[JSON.stringify(patchWithTs), newStatus, newPriority, newRunAt, queueName, jobId]
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
async removeJob(queueName, jobId) {
|
|
385
|
+
const p = this.assertPool();
|
|
386
|
+
await p.query(`DELETE FROM qjw_jobs WHERE queue_name = $1 AND id = $2`, [queueName, jobId]);
|
|
387
|
+
}
|
|
388
|
+
async listJobs(queueName, status) {
|
|
389
|
+
const p = this.assertPool();
|
|
390
|
+
let sql = `SELECT payload FROM qjw_jobs WHERE queue_name = $1`;
|
|
391
|
+
const params = [queueName];
|
|
392
|
+
if (status !== void 0) {
|
|
393
|
+
sql += ` AND status = $2`;
|
|
394
|
+
params.push(status);
|
|
395
|
+
}
|
|
396
|
+
sql += ` ORDER BY priority ASC, created_at ASC`;
|
|
397
|
+
const { rows } = await p.query(sql, params);
|
|
398
|
+
return rows.map((r) => r.payload);
|
|
399
|
+
}
|
|
400
|
+
async getNextJob(queueName) {
|
|
401
|
+
const p = this.assertPool();
|
|
402
|
+
const now = Date.now();
|
|
403
|
+
const { rows } = await p.query(
|
|
404
|
+
`WITH claimed AS (
|
|
405
|
+
SELECT id FROM qjw_jobs
|
|
406
|
+
WHERE queue_name = $1
|
|
407
|
+
AND status IN ('waiting', 'retrying')
|
|
408
|
+
AND run_at <= $2
|
|
409
|
+
ORDER BY priority ASC, created_at ASC
|
|
410
|
+
LIMIT 1
|
|
411
|
+
FOR UPDATE SKIP LOCKED
|
|
412
|
+
),
|
|
413
|
+
updated AS (
|
|
414
|
+
UPDATE qjw_jobs j
|
|
415
|
+
SET status = 'active',
|
|
416
|
+
payload = j.payload || jsonb_build_object(
|
|
417
|
+
'status', 'active',
|
|
418
|
+
'updatedAt', $2::bigint,
|
|
419
|
+
'processedAt', $2::bigint
|
|
420
|
+
)
|
|
421
|
+
FROM claimed
|
|
422
|
+
WHERE j.queue_name = $1 AND j.id = claimed.id
|
|
423
|
+
RETURNING j.payload || jsonb_build_object(
|
|
424
|
+
'status', 'active',
|
|
425
|
+
'updatedAt', $2::bigint,
|
|
426
|
+
'processedAt', $2::bigint
|
|
427
|
+
) AS payload
|
|
428
|
+
)
|
|
429
|
+
SELECT payload FROM updated`,
|
|
430
|
+
[queueName, now]
|
|
431
|
+
);
|
|
432
|
+
return rows[0]?.payload;
|
|
433
|
+
}
|
|
434
|
+
async clearQueue(queueName) {
|
|
435
|
+
const p = this.assertPool();
|
|
436
|
+
await p.query(`DELETE FROM qjw_jobs WHERE queue_name = $1`, [queueName]);
|
|
437
|
+
}
|
|
438
|
+
async countJobs(queueName) {
|
|
439
|
+
const p = this.assertPool();
|
|
440
|
+
const { rows } = await p.query(
|
|
441
|
+
`SELECT COUNT(*) AS count FROM qjw_jobs WHERE queue_name = $1`,
|
|
442
|
+
[queueName]
|
|
443
|
+
);
|
|
444
|
+
return parseInt(rows[0]?.count ?? "0", 10);
|
|
445
|
+
}
|
|
446
|
+
// ─── Private helpers ──────────────────────────────────────────────────────
|
|
447
|
+
assertPool() {
|
|
448
|
+
if (!this.pool) {
|
|
449
|
+
throw new Error(
|
|
450
|
+
"[queue-jobs-worker] PostgresStorage is not connected. Did you call client.init()?"
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
return this.pool;
|
|
454
|
+
}
|
|
455
|
+
async ensureTable() {
|
|
456
|
+
const p = this.assertPool();
|
|
457
|
+
await p.query(`
|
|
458
|
+
CREATE TABLE IF NOT EXISTS qjw_jobs (
|
|
459
|
+
id TEXT NOT NULL,
|
|
460
|
+
queue_name TEXT NOT NULL,
|
|
461
|
+
payload JSONB NOT NULL,
|
|
462
|
+
status TEXT NOT NULL,
|
|
463
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
464
|
+
run_at BIGINT NOT NULL,
|
|
465
|
+
created_at BIGINT NOT NULL,
|
|
466
|
+
PRIMARY KEY (queue_name, id)
|
|
467
|
+
);
|
|
468
|
+
CREATE INDEX IF NOT EXISTS qjw_jobs_next_idx
|
|
469
|
+
ON qjw_jobs (queue_name, status, priority ASC, created_at ASC);
|
|
470
|
+
`);
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
// src/storage/mysql.storage.ts
|
|
475
|
+
var MysqlStorage = class {
|
|
476
|
+
pool = null;
|
|
477
|
+
connectionString;
|
|
478
|
+
constructor(connectionString) {
|
|
479
|
+
this.connectionString = connectionString;
|
|
480
|
+
}
|
|
481
|
+
// ─── IStorage ─────────────────────────────────────────────────────────────
|
|
482
|
+
async connect() {
|
|
483
|
+
let mysql2Module;
|
|
1799
484
|
try {
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
const error = cronErr instanceof Error ? cronErr : new Error(
|
|
1806
|
-
`croner failed to initialize for expression "${job.cron}": ${String(cronErr)}`
|
|
1807
|
-
);
|
|
1808
|
-
this.emitter.emit("worker:error", this.id, error);
|
|
1809
|
-
return;
|
|
1810
|
-
}
|
|
1811
|
-
if (nextDate === null) {
|
|
1812
|
-
const error = new Error(
|
|
1813
|
-
`Cron expression "${job.cron}" has no future occurrences \u2014 job "${job.id}" will not be re-enqueued`
|
|
1814
|
-
);
|
|
1815
|
-
this.emitter.emit("worker:error", this.id, error);
|
|
1816
|
-
return;
|
|
1817
|
-
}
|
|
1818
|
-
const runAt = nextDate.toISOString();
|
|
1819
|
-
await this.storage.enqueue({
|
|
1820
|
-
id: generateJobId(),
|
|
1821
|
-
queue: this.queueName,
|
|
1822
|
-
type: job.type,
|
|
1823
|
-
payload: job._data.payload,
|
|
1824
|
-
maxAttempts: job.maxAttempts,
|
|
1825
|
-
retryDelay: job.retryDelay,
|
|
1826
|
-
backoff: job.backoff,
|
|
1827
|
-
timeout: job.timeout,
|
|
1828
|
-
priority: job.priority,
|
|
1829
|
-
runAt,
|
|
1830
|
-
cron: job.cron
|
|
1831
|
-
});
|
|
1832
|
-
} catch (err) {
|
|
1833
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
1834
|
-
this.emitter.emit("worker:error", this.id, error);
|
|
485
|
+
mysql2Module = await import("mysql2/promise");
|
|
486
|
+
} catch {
|
|
487
|
+
throw new Error(
|
|
488
|
+
"[queue-jobs-worker] MySQL dialect requires the `mysql2` package. Run: npm install mysql2"
|
|
489
|
+
);
|
|
1835
490
|
}
|
|
491
|
+
this.pool = mysql2Module.createPool({
|
|
492
|
+
uri: this.connectionString,
|
|
493
|
+
waitForConnections: true,
|
|
494
|
+
connectionLimit: 10
|
|
495
|
+
});
|
|
496
|
+
await this.ensureTable();
|
|
497
|
+
}
|
|
498
|
+
async disconnect() {
|
|
499
|
+
if (this.pool) {
|
|
500
|
+
await this.pool.end();
|
|
501
|
+
this.pool = null;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
async saveJob(queueName, job) {
|
|
505
|
+
const p = this.assertPool();
|
|
506
|
+
await p.execute(
|
|
507
|
+
`INSERT INTO qjw_jobs (id, queue_name, payload, status, priority, run_at, created_at)
|
|
508
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
509
|
+
ON DUPLICATE KEY UPDATE
|
|
510
|
+
payload = VALUES(payload),
|
|
511
|
+
status = VALUES(status),
|
|
512
|
+
priority = VALUES(priority),
|
|
513
|
+
run_at = VALUES(run_at)`,
|
|
514
|
+
[job.id, queueName, JSON.stringify(job), job.status, job.priority, job.runAt, job.createdAt]
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
async getJob(queueName, jobId) {
|
|
518
|
+
const p = this.assertPool();
|
|
519
|
+
const [rows] = await p.execute(
|
|
520
|
+
`SELECT payload FROM qjw_jobs WHERE queue_name = ? AND id = ? LIMIT 1`,
|
|
521
|
+
[queueName, jobId]
|
|
522
|
+
);
|
|
523
|
+
const row = rows[0];
|
|
524
|
+
if (!row) return void 0;
|
|
525
|
+
const raw = typeof row.payload === "string" ? JSON.parse(row.payload) : row.payload;
|
|
526
|
+
return raw;
|
|
527
|
+
}
|
|
528
|
+
async updateJob(queueName, jobId, patch) {
|
|
529
|
+
const p = this.assertPool();
|
|
530
|
+
const patchWithTs = { ...patch, updatedAt: Date.now() };
|
|
531
|
+
const newStatus = patch.status !== void 0 ? patch.status : null;
|
|
532
|
+
const newPriority = patch.priority !== void 0 ? patch.priority : null;
|
|
533
|
+
const newRunAt = patch.runAt !== void 0 ? patch.runAt : null;
|
|
534
|
+
await p.execute(
|
|
535
|
+
`UPDATE qjw_jobs
|
|
536
|
+
SET payload = JSON_MERGE_PATCH(payload, ?),
|
|
537
|
+
status = COALESCE(?, status),
|
|
538
|
+
priority = COALESCE(?, priority),
|
|
539
|
+
run_at = COALESCE(?, run_at)
|
|
540
|
+
WHERE queue_name = ? AND id = ?`,
|
|
541
|
+
[JSON.stringify(patchWithTs), newStatus, newPriority, newRunAt, queueName, jobId]
|
|
542
|
+
);
|
|
1836
543
|
}
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
const
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
jobId: job.id,
|
|
1849
|
-
runAt,
|
|
1850
|
-
error: error.message,
|
|
1851
|
-
attemptNumber,
|
|
1852
|
-
lockId: this.id,
|
|
1853
|
-
...error.stack !== void 0 && { stack: error.stack }
|
|
1854
|
-
});
|
|
1855
|
-
const updated = await this.storage.getJob(job.id);
|
|
1856
|
-
if (updated) {
|
|
1857
|
-
this.emitter.emit("job:retrying", updated, error, runAt);
|
|
1858
|
-
}
|
|
1859
|
-
} else {
|
|
1860
|
-
await this.storage.moveToDlq({
|
|
1861
|
-
jobId: job.id,
|
|
1862
|
-
error: error.message,
|
|
1863
|
-
attemptNumber,
|
|
1864
|
-
lockId: this.id,
|
|
1865
|
-
...error.stack !== void 0 && { stack: error.stack }
|
|
1866
|
-
});
|
|
1867
|
-
const dead = await this.storage.getJob(job.id);
|
|
1868
|
-
if (dead) {
|
|
1869
|
-
this.emitter.emit("job:dead", dead, error);
|
|
1870
|
-
}
|
|
544
|
+
async removeJob(queueName, jobId) {
|
|
545
|
+
const p = this.assertPool();
|
|
546
|
+
await p.execute(`DELETE FROM qjw_jobs WHERE queue_name = ? AND id = ?`, [queueName, jobId]);
|
|
547
|
+
}
|
|
548
|
+
async listJobs(queueName, status) {
|
|
549
|
+
const p = this.assertPool();
|
|
550
|
+
let sql = `SELECT payload FROM qjw_jobs WHERE queue_name = ?`;
|
|
551
|
+
const params = [queueName];
|
|
552
|
+
if (status !== void 0) {
|
|
553
|
+
sql += ` AND status = ?`;
|
|
554
|
+
params.push(status);
|
|
1871
555
|
}
|
|
556
|
+
sql += ` ORDER BY priority ASC, created_at ASC`;
|
|
557
|
+
const [rows] = await p.execute(sql, params);
|
|
558
|
+
return rows.map((r) => {
|
|
559
|
+
const raw = typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload;
|
|
560
|
+
return raw;
|
|
561
|
+
});
|
|
1872
562
|
}
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
if (this._status !== "running") return;
|
|
1878
|
-
this.stalledTimer = setTimeout(() => {
|
|
1879
|
-
void this.recoverStalledJobs();
|
|
1880
|
-
}, this.config.stalledInterval);
|
|
1881
|
-
}
|
|
1882
|
-
async recoverStalledJobs() {
|
|
1883
|
-
if (this._status !== "running") return;
|
|
563
|
+
async getNextJob(queueName) {
|
|
564
|
+
const p = this.assertPool();
|
|
565
|
+
const now = Date.now();
|
|
566
|
+
const conn = await p.getConnection();
|
|
1884
567
|
try {
|
|
1885
|
-
|
|
1886
|
-
const
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
568
|
+
await conn.beginTransaction();
|
|
569
|
+
const [rows] = await conn.execute(
|
|
570
|
+
`SELECT id, payload FROM qjw_jobs
|
|
571
|
+
WHERE queue_name = ?
|
|
572
|
+
AND status IN ('waiting', 'retrying')
|
|
573
|
+
AND run_at <= ?
|
|
574
|
+
ORDER BY priority ASC, created_at ASC
|
|
575
|
+
LIMIT 1
|
|
576
|
+
FOR UPDATE SKIP LOCKED`,
|
|
577
|
+
[queueName, now]
|
|
578
|
+
);
|
|
579
|
+
const row = rows[0];
|
|
580
|
+
if (!row) {
|
|
581
|
+
await conn.commit();
|
|
582
|
+
return void 0;
|
|
1890
583
|
}
|
|
584
|
+
const jobId = row.id;
|
|
585
|
+
const raw = typeof row.payload === "string" ? JSON.parse(row.payload) : row.payload;
|
|
586
|
+
const job = raw;
|
|
587
|
+
const updated = {
|
|
588
|
+
...job,
|
|
589
|
+
status: "active",
|
|
590
|
+
updatedAt: now,
|
|
591
|
+
processedAt: now
|
|
592
|
+
};
|
|
593
|
+
await conn.execute(
|
|
594
|
+
`UPDATE qjw_jobs
|
|
595
|
+
SET payload = ?, status = 'active', run_at = run_at
|
|
596
|
+
WHERE queue_name = ? AND id = ?`,
|
|
597
|
+
[JSON.stringify(updated), queueName, jobId]
|
|
598
|
+
);
|
|
599
|
+
await conn.commit();
|
|
600
|
+
return updated;
|
|
1891
601
|
} catch (err) {
|
|
1892
|
-
|
|
1893
|
-
|
|
602
|
+
await conn.rollback().catch(() => void 0);
|
|
603
|
+
throw err;
|
|
604
|
+
} finally {
|
|
605
|
+
conn.release();
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
async clearQueue(queueName) {
|
|
609
|
+
const p = this.assertPool();
|
|
610
|
+
await p.execute(`DELETE FROM qjw_jobs WHERE queue_name = ?`, [queueName]);
|
|
611
|
+
}
|
|
612
|
+
async countJobs(queueName) {
|
|
613
|
+
const p = this.assertPool();
|
|
614
|
+
const [rows] = await p.execute(
|
|
615
|
+
`SELECT COUNT(*) AS count FROM qjw_jobs WHERE queue_name = ?`,
|
|
616
|
+
[queueName]
|
|
617
|
+
);
|
|
618
|
+
const row = rows[0];
|
|
619
|
+
if (!row) return 0;
|
|
620
|
+
return parseInt(String(row.count ?? 0), 10);
|
|
621
|
+
}
|
|
622
|
+
// ─── Private helpers ──────────────────────────────────────────────────────
|
|
623
|
+
assertPool() {
|
|
624
|
+
if (!this.pool) {
|
|
625
|
+
throw new Error(
|
|
626
|
+
"[queue-jobs-worker] MysqlStorage is not connected. Did you call client.init()?"
|
|
627
|
+
);
|
|
1894
628
|
}
|
|
1895
|
-
this.
|
|
629
|
+
return this.pool;
|
|
630
|
+
}
|
|
631
|
+
async ensureTable() {
|
|
632
|
+
const p = this.assertPool();
|
|
633
|
+
await p.execute(`
|
|
634
|
+
CREATE TABLE IF NOT EXISTS qjw_jobs (
|
|
635
|
+
id VARCHAR(36) NOT NULL,
|
|
636
|
+
queue_name VARCHAR(255) NOT NULL,
|
|
637
|
+
payload JSON NOT NULL,
|
|
638
|
+
status VARCHAR(20) NOT NULL,
|
|
639
|
+
priority INT NOT NULL DEFAULT 0,
|
|
640
|
+
run_at BIGINT NOT NULL,
|
|
641
|
+
created_at BIGINT NOT NULL,
|
|
642
|
+
PRIMARY KEY (queue_name, id),
|
|
643
|
+
INDEX qjw_jobs_next_idx (queue_name, status, priority ASC, created_at ASC)
|
|
644
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
645
|
+
`);
|
|
1896
646
|
}
|
|
1897
647
|
};
|
|
1898
648
|
|
|
1899
|
-
// src/
|
|
1900
|
-
function
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
name;
|
|
1916
|
-
storage;
|
|
1917
|
-
emitter;
|
|
1918
|
-
resolvedConfig;
|
|
1919
|
-
clientDefaults;
|
|
1920
|
-
isInitialisedCheck;
|
|
1921
|
-
/** Registered processors keyed by job type. */
|
|
1922
|
-
processors = /* @__PURE__ */ new Map();
|
|
1923
|
-
/** Active worker instances created by this queue. */
|
|
1924
|
-
workers = [];
|
|
1925
|
-
constructor(name, storage, emitter, options, defaults, isInitialisedCheck) {
|
|
1926
|
-
this.name = name;
|
|
1927
|
-
this.storage = storage;
|
|
1928
|
-
this.emitter = emitter;
|
|
1929
|
-
this.clientDefaults = defaults;
|
|
1930
|
-
this.resolvedConfig = mergeQueueConfig(options, defaults);
|
|
1931
|
-
this.isInitialisedCheck = isInitialisedCheck;
|
|
1932
|
-
}
|
|
1933
|
-
assertInitialised() {
|
|
1934
|
-
if (this.isInitialisedCheck && !this.isInitialisedCheck()) {
|
|
649
|
+
// src/utils/resolve-storage.ts
|
|
650
|
+
function resolveStorage(dialect, connectionString) {
|
|
651
|
+
switch (dialect) {
|
|
652
|
+
case "memory":
|
|
653
|
+
return new MemoryStorage();
|
|
654
|
+
case "redis":
|
|
655
|
+
assertConnectionString(dialect, connectionString);
|
|
656
|
+
return new RedisStorage(connectionString);
|
|
657
|
+
case "postgres":
|
|
658
|
+
assertConnectionString(dialect, connectionString);
|
|
659
|
+
return new PostgresStorage(connectionString);
|
|
660
|
+
case "mysql":
|
|
661
|
+
assertConnectionString(dialect, connectionString);
|
|
662
|
+
return new MysqlStorage(connectionString);
|
|
663
|
+
default: {
|
|
664
|
+
const _unreachable = dialect;
|
|
1935
665
|
throw new Error(
|
|
1936
|
-
`
|
|
666
|
+
`[queue-jobs-worker] Unknown storage dialect: "${String(_unreachable)}". Valid options are: "memory", "redis", "postgres", "mysql".`
|
|
1937
667
|
);
|
|
1938
668
|
}
|
|
1939
669
|
}
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
670
|
+
}
|
|
671
|
+
function assertConnectionString(dialect, connectionString) {
|
|
672
|
+
if (!connectionString) {
|
|
673
|
+
throw new Error(
|
|
674
|
+
`[queue-jobs-worker] Dialect "${dialect}" requires a connectionString. Example: { dialect: "${dialect}", connectionString: "${exampleDsn(dialect)}" }`
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
function exampleDsn(dialect) {
|
|
679
|
+
switch (dialect) {
|
|
680
|
+
case "redis":
|
|
681
|
+
return "redis://localhost:6379";
|
|
682
|
+
case "postgres":
|
|
683
|
+
return "postgresql://user:pass@localhost:5432/mydb";
|
|
684
|
+
case "mysql":
|
|
685
|
+
return "mysql://user:pass@localhost:3306/mydb";
|
|
686
|
+
default:
|
|
687
|
+
return "";
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// src/constants/client.constants.ts
|
|
692
|
+
var DEFAULT_QUEUE_CONFIG = {
|
|
693
|
+
attempts: 3,
|
|
694
|
+
retryDelay: 1e3,
|
|
695
|
+
// 1 second base delay
|
|
696
|
+
backoff: "exponential",
|
|
697
|
+
timeout: 3e4
|
|
698
|
+
// 30 seconds
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
// src/classes/client.ts
|
|
702
|
+
var QueueClient = class _QueueClient {
|
|
703
|
+
// ── Singleton default ───────────────────────────────────────────────────
|
|
704
|
+
static _default;
|
|
705
|
+
// ── Instance state ───────────────────────────────────────────────────────
|
|
706
|
+
_dialect;
|
|
707
|
+
_connectionString;
|
|
708
|
+
_debug;
|
|
709
|
+
_config;
|
|
710
|
+
_storage = null;
|
|
711
|
+
_initialized = false;
|
|
712
|
+
_closed = false;
|
|
713
|
+
constructor(opts) {
|
|
714
|
+
this._dialect = opts.dialect;
|
|
715
|
+
this._debug = opts.debug ?? false;
|
|
716
|
+
this._config = { ...DEFAULT_QUEUE_CONFIG, ...opts.options };
|
|
717
|
+
if (opts.connectionString !== void 0) {
|
|
718
|
+
this._connectionString = opts.connectionString;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
1943
722
|
/**
|
|
1944
|
-
*
|
|
723
|
+
* Open the storage connection and mark this client as ready.
|
|
724
|
+
*
|
|
725
|
+
* Must be called before creating any Queue or Worker.
|
|
726
|
+
* Calling init() more than once throws to prevent accidental double-init.
|
|
1945
727
|
*
|
|
1946
|
-
* @
|
|
1947
|
-
* @param payload - Arbitrary serialisable payload (never logged by default).
|
|
1948
|
-
* @param options - Per-job overrides (attempts, delay, priority, etc.).
|
|
728
|
+
* @returns `this` so you can chain: await new QueueClient(…).init()
|
|
1949
729
|
*/
|
|
1950
|
-
async
|
|
1951
|
-
this.
|
|
1952
|
-
|
|
1953
|
-
const now = /* @__PURE__ */ new Date();
|
|
1954
|
-
let runAt;
|
|
1955
|
-
if (options?.schedule?.runAt !== void 0) {
|
|
1956
|
-
runAt = new Date(options.schedule.runAt);
|
|
1957
|
-
} else if (options?.schedule?.delay !== void 0) {
|
|
1958
|
-
runAt = new Date(now.getTime() + options.schedule.delay);
|
|
1959
|
-
} else {
|
|
1960
|
-
runAt = now;
|
|
730
|
+
async init() {
|
|
731
|
+
if (this._initialized) {
|
|
732
|
+
throw new Error("[queue-jobs-worker] QueueClient is already initialized.");
|
|
1961
733
|
}
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
const job = new Job(raw);
|
|
1976
|
-
this.emitter.emit("job:enqueued", raw);
|
|
1977
|
-
return job;
|
|
734
|
+
if (this._closed) {
|
|
735
|
+
throw new Error(
|
|
736
|
+
"[queue-jobs-worker] QueueClient has been closed and cannot be re-initialized. Create a new instance instead."
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
this._storage = resolveStorage(this._dialect, this._connectionString);
|
|
740
|
+
await this._storage.connect();
|
|
741
|
+
this._initialized = true;
|
|
742
|
+
if (!_QueueClient._default) {
|
|
743
|
+
_QueueClient._default = this;
|
|
744
|
+
}
|
|
745
|
+
this._log(`QueueClient initialized (dialect: ${this._dialect})`);
|
|
746
|
+
return this;
|
|
1978
747
|
}
|
|
1979
|
-
// -------------------------------------------------------------------------
|
|
1980
|
-
// Processor registration
|
|
1981
|
-
// -------------------------------------------------------------------------
|
|
1982
748
|
/**
|
|
1983
|
-
*
|
|
749
|
+
* Close the storage connection and release all resources.
|
|
1984
750
|
*
|
|
1985
|
-
*
|
|
1986
|
-
*
|
|
751
|
+
* Call this on application shutdown (e.g. SIGTERM handler, NestJS onModuleDestroy,
|
|
752
|
+
* Express app.close()). Any in-flight worker processing will finish its current
|
|
753
|
+
* job before the worker's own close() stops the polling loop.
|
|
1987
754
|
*/
|
|
1988
|
-
|
|
1989
|
-
this.
|
|
755
|
+
async close() {
|
|
756
|
+
if (this._closed) return;
|
|
757
|
+
if (!this._initialized) {
|
|
758
|
+
throw new Error("[queue-jobs-worker] Cannot close a QueueClient that was never initialized.");
|
|
759
|
+
}
|
|
760
|
+
await this._storage.disconnect();
|
|
761
|
+
this._closed = true;
|
|
762
|
+
this._initialized = false;
|
|
763
|
+
if (_QueueClient._default === this) {
|
|
764
|
+
_QueueClient._default = void 0;
|
|
765
|
+
}
|
|
766
|
+
this._log("QueueClient closed.");
|
|
767
|
+
}
|
|
768
|
+
// ── Static helpers ────────────────────────────────────────────────────────
|
|
769
|
+
/** Returns the process-wide default client, or undefined if none is set. */
|
|
770
|
+
static getDefaultClient() {
|
|
771
|
+
return _QueueClient._default;
|
|
1990
772
|
}
|
|
1991
|
-
// -------------------------------------------------------------------------
|
|
1992
|
-
// Worker creation
|
|
1993
|
-
// -------------------------------------------------------------------------
|
|
1994
773
|
/**
|
|
1995
|
-
*
|
|
1996
|
-
*
|
|
1997
|
-
* Worker-level `concurrency` overrides queue-level concurrency.
|
|
774
|
+
* Explicitly set (or replace) the process-wide default client.
|
|
775
|
+
* Useful in tests or multi-client setups.
|
|
1998
776
|
*/
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
this.assertInitialised();
|
|
2027
|
-
const raws = await this.storage.getJobs({
|
|
2028
|
-
queue: this.name,
|
|
2029
|
-
...status !== void 0 && { status },
|
|
2030
|
-
limit,
|
|
2031
|
-
offset
|
|
2032
|
-
});
|
|
2033
|
-
return raws.map((r) => new Job(r));
|
|
777
|
+
static setDefaultClient(client) {
|
|
778
|
+
_QueueClient._default = client;
|
|
779
|
+
}
|
|
780
|
+
/** Clear the default client reference (useful between tests). */
|
|
781
|
+
static clearDefaultClient() {
|
|
782
|
+
_QueueClient._default = void 0;
|
|
783
|
+
}
|
|
784
|
+
// ── Accessors (read-only to the outside world) ───────────────────────────
|
|
785
|
+
/** True after init() succeeds and before close() is called. */
|
|
786
|
+
isInitialized() {
|
|
787
|
+
return this._initialized;
|
|
788
|
+
}
|
|
789
|
+
/** True after close() has been called. */
|
|
790
|
+
isClosed() {
|
|
791
|
+
return this._closed;
|
|
792
|
+
}
|
|
793
|
+
/** The merged global job execution defaults. */
|
|
794
|
+
getConfig() {
|
|
795
|
+
return { ...this._config };
|
|
796
|
+
}
|
|
797
|
+
/** The storage dialect this client was created with. */
|
|
798
|
+
getDialect() {
|
|
799
|
+
return this._dialect;
|
|
800
|
+
}
|
|
801
|
+
/** The connection string (may be undefined for the memory dialect). */
|
|
802
|
+
getConnectionString() {
|
|
803
|
+
return this._connectionString;
|
|
2034
804
|
}
|
|
2035
|
-
/**
|
|
2036
|
-
|
|
2037
|
-
this.
|
|
2038
|
-
return this.storage.getJobCounts(this.name);
|
|
805
|
+
/** Whether debug logging is enabled. */
|
|
806
|
+
isDebugMode() {
|
|
807
|
+
return this._debug;
|
|
2039
808
|
}
|
|
2040
|
-
// -------------------------------------------------------------------------
|
|
2041
|
-
// Lifecycle
|
|
2042
|
-
// -------------------------------------------------------------------------
|
|
2043
809
|
/**
|
|
2044
|
-
*
|
|
2045
|
-
*
|
|
810
|
+
* The underlying IStorage instance.
|
|
811
|
+
*
|
|
812
|
+
* Exposed so Queue / Worker can call storage methods directly.
|
|
813
|
+
* Throws if the client has not been initialized yet.
|
|
814
|
+
*
|
|
815
|
+
* @internal
|
|
2046
816
|
*/
|
|
2047
|
-
|
|
2048
|
-
|
|
817
|
+
getStorage() {
|
|
818
|
+
if (!this._storage || !this._initialized) {
|
|
819
|
+
throw new Error(
|
|
820
|
+
"[queue-jobs-worker] QueueClient must be initialized before accessing storage. Did you forget to call await client.init()?"
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
return this._storage;
|
|
2049
824
|
}
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
825
|
+
// ── Internal helpers ──────────────────────────────────────────────────────
|
|
826
|
+
/**
|
|
827
|
+
* Write a debug-mode log line.
|
|
828
|
+
*
|
|
829
|
+
* FIX: the old signature accepted `...args: unknown[]` which let callers
|
|
830
|
+
* accidentally spread raw objects into the console output, potentially
|
|
831
|
+
* leaking internal state (job payloads, connection strings, stack traces)
|
|
832
|
+
* into log aggregators. The new signature accepts only a pre-formatted
|
|
833
|
+
* string so callers must stringify anything sensitive before passing it.
|
|
834
|
+
*
|
|
835
|
+
* @internal
|
|
836
|
+
*/
|
|
837
|
+
_log(message) {
|
|
838
|
+
if (this._debug) {
|
|
839
|
+
console.log(`[queue-jobs-worker] ${message}`);
|
|
840
|
+
}
|
|
2053
841
|
}
|
|
2054
842
|
};
|
|
2055
843
|
|
|
2056
|
-
// src/
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
844
|
+
// src/classes/queue.ts
|
|
845
|
+
import { randomUUID } from "crypto";
|
|
846
|
+
import { Cron } from "croner";
|
|
847
|
+
var VALID_QUEUE_NAME = /^[a-zA-Z0-9_\-:.]+$/;
|
|
848
|
+
function assertQueueName(name) {
|
|
849
|
+
if (!name || !VALID_QUEUE_NAME.test(name)) {
|
|
850
|
+
throw new Error(
|
|
851
|
+
`[queue-jobs-worker] Invalid queue name "${name}". Queue names must be non-empty and may only contain: letters, digits, hyphens (-), underscores (_), colons (:), and dots (.).`
|
|
852
|
+
);
|
|
2064
853
|
}
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
854
|
+
}
|
|
855
|
+
var Queue = class {
|
|
856
|
+
name;
|
|
857
|
+
client;
|
|
858
|
+
options;
|
|
859
|
+
/** Active croner handles keyed by jobId — used to cancel scheduled jobs. */
|
|
860
|
+
_cronHandles = /* @__PURE__ */ new Map();
|
|
861
|
+
/** Workers attached to this queue — auto-closed when queue.close() is called. */
|
|
862
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
863
|
+
_workers = /* @__PURE__ */ new Set();
|
|
864
|
+
/** Guards against double-close. */
|
|
865
|
+
_closed = false;
|
|
866
|
+
constructor(name, client, options = {}) {
|
|
867
|
+
const activeClient = client ?? QueueClient.getDefaultClient();
|
|
868
|
+
if (!activeClient?.isInitialized()) {
|
|
869
|
+
throw new Error(
|
|
870
|
+
"[queue-jobs-worker] QueueClient must be initialized before creating a Queue. Call await client.init() first."
|
|
871
|
+
);
|
|
2075
872
|
}
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
type: input.type,
|
|
2083
|
-
payload: input.payload,
|
|
2084
|
-
status: isDelayed ? "delayed" : "waiting",
|
|
2085
|
-
attemptsMade: 0,
|
|
2086
|
-
maxAttempts: input.maxAttempts,
|
|
2087
|
-
retryDelay: input.retryDelay,
|
|
2088
|
-
backoff: input.backoff,
|
|
2089
|
-
timeout: input.timeout,
|
|
2090
|
-
priority: input.priority,
|
|
2091
|
-
runAt,
|
|
2092
|
-
...input.cron !== void 0 && { cron: input.cron },
|
|
2093
|
-
attempts: [],
|
|
2094
|
-
lockId: null,
|
|
2095
|
-
lockExpiresAt: null,
|
|
2096
|
-
createdAt: now,
|
|
2097
|
-
updatedAt: now,
|
|
2098
|
-
completedAt: null,
|
|
2099
|
-
failedAt: null
|
|
873
|
+
assertQueueName(name);
|
|
874
|
+
this.name = name;
|
|
875
|
+
this.client = activeClient;
|
|
876
|
+
this.options = {
|
|
877
|
+
rateLimit: options.rateLimit ?? { max: 0, duration: 0 },
|
|
878
|
+
defaultJobOpts: options.defaultJobOpts ?? {}
|
|
2100
879
|
};
|
|
2101
|
-
this.jobs.set(job.id, job);
|
|
2102
|
-
return job;
|
|
2103
880
|
}
|
|
2104
|
-
//
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
async claim(input) {
|
|
2108
|
-
const { queue, lockId, lockDuration, now } = input;
|
|
2109
|
-
const nowMs = new Date(now).getTime();
|
|
2110
|
-
const eligible = [];
|
|
2111
|
-
for (const job2 of this.jobs.values()) {
|
|
2112
|
-
if (job2.queue !== queue) continue;
|
|
2113
|
-
if (job2.status !== "waiting" && job2.status !== "delayed") continue;
|
|
2114
|
-
if (new Date(job2.runAt).getTime() > nowMs) continue;
|
|
2115
|
-
if (job2.lockId !== null && job2.lockExpiresAt !== null) {
|
|
2116
|
-
const lockExpiry = new Date(job2.lockExpiresAt).getTime();
|
|
2117
|
-
if (lockExpiry > nowMs) continue;
|
|
2118
|
-
}
|
|
2119
|
-
eligible.push(job2);
|
|
2120
|
-
}
|
|
2121
|
-
if (eligible.length === 0) return null;
|
|
2122
|
-
eligible.sort((a, b) => {
|
|
2123
|
-
if (b.priority !== a.priority) return b.priority - a.priority;
|
|
2124
|
-
const runAtDiff = new Date(a.runAt).getTime() - new Date(b.runAt).getTime();
|
|
2125
|
-
if (runAtDiff !== 0) return runAtDiff;
|
|
2126
|
-
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
|
2127
|
-
});
|
|
2128
|
-
const job = eligible[0];
|
|
2129
|
-
if (!job) return null;
|
|
2130
|
-
const lockExpiresAt = new Date(nowMs + lockDuration).toISOString();
|
|
2131
|
-
job.status = "active";
|
|
2132
|
-
job.lockId = lockId;
|
|
2133
|
-
job.lockExpiresAt = lockExpiresAt;
|
|
2134
|
-
job.updatedAt = now;
|
|
2135
|
-
return job;
|
|
881
|
+
// ── Storage shortcut ──────────────────────────────────────────────────────
|
|
882
|
+
get storage() {
|
|
883
|
+
return this.client.getStorage();
|
|
2136
884
|
}
|
|
2137
|
-
//
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
if (!job) return;
|
|
2174
|
-
if (input.lockId !== void 0 && (job.status !== "active" || job.lockId !== input.lockId)) {
|
|
2175
|
-
return;
|
|
2176
|
-
}
|
|
2177
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2178
|
-
job.attempts.push({
|
|
2179
|
-
attempt: input.attemptNumber,
|
|
2180
|
-
startedAt: job.updatedAt,
|
|
2181
|
-
finishedAt: now,
|
|
2182
|
-
error: input.error,
|
|
2183
|
-
...input.stack !== void 0 && { stack: input.stack }
|
|
2184
|
-
});
|
|
2185
|
-
job.attemptsMade = input.attemptNumber;
|
|
2186
|
-
job.status = "waiting";
|
|
2187
|
-
job.runAt = input.runAt;
|
|
2188
|
-
job.lockId = null;
|
|
2189
|
-
job.lockExpiresAt = null;
|
|
2190
|
-
job.updatedAt = now;
|
|
2191
|
-
}
|
|
2192
|
-
// -------------------------------------------------------------------------
|
|
2193
|
-
// Move to DLQ
|
|
2194
|
-
// -------------------------------------------------------------------------
|
|
2195
|
-
async moveToDlq(input) {
|
|
2196
|
-
const job = this.jobs.get(input.jobId);
|
|
2197
|
-
if (!job) return;
|
|
2198
|
-
if (input.lockId !== void 0 && (job.status !== "active" || job.lockId !== input.lockId)) {
|
|
2199
|
-
return;
|
|
2200
|
-
}
|
|
2201
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2202
|
-
job.attempts.push({
|
|
2203
|
-
attempt: input.attemptNumber,
|
|
2204
|
-
startedAt: job.updatedAt,
|
|
2205
|
-
finishedAt: now,
|
|
2206
|
-
error: input.error,
|
|
2207
|
-
...input.stack !== void 0 && { stack: input.stack }
|
|
2208
|
-
});
|
|
2209
|
-
job.attemptsMade = input.attemptNumber;
|
|
2210
|
-
job.status = "dead";
|
|
2211
|
-
job.lockId = null;
|
|
2212
|
-
job.lockExpiresAt = null;
|
|
2213
|
-
job.failedAt = now;
|
|
2214
|
-
job.updatedAt = now;
|
|
2215
|
-
}
|
|
2216
|
-
// -------------------------------------------------------------------------
|
|
2217
|
-
// Release lock
|
|
2218
|
-
// -------------------------------------------------------------------------
|
|
2219
|
-
async releaseLock(jobId, lockId) {
|
|
2220
|
-
const job = this.jobs.get(jobId);
|
|
2221
|
-
if (!job) return;
|
|
2222
|
-
if (lockId !== void 0 && (job.status !== "active" || job.lockId !== lockId)) {
|
|
2223
|
-
return;
|
|
2224
|
-
}
|
|
2225
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2226
|
-
job.lockId = null;
|
|
2227
|
-
job.lockExpiresAt = now;
|
|
2228
|
-
job.updatedAt = now;
|
|
2229
|
-
}
|
|
2230
|
-
// -------------------------------------------------------------------------
|
|
2231
|
-
// Recover stalled jobs
|
|
2232
|
-
//
|
|
2233
|
-
// Fix (issue #6): snapshot lockId and lockExpiresAt before the eligibility
|
|
2234
|
-
// check, then re-validate both values at write time. In a single-process
|
|
2235
|
-
// scenario all operations are synchronous within one event-loop tick, so
|
|
2236
|
-
// the race is theoretical — but the guard makes the adapter consistent with
|
|
2237
|
-
// the Redis CAS semantics and protects against any future async paths.
|
|
2238
|
-
// -------------------------------------------------------------------------
|
|
2239
|
-
async recoverStalledJobs(queue, now) {
|
|
2240
|
-
const nowMs = new Date(now).getTime();
|
|
2241
|
-
const recovered = [];
|
|
2242
|
-
for (const job of this.jobs.values()) {
|
|
2243
|
-
if (job.queue !== queue) continue;
|
|
2244
|
-
if (job.status !== "active") continue;
|
|
2245
|
-
if (job.lockExpiresAt === null) continue;
|
|
2246
|
-
const lockExpiry = new Date(job.lockExpiresAt).getTime();
|
|
2247
|
-
if (lockExpiry > nowMs) continue;
|
|
2248
|
-
const snapshotLockId = job.lockId;
|
|
2249
|
-
const snapshotLockExpiresAt = job.lockExpiresAt;
|
|
2250
|
-
if (job.status !== "active") continue;
|
|
2251
|
-
if (job.lockId !== snapshotLockId) continue;
|
|
2252
|
-
if (job.lockExpiresAt !== snapshotLockExpiresAt) continue;
|
|
2253
|
-
job.status = "waiting";
|
|
2254
|
-
job.lockId = null;
|
|
2255
|
-
job.lockExpiresAt = null;
|
|
2256
|
-
job.updatedAt = now;
|
|
2257
|
-
recovered.push(job.id);
|
|
2258
|
-
}
|
|
2259
|
-
return recovered;
|
|
2260
|
-
}
|
|
2261
|
-
// -------------------------------------------------------------------------
|
|
2262
|
-
// Reads
|
|
2263
|
-
// -------------------------------------------------------------------------
|
|
2264
|
-
async getJob(jobId) {
|
|
2265
|
-
return this.jobs.get(jobId) ?? null;
|
|
2266
|
-
}
|
|
2267
|
-
async getJobs(filter) {
|
|
2268
|
-
const { queue, status, limit = 100, offset = 0 } = filter;
|
|
2269
|
-
const results = [];
|
|
2270
|
-
for (const job of this.jobs.values()) {
|
|
2271
|
-
if (queue !== void 0 && job.queue !== queue) continue;
|
|
2272
|
-
if (status !== void 0 && job.status !== status) continue;
|
|
2273
|
-
results.push(job);
|
|
2274
|
-
}
|
|
2275
|
-
results.sort((a, b) => {
|
|
2276
|
-
if (b.priority !== a.priority) return b.priority - a.priority;
|
|
2277
|
-
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
|
2278
|
-
});
|
|
2279
|
-
return results.slice(offset, offset + limit);
|
|
2280
|
-
}
|
|
2281
|
-
async getJobCounts(queue) {
|
|
2282
|
-
const counts = {
|
|
2283
|
-
waiting: 0,
|
|
2284
|
-
active: 0,
|
|
2285
|
-
completed: 0,
|
|
2286
|
-
delayed: 0,
|
|
2287
|
-
dead: 0
|
|
885
|
+
// ── Public API ────────────────────────────────────────────────────────────
|
|
886
|
+
/**
|
|
887
|
+
* Add a job to the queue.
|
|
888
|
+
*
|
|
889
|
+
* The job is immediately persisted to storage with status `"waiting"`.
|
|
890
|
+
* Cron jobs start as `"delayed"` and are moved to `"waiting"` on each tick.
|
|
891
|
+
*
|
|
892
|
+
* @param name - Job type identifier, e.g. `"send-welcome-email"`.
|
|
893
|
+
* @param data - Arbitrary payload forwarded to the Worker handler.
|
|
894
|
+
* @param opts - Per-job overrides for attempts, delay, priority, cron, etc.
|
|
895
|
+
* @returns The fully populated Job record (with auto-generated UUID id).
|
|
896
|
+
*/
|
|
897
|
+
async add(name, data, opts = {}) {
|
|
898
|
+
const clientCfg = this.client.getConfig();
|
|
899
|
+
const queueDefs = this.options.defaultJobOpts;
|
|
900
|
+
const delay = opts.delay ?? queueDefs.delay ?? 0;
|
|
901
|
+
const now = Date.now();
|
|
902
|
+
const job = {
|
|
903
|
+
id: opts.jobId ?? randomUUID(),
|
|
904
|
+
name,
|
|
905
|
+
data,
|
|
906
|
+
// Cron jobs begin as "delayed"; the croner callback flips them to
|
|
907
|
+
// "waiting" on each scheduled tick so a Worker can pick them up.
|
|
908
|
+
// Plain delayed jobs use "waiting" straight away — runAt gates pickup.
|
|
909
|
+
status: opts.cron ? "delayed" : "waiting",
|
|
910
|
+
opts,
|
|
911
|
+
attempts: opts.attempts ?? queueDefs.attempts ?? clientCfg.attempts,
|
|
912
|
+
attemptsMade: 0,
|
|
913
|
+
delay,
|
|
914
|
+
runAt: now + delay,
|
|
915
|
+
priority: opts.priority ?? queueDefs.priority ?? 0,
|
|
916
|
+
// exactOptionalPropertyTypes: only include the optional `cron` field
|
|
917
|
+
// when it is actually defined — never assign undefined to it explicitly.
|
|
918
|
+
...opts.cron !== void 0 && { cron: opts.cron },
|
|
919
|
+
createdAt: now,
|
|
920
|
+
updatedAt: now
|
|
2288
921
|
};
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
return counts;
|
|
2294
|
-
}
|
|
2295
|
-
// -------------------------------------------------------------------------
|
|
2296
|
-
// Rate limiting
|
|
2297
|
-
// -------------------------------------------------------------------------
|
|
2298
|
-
async checkAndIncrementRateLimit(queue, max, windowMs, now) {
|
|
2299
|
-
const nowMs = new Date(now).getTime();
|
|
2300
|
-
const existing = this.rateLimitBuckets.get(queue);
|
|
2301
|
-
if (!existing || nowMs - existing.windowStart >= windowMs) {
|
|
2302
|
-
this.rateLimitBuckets.set(queue, { count: 1, windowStart: nowMs });
|
|
2303
|
-
return true;
|
|
2304
|
-
}
|
|
2305
|
-
if (existing.count >= max) {
|
|
2306
|
-
return false;
|
|
2307
|
-
}
|
|
2308
|
-
existing.count += 1;
|
|
2309
|
-
return true;
|
|
2310
|
-
}
|
|
2311
|
-
// -------------------------------------------------------------------------
|
|
2312
|
-
// Internal helpers
|
|
2313
|
-
// -------------------------------------------------------------------------
|
|
2314
|
-
requireJob(jobId) {
|
|
2315
|
-
const job = this.jobs.get(jobId);
|
|
2316
|
-
if (!job) {
|
|
2317
|
-
throw new Error(`Job not found: ${jobId}`);
|
|
922
|
+
await this.storage.saveJob(this.name, job);
|
|
923
|
+
this.client._log(`Queue[${this.name}] added "${name}" id=${job.id}`);
|
|
924
|
+
if (opts.cron) {
|
|
925
|
+
this._scheduleCron(job);
|
|
2318
926
|
}
|
|
2319
927
|
return job;
|
|
2320
928
|
}
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
this.
|
|
2327
|
-
}
|
|
2328
|
-
// -------------------------------------------------------------------------
|
|
2329
|
-
// Subscribe
|
|
2330
|
-
// -------------------------------------------------------------------------
|
|
2331
|
-
on(event, listener) {
|
|
2332
|
-
this.emitter.on(event, listener);
|
|
2333
|
-
return this;
|
|
929
|
+
/**
|
|
930
|
+
* Fetch a single job by ID.
|
|
931
|
+
* Returns `undefined` when the job does not exist or has been removed.
|
|
932
|
+
*/
|
|
933
|
+
async get(jobId) {
|
|
934
|
+
return this.storage.getJob(this.name, jobId);
|
|
2334
935
|
}
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
936
|
+
/**
|
|
937
|
+
* Permanently remove a job from the queue.
|
|
938
|
+
* If the job has an active cron schedule, it is also cancelled.
|
|
939
|
+
*/
|
|
940
|
+
async remove(jobId) {
|
|
941
|
+
this._cancelCron(jobId);
|
|
942
|
+
await this.storage.removeJob(this.name, jobId);
|
|
943
|
+
this.client._log(`Queue[${this.name}] removed id=${jobId}`);
|
|
2338
944
|
}
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
945
|
+
/**
|
|
946
|
+
* List jobs, optionally filtered by status.
|
|
947
|
+
* Results are sorted: priority ASC, createdAt ASC.
|
|
948
|
+
*
|
|
949
|
+
* @param status - `"waiting" | "delayed" | "active" | "completed" | "failed" | "retrying"`
|
|
950
|
+
*/
|
|
951
|
+
async list(status) {
|
|
952
|
+
return this.storage.listJobs(this.name, status);
|
|
2342
953
|
}
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
// Utility
|
|
2351
|
-
// ---------------------------------------------------------------------------
|
|
2352
|
-
removeAllListeners(event) {
|
|
2353
|
-
if (event) {
|
|
2354
|
-
this.emitter.removeAllListeners(event);
|
|
2355
|
-
} else {
|
|
2356
|
-
this.emitter.removeAllListeners();
|
|
954
|
+
/**
|
|
955
|
+
* Remove every job in this queue (all statuses).
|
|
956
|
+
* All active cron schedules for this queue are also cancelled.
|
|
957
|
+
*/
|
|
958
|
+
async clear() {
|
|
959
|
+
for (const [id] of this._cronHandles) {
|
|
960
|
+
this._cancelCron(id);
|
|
2357
961
|
}
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
listenerCount(event) {
|
|
2361
|
-
return this.emitter.listenerCount(event);
|
|
962
|
+
await this.storage.clearQueue(this.name);
|
|
963
|
+
this.client._log(`Queue[${this.name}] cleared.`);
|
|
2362
964
|
}
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
retryDelay: 1e3,
|
|
2369
|
-
backoff: "exponential",
|
|
2370
|
-
timeout: 3e4,
|
|
2371
|
-
concurrency: 10,
|
|
2372
|
-
rateLimit: void 0,
|
|
2373
|
-
pollInterval: 1e3,
|
|
2374
|
-
stalledInterval: 3e4,
|
|
2375
|
-
lockDuration: 6e4
|
|
2376
|
-
};
|
|
2377
|
-
var QueueClient = class _QueueClient {
|
|
2378
|
-
/** @internal — mutable so withAdapter() can replace it */
|
|
2379
|
-
_storage;
|
|
2380
|
-
emitter;
|
|
2381
|
-
defaults;
|
|
2382
|
-
dialect;
|
|
2383
|
-
connectionString;
|
|
2384
|
-
queues = /* @__PURE__ */ new Map();
|
|
2385
|
-
initialised = false;
|
|
2386
|
-
closed = false;
|
|
2387
|
-
/** True when the adapter was supplied via {@link withAdapter}; init() skips resolveAdapter() in that case. */
|
|
2388
|
-
_customAdapter = false;
|
|
2389
|
-
constructor(options = {}) {
|
|
2390
|
-
this.defaults = { ...HARD_DEFAULTS, ...options.defaults };
|
|
2391
|
-
this.dialect = options.dialect ?? "memory";
|
|
2392
|
-
this.connectionString = options.connectionString;
|
|
2393
|
-
this._storage = this.buildMemoryOrEagerAdapter();
|
|
2394
|
-
if (this.dialect === "memory") {
|
|
2395
|
-
this.initialised = true;
|
|
2396
|
-
}
|
|
2397
|
-
this.emitter = new QueueEventEmitter();
|
|
2398
|
-
}
|
|
2399
|
-
// -------------------------------------------------------------------------
|
|
2400
|
-
// Eager adapter construction (memory only; external adapters built in init)
|
|
2401
|
-
// -------------------------------------------------------------------------
|
|
2402
|
-
buildMemoryOrEagerAdapter() {
|
|
2403
|
-
if (this.dialect === "memory") {
|
|
2404
|
-
return new InMemoryStorageAdapter();
|
|
2405
|
-
}
|
|
2406
|
-
return new InMemoryStorageAdapter();
|
|
965
|
+
/**
|
|
966
|
+
* Return the total number of jobs in this queue (all statuses combined).
|
|
967
|
+
*/
|
|
968
|
+
async count() {
|
|
969
|
+
return this.storage.countJobs(this.name);
|
|
2407
970
|
}
|
|
2408
|
-
// -------------------------------------------------------------------------
|
|
2409
|
-
// Initialisation
|
|
2410
|
-
// -------------------------------------------------------------------------
|
|
2411
971
|
/**
|
|
2412
|
-
*
|
|
972
|
+
* Gracefully shut down everything tied to this queue.
|
|
2413
973
|
*
|
|
2414
|
-
*
|
|
2415
|
-
*
|
|
2416
|
-
*
|
|
2417
|
-
*
|
|
2418
|
-
* - **memory** — no-op (always safe to call)
|
|
2419
|
-
* - **redis** — connects, sends PING, verifies PONG
|
|
2420
|
-
* - **postgres** — connects, runs `SELECT 1`, auto-creates `qjw_` tables
|
|
2421
|
-
* - **mysql** — connects, runs `SELECT 1`, auto-creates `qjw_` tables
|
|
974
|
+
* In order:
|
|
975
|
+
* 1. Stop all Workers that were created from this queue (waits for
|
|
976
|
+
* in-flight jobs to finish before each worker resolves).
|
|
977
|
+
* 2. Cancel all active cron schedules.
|
|
2422
978
|
*
|
|
2423
979
|
* Idempotent — safe to call multiple times.
|
|
980
|
+
* You do NOT need to call worker.close() separately — this handles it.
|
|
2424
981
|
*/
|
|
2425
|
-
async
|
|
2426
|
-
if (this.
|
|
2427
|
-
|
|
2428
|
-
|
|
982
|
+
async close() {
|
|
983
|
+
if (this._closed) return;
|
|
984
|
+
this._closed = true;
|
|
985
|
+
await Promise.all([...this._workers].map((w) => w.close()));
|
|
986
|
+
this._workers.clear();
|
|
987
|
+
for (const [id] of this._cronHandles) {
|
|
988
|
+
this._cancelCron(id);
|
|
2429
989
|
}
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
/**
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
case "redis": {
|
|
2443
|
-
if (!cs) throw new Error('dialect "redis" requires a connectionString.');
|
|
2444
|
-
const { RedisStorageAdapter: RedisStorageAdapter2 } = await Promise.resolve().then(() => (init_redis_adapter(), redis_adapter_exports));
|
|
2445
|
-
return new RedisStorageAdapter2(cs);
|
|
2446
|
-
}
|
|
2447
|
-
case "postgres": {
|
|
2448
|
-
if (!cs) throw new Error('dialect "postgres" requires a connectionString.');
|
|
2449
|
-
const { PostgreSQLStorageAdapter: PostgreSQLStorageAdapter2 } = await Promise.resolve().then(() => (init_postgres_adapter(), postgres_adapter_exports));
|
|
2450
|
-
return new PostgreSQLStorageAdapter2(cs);
|
|
2451
|
-
}
|
|
2452
|
-
case "mysql": {
|
|
2453
|
-
if (!cs) throw new Error('dialect "mysql" requires a connectionString.');
|
|
2454
|
-
const { MySQLStorageAdapter: MySQLStorageAdapter2 } = await Promise.resolve().then(() => (init_mysql_adapter(), mysql_adapter_exports));
|
|
2455
|
-
return new MySQLStorageAdapter2(cs);
|
|
2456
|
-
}
|
|
2457
|
-
default:
|
|
2458
|
-
throw new Error(`Unknown storage dialect: "${this.dialect}"`);
|
|
990
|
+
this.client._log(`Queue[${this.name}] closed.`);
|
|
991
|
+
}
|
|
992
|
+
// ── Internal — used by Worker only ───────────────────────────────────────
|
|
993
|
+
/**
|
|
994
|
+
* Register a Worker so it is auto-closed when queue.close() is called.
|
|
995
|
+
* @internal
|
|
996
|
+
*/
|
|
997
|
+
_registerWorker(worker) {
|
|
998
|
+
if (this._closed) {
|
|
999
|
+
throw new Error(
|
|
1000
|
+
`[queue-jobs-worker] Cannot create a Worker for Queue "${this.name}" because the queue has already been closed.`
|
|
1001
|
+
);
|
|
2459
1002
|
}
|
|
1003
|
+
this._workers.add(worker);
|
|
2460
1004
|
}
|
|
2461
|
-
// -------------------------------------------------------------------------
|
|
2462
|
-
// Queue management
|
|
2463
|
-
// -------------------------------------------------------------------------
|
|
2464
1005
|
/**
|
|
2465
|
-
*
|
|
2466
|
-
*
|
|
2467
|
-
|
|
2468
|
-
|
|
1006
|
+
* Remove a Worker from the auto-close registry (called by worker.close()).
|
|
1007
|
+
* @internal
|
|
1008
|
+
*/
|
|
1009
|
+
_unregisterWorker(worker) {
|
|
1010
|
+
this._workers.delete(worker);
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Atomically claim the next eligible job.
|
|
1014
|
+
* Eligibility: `status IN ('waiting','retrying') AND runAt <= now`.
|
|
1015
|
+
* Returns `undefined` when nothing is ready.
|
|
1016
|
+
* @internal
|
|
1017
|
+
*/
|
|
1018
|
+
async _nextJob() {
|
|
1019
|
+
return this.storage.getNextJob(this.name);
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Persist a partial status update to a job.
|
|
1023
|
+
* @internal
|
|
2469
1024
|
*/
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
1025
|
+
async _updateJob(jobId, patch) {
|
|
1026
|
+
await this.storage.updateJob(this.name, jobId, patch);
|
|
1027
|
+
}
|
|
1028
|
+
// ── Cron helpers ──────────────────────────────────────────────────────────
|
|
1029
|
+
_scheduleCron(job) {
|
|
1030
|
+
if (!job.cron) return;
|
|
1031
|
+
const handle = new Cron(job.cron, async () => {
|
|
1032
|
+
try {
|
|
1033
|
+
const current = await this.storage.getJob(this.name, job.id);
|
|
1034
|
+
if (!current) {
|
|
1035
|
+
this._cancelCron(job.id);
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
if (current.status === "completed" || current.status === "delayed" || current.status === "failed") {
|
|
1039
|
+
await this.storage.updateJob(this.name, job.id, {
|
|
1040
|
+
status: "waiting",
|
|
1041
|
+
attemptsMade: 0,
|
|
1042
|
+
runAt: Date.now(),
|
|
1043
|
+
error: null,
|
|
1044
|
+
stacktrace: null,
|
|
1045
|
+
result: null
|
|
1046
|
+
});
|
|
1047
|
+
this.client._log(`Queue[${this.name}] cron tick \u2014 "${job.name}" id=${job.id} re-queued`);
|
|
1048
|
+
}
|
|
1049
|
+
} catch (err) {
|
|
1050
|
+
this.client._log(
|
|
1051
|
+
`Queue[${this.name}] cron tick error \u2014 "${job.name}" id=${job.id}: ` + (err instanceof Error ? err.message : String(err))
|
|
1052
|
+
);
|
|
1053
|
+
console.error(
|
|
1054
|
+
`[queue-jobs-worker] Queue[${this.name}] cron tick error \u2014 "${job.name}" id=${job.id}:`,
|
|
1055
|
+
err
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
1059
|
+
this._cronHandles.set(job.id, handle);
|
|
1060
|
+
}
|
|
1061
|
+
_cancelCron(jobId) {
|
|
1062
|
+
const handle = this._cronHandles.get(jobId);
|
|
1063
|
+
if (handle) {
|
|
1064
|
+
handle.stop();
|
|
1065
|
+
this._cronHandles.delete(jobId);
|
|
2474
1066
|
}
|
|
2475
|
-
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
// src/classes/worker.ts
|
|
1071
|
+
import { EventEmitter } from "events";
|
|
1072
|
+
var Worker = class extends EventEmitter {
|
|
1073
|
+
/** The Queue this worker consumes from. Read-only after construction. */
|
|
1074
|
+
queue;
|
|
1075
|
+
handler;
|
|
1076
|
+
concurrency;
|
|
1077
|
+
pollInterval;
|
|
1078
|
+
running = false;
|
|
1079
|
+
_closed = false;
|
|
1080
|
+
loopTimer = null;
|
|
1081
|
+
activeCount = 0;
|
|
1082
|
+
/**
|
|
1083
|
+
* @param queue - The Queue to consume jobs from.
|
|
1084
|
+
* @param handler - Async function that processes one job at a time.
|
|
1085
|
+
* @param options - `concurrency` (default 1) and `pollInterval` ms (default 500).
|
|
1086
|
+
*/
|
|
1087
|
+
constructor(queue, handler, options = {}) {
|
|
1088
|
+
super();
|
|
1089
|
+
this.queue = queue;
|
|
1090
|
+
this.handler = handler;
|
|
1091
|
+
this.concurrency = Math.max(1, options.concurrency ?? 1);
|
|
1092
|
+
this.pollInterval = Math.max(0, options.pollInterval ?? 500);
|
|
1093
|
+
this.queue._registerWorker(this);
|
|
1094
|
+
}
|
|
1095
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
1096
|
+
/**
|
|
1097
|
+
* Start the polling loop.
|
|
1098
|
+
* Calling start() on an already-running worker is a no-op.
|
|
1099
|
+
* Calling start() after close() throws — create a new Worker instead.
|
|
1100
|
+
*/
|
|
1101
|
+
start() {
|
|
1102
|
+
if (this._closed) {
|
|
2476
1103
|
throw new Error(
|
|
2477
|
-
`
|
|
1104
|
+
`[queue-jobs-worker] Worker[${this.queue.name}] has been closed and cannot be restarted. Create a new Worker instance instead.`
|
|
2478
1105
|
);
|
|
2479
1106
|
}
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
() => this.initialised
|
|
2487
|
-
);
|
|
2488
|
-
this.queues.set(name, queue);
|
|
2489
|
-
return queue;
|
|
2490
|
-
}
|
|
2491
|
-
/** Returns the queue with the given name, or `undefined`. */
|
|
2492
|
-
getQueue(name) {
|
|
2493
|
-
return this.queues.get(name);
|
|
1107
|
+
if (this.running) return this;
|
|
1108
|
+
this.running = true;
|
|
1109
|
+
this.emit("started");
|
|
1110
|
+
this._log(`Worker[${this.queue.name}] started (concurrency=${this.concurrency}).`);
|
|
1111
|
+
this.scheduleLoop();
|
|
1112
|
+
return this;
|
|
2494
1113
|
}
|
|
2495
|
-
/**
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
1114
|
+
/**
|
|
1115
|
+
* Gracefully stop the worker.
|
|
1116
|
+
*
|
|
1117
|
+
* - No new jobs are picked up after this call.
|
|
1118
|
+
* - Already in-flight jobs are allowed to finish.
|
|
1119
|
+
* - Resolves once the poll loop has stopped and active count reaches 0.
|
|
1120
|
+
*
|
|
1121
|
+
* Safe to call even if start() was never called.
|
|
1122
|
+
*/
|
|
1123
|
+
async close() {
|
|
1124
|
+
if (!this.running) {
|
|
1125
|
+
this.queue._unregisterWorker(this);
|
|
1126
|
+
this._closed = true;
|
|
1127
|
+
return;
|
|
2500
1128
|
}
|
|
2501
|
-
|
|
1129
|
+
this.running = false;
|
|
1130
|
+
if (this.loopTimer) {
|
|
1131
|
+
clearTimeout(this.loopTimer);
|
|
1132
|
+
this.loopTimer = null;
|
|
1133
|
+
}
|
|
1134
|
+
await this.drainActive();
|
|
1135
|
+
this.queue._unregisterWorker(this);
|
|
1136
|
+
this._closed = true;
|
|
1137
|
+
this.emit("stopped");
|
|
1138
|
+
this._log(`Worker[${this.queue.name}] stopped.`);
|
|
2502
1139
|
}
|
|
2503
|
-
/**
|
|
2504
|
-
|
|
2505
|
-
return
|
|
1140
|
+
/** `true` while the polling loop is active. */
|
|
1141
|
+
isRunning() {
|
|
1142
|
+
return this.running;
|
|
2506
1143
|
}
|
|
2507
|
-
/** `true` after
|
|
2508
|
-
|
|
2509
|
-
return this.
|
|
1144
|
+
/** `true` after close() has been called. */
|
|
1145
|
+
isClosed() {
|
|
1146
|
+
return this._closed;
|
|
2510
1147
|
}
|
|
2511
|
-
//
|
|
2512
|
-
// Events
|
|
2513
|
-
// -------------------------------------------------------------------------
|
|
1148
|
+
// ── Typed EventEmitter overloads ──────────────────────────────────────────
|
|
2514
1149
|
on(event, listener) {
|
|
2515
|
-
|
|
2516
|
-
return this;
|
|
1150
|
+
return super.on(event, listener);
|
|
2517
1151
|
}
|
|
2518
1152
|
once(event, listener) {
|
|
2519
|
-
|
|
2520
|
-
return this;
|
|
1153
|
+
return super.once(event, listener);
|
|
2521
1154
|
}
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
1155
|
+
/**
|
|
1156
|
+
* FIX: Override emit() to guard against ERR_UNHANDLED_ERROR only when no
|
|
1157
|
+
* real "error" listener is registered. This avoids the blank-listener
|
|
1158
|
+
* anti-pattern that would have swallowed errors in tests / callers that DO
|
|
1159
|
+
* register their own listener (since all listeners are always called).
|
|
1160
|
+
*/
|
|
1161
|
+
emit(event, ...args) {
|
|
1162
|
+
if (event === "error" && this.listenerCount("error") === 0) {
|
|
1163
|
+
return false;
|
|
1164
|
+
}
|
|
1165
|
+
return super.emit(event, ...args);
|
|
1166
|
+
}
|
|
1167
|
+
// ── Poll loop ─────────────────────────────────────────────────────────────
|
|
1168
|
+
scheduleLoop() {
|
|
1169
|
+
if (!this.running) return;
|
|
1170
|
+
this.loopTimer = setTimeout(
|
|
1171
|
+
() => void this.tick().finally(() => this.scheduleLoop()),
|
|
1172
|
+
this.pollInterval
|
|
1173
|
+
);
|
|
2525
1174
|
}
|
|
2526
|
-
// -------------------------------------------------------------------------
|
|
2527
|
-
// Graceful shutdown
|
|
2528
|
-
// -------------------------------------------------------------------------
|
|
2529
1175
|
/**
|
|
2530
|
-
*
|
|
2531
|
-
*
|
|
1176
|
+
* One iteration of the poll loop.
|
|
1177
|
+
* Starts as many jobs as concurrency allows, then returns.
|
|
2532
1178
|
*/
|
|
2533
|
-
async
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
1179
|
+
async tick() {
|
|
1180
|
+
while (this.running && this.activeCount < this.concurrency) {
|
|
1181
|
+
if (!this.client.isInitialized()) break;
|
|
1182
|
+
let job;
|
|
1183
|
+
try {
|
|
1184
|
+
job = await this.queue._nextJob();
|
|
1185
|
+
} catch {
|
|
1186
|
+
break;
|
|
1187
|
+
}
|
|
1188
|
+
if (!this.running) break;
|
|
1189
|
+
if (!job) break;
|
|
1190
|
+
this.activeCount++;
|
|
1191
|
+
void this.processJob(job).finally(() => {
|
|
1192
|
+
this.activeCount = Math.max(0, this.activeCount - 1);
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
// ── Job execution ─────────────────────────────────────────────────────────
|
|
1197
|
+
async processJob(job) {
|
|
1198
|
+
const config = this.client.getConfig();
|
|
1199
|
+
const timeout = job.opts.timeout ?? config.timeout;
|
|
1200
|
+
const fetched = await this.queue.get(job.id).catch(() => void 0);
|
|
1201
|
+
const active = fetched ?? { ...job, status: "active" };
|
|
1202
|
+
this.emit("active", active);
|
|
1203
|
+
this._log(
|
|
1204
|
+
`Worker[${this.queue.name}] processing "${job.name}" id=${job.id} attempt=${active.attemptsMade + 1}/${active.attempts === 0 ? "\u221E" : active.attempts}`
|
|
1205
|
+
);
|
|
1206
|
+
let result;
|
|
1207
|
+
try {
|
|
1208
|
+
result = await this.runWithTimeout(active, timeout);
|
|
1209
|
+
} catch (raw) {
|
|
1210
|
+
await this.handleFailure(active, raw);
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
const removeOnComplete = job.opts.removeOnComplete ?? this.queue.options.defaultJobOpts.removeOnComplete ?? false;
|
|
1214
|
+
const finishedAt = Date.now();
|
|
1215
|
+
const completedSnapshot = {
|
|
1216
|
+
...active,
|
|
1217
|
+
status: "completed",
|
|
1218
|
+
result,
|
|
1219
|
+
finishedAt
|
|
1220
|
+
};
|
|
1221
|
+
if (removeOnComplete) {
|
|
1222
|
+
await this.queue.remove(job.id);
|
|
1223
|
+
} else {
|
|
1224
|
+
const encodedResult = result === null || result === void 0 || typeof result === "string" || typeof result === "number" || typeof result === "boolean" ? result : JSON.stringify(result);
|
|
1225
|
+
await this.queue._updateJob(job.id, {
|
|
1226
|
+
status: "completed",
|
|
1227
|
+
result: encodedResult,
|
|
1228
|
+
finishedAt
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
this.emit("completed", completedSnapshot, result);
|
|
1232
|
+
this._log(`Worker[${this.queue.name}] completed "${job.name}" id=${job.id}`);
|
|
1233
|
+
}
|
|
2543
1234
|
/**
|
|
2544
|
-
*
|
|
2545
|
-
*
|
|
2546
|
-
*
|
|
1235
|
+
* Race the handler against a hard timeout.
|
|
1236
|
+
* FIX: clear the deadline timer when the handler wins the race — without
|
|
1237
|
+
* this, the setTimeout handle keeps the event loop alive and Node.js will
|
|
1238
|
+
* not exit cleanly in tests or short-lived scripts.
|
|
1239
|
+
*/
|
|
1240
|
+
runWithTimeout(job, timeoutMs) {
|
|
1241
|
+
const work = Promise.resolve(this.handler(job));
|
|
1242
|
+
if (timeoutMs <= 0) return work;
|
|
1243
|
+
return new Promise((resolve, reject) => {
|
|
1244
|
+
const timer = setTimeout(
|
|
1245
|
+
() => reject(new Error(`Job timed out after ${timeoutMs} ms`)),
|
|
1246
|
+
timeoutMs
|
|
1247
|
+
);
|
|
1248
|
+
work.then(
|
|
1249
|
+
(val) => {
|
|
1250
|
+
clearTimeout(timer);
|
|
1251
|
+
resolve(val);
|
|
1252
|
+
},
|
|
1253
|
+
(err) => {
|
|
1254
|
+
clearTimeout(timer);
|
|
1255
|
+
reject(err);
|
|
1256
|
+
}
|
|
1257
|
+
);
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
/** Decide between a retry and a permanent failure. */
|
|
1261
|
+
async handleFailure(job, rawErr) {
|
|
1262
|
+
const err = rawErr instanceof Error ? rawErr : new Error(String(rawErr));
|
|
1263
|
+
const fetched = await this.queue.get(job.id).catch(() => void 0);
|
|
1264
|
+
const current = fetched ?? job;
|
|
1265
|
+
const attemptsMade = (fetched?.attemptsMade ?? job.attemptsMade) + 1;
|
|
1266
|
+
const maxAttempts = current.attempts;
|
|
1267
|
+
const canRetry = maxAttempts === 0 || attemptsMade < maxAttempts;
|
|
1268
|
+
this.emit("error", { ...current, attemptsMade }, err);
|
|
1269
|
+
this._log(
|
|
1270
|
+
`Worker[${this.queue.name}] attempt ${attemptsMade}/${maxAttempts === 0 ? "\u221E" : maxAttempts} failed \u2014 "${current.name}" id=${current.id}: ${err.message}`
|
|
1271
|
+
);
|
|
1272
|
+
if (canRetry) {
|
|
1273
|
+
const delay = this.calcBackoff(attemptsMade, current);
|
|
1274
|
+
const retryAt = Date.now() + delay;
|
|
1275
|
+
await this.queue._updateJob(current.id, {
|
|
1276
|
+
status: "retrying",
|
|
1277
|
+
attemptsMade,
|
|
1278
|
+
runAt: retryAt,
|
|
1279
|
+
error: err.message,
|
|
1280
|
+
...err.stack !== void 0 && { stacktrace: err.stack }
|
|
1281
|
+
});
|
|
1282
|
+
this._log(
|
|
1283
|
+
`Worker[${this.queue.name}] retry in ${delay}ms \u2014 attempt ${attemptsMade + 1}/${maxAttempts === 0 ? "\u221E" : maxAttempts}`
|
|
1284
|
+
);
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
const removeOnFail = current.opts.removeOnFail ?? this.queue.options.defaultJobOpts.removeOnFail ?? false;
|
|
1288
|
+
if (removeOnFail) {
|
|
1289
|
+
await this.queue.remove(current.id);
|
|
1290
|
+
} else {
|
|
1291
|
+
await this.queue._updateJob(current.id, {
|
|
1292
|
+
status: "failed",
|
|
1293
|
+
attemptsMade,
|
|
1294
|
+
error: err.message,
|
|
1295
|
+
...err.stack !== void 0 && { stacktrace: err.stack },
|
|
1296
|
+
finishedAt: Date.now()
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
const failed = await this.queue.get(current.id) ?? {
|
|
1300
|
+
...current,
|
|
1301
|
+
status: "failed",
|
|
1302
|
+
attemptsMade
|
|
1303
|
+
};
|
|
1304
|
+
this.emit("failed", failed, err);
|
|
1305
|
+
this._log(`Worker[${this.queue.name}] permanently failed \u2014 "${current.name}" id=${current.id}`);
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Calculate the delay before the next retry.
|
|
2547
1309
|
*
|
|
2548
|
-
*
|
|
2549
|
-
*
|
|
2550
|
-
*
|
|
1310
|
+
* fixed → base
|
|
1311
|
+
* linear → base × attemptsMade
|
|
1312
|
+
* exponential → base × 2^(attemptsMade − 1) capped at 30 min
|
|
2551
1313
|
*/
|
|
2552
|
-
|
|
2553
|
-
const
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
1314
|
+
calcBackoff(attemptsMade, job) {
|
|
1315
|
+
const cfg = this.client.getConfig();
|
|
1316
|
+
const base = cfg.retryDelay;
|
|
1317
|
+
const strategy = cfg.backoff;
|
|
1318
|
+
const MAX = 30 * 60 * 1e3;
|
|
1319
|
+
let delay;
|
|
1320
|
+
switch (strategy) {
|
|
1321
|
+
case "fixed":
|
|
1322
|
+
delay = base;
|
|
1323
|
+
break;
|
|
1324
|
+
case "linear":
|
|
1325
|
+
delay = base * attemptsMade;
|
|
1326
|
+
break;
|
|
1327
|
+
case "exponential":
|
|
1328
|
+
delay = base * Math.pow(2, attemptsMade - 1);
|
|
1329
|
+
break;
|
|
1330
|
+
}
|
|
1331
|
+
const perJobBase = job.opts.retryDelay;
|
|
1332
|
+
if (perJobBase !== void 0) {
|
|
1333
|
+
switch (strategy) {
|
|
1334
|
+
case "fixed":
|
|
1335
|
+
delay = perJobBase;
|
|
1336
|
+
break;
|
|
1337
|
+
case "linear":
|
|
1338
|
+
delay = perJobBase * attemptsMade;
|
|
1339
|
+
break;
|
|
1340
|
+
case "exponential":
|
|
1341
|
+
delay = perJobBase * Math.pow(2, attemptsMade - 1);
|
|
1342
|
+
break;
|
|
1343
|
+
default:
|
|
1344
|
+
delay = perJobBase;
|
|
1345
|
+
break;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
return Math.min(delay, MAX);
|
|
1349
|
+
}
|
|
1350
|
+
/** Poll every 50 ms until no jobs are actively processing, with a 30 s safety timeout. */
|
|
1351
|
+
drainActive() {
|
|
1352
|
+
return new Promise((resolve, reject) => {
|
|
1353
|
+
const DRAIN_TIMEOUT_MS = 3e4;
|
|
1354
|
+
const deadline = setTimeout(() => {
|
|
1355
|
+
reject(
|
|
1356
|
+
new Error(
|
|
1357
|
+
`[queue-jobs-worker] Worker[${this.queue.name}] drain timed out after ${DRAIN_TIMEOUT_MS}ms \u2014 ${this.activeCount} job(s) still active. This may indicate an unhandled error in a job handler.`
|
|
1358
|
+
)
|
|
1359
|
+
);
|
|
1360
|
+
}, DRAIN_TIMEOUT_MS);
|
|
1361
|
+
const poll = () => {
|
|
1362
|
+
if (this.activeCount === 0) {
|
|
1363
|
+
clearTimeout(deadline);
|
|
1364
|
+
resolve();
|
|
1365
|
+
} else {
|
|
1366
|
+
setTimeout(poll, 50);
|
|
1367
|
+
}
|
|
1368
|
+
};
|
|
1369
|
+
poll();
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
// ── Convenience accessors ─────────────────────────────────────────────────
|
|
1373
|
+
get client() {
|
|
1374
|
+
return this.queue.client;
|
|
1375
|
+
}
|
|
1376
|
+
_log(msg) {
|
|
1377
|
+
this.client._log(msg);
|
|
2558
1378
|
}
|
|
2559
1379
|
};
|
|
2560
1380
|
|
|
2561
|
-
// src/
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
1381
|
+
// src/version.ts
|
|
1382
|
+
var VERSION = "2.0.0";
|
|
1383
|
+
export {
|
|
1384
|
+
Queue,
|
|
1385
|
+
QueueClient,
|
|
1386
|
+
VERSION,
|
|
1387
|
+
Worker
|
|
1388
|
+
};
|