queue-jobs-worker 1.0.4 → 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.cjs CHANGED
@@ -14,6 +14,89 @@ var __export = (target, all) => {
14
14
  __defProp(target, name, { get: all[name], enumerable: true });
15
15
  };
16
16
 
17
+ // src/lib/scripts/claim.lua
18
+ var claim_default;
19
+ var init_claim = __esm({
20
+ "src/lib/scripts/claim.lua"() {
21
+ 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";
22
+ }
23
+ });
24
+
25
+ // src/lib/scripts/recover-stalled.lua
26
+ var recover_stalled_default;
27
+ var init_recover_stalled = __esm({
28
+ "src/lib/scripts/recover-stalled.lua"() {
29
+ 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";
30
+ }
31
+ });
32
+
33
+ // src/lib/scripts/renew-lock.lua
34
+ var renew_lock_default;
35
+ var init_renew_lock = __esm({
36
+ "src/lib/scripts/renew-lock.lua"() {
37
+ 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";
38
+ }
39
+ });
40
+
41
+ // src/lib/scripts/rate-limit.lua
42
+ var rate_limit_default;
43
+ var init_rate_limit = __esm({
44
+ "src/lib/scripts/rate-limit.lua"() {
45
+ rate_limit_default = `-- RATE_LIMIT_LUA
46
+ -- KEYS[1]: rate_count_key ("qjw:rate:{queue}")
47
+ -- KEYS[2]: rate_ts_key ("qjw:rate:{queue}:ts")
48
+ -- ARGV[1]: max (number)
49
+ -- ARGV[2]: windowMs (number)
50
+ -- ARGV[3]: nowMs (number)
51
+ -- ARGV[4]: ttlSec (number)
52
+ --
53
+ -- Returns:
54
+ -- 1: Allowed
55
+ -- 0: Rejected
56
+
57
+ local ck = KEYS[1]
58
+ local tk = KEYS[2]
59
+ local max = tonumber(ARGV[1])
60
+ local windowMs = tonumber(ARGV[2])
61
+ local nowMs = tonumber(ARGV[3])
62
+ local ttlSec = tonumber(ARGV[4])
63
+
64
+ local windowStart = redis.call('GET', tk)
65
+
66
+ if not windowStart or (nowMs - tonumber(windowStart)) >= windowMs then
67
+ redis.call('SET', ck, '1', 'EX', ttlSec)
68
+ redis.call('SET', tk, tostring(nowMs), 'EX', ttlSec)
69
+ return 1
70
+ end
71
+
72
+ local current = tonumber(redis.call('GET', ck) or '0')
73
+ if current >= max then
74
+ return 0
75
+ end
76
+
77
+ redis.call('INCR', ck)
78
+ redis.call('EXPIRE', ck, ttlSec)
79
+ redis.call('EXPIRE', tk, ttlSec)
80
+ return 1
81
+ `;
82
+ }
83
+ });
84
+
85
+ // src/lib/scripts/index.ts
86
+ var CLAIM_LUA, RECOVER_STALLED_LUA, RENEW_LOCK_LUA, RATE_LIMIT_LUA;
87
+ var init_scripts = __esm({
88
+ "src/lib/scripts/index.ts"() {
89
+ init_claim();
90
+ init_recover_stalled();
91
+ init_renew_lock();
92
+ init_rate_limit();
93
+ CLAIM_LUA = claim_default;
94
+ RECOVER_STALLED_LUA = recover_stalled_default;
95
+ RENEW_LOCK_LUA = renew_lock_default;
96
+ RATE_LIMIT_LUA = rate_limit_default;
97
+ }
98
+ });
99
+
17
100
  // src/storage/redis.adapter.ts
18
101
  var redis_adapter_exports = {};
