queasy 0.2.0 → 0.3.0
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/CLAUDE.md +1 -1
- package/Readme.md +9 -4
- package/docker-compose.yml +0 -2
- package/fuzztest/Readme.md +185 -0
- package/fuzztest/fuzz.js +354 -0
- package/fuzztest/handlers/cascade-a.js +94 -0
- package/fuzztest/handlers/cascade-b.js +72 -0
- package/fuzztest/handlers/fail-handler.js +52 -0
- package/fuzztest/handlers/periodic.js +93 -0
- package/fuzztest/process.js +100 -0
- package/fuzztest/shared/chaos.js +28 -0
- package/fuzztest/shared/stream.js +40 -0
- package/package.json +2 -3
- package/plans/redis-options.md +279 -0
- package/src/client.js +42 -12
- package/src/constants.js +1 -1
- package/src/queasy.lua +2 -3
- package/src/types.ts +15 -0
- package/test/client.test.js +1 -1
- package/test/guards.test.js +13 -23
- package/test/manager.test.js +3 -1
- package/test/pool.test.js +4 -2
- package/test/queue.test.js +5 -4
package/src/client.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import EventEmitter from 'node:events';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import { dirname, join } from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import { getEnvironmentData } from 'node:worker_threads';
|
|
7
|
+
import { createClient, createCluster } from 'redis';
|
|
6
8
|
import { HEARTBEAT_INTERVAL, HEARTBEAT_TIMEOUT, LUA_FUNCTIONS_VERSION } from './constants.js';
|
|
7
9
|
import { Manager } from './manager.js';
|
|
8
10
|
import { Pool } from './pool.js';
|
|
@@ -18,7 +20,7 @@ const luaScript = readFileSync(join(__dirname, 'queasy.lua'), 'utf8').replace(
|
|
|
18
20
|
/**
|
|
19
21
|
* Check the installed version and load our Lua functions if needed.
|
|
20
22
|
* Returns true if this client should be disconnected (newer major on server).
|
|
21
|
-
* @param {
|
|
23
|
+
* @param {{ fCall: Function, sendCommand: Function }} redis
|
|
22
24
|
* @returns {Promise<boolean>} Whether to disconnect.
|
|
23
25
|
*/
|
|
24
26
|
async function installLuaFunctions(redis) {
|
|
@@ -38,9 +40,19 @@ async function installLuaFunctions(redis) {
|
|
|
38
40
|
return installedVersion[0] > availableVersion[0];
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
/** @typedef {import('
|
|
43
|
+
/** @typedef {import('./types').RedisOptions} RedisOptions */
|
|
42
44
|
/** @typedef {import('./types').Job} Job */
|
|
43
45
|
|
|
46
|
+
/**
|
|
47
|
+
* @param {RedisOptions} options
|
|
48
|
+
*/
|
|
49
|
+
function buildRedisConnection(options) {
|
|
50
|
+
if ('rootNodes' in options) {
|
|
51
|
+
return createCluster(options);
|
|
52
|
+
}
|
|
53
|
+
return createClient(options);
|
|
54
|
+
}
|
|
55
|
+
|
|
44
56
|
/** @typedef {{ queue: Queue, bumpTimer?: NodeJS.Timeout }} QueueEntry */
|
|
45
57
|
|
|
46
58
|
/**
|
|
@@ -70,13 +82,13 @@ export function parseJob(jobArray) {
|
|
|
70
82
|
|
|
71
83
|
export class Client extends EventEmitter {
|
|
72
84
|
/**
|
|
73
|
-
* @param {
|
|
74
|
-
* @param {number
|
|
85
|
+
* @param {RedisOptions} options - Redis connection options
|
|
86
|
+
* @param {number} [workerCount] - Allow this client to dequeue jobs.
|
|
75
87
|
* @param {((client: Client) => any)} [callback] - Callback when client is ready
|
|
76
88
|
*/
|
|
77
|
-
constructor(
|
|
89
|
+
constructor(options = {}, workerCount = os.cpus().length, callback) {
|
|
78
90
|
super();
|
|
79
|
-
this.redis =
|
|
91
|
+
this.redis = buildRedisConnection(options);
|
|
80
92
|
this.clientId = generateId();
|
|
81
93
|
|
|
82
94
|
/** @type {Record<string, QueueEntry>} */
|
|
@@ -90,11 +102,21 @@ export class Client extends EventEmitter {
|
|
|
90
102
|
// Not awaited — the Lua script is read synchronously at module load,
|
|
91
103
|
// so Redis' single-threaded ordering ensures the FUNCTION LOAD completes
|
|
92
104
|
// before any subsequent fCalls from user code.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
this.redis
|
|
106
|
+
.connect()
|
|
107
|
+
.then(() => installLuaFunctions(this.redis))
|
|
108
|
+
.then((disconnect) => {
|
|
109
|
+
this.disconnected = disconnect;
|
|
110
|
+
if (disconnect) this.emit('disconnected', 'Redis has incompatible queasy version.');
|
|
111
|
+
else {
|
|
112
|
+
this.emit('connected');
|
|
113
|
+
if (callback) callback(this);
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
.catch((err) => {
|
|
117
|
+
this.disconnected = true;
|
|
118
|
+
this.emit('disconnected', err.message);
|
|
119
|
+
});
|
|
98
120
|
}
|
|
99
121
|
|
|
100
122
|
/**
|
|
@@ -155,6 +177,7 @@ export class Client extends EventEmitter {
|
|
|
155
177
|
this.queues = {};
|
|
156
178
|
this.pool = undefined;
|
|
157
179
|
this.disconnected = true;
|
|
180
|
+
await this.redis.quit().catch(() => {});
|
|
158
181
|
}
|
|
159
182
|
|
|
160
183
|
/**
|
|
@@ -211,7 +234,11 @@ export class Client extends EventEmitter {
|
|
|
211
234
|
// Heartbeats should start with the first dequeue.
|
|
212
235
|
this.scheduleBump(key);
|
|
213
236
|
|
|
214
|
-
|
|
237
|
+
const jobs = /** @type Job[] */ (
|
|
238
|
+
result.map((jobArray) => parseJob(jobArray)).filter(Boolean)
|
|
239
|
+
);
|
|
240
|
+
for (const job of jobs) this.emit('dequeue', key, job);
|
|
241
|
+
return jobs;
|
|
215
242
|
}
|
|
216
243
|
|
|
217
244
|
/**
|
|
@@ -223,6 +250,7 @@ export class Client extends EventEmitter {
|
|
|
223
250
|
keys: [key],
|
|
224
251
|
arguments: [jobId, this.clientId, String(Date.now())],
|
|
225
252
|
});
|
|
253
|
+
this.emit('finish', key, jobId);
|
|
226
254
|
}
|
|
227
255
|
|
|
228
256
|
/**
|
|
@@ -242,6 +270,7 @@ export class Client extends EventEmitter {
|
|
|
242
270
|
String(Date.now()),
|
|
243
271
|
],
|
|
244
272
|
});
|
|
273
|
+
this.emit('fail', key, jobId);
|
|
245
274
|
}
|
|
246
275
|
|
|
247
276
|
/**
|
|
@@ -254,5 +283,6 @@ export class Client extends EventEmitter {
|
|
|
254
283
|
keys: [key],
|
|
255
284
|
arguments: [jobId, this.clientId, String(retryAt), String(Date.now())],
|
|
256
285
|
});
|
|
286
|
+
this.emit('retry', key, jobId);
|
|
257
287
|
}
|
|
258
288
|
}
|
package/src/constants.js
CHANGED
|
@@ -30,7 +30,7 @@ export const FAILJOB_RETRY_OPTIONS = {
|
|
|
30
30
|
priority: 100,
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
-
export const LUA_FUNCTIONS_VERSION = '1.0';
|
|
33
|
+
export const LUA_FUNCTIONS_VERSION = '1.0.1';
|
|
34
34
|
export const HEARTBEAT_INTERVAL = 5000; // 5 seconds
|
|
35
35
|
export const HEARTBEAT_TIMEOUT = 10000; // 10 seconds
|
|
36
36
|
export const WORKER_CAPACITY = 10;
|
package/src/queasy.lua
CHANGED
|
@@ -226,9 +226,8 @@ local function dequeue(queue_key, client_id, now, expiry, limit)
|
|
|
226
226
|
table.insert(result, job)
|
|
227
227
|
end
|
|
228
228
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
end
|
|
229
|
+
-- Add this client to queue and bump its expiry
|
|
230
|
+
redis.call('ZADD', expiry_key, expiry, client_id)
|
|
232
231
|
|
|
233
232
|
-- Sweep stalled clients
|
|
234
233
|
sweep(queue_key, now)
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
import type { RedisClientOptions, RedisClusterOptions } from 'redis';
|
|
2
|
+
|
|
3
|
+
type SingleNodeOptions = Pick<
|
|
4
|
+
RedisClientOptions,
|
|
5
|
+
'url' | 'socket' | 'username' | 'password' | 'database'
|
|
6
|
+
>;
|
|
7
|
+
|
|
8
|
+
export type RedisOptions =
|
|
9
|
+
| SingleNodeOptions
|
|
10
|
+
| {
|
|
11
|
+
rootNodes: SingleNodeOptions[];
|
|
12
|
+
defaults?: Partial<SingleNodeOptions>;
|
|
13
|
+
nodeAddressMap?: RedisClusterOptions['nodeAddressMap'];
|
|
14
|
+
};
|
|
15
|
+
|
|
1
16
|
/**
|
|
2
17
|
* Core job identification and data
|
|
3
18
|
*/
|
package/test/client.test.js
CHANGED
|
@@ -14,7 +14,7 @@ describe.skip('Client heartbeat', () => {
|
|
|
14
14
|
beforeEach(async () => {
|
|
15
15
|
redis = createClient();
|
|
16
16
|
await redis.connect();
|
|
17
|
-
client = new Client(
|
|
17
|
+
client = new Client({}, 1);
|
|
18
18
|
client.scheduleBump = mock.fn();
|
|
19
19
|
if (client.manager) client.manager.addQueue = mock.fn();
|
|
20
20
|
});
|
package/test/guards.test.js
CHANGED
|
@@ -4,22 +4,17 @@ import { createClient } from 'redis';
|
|
|
4
4
|
import { Client } from '../src/index.js';
|
|
5
5
|
|
|
6
6
|
describe('Client disconnect guard', () => {
|
|
7
|
-
/** @type {import('redis').RedisClientType} */
|
|
8
|
-
let redis;
|
|
9
7
|
/** @type {import('../src/client.js').Client} */
|
|
10
8
|
let client;
|
|
11
9
|
|
|
12
|
-
beforeEach(
|
|
13
|
-
redis = createClient();
|
|
14
|
-
await redis.connect();
|
|
10
|
+
beforeEach(() => {
|
|
15
11
|
return new Promise((res) => {
|
|
16
|
-
client = new Client(
|
|
12
|
+
client = new Client({}, 0, res);
|
|
17
13
|
});
|
|
18
14
|
});
|
|
19
15
|
|
|
20
16
|
afterEach(async () => {
|
|
21
17
|
await client.close();
|
|
22
|
-
await redis.quit();
|
|
23
18
|
});
|
|
24
19
|
|
|
25
20
|
it('should throw from queue() when disconnected', () => {
|
|
@@ -29,23 +24,18 @@ describe('Client disconnect guard', () => {
|
|
|
29
24
|
});
|
|
30
25
|
|
|
31
26
|
describe('Queue disconnect guards', () => {
|
|
32
|
-
/** @type {import('redis').RedisClientType} */
|
|
33
|
-
let redis;
|
|
34
27
|
/** @type {import('../src/client.js').Client} */
|
|
35
28
|
let client;
|
|
36
29
|
|
|
37
|
-
beforeEach(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
// Wait for installLuaFunctions to settle
|
|
43
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
return new Promise((res) => {
|
|
32
|
+
client = new Client({}, 1, res);
|
|
33
|
+
if (client.manager) client.manager.addQueue = mock.fn();
|
|
34
|
+
});
|
|
44
35
|
});
|
|
45
36
|
|
|
46
37
|
afterEach(async () => {
|
|
47
38
|
await client.close();
|
|
48
|
-
await redis.quit();
|
|
49
39
|
});
|
|
50
40
|
|
|
51
41
|
it('should throw from dispatch() when disconnected', async () => {
|
|
@@ -67,12 +57,10 @@ describe('Queue disconnect guards', () => {
|
|
|
67
57
|
});
|
|
68
58
|
|
|
69
59
|
it('should throw from listen() on non-processing client', async () => {
|
|
70
|
-
const nonProcessingClient = new Client(
|
|
71
|
-
// Wait for installLuaFunctions to settle
|
|
72
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
60
|
+
const nonProcessingClient = await new Promise((res) => new Client({}, 0, res));
|
|
73
61
|
const q = nonProcessingClient.queue('guard-test');
|
|
74
62
|
await assert.rejects(() => q.listen('/some/handler.js'), /non-processing/);
|
|
75
|
-
await
|
|
63
|
+
await nonProcessingClient.close();
|
|
76
64
|
});
|
|
77
65
|
});
|
|
78
66
|
|
|
@@ -90,8 +78,10 @@ describe('Client bump lost lock', () => {
|
|
|
90
78
|
const keys = await redis.keys(`{${QUEUE_NAME}}*`);
|
|
91
79
|
if (keys.length > 0) await redis.del(keys);
|
|
92
80
|
|
|
93
|
-
|
|
94
|
-
|
|
81
|
+
await new Promise((res) => {
|
|
82
|
+
client = new Client({}, 1, res);
|
|
83
|
+
if (client.manager) client.manager.addQueue = mock.fn();
|
|
84
|
+
});
|
|
95
85
|
});
|
|
96
86
|
|
|
97
87
|
afterEach(async () => {
|
package/test/manager.test.js
CHANGED
|
@@ -163,7 +163,9 @@ describe('Manager scheduling', () => {
|
|
|
163
163
|
if (keys.length > 0) await redis.del(keys);
|
|
164
164
|
|
|
165
165
|
// Do NOT mock manager.addQueue — let the manager drive dequeuing
|
|
166
|
-
|
|
166
|
+
await new Promise((res) => {
|
|
167
|
+
client = new Client({}, 1, res);
|
|
168
|
+
});
|
|
167
169
|
});
|
|
168
170
|
|
|
169
171
|
afterEach(async () => {
|
package/test/pool.test.js
CHANGED
|
@@ -111,8 +111,10 @@ describe('Pool stall and timeout', () => {
|
|
|
111
111
|
const keys = await redis.keys(`{${QUEUE_NAME}}*`);
|
|
112
112
|
if (keys.length > 0) await redis.del(keys);
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
await new Promise((res) => {
|
|
115
|
+
client = new Client({}, 1, res);
|
|
116
|
+
if (client.manager) client.manager.addQueue = mock.fn();
|
|
117
|
+
});
|
|
116
118
|
});
|
|
117
119
|
|
|
118
120
|
afterEach(async () => {
|
package/test/queue.test.js
CHANGED
|
@@ -19,10 +19,11 @@ describe('Queue E2E', () => {
|
|
|
19
19
|
await redis.del(keys);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
await new Promise((res) => {
|
|
23
|
+
client = new Client({}, 1, res);
|
|
24
|
+
// Mock this so that no actual work is dequeued by the manager.
|
|
25
|
+
if (client.manager) client.manager.addQueue = mock.fn();
|
|
26
|
+
});
|
|
26
27
|
});
|
|
27
28
|
|
|
28
29
|
afterEach(async () => {
|