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