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.
- package/.github/workflows/check.yml +3 -0
- package/.github/workflows/publish.yml +3 -0
- package/CLAUDE.md +5 -4
- package/Readme.md +9 -4
- package/biome.json +5 -1
- package/dist/client.d.ts +33 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +199 -0
- package/dist/client.js.map +1 -0
- package/dist/constants.d.ts +10 -0
- package/dist/constants.d.ts.map +1 -0
- package/{src → dist}/constants.js +2 -10
- package/dist/constants.js.map +1 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -0
- package/{src → dist}/errors.js +1 -13
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/manager.d.ts +19 -0
- package/dist/manager.d.ts.map +1 -0
- package/dist/manager.js +67 -0
- package/dist/manager.js.map +1 -0
- package/dist/pool.d.ts +29 -0
- package/dist/pool.d.ts.map +1 -0
- package/{src → dist}/pool.js +23 -82
- package/dist/pool.js.map +1 -0
- package/dist/queasy.lua +390 -0
- package/dist/queue.d.ts +22 -0
- package/dist/queue.d.ts.map +1 -0
- package/dist/queue.js +81 -0
- package/dist/queue.js.map +1 -0
- package/dist/types.d.ts +92 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +24 -0
- package/dist/utils.js.map +1 -0
- package/dist/worker.d.ts +2 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +42 -0
- package/dist/worker.js.map +1 -0
- package/docker-compose.yml +0 -2
- package/fuzztest/Readme.md +185 -0
- package/fuzztest/fuzz.ts +356 -0
- package/fuzztest/handlers/cascade-a.ts +90 -0
- package/fuzztest/handlers/cascade-b.ts +71 -0
- package/fuzztest/handlers/fail-handler.ts +47 -0
- package/fuzztest/handlers/periodic.ts +89 -0
- package/fuzztest/process.ts +100 -0
- package/fuzztest/shared/chaos.ts +29 -0
- package/fuzztest/shared/stream.ts +40 -0
- package/package.json +8 -7
- package/plans/redis-options.md +279 -0
- package/src/client.ts +246 -0
- package/src/constants.ts +33 -0
- package/src/errors.ts +13 -0
- package/src/index.ts +2 -0
- package/src/manager.ts +78 -0
- package/src/pool.ts +129 -0
- package/src/queasy.lua +2 -3
- package/src/queue.ts +108 -0
- package/src/types.ts +16 -0
- package/src/{utils.js → utils.ts} +3 -20
- package/src/{worker.js → worker.ts} +5 -12
- package/test/{client.test.js → client.test.ts} +6 -7
- package/test/{errors.test.js → errors.test.ts} +1 -1
- package/test/fixtures/always-fail-handler.ts +5 -0
- package/test/fixtures/data-logger-handler.ts +11 -0
- package/test/fixtures/failure-handler.ts +6 -0
- package/test/fixtures/permanent-error-handler.ts +6 -0
- package/test/fixtures/slow-handler.ts +6 -0
- package/test/fixtures/success-handler.js +0 -5
- package/test/fixtures/success-handler.ts +6 -0
- package/test/fixtures/with-failure-handler.ts +5 -0
- package/test/{guards.test.js → guards.test.ts} +21 -34
- package/test/{manager.test.js → manager.test.ts} +26 -34
- package/test/{pool.test.js → pool.test.ts} +14 -16
- package/test/{queue.test.js → queue.test.ts} +21 -21
- package/test/{redis-functions.test.js → redis-functions.test.ts} +14 -20
- package/test/{utils.test.js → utils.test.ts} +1 -1
- package/tsconfig.json +20 -0
- package/jsconfig.json +0 -17
- package/src/client.js +0 -258
- package/src/index.js +0 -2
- package/src/manager.js +0 -94
- package/src/queue.js +0 -154
- package/test/fixtures/always-fail-handler.js +0 -8
- package/test/fixtures/data-logger-handler.js +0 -19
- package/test/fixtures/failure-handler.js +0 -9
- package/test/fixtures/permanent-error-handler.js +0 -10
- package/test/fixtures/slow-handler.js +0 -9
- package/test/fixtures/with-failure-handler.js +0 -8
- /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
|
-
|
|
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 =
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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 =
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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 =
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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.
|
|
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
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,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
|
-
}
|