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/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 (batched one pipeline per stalled job)
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 recoverPipeline = this.client.multi();
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
- recoverPipeline.hSet(k.job(jobId), {
327
- status: "waiting",
328
- lockId: "",
329
- lockExpiresAt: "",
330
- updatedAt: now
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
- recoverPipeline.sRem(k.active(queue), jobId);
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) return false;
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 nextMs;
1583
+ let nextDate;
1530
1584
  try {
1531
- const specifier = "croner";
1532
- const croner = await import(
1533
- /* @vite-ignore */
1534
- specifier
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
- const CronClass = croner.Cron ?? croner.default?.Cron ?? croner.default;
1537
- if (typeof CronClass === "function") {
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
- const runAt = new Date(nextMs).toISOString();
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 <= nowMs) {
1925
- job.status = "waiting";
1926
- job.lockId = null;
1927
- job.lockExpiresAt = null;
1928
- job.updatedAt = now;
1929
- recovered.push(job.id);
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
  }