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/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: (/* @__PURE__ */ new Date()).toISOString()
343
+ lockExpiresAt: now,
344
+ updatedAt: now
301
345
  });
302
346
  }
303
347
  // -------------------------------------------------------------------------
304
- // 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.
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 recoverPipeline = this.client.multi();
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
- recoverPipeline.hSet(k.job(jobId), {
326
- status: "waiting",
327
- lockId: "",
328
- lockExpiresAt: "",
329
- 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);
330
386
  });
331
- recoverPipeline.sRem(k.active(queue), jobId);
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 = NULL, updated_at = NOW()
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 = NULL, updated_at = NOW(3)
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) return false;
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 nextMs;
1583
+ let nextDate;
1529
1584
  try {
1530
- const specifier = "croner";
1531
- const croner = await import(
1532
- /* @vite-ignore */
1533
- 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)}`
1534
1590
  );
1535
- const CronClass = croner.Cron ?? croner.default?.Cron ?? croner.default;
1536
- if (typeof CronClass === "function") {
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
- 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();
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 = null;
1909
- job.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
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 <= nowMs) {
1923
- job.status = "waiting";
1924
- job.lockId = null;
1925
- job.lockExpiresAt = null;
1926
- job.updatedAt = now;
1927
- recovered.push(job.id);
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
  }