queasy 0.2.0 → 0.3.1

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.
Files changed (98) hide show
  1. package/.github/workflows/check.yml +3 -0
  2. package/.github/workflows/publish.yml +3 -0
  3. package/CLAUDE.md +5 -4
  4. package/Readme.md +9 -4
  5. package/biome.json +5 -1
  6. package/dist/client.d.ts +33 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +199 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/constants.d.ts +10 -0
  11. package/dist/constants.d.ts.map +1 -0
  12. package/{src → dist}/constants.js +2 -10
  13. package/dist/constants.js.map +1 -0
  14. package/dist/errors.d.ts +7 -0
  15. package/dist/errors.d.ts.map +1 -0
  16. package/{src → dist}/errors.js +1 -13
  17. package/dist/errors.js.map +1 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +3 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/manager.d.ts +19 -0
  23. package/dist/manager.d.ts.map +1 -0
  24. package/dist/manager.js +67 -0
  25. package/dist/manager.js.map +1 -0
  26. package/dist/pool.d.ts +29 -0
  27. package/dist/pool.d.ts.map +1 -0
  28. package/{src → dist}/pool.js +23 -82
  29. package/dist/pool.js.map +1 -0
  30. package/dist/queasy.lua +390 -0
  31. package/dist/queue.d.ts +22 -0
  32. package/dist/queue.d.ts.map +1 -0
  33. package/dist/queue.js +81 -0
  34. package/dist/queue.js.map +1 -0
  35. package/dist/types.d.ts +92 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +2 -0
  38. package/dist/types.js.map +1 -0
  39. package/dist/utils.d.ts +4 -0
  40. package/dist/utils.d.ts.map +1 -0
  41. package/dist/utils.js +24 -0
  42. package/dist/utils.js.map +1 -0
  43. package/dist/worker.d.ts +2 -0
  44. package/dist/worker.d.ts.map +1 -0
  45. package/dist/worker.js +42 -0
  46. package/dist/worker.js.map +1 -0
  47. package/docker-compose.yml +0 -2
  48. package/fuzztest/Readme.md +185 -0
  49. package/fuzztest/fuzz.ts +356 -0
  50. package/fuzztest/handlers/cascade-a.ts +90 -0
  51. package/fuzztest/handlers/cascade-b.ts +71 -0
  52. package/fuzztest/handlers/fail-handler.ts +47 -0
  53. package/fuzztest/handlers/periodic.ts +89 -0
  54. package/fuzztest/process.ts +100 -0
  55. package/fuzztest/shared/chaos.ts +29 -0
  56. package/fuzztest/shared/stream.ts +40 -0
  57. package/package.json +8 -7
  58. package/plans/redis-options.md +279 -0
  59. package/src/client.ts +246 -0
  60. package/src/constants.ts +33 -0
  61. package/src/errors.ts +13 -0
  62. package/src/index.ts +2 -0
  63. package/src/manager.ts +78 -0
  64. package/src/pool.ts +129 -0
  65. package/src/queasy.lua +2 -3
  66. package/src/queue.ts +108 -0
  67. package/src/types.ts +16 -0
  68. package/src/{utils.js → utils.ts} +3 -20
  69. package/src/{worker.js → worker.ts} +5 -12
  70. package/test/{client.test.js → client.test.ts} +6 -7
  71. package/test/{errors.test.js → errors.test.ts} +1 -1
  72. package/test/fixtures/always-fail-handler.ts +5 -0
  73. package/test/fixtures/data-logger-handler.ts +11 -0
  74. package/test/fixtures/failure-handler.ts +6 -0
  75. package/test/fixtures/permanent-error-handler.ts +6 -0
  76. package/test/fixtures/slow-handler.ts +6 -0
  77. package/test/fixtures/success-handler.js +0 -5
  78. package/test/fixtures/success-handler.ts +6 -0
  79. package/test/fixtures/with-failure-handler.ts +5 -0
  80. package/test/{guards.test.js → guards.test.ts} +21 -34
  81. package/test/{manager.test.js → manager.test.ts} +26 -34
  82. package/test/{pool.test.js → pool.test.ts} +14 -16
  83. package/test/{queue.test.js → queue.test.ts} +21 -21
  84. package/test/{redis-functions.test.js → redis-functions.test.ts} +14 -20
  85. package/test/{utils.test.js → utils.test.ts} +1 -1
  86. package/tsconfig.json +20 -0
  87. package/jsconfig.json +0 -17
  88. package/src/client.js +0 -258
  89. package/src/index.js +0 -2
  90. package/src/manager.js +0 -94
  91. package/src/queue.js +0 -154
  92. package/test/fixtures/always-fail-handler.js +0 -8
  93. package/test/fixtures/data-logger-handler.js +0 -19
  94. package/test/fixtures/failure-handler.js +0 -9
  95. package/test/fixtures/permanent-error-handler.js +0 -10
  96. package/test/fixtures/slow-handler.js +0 -9
  97. package/test/fixtures/with-failure-handler.js +0 -8
  98. /package/test/fixtures/{no-handle-handler.js → no-handle-handler.ts} +0 -0
