queue-jobs-worker 1.0.3 → 1.0.5

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
@@ -12,6 +12,89 @@ var __export = (target, all) => {
12
12
  __defProp(target, name, { get: all[name], enumerable: true });
13
13
  };
14
14
 
15
+ // src/lib/scripts/claim.lua
16
+ var claim_default;
17
+ var init_claim = __esm({
18
+ "src/lib/scripts/claim.lua"() {
19
+ claim_default = "local prefix = ARGV[4]\nlocal now_ms = tonumber(ARGV[3])\n\n-- Promote due delayed jobs into the waiting set.\nlocal due = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', now_ms)\nfor _, jid in ipairs(due) do\n local pri_raw = redis.call('HGET', prefix .. 'job:' .. jid, 'priority')\n local pri = tonumber(pri_raw) or 0\n redis.call('ZADD', KEYS[1], -pri, jid)\n redis.call('ZREM', KEYS[2], jid)\n redis.call('HSET', prefix .. 'job:' .. jid, 'status', 'waiting')\nend\n\n-- Pop the top-priority job.\nlocal items = redis.call('ZPOPMIN', KEYS[1], 1)\nif #items == 0 then return '' end\nlocal job_id = items[1]\n\n-- Lock it.\nredis.call('SADD', KEYS[3], job_id)\nredis.call('HSET', prefix .. 'job:' .. job_id,\n 'status', 'active',\n 'lockId', ARGV[1],\n 'lockExpiresAt', ARGV[2],\n 'updatedAt', ARGV[2]\n)\nreturn job_id\n";
20
+ }
21
+ });
22
+
23
+ // src/lib/scripts/recover-stalled.lua
24
+ var recover_stalled_default;
25
+ var init_recover_stalled = __esm({
26
+ "src/lib/scripts/recover-stalled.lua"() {
27
+ recover_stalled_default = "local job_key = KEYS[1]\nlocal active_set = KEYS[2]\nlocal waiting_zset = KEYS[3]\n\nlocal job_id = ARGV[1]\nlocal expected_exp = ARGV[2]\nlocal expected_lock = ARGV[3]\nlocal recovery_ts = ARGV[4]\nlocal priority_score = tonumber(ARGV[5])\n\n-- Re-read the three guard fields in one atomic HMGET.\nlocal fields = redis.call('HMGET', job_key, 'lockExpiresAt', 'lockId', 'status')\nlocal current_exp = fields[1] or ''\nlocal current_lock = fields[2] or ''\nlocal current_status = fields[3] or ''\n\n-- Guard 1: job must still be active.\n-- If the worker already completed, failed, or moved to DLQ, skip.\nif current_status ~= 'active' then return 0 end\n\n-- Guard 2: lockId must not have changed.\n-- A different worker may have claimed the job after the original lock expired\n-- and before this script runs.\nif current_lock ~= expected_lock then return 0 end\n\n-- Guard 3: lockExpiresAt must be exactly what the caller observed.\n-- If the worker renewed its lock the timestamp will be later than observed;\n-- the CAS mismatch catches that without needing ISO\u2192epoch conversion in Lua.\nif current_exp ~= expected_exp then return 0 end\n\n-- All guards passed \u2014 recover atomically.\nredis.call('HSET', job_key,\n 'status', 'waiting',\n 'lockId', '',\n 'lockExpiresAt', '',\n 'updatedAt', recovery_ts\n)\nredis.call('SREM', active_set, job_id)\nredis.call('ZADD', waiting_zset, priority_score, job_id)\nreturn 1\n";
28
+ }
29
+ });
30
+
31
+ // src/lib/scripts/renew-lock.lua
32
+ var renew_lock_default;
33
+ var init_renew_lock = __esm({
34
+ "src/lib/scripts/renew-lock.lua"() {
35
+ renew_lock_default = "local job_key = KEYS[1]\nlocal lock_id = ARGV[1]\nlocal new_exp = ARGV[2]\nlocal now_iso = ARGV[3]\n\nlocal fields = redis.call('HMGET', job_key, 'status', 'lockId', 'lockExpiresAt')\nlocal status = fields[1] or ''\nlocal current_lock = fields[2] or ''\nlocal current_exp = fields[3] or ''\n\nif status ~= 'active' then return 0 end\nif current_lock ~= lock_id then return 0 end\nif current_exp == '' or current_exp <= now_iso then return 0 end\n\nredis.call('HSET', job_key, 'lockExpiresAt', new_exp, 'updatedAt', now_iso)\nreturn 1\n";
36
+ }
37
+ });
38
+
39
+ // src/lib/scripts/rate-limit.lua
40
+ var rate_limit_default;
41
+ var init_rate_limit = __esm({
42
+ "src/lib/scripts/rate-limit.lua"() {
43
+ rate_limit_default = `-- RATE_LIMIT_LUA
44
+ -- KEYS[1]: rate_count_key ("qjw:rate:{queue}")
45
+ -- KEYS[2]: rate_ts_key ("qjw:rate:{queue}:ts")
46
+ -- ARGV[1]: max (number)
47
+ -- ARGV[2]: windowMs (number)
48
+ -- ARGV[3]: nowMs (number)
49
+ -- ARGV[4]: ttlSec (number)
50
+ --
51
+ -- Returns:
52
+ -- 1: Allowed
53
+ -- 0: Rejected
54
+
55
+ local ck = KEYS[1]
56
+ local tk = KEYS[2]
57
+ local max = tonumber(ARGV[1])
58
+ local windowMs = tonumber(ARGV[2])
59
+ local nowMs = tonumber(ARGV[3])
60
+ local ttlSec = tonumber(ARGV[4])
61
+
62
+ local windowStart = redis.call('GET', tk)
63
+
64
+ if not windowStart or (nowMs - tonumber(windowStart)) >= windowMs then
65
+ redis.call('SET', ck, '1', 'EX', ttlSec)
66
+ redis.call('SET', tk, tostring(nowMs), 'EX', ttlSec)
67
+ return 1
68
+ end
69
+
70
+ local current = tonumber(redis.call('GET', ck) or '0')
71
+ if current >= max then
72
+ return 0
73
+ end
74
+
75
+ redis.call('INCR', ck)
76
+ redis.call('EXPIRE', ck, ttlSec)
77
+ redis.call('EXPIRE', tk, ttlSec)
78
+ return 1
79
+ `;
80
+ }
81
+ });
82
+
83
+ // src/lib/scripts/index.ts
84
+ var CLAIM_LUA, RECOVER_STALLED_LUA, RENEW_LOCK_LUA, RATE_LIMIT_LUA;
85
+ var init_scripts = __esm({
86
+ "src/lib/scripts/index.ts"() {
87
+ init_claim();
88
+ init_recover_stalled();
89
+ init_renew_lock();
90
+ init_rate_limit();
91
+ CLAIM_LUA = claim_default;
92
+ RECOVER_STALLED_LUA = recover_stalled_default;
93
+ RENEW_LOCK_LUA = renew_lock_default;
94
+ RATE_LIMIT_LUA = rate_limit_default;
95
+ }
96
+ });
97
+
15
98
  // src/storage/redis.adapter.ts
