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.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;
|
|
@@ -292,14 +335,26 @@ return job_id
|
|
|
292
335
|
// Release lock
|
|
293
336
|
// -------------------------------------------------------------------------
|
|
294
337
|
async releaseLock(jobId) {
|
|
338
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
295
339
|
await this.client.hSet(k.job(jobId), {
|
|
296
340
|
lockId: "",
|
|
297
|
-
lockExpiresAt:
|
|
298
|
-
updatedAt:
|
|
341
|
+
lockExpiresAt: now,
|
|
342
|
+
updatedAt: now
|
|
299
343
|
});
|
|
300
344
|
}
|
|
301
345
|
// -------------------------------------------------------------------------
|
|
302
|
-
// 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.
|
|
303
358
|
// -------------------------------------------------------------------------
|
|
304
359
|
async recoverStalledJobs(queue, now) {
|
|
305
360
|
const nowMs = new Date(now).getTime();
|
|
@@ -307,32 +362,29 @@ return job_id
|
|
|
307
362
|
if (activeIds.length === 0) return [];
|
|
308
363
|
const fetchPipeline = this.client.multi();
|
|
309
364
|
for (const jobId of activeIds) {
|
|
310
|
-
fetchPipeline.hmGet(k.job(jobId), ["lockExpiresAt", "priority"]);
|
|
365
|
+
fetchPipeline.hmGet(k.job(jobId), ["lockExpiresAt", "lockId", "priority"]);
|
|
311
366
|
}
|
|
312
367
|
const fetchResults = await fetchPipeline.exec();
|
|
313
368
|
const recovered = [];
|
|
314
|
-
const
|
|
369
|
+
const evalPromises = [];
|
|
315
370
|
for (let i = 0; i < activeIds.length; i++) {
|
|
316
371
|
const jobId = activeIds[i];
|
|
317
372
|
const fields = fetchResults[i];
|
|
318
373
|
if (!fields) continue;
|
|
319
|
-
const [lockExpiresAt, priorityStr] = fields;
|
|
374
|
+
const [lockExpiresAt, lockId, priorityStr] = fields;
|
|
320
375
|
if (!lockExpiresAt) continue;
|
|
321
376
|
if (new Date(lockExpiresAt).getTime() > nowMs) continue;
|
|
322
377
|
const priority = Number(priorityStr ?? "0");
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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);
|
|
328
384
|
});
|
|
329
|
-
|
|
330
|
-
recoverPipeline.zAdd(k.waiting(queue), { score: -priority, value: jobId });
|
|
331
|
-
recovered.push(jobId);
|
|
332
|
-
}
|
|
333
|
-
if (recovered.length > 0) {
|
|
334
|
-
await recoverPipeline.exec();
|
|
385
|
+
evalPromises.push(p);
|
|
335
386
|
}
|
|
387
|
+
await Promise.all(evalPromises);
|
|
336
388
|
return recovered;
|
|
337
389
|
}
|
|
338
390
|
// -------------------------------------------------------------------------
|
|
@@ -676,7 +728,7 @@ CREATE TABLE IF NOT EXISTS qjw_rate_limits (
|
|
|
676
728
|
async releaseLock(jobId) {
|
|
677
729
|
await this.pool.query(
|
|
678
730
|
`UPDATE qjw_jobs
|
|
679
|
-
SET lock_id = NULL, lock_expires_at =
|
|
731
|
+
SET lock_id = NULL, lock_expires_at = NOW(), updated_at = NOW()
|
|
680
732
|
WHERE id = $1`,
|
|
681
733
|
[jobId]
|
|
682
734
|
);
|
|
@@ -1027,7 +1079,7 @@ CREATE TABLE IF NOT EXISTS qjw_rate_limits (
|
|
|
1027
1079
|
async releaseLock(jobId) {
|
|
1028
1080
|
await this.pool.query(
|
|
1029
1081
|
`UPDATE qjw_jobs
|
|
1030
|
-
SET lock_id = NULL, lock_expires_at =
|
|
1082
|
+
SET lock_id = NULL, lock_expires_at = NOW(3), updated_at = NOW(3)
|
|
1031
1083
|
WHERE id = ?`,
|
|
1032
1084
|
[jobId]
|
|
1033
1085
|
);
|
|
@@ -1458,6 +1510,13 @@ var Worker = class {
|
|
|
1458
1510
|
// -------------------------------------------------------------------------
|
|
1459
1511
|
async claimNext() {
|
|
1460
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;
|
|
1461
1520
|
if (this.config.rateLimit) {
|
|
1462
1521
|
const allowed = await this.storage.checkAndIncrementRateLimit(
|
|
1463
1522
|
this.queueName,
|
|
@@ -1465,15 +1524,11 @@ var Worker = class {
|
|
|
1465
1524
|
this.config.rateLimit.duration,
|
|
1466
1525
|
now
|
|
1467
1526
|
);
|
|
1468
|
-
if (!allowed)
|
|
1527
|
+
if (!allowed) {
|
|
1528
|
+
await this.storage.releaseLock(raw.id);
|
|
1529
|
+
return false;
|
|
1530
|
+
}
|
|
1469
1531
|
}
|
|
1470
|
-
const raw = await this.storage.claim({
|
|
1471
|
-
queue: this.queueName,
|
|
1472
|
-
lockId: this.id,
|
|
1473
|
-
lockDuration: this.config.lockDuration,
|
|
1474
|
-
now
|
|
1475
|
-
});
|
|
1476
|
-
if (!raw) return false;
|
|
1477
1532
|
const job = new Job(raw);
|
|
1478
1533
|
this.activeCount += 1;
|
|
1479
1534
|
this.activeJobIds.add(job.id);
|
|
@@ -1523,25 +1578,25 @@ var Worker = class {
|
|
|
1523
1578
|
// -------------------------------------------------------------------------
|
|
1524
1579
|
async enqueueCronNext(job) {
|
|
1525
1580
|
try {
|
|
1526
|
-
let
|
|
1581
|
+
let nextDate;
|
|
1527
1582
|
try {
|
|
1528
|
-
const
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
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)}`
|
|
1532
1588
|
);
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
const cronInstance = new CronClass(job.cron);
|
|
1536
|
-
const nextDate = cronInstance.nextRun();
|
|
1537
|
-
nextMs = nextDate ? nextDate.getTime() : Date.now() + 6e4;
|
|
1538
|
-
} else {
|
|
1539
|
-
nextMs = Date.now() + 6e4;
|
|
1540
|
-
}
|
|
1541
|
-
} catch {
|
|
1542
|
-
nextMs = Date.now() + 6e4;
|
|
1589
|
+
this.emitter.emit("worker:error", this.id, error);
|
|
1590
|
+
return;
|
|
1543
1591
|
}
|
|
1544
|
-
|
|
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();
|
|
1545
1600
|
await this.storage.enqueue({
|
|
1546
1601
|
id: generateJobId(),
|
|
1547
1602
|
queue: this.queueName,
|
|
@@ -1902,12 +1957,19 @@ var InMemoryStorageAdapter = class {
|
|
|
1902
1957
|
async releaseLock(jobId) {
|
|
1903
1958
|
const job = this.jobs.get(jobId);
|
|
1904
1959
|
if (!job) return;
|
|
1960
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1905
1961
|
job.lockId = null;
|
|
1906
|
-
job.lockExpiresAt =
|
|
1907
|
-
job.updatedAt =
|
|
1962
|
+
job.lockExpiresAt = now;
|
|
1963
|
+
job.updatedAt = now;
|
|
1908
1964
|
}
|
|
1909
1965
|
// -------------------------------------------------------------------------
|
|
1910
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.
|
|
1911
1973
|
// -------------------------------------------------------------------------
|
|
1912
1974
|
async recoverStalledJobs(queue, now) {
|
|
1913
1975
|
const nowMs = new Date(now).getTime();
|
|
@@ -1917,13 +1979,17 @@ var InMemoryStorageAdapter = class {
|
|
|
1917
1979
|
if (job.status !== "active") continue;
|
|
1918
1980
|
if (job.lockExpiresAt === null) continue;
|
|
1919
1981
|
const lockExpiry = new Date(job.lockExpiresAt).getTime();
|
|
1920
|
-
if (lockExpiry
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
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);
|
|
1927
1993
|
}
|
|
1928
1994
|
return recovered;
|
|
1929
1995
|
}
|