@@ -4,13 +4,13 @@ import { dirname, join } from 'node:path';
4
4
  import { after, afterEach, before, describe, it } from 'node:test';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { createClient } from 'redis';
7
+ import type { RedisClientType } from 'redis';
7
8
 
8
9
  const __filename = fileURLToPath(import.meta.url);
9
10
  const __dirname = dirname(__filename);
10
11
 
11
12
  describe('Redis Lua functions', () => {
12
- /** @type {import('redis').RedisClientType} */
13
- let redis;
13
+ let redis: RedisClientType;
14
14
  const QUEUE_NAME = '{test-queue}';
15
15
 
16
16
  before(async () => {
@@ -281,12 +281,10 @@ describe('Redis Lua functions', () => {
281
281
 
282
282
  // Dequeue
283
283
  const expiryTime = now + 10000;
284
- const result = /** @type {string[][]} */ (
285
- await redis.fCall('queasy_dequeue', {
286
- keys: [QUEUE_NAME],
287
- arguments: [clientId, now.toString(), expiryTime.toString(), '10'],
288
- })
289
- );
284
+ const result = await redis.fCall('queasy_dequeue', {
285
+ keys: [QUEUE_NAME],
286
+ arguments: [clientId, now.toString(), expiryTime.toString(), '10'],
287
+ }) as string[][];
290
288
 
291
289
  assert.equal(result.length, 2);
292
290
  assert.deepEqual(result[0], ['id', jobId1]);
@@ -318,12 +316,10 @@ describe('Redis Lua functions', () => {
318
316
  await redis.hSet(`${QUEUE_NAME}:waiting_job:${jobId}`, 'id', jobId);
319
317
 
320
318
  // Try to dequeue
321
- const result = /** @type {string[][]} */ (
322
- await redis.fCall('queasy_dequeue', {
323
- keys: [QUEUE_NAME],
324
- arguments: [clientId, now.toString(), String(now + 10000), '10'],
325
- })
326
- );
319
+ const result = await redis.fCall('queasy_dequeue', {
320
+ keys: [QUEUE_NAME],
321
+ arguments: [clientId, now.toString(), String(now + 10000), '10'],
322
+ }) as string[][];
327
323
 
328
324
  assert.equal(result.length, 0);
329
325
  });
@@ -338,12 +334,10 @@ describe('Redis Lua functions', () => {
338
334
  await redis.hSet(`${QUEUE_NAME}:waiting_job:${jobId}`, 'id', jobId);
339
335
 
340
336
  // Try to dequeue
341
- const result = /** @type {string[][]} */ (
342
- await redis.fCall('queasy_dequeue', {
343
- keys: [QUEUE_NAME],
344
- arguments: [clientId, now.toString(), String(now + 10000), '10'],
345
- })
346
- );
337
+ const result = await redis.fCall('queasy_dequeue', {
338
+ keys: [QUEUE_NAME],
339
+ arguments: [clientId, now.toString(), String(now + 10000), '10'],
340
+ }) as string[][];
347
341
 
348
342
  assert.equal(result.length, 0);
349
343
  });
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert';
2
2
  import { describe, it } from 'node:test';
3
- import { compareSemver, parseVersion } from '../src/utils.js';
3
+ import { compareSemver, parseVersion } from '../src/utils.ts';
4
4
 
5
5
  describe('parseVersion', () => {
6
6
  it('should parse a simple major.minor version', () => {
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "strict": true,
4
+ "target": "esnext",
5
+ "module": "nodenext",
6
+ "moduleResolution": "nodenext",
7
+ "rewriteRelativeImportExtensions": true,
8
+ "outDir": "dist",
9
+ "rootDir": "src",
10
+ "declaration": true,
11
+ "declarationMap": true,
12
+ "sourceMap": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "erasableSyntaxOnly": true,
16
+ "resolveJsonModule": true,
17
+ "lib": ["ES2022"]
18
+ },
19
+ "include": ["src/**/*.ts"]
20
+ }
package/jsconfig.json DELETED
@@ -1,17 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "checkJs": true,
4
- "strict": true,
5
- "target": "ES2022",
6
- "module": "ESNext",
7
- "moduleResolution": "node",
8
- "allowSyntheticDefaultImports": true,
9
- "esModuleInterop": true,
10
- "skipLibCheck": true,
11
- "forceConsistentCasingInFileNames": true,
12
- "resolveJsonModule": true,
13
- "noEmit": true,
14
- "lib": ["ES2022"]
15
- },
16
- "include": ["src/**/*.js", "test/**/*.js"]
17
- }
package/src/client.js DELETED
@@ -1,258 +0,0 @@
1
- import EventEmitter from 'node:events';
2
- import { readFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import { getEnvironmentData } from 'node:worker_threads';
6
- import { HEARTBEAT_INTERVAL, HEARTBEAT_TIMEOUT, LUA_FUNCTIONS_VERSION } from './constants.js';
7
- import { Manager } from './manager.js';
8
- import { Pool } from './pool.js';
9
- import { Queue } from './queue.js';
10
- import { compareSemver, generateId, parseVersion } from './utils.js';
11
-
12
- const __dirname = dirname(fileURLToPath(import.meta.url));
13
- const luaScript = readFileSync(join(__dirname, 'queasy.lua'), 'utf8').replace(
14
- '__QUEASY_VERSION__',
15
- LUA_FUNCTIONS_VERSION
16
- );
17
-
18
- /**
19
- * Check the installed version and load our Lua functions if needed.
20
- * Returns true if this client should be disconnected (newer major on server).
21
- * @param {RedisClient} redis
22
- * @returns {Promise<boolean>} Whether to disconnect.
23
- */
24
- async function installLuaFunctions(redis) {
25
- const installedVersionString = /** @type {string?} */ (
26
- await redis.fCall('queasy_version', { keys: [], arguments: [] }).catch(() => null)
27
- );
28
- const installedVersion = parseVersion(installedVersionString);
29
- const availableVersion = parseVersion(LUA_FUNCTIONS_VERSION);
30
-
31
- // No script installed or our version is later
32
- if (compareSemver(availableVersion, installedVersion) > 0) {
33
- await redis.sendCommand(['FUNCTION', 'LOAD', 'REPLACE', luaScript]);
34
- return false;
35
- }
36
-
37
- // Keep the installed (newer) version. Return disconnect=true if the major versions disagree
38
- return installedVersion[0] > availableVersion[0];
39
- }
40
-
41
- /** @typedef {import('redis').RedisClientType} RedisClient */
42
- /** @typedef {import('./types').Job} Job */
43
-
44
- /** @typedef {{ queue: Queue, bumpTimer?: NodeJS.Timeout }} QueueEntry */
45
-
46
- /**
47
- * Parse job data from Redis response
48
- * @param {string[]} jobArray - Flat array from HGETALL
49
- * @returns {Job | null}
50
- */
51
- export function parseJob(jobArray) {
52
- if (!jobArray || jobArray.length === 0) return null;
53
-
54
- /** @type {Record<string, string>} */
55
- const job = {};
56
- for (let i = 0; i < jobArray.length; i += 2) {
57
- const key = jobArray[i];
58
- const value = jobArray[i + 1];
59
- job[key] = value;
60
- }
61
-
62
- return {
63
- id: job.id,
64
- data: job.data ? JSON.parse(job.data) : undefined,
65
- runAt: job.run_at ? Number(job.run_at) : 0,
66
- retryCount: Number(job.retry_count || 0),
67
- stallCount: Number(job.stall_count || 0),
68
- };
69
- }
70
-
71
- export class Client extends EventEmitter {
72
- /**
73
- * @param {RedisClient} redis - Redis client
74
- * @param {number?} workerCount - Allow this client to dequeue jobs.
75
- * @param {((client: Client) => any)} [callback] - Callback when client is ready
76
- */
77
- constructor(redis, workerCount, callback) {
78
- super();
79
- this.redis = redis;
80
- this.clientId = generateId();
81
-
82
- /** @type {Record<string, QueueEntry>} */
83
- this.queues = {};
84
- this.disconnected = false;
85
-
86
- const inWorker = getEnvironmentData('queasy_worker_context');
87
- this.pool = !inWorker && workerCount !== 0 ? new Pool(workerCount) : undefined;
88
- if (this.pool) this.manager = new Manager(this.pool);
89
-
90
- // Not awaited — the Lua script is read synchronously at module load,
91
- // so Redis' single-threaded ordering ensures the FUNCTION LOAD completes
92
- // before any subsequent fCalls from user code.
93
- installLuaFunctions(this.redis).then((disconnect) => {
94
- this.disconnected = disconnect;
95
- if (disconnect) this.emit('disconnected', 'Redis has incompatible queasy version.');
96
- else if (callback) callback(this);
97
- });
98
- }
99
-
100
- /**
101
- * Create a queue object for interacting with a named queue
102
- * @param {string} name - Queue name (without braces - they will be added automatically)
103
- * @returns {Queue} Queue object with dispatch, cancel, and listen methods
104
- */
105
- queue(name, isKey = false) {
106
- if (this.disconnected) throw new Error('Can’t add queue: client disconnected');
107
-
108
- const key = isKey ? name : `{${name}}`;
109
- if (!this.queues[key]) {
110
- this.queues[key] = /** @type {QueueEntry} */ ({
111
- queue: new Queue(key, this, this.pool, this.manager),
112
- });
113
- }
114
- return this.queues[key].queue;
115
- }
116
-
117
- /**
118
- * Schedule the next bump timer
119
- * @param {string} key
120
- */
121
- scheduleBump(key) {
122
- const queueEntry = this.queues[key];
123
- if (queueEntry.bumpTimer) clearTimeout(queueEntry.bumpTimer);
124
- queueEntry.bumpTimer = setTimeout(() => this.bump(key), HEARTBEAT_INTERVAL);
125
- }
126
-
127
- /**
128
- * @param {string} key
129
- */
130
- async bump(key) {
131
- if (this.disconnected) return;
132
- // Set up the next bump first, in case this
133
- this.scheduleBump(key);
134
- const now = Date.now();
135
- const expiry = now + HEARTBEAT_TIMEOUT;
136
- const bumped = await this.redis.fCall('queasy_bump', {
137
- keys: [key],
138
- arguments: [this.clientId, String(now), String(expiry)],
139
- });
140
-
141
- if (!bumped) {
142
- // This client’s lock was lost and its jobs retried.
143
- // We must stop processing jobs here to avoid duplication.
144
- await this.close();
145
- this.emit('disconnected', 'Lost locks, possible main thread freeze');
146
- }
147
- }
148
-
149
- /**
150
- * This marks this as disconnected.
151
- */
152
- async close() {
153
- if (this.pool) await this.pool.close();
154
- if (this.manager) await this.manager.close();
155
- this.queues = {};
156
- this.pool = undefined;
157
- this.disconnected = true;
158
- }
159
-
160
- /**
161
- * @param {string} key
162
- * @param {string} id
163
- * @param {number} runAt
164
- * @param {any} data
165
- * @param {boolean} updateData
166
- * @param {boolean | string} updateRunAt
167
- * @param {boolean} resetCounts
168
- */
169
- async dispatch(key, id, runAt, data, updateData, updateRunAt, resetCounts) {
170
- await this.redis.fCall('queasy_dispatch', {
171
- keys: [key],
172
- arguments: [
173
- id,
174
- String(runAt),
175
- JSON.stringify(data),
176
- String(updateData),
177
- String(updateRunAt),
178
- String(resetCounts),
179
- ],
180
- });
181
- }
182
-
183
- /**
184
- * @param {string} key
185
- * @param {string} id
186
- * @returns {Promise<boolean>}
187
- */
188
- async cancel(key, id) {
189
- const result = await this.redis.fCall('queasy_cancel', {
190
- keys: [key],
191
- arguments: [id],
192
- });
193
- return result === 1;
194
- }
195
-
196
- /**
197
- * @param {string} key
198
- * @param {number} count
199
- * @returns {Promise<Job[]>}
200
- */
201
- async dequeue(key, count) {
202
- const now = Date.now();
203
- const expiry = now + HEARTBEAT_TIMEOUT;
204
- const result = /** @type {string[][]} */ (
205
- await this.redis.fCall('queasy_dequeue', {
206
- keys: [key],
207
- arguments: [this.clientId, String(now), String(expiry), String(count)],
208
- })
209
- );
210
-
211
- // Heartbeats should start with the first dequeue.
212
- this.scheduleBump(key);
213
-
214
- return /** @type Job[] */ (result.map((jobArray) => parseJob(jobArray)).filter(Boolean));
215
- }
216
-
217
- /**
218
- * @param {string} key
219
- * @param {string} jobId
220
- */
221
- async finish(key, jobId) {
222
- await this.redis.fCall('queasy_finish', {
223
- keys: [key],
224
- arguments: [jobId, this.clientId, String(Date.now())],
225
- });
226
- }
227
-
228
- /**
229
- * @param {string} key
230
- * @param {string} failkey
231
- * @param {string} jobId
232
- * @param {any} failJobData
233
- */
234
- async fail(key, failkey, jobId, failJobData) {
235
- await this.redis.fCall('queasy_fail', {
236
- keys: [key, failkey],
237
- arguments: [
238
- jobId,
239
- this.clientId,
240
- generateId(),
241
- JSON.stringify(failJobData),
242
- String(Date.now()),
243
- ],
244
- });
245
- }
246
-
247
- /**
248
- * @param {string} key
249
- * @param {string} jobId
250
- * @param {number} retryAt
251
- */
252
- async retry(key, jobId, retryAt) {
253
- await this.redis.fCall('queasy_retry', {
254
- keys: [key],
255
- arguments: [jobId, this.clientId, String(retryAt), String(Date.now())],
256
- });
257
- }
258
- }
package/src/index.js DELETED
@@ -1,2 +0,0 @@
1
- export { Client } from './client.js';
2
- export { PermanentError, StallError } from './errors.js';
package/src/manager.js DELETED
@@ -1,94 +0,0 @@
1
- /**
2
- * This class manages resource allocation between
3
- * different queues based on the size of the queue
4
- */
5
-
6
- import { DEQUEUE_INTERVAL } from './constants.js';
7
-
8
- /** @typedef {import('./pool').Pool} Pool */
9
- /** @typedef {import('./queue').ProcessingQueue} Queue */
10
- /** @typedef {{ queue: Queue, lastDequeuedAt: number, isBusy: boolean }} QueueEntry */
11
-
12
- export class Manager {
13
- /** @param {Pool} pool */
14
- constructor(pool) {
15
- this.pool = pool;
16
-
17
- /** @type {Array<QueueEntry>} */
18
- this.queues = [];
19
-
20
- /** @type {NodeJS.Timeout?} */
21
- this.timer = null;
22
-
23
- this.busyCount = 0;
24
- }
25
-
26
- /** @param {Queue} queue */
27
- addQueue(queue) {
28
- // Add this at the beginning so we dequeue it at the next available opportunity.
29
- this.queues.unshift({ queue, lastDequeuedAt: 0, isBusy: false });
30
- this.busyCount += 1;
31
-
32
- // This delay is required for queue listen tests
33
- // as they need to be able to control dequeueing
34
- this.next();
35
- }
36
-
37
- async next() {
38
- // If this function is called while the previous execution is in progress,
39
- // we do not want both executions to use the same queue.
40
- const entry = this.queues.shift();
41
- if (!entry) return;
42
- if (this.timer) {
43
- clearTimeout(this.timer);
44
- this.timer = null;
45
- }
46
-
47
- const size = entry.queue.handlerOptions.size;
48
- if (this.pool.capacity < size) return;
49
-
50
- const batchSize = Math.max(1, Math.floor(this.pool.capacity / this.busyCount / size));
51
- entry.lastDequeuedAt = Date.now(); // We store the time just before the call to dequeue.
52
- const { count } = await entry.queue.dequeue(batchSize);
53
-
54
- // Update
55
- const nowBusy = count >= batchSize;
56
- this.busyCount += Number(nowBusy) - Number(entry.isBusy);
57
- entry.isBusy = nowBusy;
58
-
59
- this.queues.push(entry);
60
- this.queues.sort(compareQueueEntries);
61
-
62
- if (!this.timer && this.queues.length) {
63
- const { isBusy, lastDequeuedAt } = this.queues[0];
64
- // If the current top queue is busy, retry now.
65
- const delay = isBusy ? 0 : Math.max(0, lastDequeuedAt - Date.now() + DEQUEUE_INTERVAL);
66
- this.timer = setTimeout(() => this.next(), delay);
67
- }
68
- }
69
-
70
- close() {
71
- if (this.timer) clearTimeout(this.timer);
72
- }
73
- }
74
-
75
- /**
76
- * @param {QueueEntry} a
77
- * @param {QueueEntry} b
78
- * @returns -1 | 0 | 1
79
- */
80
- function compareQueueEntries(a, b) {
81
- if (a.isBusy > b.isBusy) return -1; // a busy, b not -> a first
82
- if (a.isBusy < b.isBusy) return 1; // a free, b busy -> b first
83
-
84
- if (a.queue.handlerOptions.priority > b.queue.handlerOptions.priority) return 1; // a higher -> a first
85
- if (a.queue.handlerOptions.priority < b.queue.handlerOptions.priority) return -1; // b higher -> b first
86
-
87
- if (a.lastDequeuedAt > b.lastDequeuedAt) return -1; // a newer -> b first
88
- if (a.lastDequeuedAt < b.lastDequeuedAt) return 1; // a older -> a first
89
-
90
- if (a.queue.handlerOptions.size > b.queue.handlerOptions.size) return 1; // a larger -> a first
91
- if (a.queue.handlerOptions.size < b.queue.handlerOptions.size) return -1; // b larger -> b first
92
-
93
- return 0;
94
- }
package/src/queue.js DELETED
@@ -1,154 +0,0 @@
1
- import { DEFAULT_RETRY_OPTIONS, FAILJOB_RETRY_OPTIONS } from './constants.js';
2
- import { generateId } from './utils.js';
3
-
4
- // Import types:
5
- /** @typedef {import('redis').RedisClientType} RedisClient */
6
- /** @typedef {import('./types').HandlerOptions} HandlerOptions */
7
- /** @typedef {import('./types').ListenOptions} ListenOptions */
8
- /** @typedef {import('./types').JobOptions} JobOptions */
9
- /** @typedef {import('./types').Job} Job */
10
- /** @typedef {import('./types').DoneMessage} DoneMessage */
11
- /** @typedef {import('./client').Client} Client */
12
- /** @typedef {import('./pool').Pool} Pool */
13
- /** @typedef {import('./manager').Manager} Manager */
14
-
15
- /** @typedef {Required<Partial<Pick<Queue, keyof Queue>>>} ProcessingQueue */
16
-
17
- /**
18
- * Queue instance for managing a named job queue
19
- */
20
- export class Queue {
21
- /**
22
- * @param {string} key - Queue key
23
- * @param {Client} client - Redis client wrapper
24
- * @param {Pool | undefined} pool - Worker pool
25
- * @param {Manager | undefined} manager - Capacity allocation manager
26
- */
27
- constructor(key, client, pool, manager) {
28
- this.key = key;
29
- this.client = client;
30
- this.pool = pool;
31
- this.manager = manager;
32
-
33
- /** @type {NodeJS.Timeout | undefined} */
34
- // this.dequeueInterval = undefined;
35
-
36
- /** @type {Required<HandlerOptions> | undefined} */
37
- this.handlerOptions = undefined;
38
-
39
- /** @type {string | undefined} */
40
- this.handlerPath = undefined;
41
-
42
- /** @type {string | undefined} */
43
- this.failKey = undefined;
44
- }
45
-
46
- /**
47
- * Attach handlers to process jobs from this queue
48
- * @param {string} handlerPath - Path to handler module
49
- * @param {ListenOptions} [options] - Retry strategy options and failure handler
50
- * @returns {Promise<void>}
51
- */
52
- async listen(handlerPath, { failHandler, failRetryOptions, ...retryOptions } = {}) {
53
- if (this.client.disconnected) throw new Error('Can’t listen: client disconnected');
54
- if (!this.pool || !this.manager) throw new Error('Can’t listen: non-processing client');
55
-
56
- this.handlerPath = handlerPath;
57
- this.handlerOptions = { ...DEFAULT_RETRY_OPTIONS, ...retryOptions };
58
-
59
- // Initialize failure handler on all workers if provided
60
- if (failHandler) {
61
- this.failKey = `${this.key}-fail`;
62
- const failQueue = this.client.queue(this.failKey, true);
63
- failQueue.listen(failHandler, { ...FAILJOB_RETRY_OPTIONS, ...failRetryOptions });
64
- }
65
-
66
- this.manager.addQueue(/** @type {ProcessingQueue} */ (this));
67
-
68
- // if (!this.dequeueInterval) {
69
- // this.dequeueInterval = setInterval(() => this.dequeue(), DEQUEUE_INTERVAL);
70
- // }
71
- }
72
-
73
- /**
74
- * Add a job to the queue
75
- * @param {any} data - Job data (any JSON-serializable value)
76
- * @param {JobOptions} [options] - Job options
77
- * @returns {Promise<string>} Job ID
78
- */
79
- async dispatch(data, options = {}) {
80
- if (this.client.disconnected) throw new Error('Can’t dispatch: client disconnected');
81
- const {
82
- id = generateId(),
83
- runAt = 0,
84
- updateData = false,
85
- updateRunAt = false,
86
- resetCounts = false,
87
- } = options;
88
-
89
- await this.client.dispatch(this.key, id, runAt, data, updateData, updateRunAt, resetCounts);
90
- return id;
91
- }
92
-
93
- /**
94
- * Cancel a waiting job
95
- * @param {string} id - Job ID
96
- * @returns {Promise<boolean>} True if job was cancelled
97
- */
98
- async cancel(id) {
99
- if (this.client.disconnected) throw new Error('Can’t cancel: client disconnected');
100
- return await this.client.cancel(this.key, id);
101
- }
102
-
103
- /**
104
- * Picks jobs from the queue and processes them
105
- * @param {number} count
106
- * @returns {Promise<{count: number, promise: Promise<Array<unknown>>}>}
107
- */
108
-
109
- async dequeue(count) {
110
- const { pool, handlerPath, handlerOptions } = /** @type {ProcessingQueue} */ (this);
111
- const { maxRetries, maxStalls, maxBackoff, minBackoff, size, timeout } = handlerOptions;
112
-
113
- // const capacity = pool.getCapacity(size);
114
- // if (capacity <= 0) return;
115
-
116
- const jobs = await this.client.dequeue(this.key, count);
117
-
118
- const promise = Promise.all(
119
- jobs.map(async (job) => {
120
- // Check if job has exceeded stall limit
121
- if (job.stallCount >= maxStalls) {
122
- // Job has stalled too many times - fail it permanently
123
- if (!this.failKey) return this.client.finish(this.key, job.id);
124
-
125
- const failJobData = [job.id, job.data, { message: 'Max stalls exceeded' }];
126
- return this.client.fail(this.key, this.failKey, job.id, failJobData);
127
- }
128
-
129
- try {
130
- await pool.process(handlerPath, job, size, timeout);
131
- await this.client.finish(this.key, job.id);
132
- } catch (message) {
133
- const { error } = /** @type {Required<DoneMessage>} */ (message);
134
- const { retryAt = 0, kind } = error;
135
-
136
- if (kind === 'permanent' || job.retryCount >= maxRetries) {
137
- if (!this.failKey) return this.client.finish(this.key, job.id);
138
-
139
- const failJobData = [job.id, job.data, error];
140
- return this.client.fail(this.key, this.failKey, job.id, failJobData);
141
- }
142
-
143
- const backoffUntil =
144
- Date.now() + Math.min(maxBackoff, minBackoff * 2 ** job.retryCount);
145
-
146
- // Retriable error: call retry
147
- await this.client.retry(this.key, job.id, Math.max(retryAt, backoffUntil));
148
- }
149
- })
150
- );
151
-
152
- return { count: jobs.length, promise };
153
- }
154
- }
@@ -1,8 +0,0 @@
1
- /**
2
- * Handler that always fails with a retriable error
3
- * @param {any} _data - Job data
4
- * @param {import('../../src/types.js').Job} _job - Job metadata
5
- */
6
- export async function handle(_data, _job) {
7
- throw new Error('Always fails');
8
- }
@@ -1,19 +0,0 @@
1
- /**
2
- * Handler that logs received data to a global array (for testing)
3
- * @param {any} data - Job data
4
- * @param {import('../../src/types.js').Job} job - Job metadata
5
- */
6
- /** @type {{ data: any, job: import('../../src/types.js').Job }[]} */
7
- export const receivedJobs = [];
8
-
9
- /**
10
- * @param {any} data - Job data
11
- * @param {import('../../src/types.js').Job} job - Job metadata
12
- */
13
- export async function handle(data, job) {
14
- receivedJobs.push({ data, job });
15
- }
16
-
17
- export function clearLogs() {
18
- receivedJobs.length = 0;
19
- }
@@ -1,9 +0,0 @@
1
- /**
2
- * Failure handler
3
- * @param {any} data - Failure data [originalJobId, originalData, error]
4
- * @param {import('../../src/types.js').Job} _job - Job metadata
5
- */
6
- export async function handle(data, _job) {
7
- // Just succeed to process the failure
8
- console.log('Failure handler called with:', data);
9
- }
@@ -1,10 +0,0 @@
1
- import { PermanentError } from '../../src/errors.js';
2
-
3
- /**
4
- * Handler that throws a PermanentError
5
- * @param {any} _data - Job data
6
- * @param {import('../../src/types.js').Job} _job - Job metadata
7
- */
8
- export async function handle(_data, _job) {
9
- throw new PermanentError('Permanent failure');
10
- }