16
99
  var redis_adapter_exports = {};
17
100
  __export(redis_adapter_exports, {
@@ -75,9 +158,10 @@ function hashToJob(h) {
75
158
  if (h["cron"]) job.cron = h["cron"];
76
159
  return job;
77
160
  }
78
- var PREFIX, k, CLAIM_LUA, RECOVER_STALLED_LUA, RENEW_LOCK_LUA, RedisStorageAdapter;
161
+ var PREFIX, k, RedisStorageAdapter;
79
162
  var init_redis_adapter = __esm({
80
163
  "src/storage/redis.adapter.ts"() {
164
+ init_scripts();
81
165
  PREFIX = "qjw:";
82
166
  k = {
83
167
  job: (id) => `${PREFIX}job:${id}`,
@@ -89,95 +173,6 @@ var init_redis_adapter = __esm({
89
173
  rateCount: (q) => `${PREFIX}rate:${q}`,
90
174
  rateTs: (q) => `${PREFIX}rate:${q}:ts`
91
175
  };
92
- CLAIM_LUA = `
93
- local prefix = ARGV[4]
94
- local now_ms = tonumber(ARGV[3])
95
-
96
- -- Promote due delayed jobs into the waiting set.
97
- local due = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', now_ms)
98
- for _, jid in ipairs(due) do
99
- local pri_raw = redis.call('HGET', prefix .. 'job:' .. jid, 'priority')
100
- local pri = tonumber(pri_raw) or 0
101
- redis.call('ZADD', KEYS[1], -pri, jid)
102
- redis.call('ZREM', KEYS[2], jid)
103
- redis.call('HSET', prefix .. 'job:' .. jid, 'status', 'waiting')
104
- end
105
-
106
- -- Pop the top-priority job.
107
- local items = redis.call('ZPOPMIN', KEYS[1], 1)
108
- if #items == 0 then return '' end
109
- local job_id = items[1]
110
-
111
- -- Lock it.
112
- redis.call('SADD', KEYS[3], job_id)
113
- redis.call('HSET', prefix .. 'job:' .. job_id,
114
- 'status', 'active',
115
- 'lockId', ARGV[1],
116
- 'lockExpiresAt', ARGV[2],
117
- 'updatedAt', ARGV[2]
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
162
- `;
163
- RENEW_LOCK_LUA = `
164
- local job_key = KEYS[1]
165
- local lock_id = ARGV[1]
166
- local new_exp = ARGV[2]
167
- local now_iso = ARGV[3]
168
-
169
- local fields = redis.call('HMGET', job_key, 'status', 'lockId', 'lockExpiresAt')
170
- local status = fields[1] or ''
171
- local current_lock = fields[2] or ''
172
- local current_exp = fields[3] or ''
173
-
174
- if status ~= 'active' then return 0 end
175
- if current_lock ~= lock_id then return 0 end
176
- if current_exp == '' or current_exp <= now_iso then return 0 end
177
-
178
- redis.call('HSET', job_key, 'lockExpiresAt', new_exp, 'updatedAt', now_iso)
179
- return 1
180
- `;
181
176
  RedisStorageAdapter = class {
182
177
  client;
183
178
  url;
@@ -500,23 +495,12 @@ return 1
500
495
  const nowMs = new Date(now).getTime();
501
496
  const ck = k.rateCount(queue);
502
497
  const tk = k.rateTs(queue);
503
- const windowStart = await this.client.get(tk);
504
- const ttlSec = Math.ceil(windowMs / 1e3);
505
- if (!windowStart || nowMs - Number(windowStart) >= windowMs) {
506
- const multi = this.client.multi();
507
- multi.set(ck, "1");
508
- multi.expire(ck, ttlSec);
509
- multi.set(tk, String(nowMs));
510
- multi.expire(tk, ttlSec);
511
- await multi.exec();
512
- return true;
513
- }
514
- const count = await this.client.incr(ck);
515
- if (count > max) {
516
- await this.client.decr(ck);
517
- return false;
518
- }
519
- return true;
498
+ const ttlSec = Math.max(1, Math.ceil(windowMs / 1e3));
499
+ const res = await this.client.eval(RATE_LIMIT_LUA, {
500
+ keys: [ck, tk],
501
+ arguments: [String(max), String(windowMs), String(nowMs), String(ttlSec)]
502
+ });
503
+ return Number(res) === 1;
520
504
  }
521
505
  };
522
506
  }
@@ -911,24 +895,42 @@ CREATE TABLE IF NOT EXISTS qjw_rate_limits (
911
895
  // -------------------------------------------------------------------------
912
896
  async checkAndIncrementRateLimit(queue, max, windowMs, now) {
913
897
  const nowMs = new Date(now).getTime();
914
- const res = await this.pool.query(
915
- "SELECT count, window_start FROM qjw_rate_limits WHERE queue = $1",
916
- [queue]
917
- );
918
- if (res.rows.length === 0 || nowMs - Number(res.rows[0]?.window_start ?? 0) >= windowMs) {
919
- await this.pool.query(
920
- `INSERT INTO qjw_rate_limits (queue, count, window_start)
921
- VALUES ($1, 1, $2)
922
- ON CONFLICT (queue) DO UPDATE
923
- SET count = 1, window_start = EXCLUDED.window_start`,
924
- [queue, nowMs]
898
+ const client = await this.pool.connect();
899
+ try {
900
+ await client.query("BEGIN");
901
+ await client.query(
902
+ `INSERT INTO qjw_rate_limits (queue, count, window_start) VALUES ($1, 0, 0)
903
+ ON CONFLICT (queue) DO NOTHING`,
904
+ [queue]
905
+ );
906
+ const res = await client.query(
907
+ "SELECT count, window_start FROM qjw_rate_limits WHERE queue = $1 FOR UPDATE",
908
+ [queue]
925
909
  );
910
+ const row = res.rows[0];
911
+ const windowStart = Number(row?.window_start ?? 0);
912
+ const count = Number(row?.count ?? 0);
913
+ if (!row || nowMs - windowStart >= windowMs) {
914
+ await client.query(
915
+ "UPDATE qjw_rate_limits SET count = 1, window_start = $2 WHERE queue = $1",
916
+ [queue, nowMs]
917
+ );
918
+ await client.query("COMMIT");
919
+ return true;
920
+ }
921
+ if (count >= max) {
922
+ await client.query("COMMIT");
923
+ return false;
924
+ }
925
+ await client.query("UPDATE qjw_rate_limits SET count = count + 1 WHERE queue = $1", [queue]);
926
+ await client.query("COMMIT");
926
927
  return true;
928
+ } catch (err) {
929
+ await client.query("ROLLBACK");
930
+ throw err;
931
+ } finally {
932
+ client.release();
927
933
  }
928
- const current = res.rows[0].count;
929
- if (current >= max) return false;
930
- await this.pool.query("UPDATE qjw_rate_limits SET count = count + 1 WHERE queue = $1", [queue]);
931
- return true;
932
934
  }
933
935
  };
934
936
  }
@@ -1342,22 +1344,42 @@ CREATE TABLE IF NOT EXISTS qjw_rate_limits (
1342
1344
  // -------------------------------------------------------------------------
1343
1345
  async checkAndIncrementRateLimit(queue, max, windowMs, now) {
1344
1346
  const nowMs = new Date(now).getTime();
1345
- const [rows] = await this.pool.query(
1346
- "SELECT count, window_start FROM qjw_rate_limits WHERE queue = ?",
1347
- [queue]
1348
- );
1349
- const existing = rows[0];
1350
- if (!existing || nowMs - Number(existing["window_start"]) >= windowMs) {
1351
- await this.pool.query(
1352
- `INSERT INTO qjw_rate_limits (queue, count, window_start) VALUES (?,1,?)
1353
- ON DUPLICATE KEY UPDATE count = 1, window_start = VALUES(window_start)`,
1354
- [queue, nowMs]
1347
+ const conn = await this.pool.getConnection();
1348
+ try {
1349
+ await conn.beginTransaction();
1350
+ await conn.query(
1351
+ `INSERT INTO qjw_rate_limits (queue, count, window_start) VALUES (?, 0, 0)
1352
+ ON DUPLICATE KEY UPDATE queue = queue`,
1353
+ [queue]
1354
+ );
1355
+ const [rows] = await conn.query(
1356
+ "SELECT count, window_start FROM qjw_rate_limits WHERE queue = ? FOR UPDATE",
1357
+ [queue]
1355
1358
  );
1359
+ const existing = rows[0];
1360
+ const windowStart = Number(existing?.["window_start"] ?? 0);
1361
+ const count = Number(existing?.["count"] ?? 0);
1362
+ if (!existing || nowMs - windowStart >= windowMs) {
1363
+ await conn.query("UPDATE qjw_rate_limits SET count = 1, window_start = ? WHERE queue = ?", [
1364
+ nowMs,
1365
+ queue
1366
+ ]);
1367
+ await conn.commit();
1368
+ return true;
1369
+ }
1370
+ if (count >= max) {
1371
+ await conn.commit();
1372
+ return false;
1373
+ }
1374
+ await conn.query("UPDATE qjw_rate_limits SET count = count + 1 WHERE queue = ?", [queue]);
1375
+ await conn.commit();
1356
1376
  return true;
1377
+ } catch (err) {
1378
+ await conn.rollback();
1379
+ throw err;
1380
+ } finally {
1381
+ conn.release();
1357
1382
  }
1358
- if (Number(existing["count"]) >= max) return false;
1359
- await this.pool.query("UPDATE qjw_rate_limits SET count = count + 1 WHERE queue = ?", [queue]);
1360
- return true;
1361
1383
  }
1362
1384
  };
1363
1385
  }
@@ -1895,16 +1917,25 @@ var Queue = class {
1895
1917
  emitter;
1896
1918
  resolvedConfig;
1897
1919
  clientDefaults;
1920
+ isInitialisedCheck;
1898
1921
  /** Registered processors keyed by job type. */
1899
1922
  processors = /* @__PURE__ */ new Map();
1900
1923
  /** Active worker instances created by this queue. */
1901
1924
  workers = [];
1902
- constructor(name, storage, emitter, options, defaults) {
1925
+ constructor(name, storage, emitter, options, defaults, isInitialisedCheck) {
1903
1926
  this.name = name;
1904
1927
  this.storage = storage;
1905
1928
  this.emitter = emitter;
1906
1929
  this.clientDefaults = defaults;
1907
1930
  this.resolvedConfig = mergeQueueConfig(options, defaults);
1931
+ this.isInitialisedCheck = isInitialisedCheck;
1932
+ }
1933
+ assertInitialised() {
1934
+ if (this.isInitialisedCheck && !this.isInitialisedCheck()) {
1935
+ throw new Error(
1936
+ `Cannot execute queue operations on queue "${this.name}" because the QueueClient is not initialised. Call "await client.init()" first.`
1937
+ );
1938
+ }
1908
1939
  }
1909
1940
  // -------------------------------------------------------------------------
1910
1941
  // Enqueue
@@ -1917,6 +1948,7 @@ var Queue = class {
1917
1948
  * @param options - Per-job overrides (attempts, delay, priority, etc.).
1918
1949
  */
1919
1950
  async enqueue(type, payload, options) {
1951
+ this.assertInitialised();
1920
1952
  const cfg = this.resolvedConfig;
1921
1953
  const now = /* @__PURE__ */ new Date();
1922
1954
  let runAt;
@@ -1965,6 +1997,7 @@ var Queue = class {
1965
1997
  * Worker-level `concurrency` overrides queue-level concurrency.
1966
1998
  */
1967
1999
  createWorker(options) {
2000
+ this.assertInitialised();
1968
2001
  const worker = new Worker(
1969
2002
  this.name,
1970
2003
  this.storage,
@@ -1983,12 +2016,14 @@ var Queue = class {
1983
2016
  // -------------------------------------------------------------------------
1984
2017
  /** Fetch a single job by its ID. Returns null if not found or wrong queue. */
1985
2018
  async getJob(jobId) {
2019
+ this.assertInitialised();
1986
2020
  const raw = await this.storage.getJob(jobId);
1987
2021
  if (!raw || raw.queue !== this.name) return null;
1988
2022
  return new Job(raw);
1989
2023
  }
1990
2024
  /** Fetch jobs from this queue, optionally filtered by status. */
1991
2025
  async getJobs(status, limit = 100, offset = 0) {
2026
+ this.assertInitialised();
1992
2027
  const raws = await this.storage.getJobs({
1993
2028
  queue: this.name,
1994
2029
  ...status !== void 0 && { status },
@@ -1999,6 +2034,7 @@ var Queue = class {
1999
2034
  }
2000
2035
  /** Get job counts by status for this queue. */
2001
2036
  async getJobCounts() {
2037
+ this.assertInitialised();
2002
2038
  return this.storage.getJobCounts(this.name);
2003
2039
  }
2004
2040
  // -------------------------------------------------------------------------
@@ -2348,11 +2384,16 @@ var QueueClient = class _QueueClient {
2348
2384
  queues = /* @__PURE__ */ new Map();
2349
2385
  initialised = false;
2350
2386
  closed = false;
2387
+ /** True when the adapter was supplied via {@link withAdapter}; init() skips resolveAdapter() in that case. */
2388
+ _customAdapter = false;
2351
2389
  constructor(options = {}) {
2352
2390
  this.defaults = { ...HARD_DEFAULTS, ...options.defaults };
2353
2391
  this.dialect = options.dialect ?? "memory";
2354
2392
  this.connectionString = options.connectionString;
2355
2393
  this._storage = this.buildMemoryOrEagerAdapter();
2394
+ if (this.dialect === "memory") {
2395
+ this.initialised = true;
2396
+ }
2356
2397
  this.emitter = new QueueEventEmitter();
2357
2398
  }
2358
2399
  // -------------------------------------------------------------------------
@@ -2383,7 +2424,9 @@ var QueueClient = class _QueueClient {
2383
2424
  */
2384
2425
  async init() {
2385
2426
  if (this.initialised) return;
2386
- this._storage = await this.resolveAdapter();
2427
+ if (!this._customAdapter) {
2428
+ this._storage = await this.resolveAdapter();
2429
+ }
2387
2430
  await this._storage.initialize();
2388
2431
  this.initialised = true;
2389
2432
  }
@@ -2429,10 +2472,19 @@ var QueueClient = class _QueueClient {
2429
2472
  if (this.queues.has(name)) {
2430
2473
  throw new Error(`A queue named "${name}" already exists on this client.`);
2431
2474
  }
2432
- if (!this.initialised && this.dialect === "memory") {
2433
- void this.init();
2475
+ if (!this.initialised) {
2476
+ throw new Error(
2477
+ `QueueClient is not initialised. Call "await client.init()" before creating queues for dialect "${this.dialect}".`
2478
+ );
2434
2479
  }
2435
- const queue = new Queue(name, this._storage, this.emitter, options, this.defaults);
2480
+ const queue = new Queue(
2481
+ name,
2482
+ this._storage,
2483
+ this.emitter,
2484
+ options,
2485
+ this.defaults,
2486
+ () => this.initialised
2487
+ );
2436
2488
  this.queues.set(name, queue);
2437
2489
  return queue;
2438
2490
  }
@@ -2500,6 +2552,8 @@ var QueueClient = class _QueueClient {
2500
2552
  static withAdapter(adapter, options = {}) {
2501
2553
  const client = new _QueueClient(options);
2502
2554
  client._storage = adapter;
2555
+ client._customAdapter = true;
2556
+ client.initialised = false;
2503
2557
  return client;
2504
2558
  }
2505
2559
  };