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.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'crypto';
|
|
2
|
+
import { Cron } from 'croner';
|
|
2
3
|
import { EventEmitter } from 'events';
|
|
3
4
|
|
|
4
5
|
var __defProp = Object.defineProperty;
|
|
@@ -74,7 +75,7 @@ function hashToJob(h) {
|
|
|
74
75
|
if (h["cron"]) job.cron = h["cron"];
|
|
75
76
|
return job;
|
|
76
77
|
}
|
|
77
|
-
var PREFIX, k, CLAIM_LUA, RedisStorageAdapter;
|
|
78
|
+
var PREFIX, k, CLAIM_LUA, RECOVER_STALLED_LUA, RedisStorageAdapter;
|
|
78
79
|
var init_redis_adapter = __esm({
|
|
79
80
|
"src/storage/redis.adapter.ts"() {
|
|
80
81
|
PREFIX = "qjw:";
|
|
@@ -116,6 +117,48 @@ redis.call('HSET', prefix .. 'job:' .. job_id,
|
|
|
116
117
|
'updatedAt', ARGV[2]
|
|
117
118
|
)
|
|
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
|
|
119
162
|
`;
|
|
120
163
|
RedisStorageAdapter = class {
|
|
121
164
|
client;
|
|
@@ -300,7 +343,18 @@ return job_id
|
|
|
300
343
|
});
|
|
301
344
|
}
|
|
302
345
|
// -------------------------------------------------------------------------
|
|
303
|
-
// Recover stalled jobs (
|
|
346
|
+
// Recover stalled jobs (atomic compare-and-swap per job via Lua)
|
|
347
|
+
//
|
|
348
|
+
// Previous implementation: two-phase read pipeline → write pipeline.
|
|
349
|
+
// Race condition: a worker could complete or renew its lock between the two
|
|
350
|
+
// phases, causing recovery to overwrite a legitimately-active job.
|
|
351
|
+
//
|
|
352
|
+
// Fix (issue #6): for each candidate job the RECOVER_STALLED_LUA script
|
|
353
|
+
// re-reads lockExpiresAt, lockId, and status atomically and only applies
|
|
354
|
+
// the recovery if all three still match what was observed in the read phase
|
|
355
|
+
// (compare-and-swap). If the worker renewed or completed the job in the
|
|
356
|
+
// window between the read and the Lua call, the CAS mismatch causes the
|
|
357
|
+
// script to return 0 and the job is left untouched.
|
|
304
358
|
// -------------------------------------------------------------------------
|
|
305
359
|
async recoverStalledJobs(queue, now) {
|
|
306
360
|
const nowMs = new Date(now).getTime();
|
|
@@ -308,32 +362,29 @@ return job_id
|
|
|
308
362
|
if (activeIds.length === 0) return [];
|
|
309
363
|
const fetchPipeline = this.client.multi();
|
|
310
364
|
for (const jobId of activeIds) {
|
|
311
|
-
fetchPipeline.hmGet(k.job(jobId), ["lockExpiresAt", "priority"]);
|
|
365
|
+
fetchPipeline.hmGet(k.job(jobId), ["lockExpiresAt", "lockId", "priority"]);
|
|
312
366
|
}
|
|
313
367
|
const fetchResults = await fetchPipeline.exec();
|
|
314
368
|
const recovered = [];
|
|
315
|
-
const
|
|
369
|
+
const evalPromises = [];
|
|
316
370
|
for (let i = 0; i < activeIds.length; i++) {
|
|
317
371
|
const jobId = activeIds[i];
|
|
318
372
|
const fields = fetchResults[i];
|
|
319
373
|
if (!fields) continue;
|
|
320
|
-
const [lockExpiresAt, priorityStr] = fields;
|
|
374
|
+
const [lockExpiresAt, lockId, priorityStr] = fields;
|
|
321
375
|
if (!lockExpiresAt) continue;
|
|
322
376
|
if (new Date(lockExpiresAt).getTime() > nowMs) continue;
|
|
323
377
|
const priority = Number(priorityStr ?? "0");
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
378
|
+
const priorityScore = String(-priority);
|
|
379
|
+
const p = this.client.eval(RECOVER_STALLED_LUA, {
|
|
380
|
+
keys: [k.job(jobId), k.active(queue), k.waiting(queue)],
|
|
381
|
+
arguments: [jobId, lockExpiresAt, lockId ?? "", now, priorityScore]
|
|
382
|
+
}).then((result) => {
|
|
383
|
+
if (result === 1) recovered.push(jobId);
|
|
329
384
|
});
|
|
330
|
-
|
|
331
|
-
recoverPipeline.zAdd(k.waiting(queue), { score: -priority, value: jobId });
|
|
332
|
-
recovered.push(jobId);
|
|
333
|
-
}
|
|
334
|
-
if (recovered.length > 0) {
|
|
335
|
-
await recoverPipeline.exec();
|
|
385
|
+
evalPromises.push(p);
|
|
336
386
|
}
|
|
387
|
+
await Promise.all(evalPromises);
|
|
337
388
|
return recovered;
|
|
338
389
|
}
|
|
339
390
|
// -------------------------------------------------------------------------
|
|
@@ -1459,6 +1510,13 @@ var Worker = class {
|
|
|
1459
1510
|
// -------------------------------------------------------------------------
|
|
1460
1511
|
async claimNext() {
|
|
1461
1512
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1513
|
+
const raw = await this.storage.claim({
|
|
1514
|
+
queue: this.queueName,
|
|
1515
|
+
lockId: this.id,
|
|
1516
|
+
lockDuration: this.config.lockDuration,
|
|
1517
|
+
now
|
|
1518
|
+
});
|
|
1519
|
+
if (!raw) return false;
|
|
1462
1520
|
if (this.config.rateLimit) {
|
|
1463
1521
|
const allowed = await this.storage.checkAndIncrementRateLimit(
|
|
1464
1522
|
this.queueName,
|
|
@@ -1466,15 +1524,11 @@ var Worker = class {
|
|
|
1466
1524
|
this.config.rateLimit.duration,
|
|
1467
1525
|
now
|
|
1468
1526
|
);
|
|
1469
|
-
if (!allowed)
|
|
1527
|
+
if (!allowed) {
|
|
1528
|
+
await this.storage.releaseLock(raw.id);
|
|
1529
|
+
return false;
|
|
1530
|
+
}
|
|
1470
1531
|
}
|
|
1471
|
-
const raw = await this.storage.claim({
|
|
1472
|
-
queue: this.queueName,
|
|
1473
|
-
lockId: this.id,
|
|
1474
|
-
lockDuration: this.config.lockDuration,
|
|
1475
|
-
now
|
|
1476
|
-
});
|
|
1477
|
-
if (!raw) return false;
|
|
1478
1532
|
const job = new Job(raw);
|
|
1479
1533
|
this.activeCount += 1;
|
|
1480
1534
|
this.activeJobIds.add(job.id);
|
|
@@ -1524,25 +1578,25 @@ var Worker = class {
|
|
|
1524
1578
|
// -------------------------------------------------------------------------
|
|
1525
1579
|
async enqueueCronNext(job) {
|
|
1526
1580
|
try {
|
|
1527
|
-
let
|
|
1581
|
+
let nextDate;
|
|
1528
1582
|
try {
|
|
1529
|
-
const
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1583
|
+
const cronInstance = new Cron(job.cron);
|
|
1584
|
+
nextDate = cronInstance.nextRun();
|
|
1585
|
+
} catch (cronErr) {
|
|
1586
|
+
const error = cronErr instanceof Error ? cronErr : new Error(
|
|
1587
|
+
`croner failed to initialize for expression "${job.cron}": ${String(cronErr)}`
|
|
1533
1588
|
);
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
const cronInstance = new CronClass(job.cron);
|
|
1537
|
-
const nextDate = cronInstance.nextRun();
|
|
1538
|
-
nextMs = nextDate ? nextDate.getTime() : Date.now() + 6e4;
|
|
1539
|
-
} else {
|
|
1540
|
-
nextMs = Date.now() + 6e4;
|
|
1541
|
-
}
|
|
1542
|
-
} catch {
|
|
1543
|
-
nextMs = Date.now() + 6e4;
|
|
1589
|
+
this.emitter.emit("worker:error", this.id, error);
|
|
1590
|
+
return;
|
|
1544
1591
|
}
|
|
1545
|
-
|
|
1592
|
+
if (nextDate === null) {
|
|
1593
|
+
const error = new Error(
|
|
1594
|
+
`Cron expression "${job.cron}" has no future occurrences \u2014 job "${job.id}" will not be re-enqueued`
|
|
1595
|
+
);
|
|
1596
|
+
this.emitter.emit("worker:error", this.id, error);
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
const runAt = nextDate.toISOString();
|
|
1546
1600
|
await this.storage.enqueue({
|
|
1547
1601
|
id: generateJobId(),
|
|
1548
1602
|
queue: this.queueName,
|
|
@@ -1910,6 +1964,12 @@ var InMemoryStorageAdapter = class {
|
|
|
1910
1964
|
}
|
|
1911
1965
|
// -------------------------------------------------------------------------
|
|
1912
1966
|
// Recover stalled jobs
|
|
1967
|
+
//
|
|
1968
|
+
// Fix (issue #6): snapshot lockId and lockExpiresAt before the eligibility
|
|
1969
|
+
// check, then re-validate both values at write time. In a single-process
|
|
1970
|
+
// scenario all operations are synchronous within one event-loop tick, so
|
|
1971
|
+
// the race is theoretical — but the guard makes the adapter consistent with
|
|
1972
|
+
// the Redis CAS semantics and protects against any future async paths.
|
|
1913
1973
|
// -------------------------------------------------------------------------
|
|
1914
1974
|
async recoverStalledJobs(queue, now) {
|
|
1915
1975
|
const nowMs = new Date(now).getTime();
|
|
@@ -1919,13 +1979,17 @@ var InMemoryStorageAdapter = class {
|
|
|
1919
1979
|
if (job.status !== "active") continue;
|
|
1920
1980
|
if (job.lockExpiresAt === null) continue;
|
|
1921
1981
|
const lockExpiry = new Date(job.lockExpiresAt).getTime();
|
|
1922
|
-
if (lockExpiry
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1982
|
+
if (lockExpiry > nowMs) continue;
|
|
1983
|
+
const snapshotLockId = job.lockId;
|
|
1984
|
+
const snapshotLockExpiresAt = job.lockExpiresAt;
|
|
1985
|
+
if (job.status !== "active") continue;
|
|
1986
|
+
if (job.lockId !== snapshotLockId) continue;
|
|
1987
|
+
if (job.lockExpiresAt !== snapshotLockExpiresAt) continue;
|
|
1988
|
+
job.status = "waiting";
|
|
1989
|
+
job.lockId = null;
|
|
1990
|
+
job.lockExpiresAt = null;
|
|
1991
|
+
job.updatedAt = now;
|
|
1992
|
+
recovered.push(job.id);
|
|
1929
1993
|
}
|
|
1930
1994
|
return recovered;
|
|
1931
1995
|
}
|