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