19
102
  __export(redis_adapter_exports, {
@@ -77,9 +160,10 @@ function hashToJob(h) {
77
160
  if (h["cron"]) job.cron = h["cron"];
78
161
  return job;
79
162
  }
80
- var PREFIX, k, CLAIM_LUA, RECOVER_STALLED_LUA, RENEW_LOCK_LUA; exports.RedisStorageAdapter = void 0;
163
+ var PREFIX, k; exports.RedisStorageAdapter = void 0;
81
164
  var init_redis_adapter = __esm({
82
165
  "src/storage/redis.adapter.ts"() {
166
+ init_scripts();
83
167
  PREFIX = "qjw:";
84
168
  k = {
85
169
  job: (id) => `${PREFIX}job:${id}`,
@@ -91,95 +175,6 @@ var init_redis_adapter = __esm({
91
175
  rateCount: (q) => `${PREFIX}rate:${q}`,
92
176
  rateTs: (q) => `${PREFIX}rate:${q}:ts`
93
177
  };
94
- CLAIM_LUA = `
95
- local prefix = ARGV[4]
96
- local now_ms = tonumber(ARGV[3])
97
-
98
- -- Promote due delayed jobs into the waiting set.
99
- local due = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', now_ms)
100
- for _, jid in ipairs(due) do
101
- local pri_raw = redis.call('HGET', prefix .. 'job:' .. jid, 'priority')
102
- local pri = tonumber(pri_raw) or 0
103
- redis.call('ZADD', KEYS[1], -pri, jid)
104
- redis.call('ZREM', KEYS[2], jid)
105
- redis.call('HSET', prefix .. 'job:' .. jid, 'status', 'waiting')
106
- end
107
-
108
- -- Pop the top-priority job.
109
- local items = redis.call('ZPOPMIN', KEYS[1], 1)
110
- if #items == 0 then return '' end
111
- local job_id = items[1]
112
-
113
- -- Lock it.
114
- redis.call('SADD', KEYS[3], job_id)
115
- redis.call('HSET', prefix .. 'job:' .. job_id,
116
- 'status', 'active',
117
- 'lockId', ARGV[1],
118
- 'lockExpiresAt', ARGV[2],
119
- 'updatedAt', ARGV[2]
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
164
- `;
165
- RENEW_LOCK_LUA = `
166
- local job_key = KEYS[1]
167
- local lock_id = ARGV[1]
168
- local new_exp = ARGV[2]
169
- local now_iso = ARGV[3]
170
-
171
- local fields = redis.call('HMGET', job_key, 'status', 'lockId', 'lockExpiresAt')
172
- local status = fields[1] or ''
173
- local current_lock = fields[2] or ''
174
- local current_exp = fields[3] or ''
175
-
176
- if status ~= 'active' then return 0 end
177
- if current_lock ~= lock_id then return 0 end
178
- if current_exp == '' or current_exp <= now_iso then return 0 end
179
-
180
- redis.call('HSET', job_key, 'lockExpiresAt', new_exp, 'updatedAt', now_iso)
181
- return 1
182
- `;
183
178
  exports.RedisStorageAdapter = class {
184
179
  client;
185
180
  url;
@@ -502,23 +497,12 @@ return 1
502
497
  const nowMs = new Date(now).getTime();
503
498
  const ck = k.rateCount(queue);
504
499
  const tk = k.rateTs(queue);
505
- const windowStart = await this.client.get(tk);
506
- const ttlSec = Math.ceil(windowMs / 1e3);
507
- if (!windowStart || nowMs - Number(windowStart) >= windowMs) {
508
- const multi = this.client.multi();
509
- multi.set(ck, "1");
510
- multi.expire(ck, ttlSec);
511
- multi.set(tk, String(nowMs));
512
- multi.expire(tk, ttlSec);
513
- await multi.exec();
514
- return true;
515
- }
516
- const count = await this.client.incr(ck);
517
- if (count > max) {
518
- await this.client.decr(ck);
519
- return false;
520
- }
521
- return true;
500
+ const ttlSec = Math.max(1, Math.ceil(windowMs / 1e3));
501
+ const res = await this.client.eval(RATE_LIMIT_LUA, {
502
+ keys: [ck, tk],
503
+ arguments: [String(max), String(windowMs), String(nowMs), String(ttlSec)]
504
+ });
505
+ return Number(res) === 1;
522
506
  }
523
507
  };
524
508
  }
@@ -913,24 +897,42 @@ CREATE TABLE IF NOT EXISTS qjw_rate_limits (
913
897
  // -------------------------------------------------------------------------
914
898
  async checkAndIncrementRateLimit(queue, max, windowMs, now) {
915
899
  const nowMs = new Date(now).getTime();
916
- const res = await this.pool.query(
917
- "SELECT count, window_start FROM qjw_rate_limits WHERE queue = $1",
918
- [queue]
919
- );
920
- if (res.rows.length === 0 || nowMs - Number(res.rows[0]?.window_start ?? 0) >= windowMs) {
921
- await this.pool.query(
922
- `INSERT INTO qjw_rate_limits (queue, count, window_start)
923
- VALUES ($1, 1, $2)
924
- ON CONFLICT (queue) DO UPDATE
925
- SET count = 1, window_start = EXCLUDED.window_start`,
926
- [queue, nowMs]
900
+ const client = await this.pool.connect();
901
+ try {
902
+ await client.query("BEGIN");
903
+ await client.query(
904
+ `INSERT INTO qjw_rate_limits (queue, count, window_start) VALUES ($1, 0, 0)
905
+ ON CONFLICT (queue) DO NOTHING`,
906
+ [queue]
907
+ );
908
+ const res = await client.query(
909
+ "SELECT count, window_start FROM qjw_rate_limits WHERE queue = $1 FOR UPDATE",
910
+ [queue]
927
911
  );
912
+ const row = res.rows[0];
913
+ const windowStart = Number(row?.window_start ?? 0);
914
+ const count = Number(row?.count ?? 0);
915
+ if (!row || nowMs - windowStart >= windowMs) {
916
+ await client.query(
917
+ "UPDATE qjw_rate_limits SET count = 1, window_start = $2 WHERE queue = $1",
918
+ [queue, nowMs]
919
+ );
920
+ await client.query("COMMIT");
921
+ return true;
922
+ }
923
+ if (count >= max) {
924
+ await client.query("COMMIT");
925
+ return false;
926
+ }
927
+ await client.query("UPDATE qjw_rate_limits SET count = count + 1 WHERE queue = $1", [queue]);
928
+ await client.query("COMMIT");
928
929
  return true;
930
+ } catch (err) {
931
+ await client.query("ROLLBACK");
932
+ throw err;
933
+ } finally {
934
+ client.release();
929
935
  }
930
- const current = res.rows[0].count;
931
- if (current >= max) return false;
932
- await this.pool.query("UPDATE qjw_rate_limits SET count = count + 1 WHERE queue = $1", [queue]);
933
- return true;
934
936
  }
935
937
  };
936
938
  }
@@ -1344,22 +1346,42 @@ CREATE TABLE IF NOT EXISTS qjw_rate_limits (
1344
1346
  // -------------------------------------------------------------------------
1345
1347
  async checkAndIncrementRateLimit(queue, max, windowMs, now) {
1346
1348
  const nowMs = new Date(now).getTime();
1347
- const [rows] = await this.pool.query(
1348
- "SELECT count, window_start FROM qjw_rate_limits WHERE queue = ?",
1349
- [queue]
1350
- );
1351
- const existing = rows[0];
1352
- if (!existing || nowMs - Number(existing["window_start"]) >= windowMs) {
1353
- await this.pool.query(
1354
- `INSERT INTO qjw_rate_limits (queue, count, window_start) VALUES (?,1,?)
1355
- ON DUPLICATE KEY UPDATE count = 1, window_start = VALUES(window_start)`,
1356
- [queue, nowMs]
1349
+ const conn = await this.pool.getConnection();
1350
+ try {
1351
+ await conn.beginTransaction();
1352
+ await conn.query(
1353
+ `INSERT INTO qjw_rate_limits (queue, count, window_start) VALUES (?, 0, 0)
1354
+ ON DUPLICATE KEY UPDATE queue = queue`,
1355
+ [queue]
1356
+ );
1357
+ const [rows] = await conn.query(
1358
+ "SELECT count, window_start FROM qjw_rate_limits WHERE queue = ? FOR UPDATE",
1359
+ [queue]
1357
1360
  );
1361
+ const existing = rows[0];
1362
+ const windowStart = Number(existing?.["window_start"] ?? 0);
1363
+ const count = Number(existing?.["count"] ?? 0);
1364
+ if (!existing || nowMs - windowStart >= windowMs) {
1365
+ await conn.query("UPDATE qjw_rate_limits SET count = 1, window_start = ? WHERE queue = ?", [
1366
+ nowMs,
1367
+ queue
1368
+ ]);
1369
+ await conn.commit();
1370
+ return true;
1371
+ }
1372
+ if (count >= max) {
1373
+ await conn.commit();
1374
+ return false;
1375
+ }
1376
+ await conn.query("UPDATE qjw_rate_limits SET count = count + 1 WHERE queue = ?", [queue]);
1377
+ await conn.commit();
1358
1378
  return true;
1379
+ } catch (err) {
1380
+ await conn.rollback();
1381
+ throw err;
1382
+ } finally {
1383
+ conn.release();
1359
1384
  }
1360
- if (Number(existing["count"]) >= max) return false;
1361
- await this.pool.query("UPDATE qjw_rate_limits SET count = count + 1 WHERE queue = ?", [queue]);
1362
- return true;
1363
1385
  }
1364
1386
  };
1365
1387
  }
@@ -1897,16 +1919,25 @@ var Queue = class {
1897
1919
  emitter;
1898
1920
  resolvedConfig;
1899
1921
  clientDefaults;
1922
+ isInitialisedCheck;
1900
1923
  /** Registered processors keyed by job type. */
1901
1924
  processors = /* @__PURE__ */ new Map();
1902
1925
  /** Active worker instances created by this queue. */
1903
1926
  workers = [];
1904
- constructor(name, storage, emitter, options, defaults) {
1927
+ constructor(name, storage, emitter, options, defaults, isInitialisedCheck) {
1905
1928
  this.name = name;
1906
1929
  this.storage = storage;
1907
1930
  this.emitter = emitter;
1908
1931
  this.clientDefaults = defaults;
1909
1932
  this.resolvedConfig = mergeQueueConfig(options, defaults);
1933
+ this.isInitialisedCheck = isInitialisedCheck;
1934
+ }
1935
+ assertInitialised() {
1936
+ if (this.isInitialisedCheck && !this.isInitialisedCheck()) {
1937
+ throw new Error(
1938
+ `Cannot execute queue operations on queue "${this.name}" because the QueueClient is not initialised. Call "await client.init()" first.`
1939
+ );
1940
+ }
1910
1941
  }
1911
1942
  // -------------------------------------------------------------------------
1912
1943
  // Enqueue
@@ -1919,6 +1950,7 @@ var Queue = class {
1919
1950
  * @param options - Per-job overrides (attempts, delay, priority, etc.).
1920
1951
  */
1921
1952
  async enqueue(type, payload, options) {
1953
+ this.assertInitialised();
1922
1954
  const cfg = this.resolvedConfig;
1923
1955
  const now = /* @__PURE__ */ new Date();
1924
1956
  let runAt;
@@ -1967,6 +1999,7 @@ var Queue = class {
1967
1999
  * Worker-level `concurrency` overrides queue-level concurrency.
1968
2000
  */
1969
2001
  createWorker(options) {
2002
+ this.assertInitialised();
1970
2003
  const worker = new Worker(
1971
2004
  this.name,
1972
2005
  this.storage,
@@ -1985,12 +2018,14 @@ var Queue = class {
1985
2018
  // -------------------------------------------------------------------------
1986
2019
  /** Fetch a single job by its ID. Returns null if not found or wrong queue. */
1987
2020
  async getJob(jobId) {
2021
+ this.assertInitialised();
1988
2022
  const raw = await this.storage.getJob(jobId);
1989
2023
  if (!raw || raw.queue !== this.name) return null;
1990
2024
  return new Job(raw);
1991
2025
  }
1992
2026
  /** Fetch jobs from this queue, optionally filtered by status. */
1993
2027
  async getJobs(status, limit = 100, offset = 0) {
2028
+ this.assertInitialised();
1994
2029
  const raws = await this.storage.getJobs({
1995
2030
  queue: this.name,
1996
2031
  ...status !== void 0 && { status },
@@ -2001,6 +2036,7 @@ var Queue = class {
2001
2036
  }
2002
2037
  /** Get job counts by status for this queue. */
2003
2038
  async getJobCounts() {
2039
+ this.assertInitialised();
2004
2040
  return this.storage.getJobCounts(this.name);
2005
2041
  }
2006
2042
  // -------------------------------------------------------------------------
@@ -2357,6 +2393,9 @@ var QueueClient = class _QueueClient {
2357
2393
  this.dialect = options.dialect ?? "memory";
2358
2394
  this.connectionString = options.connectionString;
2359
2395
  this._storage = this.buildMemoryOrEagerAdapter();
2396
+ if (this.dialect === "memory") {
2397
+ this.initialised = true;
2398
+ }
2360
2399
  this.emitter = new QueueEventEmitter();
2361
2400
  }
2362
2401
  // -------------------------------------------------------------------------
@@ -2435,10 +2474,19 @@ var QueueClient = class _QueueClient {
2435
2474
  if (this.queues.has(name)) {
2436
2475
  throw new Error(`A queue named "${name}" already exists on this client.`);
2437
2476
  }
2438
- if (!this.initialised && this.dialect === "memory") {
2439
- void this.init();
2477
+ if (!this.initialised) {
2478
+ throw new Error(
2479
+ `QueueClient is not initialised. Call "await client.init()" before creating queues for dialect "${this.dialect}".`
2480
+ );
2440
2481
  }
2441
- const queue = new Queue(name, this._storage, this.emitter, options, this.defaults);
2482
+ const queue = new Queue(
2483
+ name,
2484
+ this._storage,
2485
+ this.emitter,
2486
+ options,
2487
+ this.defaults,
2488
+ () => this.initialised
2489
+ );
2442
2490
  this.queues.set(name, queue);
2443
2491
  return queue;
2444
2492
  }
@@ -2507,6 +2555,7 @@ var QueueClient = class _QueueClient {
2507
2555
  const client = new _QueueClient(options);
2508
2556
  client._storage = adapter;
2509
2557
  client._customAdapter = true;
2558
+ client.initialised = false;
2510
2559
  return client;
2511
2560
  }
2512
2561
  };