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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cascade-b handler — terminal handler, dispatches no further jobs.
|
|
3
|
+
* Subject to all chaos behaviors.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { BroadcastChannel } from 'node:worker_threads';
|
|
7
|
+
import { createClient } from 'redis';
|
|
8
|
+
import { PermanentError } from '../../src/index.js';
|
|
9
|
+
import { pickChaos } from '../shared/chaos.js';
|
|
10
|
+
import { emitEvent } from '../shared/stream.js';
|
|
11
|
+
|
|
12
|
+
const eventRedis = createClient();
|
|
13
|
+
await eventRedis.connect();
|
|
14
|
+
|
|
15
|
+
const crashChannel = new BroadcastChannel('fuzz-crash');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {any} data
|
|
19
|
+
* @param {import('../../src/types.js').Job} job
|
|
20
|
+
*/
|
|
21
|
+
export async function handle(_data, job) {
|
|
22
|
+
const startedAt = Date.now();
|
|
23
|
+
await emitEvent(eventRedis, {
|
|
24
|
+
type: 'start',
|
|
25
|
+
queue: '{fuzz}:cascade-b',
|
|
26
|
+
id: job.id,
|
|
27
|
+
pid: String(process.pid),
|
|
28
|
+
runAt: String(job.runAt),
|
|
29
|
+
startedAt: String(startedAt),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const chaos = pickChaos();
|
|
33
|
+
await emitEvent(eventRedis, {
|
|
34
|
+
type: 'chaos',
|
|
35
|
+
queue: '{fuzz}:cascade-b',
|
|
36
|
+
id: job.id,
|
|
37
|
+
chaos,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (chaos === 'crash') {
|
|
41
|
+
crashChannel.postMessage({ type: 'crash' });
|
|
42
|
+
// Stall so the process exits before we return
|
|
43
|
+
await new Promise(() => {});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (chaos === 'stall') {
|
|
47
|
+
await new Promise(() => {});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (chaos === 'spin') {
|
|
51
|
+
const end = Date.now() + 10_000;
|
|
52
|
+
while (Date.now() < end) {
|
|
53
|
+
/* busy wait */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (chaos === 'permanent') {
|
|
58
|
+
throw new PermanentError('cascade-b: permanent chaos');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (chaos === 'retriable') {
|
|
62
|
+
throw new Error('cascade-b: retriable chaos');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Normal completion
|
|
66
|
+
await emitEvent(eventRedis, {
|
|
67
|
+
type: 'finish',
|
|
68
|
+
queue: '{fuzz}:cascade-b',
|
|
69
|
+
id: job.id,
|
|
70
|
+
finishedAt: String(Date.now()),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail handler — invoked for every job that exhausts retries/stalls on any queue.
|
|
3
|
+
*
|
|
4
|
+
* Received data: [originalJobId, originalData, errorObject]
|
|
5
|
+
*
|
|
6
|
+
* For periodic jobs (id starts with 'fuzz-periodic-'), re-dispatches the job
|
|
7
|
+
* so that periodic jobs keep running indefinitely even after permanent failure.
|
|
8
|
+
* For cascade jobs, simply records the failure.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createClient } from 'redis';
|
|
12
|
+
import { Client } from '../../src/index.js';
|
|
13
|
+
import { emitEvent } from '../shared/stream.js';
|
|
14
|
+
|
|
15
|
+
const redis = createClient();
|
|
16
|
+
const eventRedis = createClient();
|
|
17
|
+
|
|
18
|
+
await redis.connect();
|
|
19
|
+
await eventRedis.connect();
|
|
20
|
+
|
|
21
|
+
// Dispatch-only queasy client (await ready to avoid Function not found race)
|
|
22
|
+
const client = await new Promise((resolve) => new Client(redis, 0, resolve));
|
|
23
|
+
|
|
24
|
+
// Queue references (keys already include braces)
|
|
25
|
+
const periodicQueue = client.queue('{fuzz}:periodic', true);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {[string, any, {message: string}]} data
|
|
29
|
+
* @param {import('../../src/types.js').Job} job
|
|
30
|
+
*/
|
|
31
|
+
export async function handle(data, job) {
|
|
32
|
+
const [originalId, originalData, error] = data;
|
|
33
|
+
|
|
34
|
+
await emitEvent(eventRedis, {
|
|
35
|
+
type: 'fail',
|
|
36
|
+
queue: 'fail',
|
|
37
|
+
id: originalId,
|
|
38
|
+
failJobId: job.id,
|
|
39
|
+
error: error?.message ?? String(error),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Re-dispatch periodic jobs so they continue indefinitely.
|
|
43
|
+
if (originalId.startsWith('fuzz-periodic-')) {
|
|
44
|
+
const delay = 1000 + Math.random() * 4000;
|
|
45
|
+
await periodicQueue.dispatch(originalData, {
|
|
46
|
+
id: originalId,
|
|
47
|
+
runAt: Date.now() + delay,
|
|
48
|
+
updateRunAt: true,
|
|
49
|
+
updateData: true,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* periodic handler — re-queues itself with the same job ID so it fires indefinitely.
|
|
3
|
+
* Also dispatches cascade-a jobs on normal completion.
|
|
4
|
+
* Subject to all chaos behaviors.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { BroadcastChannel } from 'node:worker_threads';
|
|
8
|
+
import { createClient } from 'redis';
|
|
9
|
+
import { Client, PermanentError } from '../../src/index.js';
|
|
10
|
+
import { pickChaos } from '../shared/chaos.js';
|
|
11
|
+
import { emitEvent } from '../shared/stream.js';
|
|
12
|
+
|
|
13
|
+
const redis = createClient();
|
|
14
|
+
const eventRedis = createClient();
|
|
15
|
+
|
|
16
|
+
await redis.connect();
|
|
17
|
+
await eventRedis.connect();
|
|
18
|
+
|
|
19
|
+
// Dispatch-only queasy client (await ready to avoid Function not found race)
|
|
20
|
+
const client = await new Promise((resolve) => new Client(redis, 0, resolve));
|
|
21
|
+
const periodicQueue = client.queue('{fuzz}:periodic', true);
|
|
22
|
+
const cascadeAQueue = client.queue('{fuzz}:cascade-a', true);
|
|
23
|
+
|
|
24
|
+
const crashChannel = new BroadcastChannel('fuzz-crash');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {any} data
|
|
28
|
+
* @param {import('../../src/types.js').Job} job
|
|
29
|
+
*/
|
|
30
|
+
export async function handle(data, job) {
|
|
31
|
+
const startedAt = Date.now();
|
|
32
|
+
await emitEvent(eventRedis, {
|
|
33
|
+
type: 'start',
|
|
34
|
+
queue: '{fuzz}:periodic',
|
|
35
|
+
id: job.id,
|
|
36
|
+
pid: String(process.pid),
|
|
37
|
+
runAt: String(job.runAt),
|
|
38
|
+
startedAt: String(startedAt),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const chaos = pickChaos();
|
|
42
|
+
await emitEvent(eventRedis, {
|
|
43
|
+
type: 'chaos',
|
|
44
|
+
queue: '{fuzz}:periodic',
|
|
45
|
+
id: job.id,
|
|
46
|
+
chaos,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (chaos === 'crash') {
|
|
50
|
+
crashChannel.postMessage({ type: 'crash' });
|
|
51
|
+
await new Promise(() => {});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (chaos === 'stall') {
|
|
55
|
+
await new Promise(() => {});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (chaos === 'spin') {
|
|
59
|
+
const end = Date.now() + 10_000;
|
|
60
|
+
while (Date.now() < end) {
|
|
61
|
+
/* busy wait */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (chaos === 'permanent') {
|
|
66
|
+
throw new PermanentError('periodic: permanent chaos');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (chaos === 'retriable') {
|
|
70
|
+
throw new Error('periodic: retriable chaos');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Normal completion: dispatch a cascade-a job and re-queue self
|
|
74
|
+
const cascadeRunAt = Date.now() + Math.random() * 2000;
|
|
75
|
+
const selfDelay = 1000 + Math.random() * 4000;
|
|
76
|
+
|
|
77
|
+
const [cascadeId] = await Promise.all([
|
|
78
|
+
cascadeAQueue.dispatch({ from: job.id }, { runAt: cascadeRunAt }),
|
|
79
|
+
periodicQueue.dispatch(data, {
|
|
80
|
+
id: job.id,
|
|
81
|
+
runAt: Date.now() + selfDelay,
|
|
82
|
+
updateRunAt: true,
|
|
83
|
+
}),
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
await emitEvent(eventRedis, {
|
|
87
|
+
type: 'finish',
|
|
88
|
+
queue: '{fuzz}:periodic',
|
|
89
|
+
id: job.id,
|
|
90
|
+
finishedAt: String(Date.now()),
|
|
91
|
+
dispatched: cascadeId,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fuzz test child process entry point.
|
|
3
|
+
*
|
|
4
|
+
* Each child process creates one queasy Client (with worker threads) and
|
|
5
|
+
* calls listen() on all three queues. Handlers run inside queasy's internal
|
|
6
|
+
* worker threads and communicate crash signals via BroadcastChannel.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { BroadcastChannel } from 'node:worker_threads';
|
|
12
|
+
import { Client } from '../src/index.js';
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
// ── Configuration ─────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const WORKER_THREADS = 2; // worker threads per child process
|
|
19
|
+
|
|
20
|
+
const BASE_OPTIONS = {
|
|
21
|
+
maxRetries: 3,
|
|
22
|
+
maxStalls: 2,
|
|
23
|
+
minBackoff: 200,
|
|
24
|
+
maxBackoff: 2000,
|
|
25
|
+
timeout: 3000,
|
|
26
|
+
size: 10,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const FAIL_RETRY_OPTIONS = {
|
|
30
|
+
maxRetries: 5,
|
|
31
|
+
minBackoff: 200,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ── Handler paths ──────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
const failHandlerPath = join(__dirname, 'handlers', 'fail-handler.js');
|
|
37
|
+
const periodicPath = join(__dirname, 'handlers', 'periodic.js');
|
|
38
|
+
const cascadeAPath = join(__dirname, 'handlers', 'cascade-a.js');
|
|
39
|
+
const cascadeBPath = join(__dirname, 'handlers', 'cascade-b.js');
|
|
40
|
+
|
|
41
|
+
// ── Crash signal ───────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
// Handlers running in worker threads post to this channel to trigger a crash.
|
|
44
|
+
const crashChannel = new BroadcastChannel('fuzz-crash');
|
|
45
|
+
crashChannel.onmessage = () => {
|
|
46
|
+
process.exit(1);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ── Redis + queasy client ──────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const client = await new Promise((resolve) => new Client({}, WORKER_THREADS, resolve));
|
|
52
|
+
|
|
53
|
+
client.on('disconnected', (reason) => {
|
|
54
|
+
console.error(`[process ${process.pid}] Client disconnected: ${reason}`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Forward client lifecycle events to the orchestrator via IPC.
|
|
59
|
+
// The orchestrator uses these to authoritatively track active jobs.
|
|
60
|
+
client.on('dequeue', (queue, job) => {
|
|
61
|
+
process.send({ type: 'dequeue', queue, jobId: job.id, runAt: job.runAt });
|
|
62
|
+
});
|
|
63
|
+
client.on('finish', (queue, jobId) => {
|
|
64
|
+
process.send({ type: 'finish', queue, jobId });
|
|
65
|
+
});
|
|
66
|
+
client.on('retry', (queue, jobId) => {
|
|
67
|
+
process.send({ type: 'retry', queue, jobId });
|
|
68
|
+
});
|
|
69
|
+
client.on('fail', (queue, jobId) => {
|
|
70
|
+
process.send({ type: 'fail', queue, jobId });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ── Queue setup ────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const periodicQueue = client.queue('{fuzz}:periodic', true);
|
|
76
|
+
const cascadeAQueue = client.queue('{fuzz}:cascade-a', true);
|
|
77
|
+
const cascadeBQueue = client.queue('{fuzz}:cascade-b', true);
|
|
78
|
+
|
|
79
|
+
await Promise.all([
|
|
80
|
+
periodicQueue.listen(periodicPath, {
|
|
81
|
+
...BASE_OPTIONS,
|
|
82
|
+
priority: 300,
|
|
83
|
+
failHandler: failHandlerPath,
|
|
84
|
+
failRetryOptions: FAIL_RETRY_OPTIONS,
|
|
85
|
+
}),
|
|
86
|
+
cascadeAQueue.listen(cascadeAPath, {
|
|
87
|
+
...BASE_OPTIONS,
|
|
88
|
+
priority: 200,
|
|
89
|
+
failHandler: failHandlerPath,
|
|
90
|
+
failRetryOptions: FAIL_RETRY_OPTIONS,
|
|
91
|
+
}),
|
|
92
|
+
cascadeBQueue.listen(cascadeBPath, {
|
|
93
|
+
...BASE_OPTIONS,
|
|
94
|
+
priority: 100,
|
|
95
|
+
failHandler: failHandlerPath,
|
|
96
|
+
failRetryOptions: FAIL_RETRY_OPTIONS,
|
|
97
|
+
}),
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
console.log(`[process ${process.pid}] Ready`);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weighted random chaos behavior picker.
|
|
3
|
+
* All handlers apply the same set of chaos behaviors.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const BEHAVIORS = [
|
|
7
|
+
{ action: 'normal', weight: 65 },
|
|
8
|
+
{ action: 'retriable', weight: 15 },
|
|
9
|
+
{ action: 'permanent', weight: 5 },
|
|
10
|
+
{ action: 'stall', weight: 10 },
|
|
11
|
+
{ action: 'spin', weight: 3 },
|
|
12
|
+
{ action: 'crash', weight: 2 },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const TOTAL_WEIGHT = BEHAVIORS.reduce((sum, b) => sum + b.weight, 0);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Pick a chaos action based on weighted probability.
|
|
19
|
+
* @returns {'normal' | 'retriable' | 'permanent' | 'stall' | 'spin' | 'crash'}
|
|
20
|
+
*/
|
|
21
|
+
export function pickChaos() {
|
|
22
|
+
let r = Math.random() * TOTAL_WEIGHT;
|
|
23
|
+
for (const { action, weight } of BEHAVIORS) {
|
|
24
|
+
r -= weight;
|
|
25
|
+
if (r <= 0) return /** @type {any} */ (action);
|
|
26
|
+
}
|
|
27
|
+
return 'normal';
|
|
28
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redis stream helpers for the fuzz test event log.
|
|
3
|
+
* All events are written to the 'fuzz:events' stream.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const STREAM_KEY = 'fuzz:events';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Emit a structured event to the fuzz:events stream.
|
|
10
|
+
* @param {import('redis').RedisClientType} redis
|
|
11
|
+
* @param {Record<string, string>} fields
|
|
12
|
+
*/
|
|
13
|
+
export async function emitEvent(redis, fields) {
|
|
14
|
+
try {
|
|
15
|
+
await redis.xAdd(STREAM_KEY, '*', fields);
|
|
16
|
+
} catch {
|
|
17
|
+
// Never let event emission crash a handler
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Async generator that yields parsed event objects from the fuzz:events stream.
|
|
23
|
+
* Blocks for up to 1 second waiting for new events, then yields control back.
|
|
24
|
+
* @param {import('redis').RedisClientType} redis
|
|
25
|
+
* @param {string} [lastId='0'] - Stream ID to start reading from ('0' = from beginning, '$' = new only)
|
|
26
|
+
* @yields {Record<string, string>}
|
|
27
|
+
*/
|
|
28
|
+
export async function* readEvents(redis, lastId = '0') {
|
|
29
|
+
let id = lastId;
|
|
30
|
+
while (true) {
|
|
31
|
+
const results = await redis.xRead({ key: STREAM_KEY, id }, { BLOCK: 1000, COUNT: 100 });
|
|
32
|
+
if (!results) continue;
|
|
33
|
+
for (const { messages } of results) {
|
|
34
|
+
for (const { id: msgId, message } of messages) {
|
|
35
|
+
id = msgId;
|
|
36
|
+
yield message;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "queasy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A simple Redis-backed queue library for Node.js",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -29,13 +29,12 @@
|
|
|
29
29
|
],
|
|
30
30
|
"author": "",
|
|
31
31
|
"license": "ISC",
|
|
32
|
-
"
|
|
32
|
+
"dependencies": {
|
|
33
33
|
"redis": "^5.10.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@biomejs/biome": "^2.3.14",
|
|
37
37
|
"@types/node": "^25.2.0",
|
|
38
|
-
"redis": "^5.10.0",
|
|
39
38
|
"typescript": "^5.9.3"
|
|
40
39
|
}
|
|
41
40
|
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# Plan: Client constructs its own Redis connection
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Change the `Client` constructor so that instead of accepting a pre-built node-redis
|
|
6
|
+
connection as its first argument, it accepts an options object and constructs the
|
|
7
|
+
connection itself (calling either `createClient` or `createCluster` from the `redis`
|
|
8
|
+
package). This gives Queasy full control over connection lifecycle.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 1. Options object design
|
|
13
|
+
|
|
14
|
+
The first argument changes from `RedisClient` to a `RedisOptions` object:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import type { RedisClientOptions, RedisClusterOptions } from 'redis';
|
|
18
|
+
|
|
19
|
+
type SingleNodeOptions = Pick<RedisClientOptions, 'url' | 'socket' | 'username' | 'password' | 'database'>;
|
|
20
|
+
|
|
21
|
+
type RedisOptions =
|
|
22
|
+
| SingleNodeOptions
|
|
23
|
+
| {
|
|
24
|
+
rootNodes: SingleNodeOptions[];
|
|
25
|
+
defaults?: Partial<SingleNodeOptions>;
|
|
26
|
+
nodeAddressMap?: RedisClusterOptions['nodeAddressMap'];
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`RedisClientOptions` and `RedisClusterOptions` are both exported from the top-level
|
|
31
|
+
`redis` package and can be imported directly. `RedisClusterClientOptions` (the type
|
|
32
|
+
node-redis uses for `rootNodes` elements and `defaults`) is **not** exported at the
|
|
33
|
+
top level, so we define the cluster form inline, reusing `SingleNodeOptions` to
|
|
34
|
+
constrain both `rootNodes` elements and `defaults` to the same permitted fields as
|
|
35
|
+
the single-node case. `nodeAddressMap` is taken via an index type from
|
|
36
|
+
`RedisClusterOptions` directly to avoid depending on the unexported `NodeAddressMap`
|
|
37
|
+
type.
|
|
38
|
+
|
|
39
|
+
The dispatch rule: **if `options.rootNodes` exists, use `createCluster`; otherwise
|
|
40
|
+
use `createClient`.**
|
|
41
|
+
|
|
42
|
+
- If `options.rootNodes` is present: call `createCluster(options)`, passing the
|
|
43
|
+
object through directly. The caller may also provide `defaults` (shared auth/TLS
|
|
44
|
+
for all nodes) and `nodeAddressMap` — all standard `createCluster` options,
|
|
45
|
+
named consistently with the node-redis API.
|
|
46
|
+
- Otherwise: call `createClient(options)`, passing the object through directly.
|
|
47
|
+
- The constructed connection is stored as `this.redis`.
|
|
48
|
+
- `connect()` is called internally in the constructor.
|
|
49
|
+
- `close()` calls `this.redis.destroy()` to disconnect.
|
|
50
|
+
|
|
51
|
+
### Constructor signature
|
|
52
|
+
|
|
53
|
+
All three arguments default so that `new Client()` works (connects to `localhost:6379`):
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
constructor(options = {}, workerCount = os.cpus().length, callback = undefined)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 2. Changes to `src/client.js`
|
|
62
|
+
|
|
63
|
+
1. Add imports:
|
|
64
|
+
```js
|
|
65
|
+
import { createClient, createCluster } from 'redis';
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
2. Add a helper at module level:
|
|
69
|
+
```js
|
|
70
|
+
function buildRedisConnection(options) {
|
|
71
|
+
if (options.rootNodes) {
|
|
72
|
+
return createCluster(options);
|
|
73
|
+
}
|
|
74
|
+
return createClient(options);
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
3. Update constructor:
|
|
79
|
+
- Parameter `redis` → `options` (with JSDoc type `RedisOptions`)
|
|
80
|
+
- Replace `this.redis = redis;` with:
|
|
81
|
+
```js
|
|
82
|
+
this.redis = buildRedisConnection(options);
|
|
83
|
+
```
|
|
84
|
+
- Replace the bare `installLuaFunctions(this.redis).then(...)` call with:
|
|
85
|
+
```js
|
|
86
|
+
this.redis.connect()
|
|
87
|
+
.then(() => installLuaFunctions(this.redis))
|
|
88
|
+
.then((disconnect) => { ... })
|
|
89
|
+
.catch((err) => {
|
|
90
|
+
this.disconnected = true;
|
|
91
|
+
this.emit('disconnected', err.message);
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
- Apply defaults to all parameters:
|
|
95
|
+
```js
|
|
96
|
+
constructor(options = {}, workerCount = os.cpus().length, callback)
|
|
97
|
+
```
|
|
98
|
+
Add `import os from 'node:os';` (check if already imported; if so, reuse).
|
|
99
|
+
|
|
100
|
+
4. Update `close()`:
|
|
101
|
+
- After `this.disconnected = true;`, add:
|
|
102
|
+
```js
|
|
103
|
+
await this.redis.destroy().catch(() => {});
|
|
104
|
+
```
|
|
105
|
+
`destroy()` is the documented disconnect method for both `RedisClientType`
|
|
106
|
+
and `RedisClusterType` in node-redis v5.
|
|
107
|
+
|
|
108
|
+
5. Update JSDoc typedef:
|
|
109
|
+
```js
|
|
110
|
+
/** @typedef {import('./types').RedisOptions} RedisOptions */
|
|
111
|
+
```
|
|
112
|
+
Point to `types.ts` rather than duplicating the type inline in JS, since the
|
|
113
|
+
full `Pick<>` construction is cleaner to express in TypeScript. Remove the old
|
|
114
|
+
`RedisClient` typedef.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## 3. Changes to `src/types.ts`
|
|
119
|
+
|
|
120
|
+
Import the relevant node-redis types and define `RedisOptions` using `Pick<>` and
|
|
121
|
+
an inline cluster shape:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import type { RedisClientOptions, RedisClusterOptions } from 'redis';
|
|
125
|
+
|
|
126
|
+
type SingleNodeOptions = Pick<RedisClientOptions, 'url' | 'socket' | 'username' | 'password' | 'database'>;
|
|
127
|
+
|
|
128
|
+
export type RedisOptions =
|
|
129
|
+
| SingleNodeOptions
|
|
130
|
+
| {
|
|
131
|
+
rootNodes: SingleNodeOptions[];
|
|
132
|
+
defaults?: Partial<SingleNodeOptions>;
|
|
133
|
+
nodeAddressMap?: RedisClusterOptions['nodeAddressMap'];
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`SingleNodeOptions` is not exported — it's an internal building block. Only
|
|
138
|
+
`RedisOptions` is exported. Update the `Client` constructor signature to accept
|
|
139
|
+
`RedisOptions` instead of `RedisClientType`.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## 4. Changes to `package.json`
|
|
144
|
+
|
|
145
|
+
- Move `redis` from `peerDependencies` + `devDependencies` to **`dependencies`**
|
|
146
|
+
(queasy now owns the connection; callers no longer need to install it themselves).
|
|
147
|
+
- Remove the `peerDependencies` section entirely.
|
|
148
|
+
|
|
149
|
+
Before:
|
|
150
|
+
```json
|
|
151
|
+
"peerDependencies": { "redis": "^5.10.0" },
|
|
152
|
+
"devDependencies": { "redis": "^5.10.0", ... }
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
After:
|
|
156
|
+
```json
|
|
157
|
+
"dependencies": { "redis": "^5.10.0" },
|
|
158
|
+
"devDependencies": { ... }
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## 5. Changes to `test/queue.test.js`
|
|
164
|
+
|
|
165
|
+
Currently each `beforeEach` creates and connects a Redis client, passes it to
|
|
166
|
+
`Client`, and each `afterEach` calls `redis.quit()`.
|
|
167
|
+
|
|
168
|
+
Changes:
|
|
169
|
+
- Remove the `createClient` import and the `redis` variable.
|
|
170
|
+
- Change `new Client(redis, 1)` → `new Client({}, 1)`.
|
|
171
|
+
- Remove `await redis.quit()` — `client.close()` now handles disconnection.
|
|
172
|
+
- Any direct Redis inspection calls (`redis.zScore`, `redis.hGetAll`, `redis.keys`,
|
|
173
|
+
`redis.del`) still need a Redis connection. Add a dedicated `redisInspect` client
|
|
174
|
+
used only for test-side assertions:
|
|
175
|
+
```js
|
|
176
|
+
// beforeEach
|
|
177
|
+
redisInspect = createClient();
|
|
178
|
+
await redisInspect.connect();
|
|
179
|
+
// afterEach
|
|
180
|
+
await redisInspect.quit();
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## 6. Changes to `test/client.test.js`
|
|
186
|
+
|
|
187
|
+
Same pattern as `queue.test.js`:
|
|
188
|
+
- Remove `createClient` import and `redis` variable.
|
|
189
|
+
- `new Client(redis, 1)` → `new Client({}, 1)`.
|
|
190
|
+
- Direct Redis calls (`redis.zScore`, `redis.keys`, `redis.del`) move to a
|
|
191
|
+
separate `redisInspect` client.
|
|
192
|
+
- Remove `await redis.quit()` from `afterEach`; `client.close()` handles it.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## 7. Changes to `test/redis-functions.test.js`
|
|
197
|
+
|
|
198
|
+
This test file does **not** use the `Client` class — it builds its own `redis`
|
|
199
|
+
connection to test Lua functions directly. **No changes needed.**
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## 8. Changes to `fuzztest/process.js`
|
|
204
|
+
|
|
205
|
+
Currently:
|
|
206
|
+
```js
|
|
207
|
+
import { createClient } from 'redis';
|
|
208
|
+
const redis = createClient();
|
|
209
|
+
await redis.connect();
|
|
210
|
+
const client = await new Promise((resolve) => new Client(redis, WORKER_THREADS, resolve));
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Change to:
|
|
214
|
+
```js
|
|
215
|
+
const client = await new Promise((resolve) => new Client({}, WORKER_THREADS, resolve));
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Remove the `import { createClient } from 'redis'` line and the three lines that
|
|
219
|
+
create and connect `redis`.
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## 9. Changes to `Readme.md`
|
|
224
|
+
|
|
225
|
+
Update the `client()` API section:
|
|
226
|
+
|
|
227
|
+
**Before:**
|
|
228
|
+
```
|
|
229
|
+
### `client(redisConnection, workerCount)`
|
|
230
|
+
Returns a Queasy client.
|
|
231
|
+
- `redisConnection`: a node-redis connection object.
|
|
232
|
+
- `workerCount`: number; Size of the worker pool. ...
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
**After:**
|
|
236
|
+
```
|
|
237
|
+
### `new Client(options, workerCount)`
|
|
238
|
+
Returns a Queasy client. Queasy creates and manages its own Redis connection internally.
|
|
239
|
+
- `options`: connection options. Two forms are accepted:
|
|
240
|
+
- **Single node** (plain object): passed to node-redis `createClient`. Accepts
|
|
241
|
+
`url`, `socket`, `username`, `password`, and `database`. Defaults to `{}`
|
|
242
|
+
(connects to `localhost:6379`).
|
|
243
|
+
- **Cluster** (object with `rootNodes`): passed to node-redis `createCluster`.
|
|
244
|
+
Accepts `rootNodes` (required — array of per-node connection options, at least
|
|
245
|
+
three recommended), `defaults` (options shared across all nodes, e.g. auth and
|
|
246
|
+
TLS), and `nodeAddressMap` (for address translation in NAT environments).
|
|
247
|
+
- `workerCount`: number; size of the worker pool. Defaults to number of CPUs.
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Also update the terminology section to reflect that the client manages its own
|
|
251
|
+
connection rather than accepting one from the caller.
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## 10. Changes to `CLAUDE.md`
|
|
256
|
+
|
|
257
|
+
In the Architecture section, update the JS layer description:
|
|
258
|
+
- "On construction, it calls `createClient` or `createCluster` (based on whether
|
|
259
|
+
`options.rootNodes` is present), connects, then uploads the Lua script via
|
|
260
|
+
`FUNCTION LOAD REPLACE`."
|
|
261
|
+
- Remove the mention of `WeakSet (initializedClients)` tracking which Redis clients
|
|
262
|
+
have had functions loaded (no longer relevant since the connection is internal and
|
|
263
|
+
only created once per `Client` instance).
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## Summary of file changes
|
|
268
|
+
|
|
269
|
+
| File | Change |
|
|
270
|
+
|---|---|
|
|
271
|
+
| `src/client.js` | Accept `RedisOptions`; call `createClient`/`createCluster`; connect in constructor; `destroy()` in `close()`; emit `'disconnected'` on connect error; default all args |
|
|
272
|
+
| `src/types.ts` | Add `RedisOptions` type using `Pick<RedisClientOptions, ...> \| Pick<RedisClusterOptions, ...>`; remove `RedisClientType` reference |
|
|
273
|
+
| `package.json` | Move `redis` from `peerDependencies`+`devDependencies` to `dependencies` |
|
|
274
|
+
| `test/queue.test.js` | Remove external `redis` client; pass `{}` to `Client`; add `redisInspect` for assertions |
|
|
275
|
+
| `test/client.test.js` | Same as above |
|
|
276
|
+
| `test/redis-functions.test.js` | No changes |
|
|
277
|
+
| `fuzztest/process.js` | Remove `createClient`/`redis`; pass `{}` to `Client` |
|
|
278
|
+
| `Readme.md` | Update `client()` API docs |
|
|
279
|
+
| `CLAUDE.md` | Update architecture description |
|