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.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: (/* @__PURE__ */ new Date()).toISOString()
341
+ lockExpiresAt: now,
342
+ updatedAt: now
299
343
  });
300
344
  }
301
345
  // -------------------------------------------------------------------------
302
- // 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.
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 recoverPipeline = this.client.multi();
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
- recoverPipeline.hSet(k.job(jobId), {
324
- status: "waiting",
325
- lockId: "",
326
- lockExpiresAt: "",
327
- 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);
328
384
  });
329
- recoverPipeline.sRem(k.active(queue), jobId);
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 = NULL, updated_at = NOW()
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 = NULL, updated_at = NOW(3)
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) return false;
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 nextMs;
1581
+ let nextDate;
1527
1582
  try {
1528
- const specifier = "croner";
1529
- const croner = await import(
1530
- /* @vite-ignore */
1531
- 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)}`
1532
1588
  );
1533
- const CronClass = croner.Cron ?? croner.default?.Cron ?? croner.default;
1534
- if (typeof CronClass === "function") {
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
- 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();
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 = null;
1907
- job.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
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 <= nowMs) {
1921
- job.status = "waiting";
1922
- job.lockId = null;
1923
- job.lockExpiresAt = null;
1924
- job.updatedAt = now;
1925
- recovered.push(job.id);
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
  }