queue-jobs-worker 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +128 -76
- package/README.md +38 -118
- package/dist/core/worker.d.ts.map +1 -1
- package/dist/index.cjs +119 -53
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +119 -53
- package/dist/index.js.map +1 -1
- package/dist/storage/in-memory.adapter.d.ts.map +1 -1
- package/dist/storage/mysql.adapter.d.ts.map +1 -1
- package/dist/storage/postgres.adapter.d.ts.map +1 -1
- package/dist/storage/redis.adapter.d.ts.map +1 -1
- package/package.json +20 -8
package/dist/index.cjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var crypto = require('crypto');
|
|
4
|
+
var croner = require('croner');
|
|
4
5
|
var events = require('events');
|
|
5
6
|
|
|
6
7
|
var __defProp = Object.defineProperty;
|
|
@@ -76,7 +77,7 @@ function hashToJob(h) {
|
|
|
76
77
|
if (h["cron"]) job.cron = h["cron"];
|
|
77
78
|
return job;
|
|
78
79
|
}
|
|
79
|
-
var PREFIX, k, CLAIM_LUA; exports.RedisStorageAdapter = void 0;
|
|
80
|
+
var PREFIX, k, CLAIM_LUA, RECOVER_STALLED_LUA; exports.RedisStorageAdapter = void 0;
|
|
80
81
|
var init_redis_adapter = __esm({
|
|
81
82
|
"src/storage/redis.adapter.ts"() {
|
|
82
83
|
PREFIX = "qjw:";
|
|
@@ -118,6 +119,48 @@ redis.call('HSET', prefix .. 'job:' .. job_id,
|
|
|
118
119
|
'updatedAt', ARGV[2]
|
|
119
120
|
)
|
|
120
121
|
return job_id
|
|
122
|
+
`;
|
|
123
|
+
RECOVER_STALLED_LUA = `
|
|
124
|
+
local job_key = KEYS[1]
|
|
125
|
+
local active_set = KEYS[2]
|
|
126
|
+
local waiting_zset = KEYS[3]
|
|
127
|
+
|
|
128
|
+
local job_id = ARGV[1]
|
|
129
|
+
local expected_exp = ARGV[2]
|
|
130
|
+
local expected_lock = ARGV[3]
|
|
131
|
+
local recovery_ts = ARGV[4]
|
|
132
|
+
local priority_score = tonumber(ARGV[5])
|
|
133
|
+
|
|
134
|
+
-- Re-read the three guard fields in one atomic HMGET.
|
|
135
|
+
local fields = redis.call('HMGET', job_key, 'lockExpiresAt', 'lockId', 'status')
|
|
136
|
+
local current_exp = fields[1] or ''
|
|
137
|
+
local current_lock = fields[2] or ''
|
|
138
|
+
local current_status = fields[3] or ''
|
|
139
|
+
|
|
140
|
+
-- Guard 1: job must still be active.
|
|
141
|
+
-- If the worker already completed, failed, or moved to DLQ, skip.
|
|
142
|
+
if current_status ~= 'active' then return 0 end
|
|
143
|
+
|
|
144
|
+
-- Guard 2: lockId must not have changed.
|
|
145
|
+
-- A different worker may have claimed the job after the original lock expired
|
|
146
|
+
-- and before this script runs.
|
|
147
|
+
if current_lock ~= expected_lock then return 0 end
|
|
148
|
+
|
|
149
|
+
-- Guard 3: lockExpiresAt must be exactly what the caller observed.
|
|
150
|
+
-- If the worker renewed its lock the timestamp will be later than observed;
|
|
151
|
+
-- the CAS mismatch catches that without needing ISO\u2192epoch conversion in Lua.
|
|
152
|
+
if current_exp ~= expected_exp then return 0 end
|
|
153
|
+
|
|
154
|
+
-- All guards passed \u2014 recover atomically.
|
|
155
|
+
redis.call('HSET', job_key,
|
|
156
|
+
'status', 'waiting',
|
|
157
|
+
'lockId', '',
|
|
158
|
+
'lockExpiresAt', '',
|
|
159
|
+
'updatedAt', recovery_ts
|
|
160
|
+
)
|
|
161
|
+
redis.call('SREM', active_set, job_id)
|
|
162
|
+
redis.call('ZADD', waiting_zset, priority_score, job_id)
|
|
163
|
+
return 1
|
|
121
164
|
`;
|
|
122
165
|
exports.RedisStorageAdapter = class {
|
|
123
166
|
client;
|
|
@@ -294,14 +337,26 @@ return job_id
|
|
|
294
337
|
// Release lock
|
|
295
338
|
// -------------------------------------------------------------------------
|
|
296
339
|
async releaseLock(jobId) {
|
|
340
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
297
341
|
await this.client.hSet(k.job(jobId), {
|
|
298
342
|
lockId: "",
|
|
299
|
-
lockExpiresAt:
|
|
300
|
-
updatedAt:
|
|
343
|
+
lockExpiresAt: now,
|
|
344
|
+
updatedAt: now
|
|
301
345
|
});
|
|
302
346
|
}
|
|
303
347
|
// -------------------------------------------------------------------------
|
|
304
|
-
// Recover stalled jobs (
|
|
348
|
+
// Recover stalled jobs (atomic compare-and-swap per job via Lua)
|
|
349
|
+
//
|
|
350
|
+
// Previous implementation: two-phase read pipeline → write pipeline.
|
|
351
|
+
// Race condition: a worker could complete or renew its lock between the two
|
|
352
|
+
// phases, causing recovery to overwrite a legitimately-active job.
|
|
353
|
+
//
|
|
354
|
+
// Fix (issue #6): for each candidate job the RECOVER_STALLED_LUA script
|
|
355
|
+
// re-reads lockExpiresAt, lockId, and status atomically and only applies
|
|
356
|
+
// the recovery if all three still match what was observed in the read phase
|
|
357
|
+
// (compare-and-swap). If the worker renewed or completed the job in the
|
|
358
|
+
// window between the read and the Lua call, the CAS mismatch causes the
|
|
359
|
+
// script to return 0 and the job is left untouched.
|
|
305
360
|
// -------------------------------------------------------------------------
|
|
306
361
|
async recoverStalledJobs(queue, now) {
|
|
307
362
|
const nowMs = new Date(now).getTime();
|
|
@@ -309,32 +364,29 @@ return job_id
|
|
|
309
364
|
if (activeIds.length === 0) return [];
|
|
310
365
|
const fetchPipeline = this.client.multi();
|
|
311
366
|
for (const jobId of activeIds) {
|
|
312
|
-
fetchPipeline.hmGet(k.job(jobId), ["lockExpiresAt", "priority"]);
|
|
367
|
+
fetchPipeline.hmGet(k.job(jobId), ["lockExpiresAt", "lockId", "priority"]);
|
|
313
368
|
}
|
|
314
369
|
const fetchResults = await fetchPipeline.exec();
|
|
315
370
|
const recovered = [];
|
|
316
|
-
const
|
|
371
|
+
const evalPromises = [];
|
|
317
372
|
for (let i = 0; i < activeIds.length; i++) {
|
|
318
373
|
const jobId = activeIds[i];
|
|
319
374
|
const fields = fetchResults[i];
|
|
320
375
|
if (!fields) continue;
|
|
321
|
-
const [lockExpiresAt, priorityStr] = fields;
|
|
376
|
+
const [lockExpiresAt, lockId, priorityStr] = fields;
|
|
322
377
|
if (!lockExpiresAt) continue;
|
|
323
378
|
if (new Date(lockExpiresAt).getTime() > nowMs) continue;
|
|
324
379
|
const priority = Number(priorityStr ?? "0");
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
380
|
+
const priorityScore = String(-priority);
|
|
381
|
+
const p = this.client.eval(RECOVER_STALLED_LUA, {
|
|
382
|
+
keys: [k.job(jobId), k.active(queue), k.waiting(queue)],
|
|
383
|
+
arguments: [jobId, lockExpiresAt, lockId ?? "", now, priorityScore]
|
|
384
|
+
}).then((result) => {
|
|
385
|
+
if (result === 1) recovered.push(jobId);
|
|
330
386
|
});
|
|
331
|
-
|
|
332
|
-
recoverPipeline.zAdd(k.waiting(queue), { score: -priority, value: jobId });
|
|
333
|
-
recovered.push(jobId);
|
|
334
|
-
}
|
|
335
|
-
if (recovered.length > 0) {
|
|
336
|
-
await recoverPipeline.exec();
|
|
387
|
+
evalPromises.push(p);
|
|
337
388
|
}
|
|
389
|
+
await Promise.all(evalPromises);
|
|
338
390
|
return recovered;
|
|
339
391
|
}
|
|
340
392
|
// -------------------------------------------------------------------------
|
|
@@ -678,7 +730,7 @@ CREATE TABLE IF NOT EXISTS qjw_rate_limits (
|
|
|
678
730
|
async releaseLock(jobId) {
|
|
679
731
|
await this.pool.query(
|
|
680
732
|
`UPDATE qjw_jobs
|
|
681
|
-
SET lock_id = NULL, lock_expires_at =
|
|
733
|
+
SET lock_id = NULL, lock_expires_at = NOW(), updated_at = NOW()
|
|
682
734
|
WHERE id = $1`,
|
|
683
735
|
[jobId]
|
|
684
736
|
);
|
|
@@ -1029,7 +1081,7 @@ CREATE TABLE IF NOT EXISTS qjw_rate_limits (
|
|
|
1029
1081
|
async releaseLock(jobId) {
|
|
1030
1082
|
await this.pool.query(
|
|
1031
1083
|
`UPDATE qjw_jobs
|
|
1032
|
-
SET lock_id = NULL, lock_expires_at =
|
|
1084
|
+
SET lock_id = NULL, lock_expires_at = NOW(3), updated_at = NOW(3)
|
|
1033
1085
|
WHERE id = ?`,
|
|
1034
1086
|
[jobId]
|
|
1035
1087
|
);
|
|
@@ -1460,6 +1512,13 @@ var Worker = class {
|
|
|
1460
1512
|
// -------------------------------------------------------------------------
|
|
1461
1513
|
async claimNext() {
|
|
1462
1514
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1515
|
+
const raw = await this.storage.claim({
|
|
1516
|
+
queue: this.queueName,
|
|
1517
|
+
lockId: this.id,
|
|
1518
|
+
lockDuration: this.config.lockDuration,
|
|
1519
|
+
now
|
|
1520
|
+
});
|
|
1521
|
+
if (!raw) return false;
|
|
1463
1522
|
if (this.config.rateLimit) {
|
|
1464
1523
|
const allowed = await this.storage.checkAndIncrementRateLimit(
|
|
1465
1524
|
this.queueName,
|
|
@@ -1467,15 +1526,11 @@ var Worker = class {
|
|
|
1467
1526
|
this.config.rateLimit.duration,
|
|
1468
1527
|
now
|
|
1469
1528
|
);
|
|
1470
|
-
if (!allowed)
|
|
1529
|
+
if (!allowed) {
|
|
1530
|
+
await this.storage.releaseLock(raw.id);
|
|
1531
|
+
return false;
|
|
1532
|
+
}
|
|
1471
1533
|
}
|
|
1472
|
-
const raw = await this.storage.claim({
|
|
1473
|
-
queue: this.queueName,
|
|
1474
|
-
lockId: this.id,
|
|
1475
|
-
lockDuration: this.config.lockDuration,
|
|
1476
|
-
now
|
|
1477
|
-
});
|
|
1478
|
-
if (!raw) return false;
|
|
1479
1534
|
const job = new Job(raw);
|
|
1480
1535
|
this.activeCount += 1;
|
|
1481
1536
|
this.activeJobIds.add(job.id);
|
|
@@ -1525,25 +1580,25 @@ var Worker = class {
|
|
|
1525
1580
|
// -------------------------------------------------------------------------
|
|
1526
1581
|
async enqueueCronNext(job) {
|
|
1527
1582
|
try {
|
|
1528
|
-
let
|
|
1583
|
+
let nextDate;
|
|
1529
1584
|
try {
|
|
1530
|
-
const
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1585
|
+
const cronInstance = new croner.Cron(job.cron);
|
|
1586
|
+
nextDate = cronInstance.nextRun();
|
|
1587
|
+
} catch (cronErr) {
|
|
1588
|
+
const error = cronErr instanceof Error ? cronErr : new Error(
|
|
1589
|
+
`croner failed to initialize for expression "${job.cron}": ${String(cronErr)}`
|
|
1534
1590
|
);
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
const cronInstance = new CronClass(job.cron);
|
|
1538
|
-
const nextDate = cronInstance.nextRun();
|
|
1539
|
-
nextMs = nextDate ? nextDate.getTime() : Date.now() + 6e4;
|
|
1540
|
-
} else {
|
|
1541
|
-
nextMs = Date.now() + 6e4;
|
|
1542
|
-
}
|
|
1543
|
-
} catch {
|
|
1544
|
-
nextMs = Date.now() + 6e4;
|
|
1591
|
+
this.emitter.emit("worker:error", this.id, error);
|
|
1592
|
+
return;
|
|
1545
1593
|
}
|
|
1546
|
-
|
|
1594
|
+
if (nextDate === null) {
|
|
1595
|
+
const error = new Error(
|
|
1596
|
+
`Cron expression "${job.cron}" has no future occurrences \u2014 job "${job.id}" will not be re-enqueued`
|
|
1597
|
+
);
|
|
1598
|
+
this.emitter.emit("worker:error", this.id, error);
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
const runAt = nextDate.toISOString();
|
|
1547
1602
|
await this.storage.enqueue({
|
|
1548
1603
|
id: generateJobId(),
|
|
1549
1604
|
queue: this.queueName,
|
|
@@ -1904,12 +1959,19 @@ var InMemoryStorageAdapter = class {
|
|
|
1904
1959
|
async releaseLock(jobId) {
|
|
1905
1960
|
const job = this.jobs.get(jobId);
|
|
1906
1961
|
if (!job) return;
|
|
1962
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1907
1963
|
job.lockId = null;
|
|
1908
|
-
job.lockExpiresAt =
|
|
1909
|
-
job.updatedAt =
|
|
1964
|
+
job.lockExpiresAt = now;
|
|
1965
|
+
job.updatedAt = now;
|
|
1910
1966
|
}
|
|
1911
1967
|
// -------------------------------------------------------------------------
|
|
1912
1968
|
// Recover stalled jobs
|
|
1969
|
+
//
|
|
1970
|
+
// Fix (issue #6): snapshot lockId and lockExpiresAt before the eligibility
|
|
1971
|
+
// check, then re-validate both values at write time. In a single-process
|
|
1972
|
+
// scenario all operations are synchronous within one event-loop tick, so
|
|
1973
|
+
// the race is theoretical — but the guard makes the adapter consistent with
|
|
1974
|
+
// the Redis CAS semantics and protects against any future async paths.
|
|
1913
1975
|
// -------------------------------------------------------------------------
|
|
1914
1976
|
async recoverStalledJobs(queue, now) {
|
|
1915
1977
|
const nowMs = new Date(now).getTime();
|
|
@@ -1919,13 +1981,17 @@ var InMemoryStorageAdapter = class {
|
|
|
1919
1981
|
if (job.status !== "active") continue;
|
|
1920
1982
|
if (job.lockExpiresAt === null) continue;
|
|
1921
1983
|
const lockExpiry = new Date(job.lockExpiresAt).getTime();
|
|
1922
|
-
if (lockExpiry
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1984
|
+
if (lockExpiry > nowMs) continue;
|
|
1985
|
+
const snapshotLockId = job.lockId;
|
|
1986
|
+
const snapshotLockExpiresAt = job.lockExpiresAt;
|
|
1987
|
+
if (job.status !== "active") continue;
|
|
1988
|
+
if (job.lockId !== snapshotLockId) continue;
|
|
1989
|
+
if (job.lockExpiresAt !== snapshotLockExpiresAt) continue;
|
|
1990
|
+
job.status = "waiting";
|
|
1991
|
+
job.lockId = null;
|
|
1992
|
+
job.lockExpiresAt = null;
|
|
1993
|
+
job.updatedAt = now;
|
|
1994
|
+
recovered.push(job.id);
|
|
1929
1995
|
}
|
|
1930
1996
|
return recovered;
|
|
1931
1997
|
}
|