queue-jobs-worker 1.0.1 → 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 +75 -69
- package/README.md +34 -120
- package/dist/core/worker.d.ts.map +1 -1
- package/dist/index.cjs +111 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +111 -47
- package/dist/index.js.map +1 -1
- package/dist/storage/in-memory.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;
|
|
@@ -302,7 +345,18 @@ return job_id
|
|
|
302
345
|
});
|
|
303
346
|
}
|
|
304
347
|
// -------------------------------------------------------------------------
|
|
305
|
-
// 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.
|
|
306
360
|
// -------------------------------------------------------------------------
|
|
307
361
|
async recoverStalledJobs(queue, now) {
|
|
308
362
|
const nowMs = new Date(now).getTime();
|
|
@@ -310,32 +364,29 @@ return job_id
|
|
|
310
364
|
if (activeIds.length === 0) return [];
|
|
311
365
|
const fetchPipeline = this.client.multi();
|
|
312
366
|
for (const jobId of activeIds) {
|
|
313
|
-
fetchPipeline.hmGet(k.job(jobId), ["lockExpiresAt", "priority"]);
|
|
367
|
+
fetchPipeline.hmGet(k.job(jobId), ["lockExpiresAt", "lockId", "priority"]);
|
|
314
368
|
}
|
|
315
369
|
const fetchResults = await fetchPipeline.exec();
|
|
316
370
|
const recovered = [];
|
|
317
|
-
const
|
|
371
|
+
const evalPromises = [];
|
|
318
372
|
for (let i = 0; i < activeIds.length; i++) {
|
|
319
373
|
const jobId = activeIds[i];
|
|
320
374
|
const fields = fetchResults[i];
|
|
321
375
|
if (!fields) continue;
|
|
322
|
-
const [lockExpiresAt, priorityStr] = fields;
|
|
376
|
+
const [lockExpiresAt, lockId, priorityStr] = fields;
|
|
323
377
|
if (!lockExpiresAt) continue;
|
|
324
378
|
if (new Date(lockExpiresAt).getTime() > nowMs) continue;
|
|
325
379
|
const priority = Number(priorityStr ?? "0");
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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);
|
|
331
386
|
});
|
|
332
|
-
|
|
333
|
-
recoverPipeline.zAdd(k.waiting(queue), { score: -priority, value: jobId });
|
|
334
|
-
recovered.push(jobId);
|
|
335
|
-
}
|
|
336
|
-
if (recovered.length > 0) {
|
|
337
|
-
await recoverPipeline.exec();
|
|
387
|
+
evalPromises.push(p);
|
|
338
388
|
}
|
|
389
|
+
await Promise.all(evalPromises);
|
|
339
390
|
return recovered;
|
|
340
391
|
}
|
|
341
392
|
// -------------------------------------------------------------------------
|
|
@@ -1461,6 +1512,13 @@ var Worker = class {
|
|
|
1461
1512
|
// -------------------------------------------------------------------------
|
|
1462
1513
|
async claimNext() {
|
|
1463
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;
|
|
1464
1522
|
if (this.config.rateLimit) {
|
|
1465
1523
|
const allowed = await this.storage.checkAndIncrementRateLimit(
|
|
1466
1524
|
this.queueName,
|
|
@@ -1468,15 +1526,11 @@ var Worker = class {
|
|
|
1468
1526
|
this.config.rateLimit.duration,
|
|
1469
1527
|
now
|
|
1470
1528
|
);
|
|
1471
|
-
if (!allowed)
|
|
1529
|
+
if (!allowed) {
|
|
1530
|
+
await this.storage.releaseLock(raw.id);
|
|
1531
|
+
return false;
|
|
1532
|
+
}
|
|
1472
1533
|
}
|
|
1473
|
-
const raw = await this.storage.claim({
|
|
1474
|
-
queue: this.queueName,
|
|
1475
|
-
lockId: this.id,
|
|
1476
|
-
lockDuration: this.config.lockDuration,
|
|
1477
|
-
now
|
|
1478
|
-
});
|
|
1479
|
-
if (!raw) return false;
|
|
1480
1534
|
const job = new Job(raw);
|
|
1481
1535
|
this.activeCount += 1;
|
|
1482
1536
|
this.activeJobIds.add(job.id);
|
|
@@ -1526,25 +1580,25 @@ var Worker = class {
|
|
|
1526
1580
|
// -------------------------------------------------------------------------
|
|
1527
1581
|
async enqueueCronNext(job) {
|
|
1528
1582
|
try {
|
|
1529
|
-
let
|
|
1583
|
+
let nextDate;
|
|
1530
1584
|
try {
|
|
1531
|
-
const
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
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)}`
|
|
1535
1590
|
);
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
const cronInstance = new CronClass(job.cron);
|
|
1539
|
-
const nextDate = cronInstance.nextRun();
|
|
1540
|
-
nextMs = nextDate ? nextDate.getTime() : Date.now() + 6e4;
|
|
1541
|
-
} else {
|
|
1542
|
-
nextMs = Date.now() + 6e4;
|
|
1543
|
-
}
|
|
1544
|
-
} catch {
|
|
1545
|
-
nextMs = Date.now() + 6e4;
|
|
1591
|
+
this.emitter.emit("worker:error", this.id, error);
|
|
1592
|
+
return;
|
|
1546
1593
|
}
|
|
1547
|
-
|
|
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();
|
|
1548
1602
|
await this.storage.enqueue({
|
|
1549
1603
|
id: generateJobId(),
|
|
1550
1604
|
queue: this.queueName,
|
|
@@ -1912,6 +1966,12 @@ var InMemoryStorageAdapter = class {
|
|
|
1912
1966
|
}
|
|
1913
1967
|
// -------------------------------------------------------------------------
|
|
1914
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.
|
|
1915
1975
|
// -------------------------------------------------------------------------
|
|
1916
1976
|
async recoverStalledJobs(queue, now) {
|
|
1917
1977
|
const nowMs = new Date(now).getTime();
|
|
@@ -1921,13 +1981,17 @@ var InMemoryStorageAdapter = class {
|
|
|
1921
1981
|
if (job.status !== "active") continue;
|
|
1922
1982
|
if (job.lockExpiresAt === null) continue;
|
|
1923
1983
|
const lockExpiry = new Date(job.lockExpiresAt).getTime();
|
|
1924
|
-
if (lockExpiry
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
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);
|
|
1931
1995
|
}
|
|
1932
1996
|
return recovered;
|
|
1933
1997
|
}
|