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
package/src/client.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import EventEmitter from 'node:events';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { getEnvironmentData } from 'node:worker_threads';
|
|
7
|
+
import { createClient, createCluster } from 'redis';
|
|
8
|
+
import { HEARTBEAT_INTERVAL, HEARTBEAT_TIMEOUT, LUA_FUNCTIONS_VERSION } from './constants.ts';
|
|
9
|
+
import { Manager } from './manager.ts';
|
|
10
|
+
import { Pool } from './pool.ts';
|
|
11
|
+
import { Queue } from './queue.ts';
|
|
12
|
+
import type { Job, RedisOptions } from './types.ts';
|
|
13
|
+
import { compareSemver, generateId, parseVersion } from './utils.ts';
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const luaScript = readFileSync(join(__dirname, 'queasy.lua'), 'utf8').replace(
|
|
17
|
+
'__QUEASY_VERSION__',
|
|
18
|
+
LUA_FUNCTIONS_VERSION
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
type RedisConnection = ReturnType<typeof createClient> | ReturnType<typeof createCluster>;
|
|
22
|
+
|
|
23
|
+
async function installLuaFunctions(redis: RedisConnection): Promise<boolean> {
|
|
24
|
+
const installedVersionString = (await (redis as ReturnType<typeof createClient>)
|
|
25
|
+
.fCall('queasy_version', { keys: [], arguments: [] })
|
|
26
|
+
.catch(() => null)) as string | null;
|
|
27
|
+
const installedVersion = parseVersion(installedVersionString);
|
|
28
|
+
const availableVersion = parseVersion(LUA_FUNCTIONS_VERSION);
|
|
29
|
+
|
|
30
|
+
if (compareSemver(availableVersion, installedVersion) > 0) {
|
|
31
|
+
await (redis as ReturnType<typeof createClient>).sendCommand([
|
|
32
|
+
'FUNCTION',
|
|
33
|
+
'LOAD',
|
|
34
|
+
'REPLACE',
|
|
35
|
+
luaScript,
|
|
36
|
+
]);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return installedVersion[0] > availableVersion[0];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildRedisConnection(options: RedisOptions): RedisConnection {
|
|
44
|
+
if ('rootNodes' in options) {
|
|
45
|
+
return createCluster(options);
|
|
46
|
+
}
|
|
47
|
+
return createClient(options);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface QueueEntry {
|
|
51
|
+
queue: Queue;
|
|
52
|
+
bumpTimer?: NodeJS.Timeout;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseJob(jobArray: string[]): Job | null {
|
|
56
|
+
if (!jobArray || jobArray.length === 0) return null;
|
|
57
|
+
|
|
58
|
+
const job: Record<string, string> = {};
|
|
59
|
+
for (let i = 0; i < jobArray.length; i += 2) {
|
|
60
|
+
const key = jobArray[i];
|
|
61
|
+
const value = jobArray[i + 1];
|
|
62
|
+
job[key] = value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
id: job.id,
|
|
67
|
+
data: job.data ? JSON.parse(job.data) : undefined,
|
|
68
|
+
runAt: job.run_at ? Number(job.run_at) : 0,
|
|
69
|
+
retryCount: Number(job.retry_count || 0),
|
|
70
|
+
stallCount: Number(job.stall_count || 0),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class Client extends EventEmitter {
|
|
75
|
+
redis: RedisConnection;
|
|
76
|
+
clientId: string;
|
|
77
|
+
queues: Record<string, QueueEntry>;
|
|
78
|
+
disconnected: boolean;
|
|
79
|
+
pool: Pool | undefined;
|
|
80
|
+
manager: Manager | undefined;
|
|
81
|
+
|
|
82
|
+
constructor(
|
|
83
|
+
options: RedisOptions = {},
|
|
84
|
+
workerCount: number = os.cpus().length,
|
|
85
|
+
callback?: (client: Client) => unknown
|
|
86
|
+
) {
|
|
87
|
+
super();
|
|
88
|
+
this.redis = buildRedisConnection(options);
|
|
89
|
+
this.clientId = generateId();
|
|
90
|
+
this.queues = {};
|
|
91
|
+
this.disconnected = false;
|
|
92
|
+
|
|
93
|
+
const inWorker = getEnvironmentData('queasy_worker_context');
|
|
94
|
+
this.pool = !inWorker && workerCount !== 0 ? new Pool(workerCount) : undefined;
|
|
95
|
+
if (this.pool) this.manager = new Manager(this.pool);
|
|
96
|
+
|
|
97
|
+
this.redis
|
|
98
|
+
.connect()
|
|
99
|
+
.then(() => installLuaFunctions(this.redis))
|
|
100
|
+
.then((disconnect) => {
|
|
101
|
+
this.disconnected = disconnect;
|
|
102
|
+
if (disconnect) this.emit('disconnected', 'Redis has incompatible queasy version.');
|
|
103
|
+
else {
|
|
104
|
+
this.emit('connected');
|
|
105
|
+
if (callback) callback(this);
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
.catch((err: Error) => {
|
|
109
|
+
this.disconnected = true;
|
|
110
|
+
this.emit('disconnected', err.message);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
queue(name: string, isKey = false): Queue {
|
|
115
|
+
if (this.disconnected) throw new Error("Can't add queue: client disconnected");
|
|
116
|
+
|
|
117
|
+
const key = isKey ? name : `{${name}}`;
|
|
118
|
+
if (!this.queues[key]) {
|
|
119
|
+
this.queues[key] = {
|
|
120
|
+
queue: new Queue(key, this, this.pool, this.manager),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return this.queues[key].queue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
scheduleBump(key: string): void {
|
|
127
|
+
const queueEntry = this.queues[key];
|
|
128
|
+
if (queueEntry.bumpTimer) clearTimeout(queueEntry.bumpTimer);
|
|
129
|
+
queueEntry.bumpTimer = setTimeout(() => this.bump(key), HEARTBEAT_INTERVAL);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async bump(key: string): Promise<void> {
|
|
133
|
+
if (this.disconnected) return;
|
|
134
|
+
this.scheduleBump(key);
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
const expiry = now + HEARTBEAT_TIMEOUT;
|
|
137
|
+
const bumped = await (this.redis as ReturnType<typeof createClient>).fCall('queasy_bump', {
|
|
138
|
+
keys: [key],
|
|
139
|
+
arguments: [this.clientId, String(now), String(expiry)],
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (!bumped) {
|
|
143
|
+
await this.close();
|
|
144
|
+
this.emit('disconnected', 'Lost locks, possible main thread freeze');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async close(): Promise<void> {
|
|
149
|
+
if (this.pool) await this.pool.close();
|
|
150
|
+
if (this.manager) await this.manager.close();
|
|
151
|
+
this.queues = {};
|
|
152
|
+
this.pool = undefined;
|
|
153
|
+
this.disconnected = true;
|
|
154
|
+
await this.redis.quit().catch(() => {});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async dispatch(
|
|
158
|
+
key: string,
|
|
159
|
+
id: string,
|
|
160
|
+
runAt: number,
|
|
161
|
+
// biome-ignore lint/suspicious/noExplicitAny: Data is any serializable value
|
|
162
|
+
data: any,
|
|
163
|
+
updateData: boolean,
|
|
164
|
+
updateRunAt: boolean | string,
|
|
165
|
+
resetCounts: boolean
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
await (this.redis as ReturnType<typeof createClient>).fCall('queasy_dispatch', {
|
|
168
|
+
keys: [key],
|
|
169
|
+
arguments: [
|
|
170
|
+
id,
|
|
171
|
+
String(runAt),
|
|
172
|
+
JSON.stringify(data),
|
|
173
|
+
String(updateData),
|
|
174
|
+
String(updateRunAt),
|
|
175
|
+
String(resetCounts),
|
|
176
|
+
],
|
|
177
|
+
});
|
|
178
|
+
this.emit('dispatch', key, { id, runAt, data, updateData, updateRunAt, resetCounts });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async cancel(key: string, id: string): Promise<boolean> {
|
|
182
|
+
const result = await (this.redis as ReturnType<typeof createClient>).fCall(
|
|
183
|
+
'queasy_cancel',
|
|
184
|
+
{
|
|
185
|
+
keys: [key],
|
|
186
|
+
arguments: [id],
|
|
187
|
+
}
|
|
188
|
+
);
|
|
189
|
+
this.emit('cancel', key, id);
|
|
190
|
+
return result === 1;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async dequeue(key: string, count: number): Promise<Job[]> {
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
const expiry = now + HEARTBEAT_TIMEOUT;
|
|
196
|
+
const result = (await (this.redis as ReturnType<typeof createClient>).fCall(
|
|
197
|
+
'queasy_dequeue',
|
|
198
|
+
{
|
|
199
|
+
keys: [key],
|
|
200
|
+
arguments: [this.clientId, String(now), String(expiry), String(count)],
|
|
201
|
+
}
|
|
202
|
+
)) as string[][];
|
|
203
|
+
|
|
204
|
+
this.scheduleBump(key);
|
|
205
|
+
|
|
206
|
+
const jobs = result.map((jobArray) => parseJob(jobArray)).filter(Boolean) as Job[];
|
|
207
|
+
for (const job of jobs) this.emit('dequeue', key, job);
|
|
208
|
+
return jobs;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async finish(key: string, jobId: string): Promise<void> {
|
|
212
|
+
await (this.redis as ReturnType<typeof createClient>).fCall('queasy_finish', {
|
|
213
|
+
keys: [key],
|
|
214
|
+
arguments: [jobId, this.clientId, String(Date.now())],
|
|
215
|
+
});
|
|
216
|
+
this.emit('finish', key, jobId);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async fail(
|
|
220
|
+
key: string,
|
|
221
|
+
failkey: string,
|
|
222
|
+
jobId: string,
|
|
223
|
+
// biome-ignore lint/suspicious/noExplicitAny: Fail job data is any serializable value
|
|
224
|
+
failJobData: any
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
await (this.redis as ReturnType<typeof createClient>).fCall('queasy_fail', {
|
|
227
|
+
keys: [key, failkey],
|
|
228
|
+
arguments: [
|
|
229
|
+
jobId,
|
|
230
|
+
this.clientId,
|
|
231
|
+
generateId(),
|
|
232
|
+
JSON.stringify(failJobData),
|
|
233
|
+
String(Date.now()),
|
|
234
|
+
],
|
|
235
|
+
});
|
|
236
|
+
this.emit('fail', key, jobId);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async retry(key: string, jobId: string, retryAt: number): Promise<void> {
|
|
240
|
+
await (this.redis as ReturnType<typeof createClient>).fCall('queasy_retry', {
|
|
241
|
+
keys: [key],
|
|
242
|
+
arguments: [jobId, this.clientId, String(retryAt), String(Date.now())],
|
|
243
|
+
});
|
|
244
|
+
this.emit('retry', key, jobId);
|
|
245
|
+
}
|
|
246
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { HandlerOptions, JobUpdateOptions } from './types.ts';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_RETRY_OPTIONS: Required<HandlerOptions> = {
|
|
4
|
+
maxRetries: 10,
|
|
5
|
+
maxStalls: 3,
|
|
6
|
+
minBackoff: 2_000,
|
|
7
|
+
maxBackoff: 300_000, // 5 minutes
|
|
8
|
+
size: 10,
|
|
9
|
+
timeout: 60_000, // 1 minute
|
|
10
|
+
priority: 100,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_UPDATE_OPTIONS: Required<JobUpdateOptions> = {
|
|
14
|
+
updateData: true,
|
|
15
|
+
updateRunAt: true,
|
|
16
|
+
resetCounts: false,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const FAILJOB_RETRY_OPTIONS: Required<HandlerOptions> = {
|
|
20
|
+
maxRetries: 100,
|
|
21
|
+
maxStalls: 3,
|
|
22
|
+
minBackoff: 10_000,
|
|
23
|
+
maxBackoff: 900_000, // 15 minutes
|
|
24
|
+
size: 2,
|
|
25
|
+
timeout: 60_000,
|
|
26
|
+
priority: 100,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const LUA_FUNCTIONS_VERSION = '1.0.1';
|
|
30
|
+
export const HEARTBEAT_INTERVAL = 5000; // 5 seconds
|
|
31
|
+
export const HEARTBEAT_TIMEOUT = 10000; // 10 seconds
|
|
32
|
+
export const WORKER_CAPACITY = 10;
|
|
33
|
+
export const DEQUEUE_INTERVAL = 100; // ms
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export class PermanentError extends Error {
|
|
2
|
+
constructor(message: string) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = 'PermanentError';
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class StallError extends Error {
|
|
9
|
+
constructor(message: string) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'StallError';
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/index.ts
ADDED
package/src/manager.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { DEQUEUE_INTERVAL } from './constants.ts';
|
|
2
|
+
import type { Pool } from './pool.ts';
|
|
3
|
+
import type { Queue } from './queue.ts';
|
|
4
|
+
|
|
5
|
+
interface QueueEntry {
|
|
6
|
+
queue: Queue;
|
|
7
|
+
lastDequeuedAt: number;
|
|
8
|
+
isBusy: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class Manager {
|
|
12
|
+
pool: Pool;
|
|
13
|
+
queues: QueueEntry[];
|
|
14
|
+
timer: NodeJS.Timeout | null;
|
|
15
|
+
busyCount: number;
|
|
16
|
+
|
|
17
|
+
constructor(pool: Pool) {
|
|
18
|
+
this.pool = pool;
|
|
19
|
+
this.queues = [];
|
|
20
|
+
this.timer = null;
|
|
21
|
+
this.busyCount = 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
addQueue(queue: Queue): void {
|
|
25
|
+
this.queues.unshift({ queue, lastDequeuedAt: 0, isBusy: false });
|
|
26
|
+
this.busyCount += 1;
|
|
27
|
+
this.next();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async next(): Promise<void> {
|
|
31
|
+
const entry = this.queues.shift();
|
|
32
|
+
if (!entry) return;
|
|
33
|
+
if (this.timer) {
|
|
34
|
+
clearTimeout(this.timer);
|
|
35
|
+
this.timer = null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const size = entry.queue.handlerOptions!.size;
|
|
39
|
+
if (this.pool.capacity < size) return;
|
|
40
|
+
|
|
41
|
+
const batchSize = Math.max(1, Math.floor(this.pool.capacity / this.busyCount / size));
|
|
42
|
+
entry.lastDequeuedAt = Date.now();
|
|
43
|
+
const { count } = await entry.queue.dequeue(batchSize);
|
|
44
|
+
|
|
45
|
+
const nowBusy = count >= batchSize;
|
|
46
|
+
this.busyCount += Number(nowBusy) - Number(entry.isBusy);
|
|
47
|
+
entry.isBusy = nowBusy;
|
|
48
|
+
|
|
49
|
+
this.queues.push(entry);
|
|
50
|
+
this.queues.sort(compareQueueEntries);
|
|
51
|
+
|
|
52
|
+
if (!this.timer && this.queues.length) {
|
|
53
|
+
const { isBusy, lastDequeuedAt } = this.queues[0];
|
|
54
|
+
const delay = isBusy ? 0 : Math.max(0, lastDequeuedAt - Date.now() + DEQUEUE_INTERVAL);
|
|
55
|
+
this.timer = setTimeout(() => this.next(), delay);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
close(): void {
|
|
60
|
+
if (this.timer) clearTimeout(this.timer);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function compareQueueEntries(a: QueueEntry, b: QueueEntry): -1 | 0 | 1 {
|
|
65
|
+
if (a.isBusy > b.isBusy) return -1;
|
|
66
|
+
if (a.isBusy < b.isBusy) return 1;
|
|
67
|
+
|
|
68
|
+
if (a.queue.handlerOptions!.priority > b.queue.handlerOptions!.priority) return 1;
|
|
69
|
+
if (a.queue.handlerOptions!.priority < b.queue.handlerOptions!.priority) return -1;
|
|
70
|
+
|
|
71
|
+
if (a.lastDequeuedAt > b.lastDequeuedAt) return -1;
|
|
72
|
+
if (a.lastDequeuedAt < b.lastDequeuedAt) return 1;
|
|
73
|
+
|
|
74
|
+
if (a.queue.handlerOptions!.size > b.queue.handlerOptions!.size) return 1;
|
|
75
|
+
if (a.queue.handlerOptions!.size < b.queue.handlerOptions!.size) return -1;
|
|
76
|
+
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
package/src/pool.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { availableParallelism } from 'node:os';
|
|
2
|
+
import { Worker } from 'node:worker_threads';
|
|
3
|
+
import { WORKER_CAPACITY } from './constants.ts';
|
|
4
|
+
import type { DoneMessage, Job } from './types.ts';
|
|
5
|
+
import { generateId } from './utils.ts';
|
|
6
|
+
|
|
7
|
+
interface WorkerEntry {
|
|
8
|
+
worker: Worker;
|
|
9
|
+
capacity: number;
|
|
10
|
+
id: string;
|
|
11
|
+
jobCount: number;
|
|
12
|
+
stalledJobs: Set<string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface JobEntry {
|
|
16
|
+
resolve: (value: DoneMessage) => void;
|
|
17
|
+
reject: (reason: DoneMessage) => void;
|
|
18
|
+
size: number;
|
|
19
|
+
timer: NodeJS.Timeout;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class Pool {
|
|
23
|
+
workers: Set<WorkerEntry>;
|
|
24
|
+
activeJobs: Map<string, JobEntry>;
|
|
25
|
+
capacity: number;
|
|
26
|
+
|
|
27
|
+
constructor(targetCount?: number | null) {
|
|
28
|
+
this.workers = new Set();
|
|
29
|
+
this.activeJobs = new Map();
|
|
30
|
+
this.capacity = 0;
|
|
31
|
+
|
|
32
|
+
const count = targetCount ?? availableParallelism();
|
|
33
|
+
for (let i = 0; i < count; i++) this.createWorker();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
createWorker(): void {
|
|
37
|
+
// Copy file extension from current file so it works in src and dist.
|
|
38
|
+
const workerFilename = `./worker${import.meta.url.slice(-3)}`;
|
|
39
|
+
const worker = new Worker(new URL(workerFilename, import.meta.url));
|
|
40
|
+
const entry: WorkerEntry = {
|
|
41
|
+
worker,
|
|
42
|
+
capacity: WORKER_CAPACITY,
|
|
43
|
+
id: generateId(),
|
|
44
|
+
jobCount: 0,
|
|
45
|
+
stalledJobs: new Set(),
|
|
46
|
+
};
|
|
47
|
+
this.capacity += WORKER_CAPACITY;
|
|
48
|
+
worker.on('message', (message) => this.handleWorkerMessage(entry, message));
|
|
49
|
+
this.workers.add(entry);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
handleWorkerMessage(workerEntry: WorkerEntry, message: DoneMessage): void {
|
|
53
|
+
const { jobId, error } = message;
|
|
54
|
+
const jobEntry = this.activeJobs.get(jobId);
|
|
55
|
+
if (!jobEntry) {
|
|
56
|
+
console.warn('Worker message with unknown Job ID; Ignoring.');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
clearTimeout(jobEntry.timer);
|
|
60
|
+
workerEntry.capacity += jobEntry.size;
|
|
61
|
+
this.capacity += jobEntry.size;
|
|
62
|
+
workerEntry.jobCount -= 1;
|
|
63
|
+
|
|
64
|
+
if (workerEntry.stalledJobs.has(jobId)) workerEntry.stalledJobs.delete(jobId);
|
|
65
|
+
|
|
66
|
+
this.activeJobs.delete(jobId);
|
|
67
|
+
jobEntry[error ? 'reject' : 'resolve'](message);
|
|
68
|
+
|
|
69
|
+
if (!this.workers.has(workerEntry)) this.terminateIfEmpty(workerEntry);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
handleTimeout(workerEntry: WorkerEntry, jobId: string): void {
|
|
73
|
+
workerEntry.stalledJobs.add(jobId);
|
|
74
|
+
|
|
75
|
+
if (this.workers.delete(workerEntry)) this.createWorker();
|
|
76
|
+
this.capacity -= workerEntry.capacity;
|
|
77
|
+
|
|
78
|
+
this.terminateIfEmpty(workerEntry);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async terminateIfEmpty({ stalledJobs, jobCount, worker }: WorkerEntry): Promise<void> {
|
|
82
|
+
if (jobCount > stalledJobs.size) return;
|
|
83
|
+
await worker.terminate();
|
|
84
|
+
|
|
85
|
+
for (const jobId of stalledJobs) {
|
|
86
|
+
const jobEntry = this.activeJobs.get(jobId);
|
|
87
|
+
this.activeJobs.delete(jobId);
|
|
88
|
+
jobEntry?.reject({
|
|
89
|
+
op: 'done',
|
|
90
|
+
jobId,
|
|
91
|
+
error: { name: 'StallError', message: 'Job stalled', kind: 'stall' },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
process(handlerPath: string, job: Job, size: number, timeout: number): Promise<DoneMessage> {
|
|
97
|
+
let workerEntry: WorkerEntry | null = null;
|
|
98
|
+
for (const entry of this.workers) {
|
|
99
|
+
if (!workerEntry || entry.capacity > workerEntry.capacity) workerEntry = entry;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!workerEntry) throw Error("Can't process job without workers");
|
|
103
|
+
|
|
104
|
+
const timer = setTimeout(() => this.handleTimeout(workerEntry!, job.id), timeout);
|
|
105
|
+
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
this.activeJobs.set(job.id, { resolve, reject, size, timer });
|
|
108
|
+
workerEntry!.capacity -= size;
|
|
109
|
+
this.capacity -= size;
|
|
110
|
+
workerEntry!.jobCount += 1;
|
|
111
|
+
workerEntry!.worker.postMessage({ op: 'exec', handlerPath, job });
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async close(): Promise<void> {
|
|
116
|
+
await Promise.all([...this.workers].map(async ({ worker }) => worker.terminate()));
|
|
117
|
+
for (const [jobId, { reject, timer }] of this.activeJobs.entries()) {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
reject({
|
|
120
|
+
op: 'done',
|
|
121
|
+
jobId,
|
|
122
|
+
error: { name: 'StallError', message: 'Pool is closing', kind: 'stall' },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
this.workers = new Set();
|
|
127
|
+
this.activeJobs.clear();
|
|
128
|
+
}
|
|
129
|
+
}
|
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/queue.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Client } from './client.ts';
|
|
2
|
+
import { DEFAULT_RETRY_OPTIONS, FAILJOB_RETRY_OPTIONS } from './constants.ts';
|
|
3
|
+
import type { Manager } from './manager.ts';
|
|
4
|
+
import type { Pool } from './pool.ts';
|
|
5
|
+
import type { DoneMessage, HandlerOptions, Job, JobOptions, ListenOptions } from './types.ts';
|
|
6
|
+
import { generateId } from './utils.ts';
|
|
7
|
+
|
|
8
|
+
export class Queue {
|
|
9
|
+
key: string;
|
|
10
|
+
client: Client;
|
|
11
|
+
pool: Pool | undefined;
|
|
12
|
+
manager: Manager | undefined;
|
|
13
|
+
handlerOptions: Required<HandlerOptions> | undefined;
|
|
14
|
+
handlerPath: string | undefined;
|
|
15
|
+
failKey: string | undefined;
|
|
16
|
+
|
|
17
|
+
constructor(key: string, client: Client, pool: Pool | undefined, manager: Manager | undefined) {
|
|
18
|
+
this.key = key;
|
|
19
|
+
this.client = client;
|
|
20
|
+
this.pool = pool;
|
|
21
|
+
this.manager = manager;
|
|
22
|
+
this.handlerOptions = undefined;
|
|
23
|
+
this.handlerPath = undefined;
|
|
24
|
+
this.failKey = undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async listen(handlerPath: string, options: ListenOptions = {}): Promise<void> {
|
|
28
|
+
const { failHandler, failRetryOptions, ...retryOptions } = options;
|
|
29
|
+
if (this.client.disconnected) throw new Error("Can't listen: client disconnected");
|
|
30
|
+
if (!this.pool || !this.manager) throw new Error("Can't listen: non-processing client");
|
|
31
|
+
|
|
32
|
+
this.handlerPath = handlerPath;
|
|
33
|
+
this.handlerOptions = { ...DEFAULT_RETRY_OPTIONS, ...retryOptions };
|
|
34
|
+
|
|
35
|
+
if (failHandler) {
|
|
36
|
+
this.failKey = `${this.key}-fail`;
|
|
37
|
+
const failQueue = this.client.queue(this.failKey, true);
|
|
38
|
+
failQueue.listen(failHandler, { ...FAILJOB_RETRY_OPTIONS, ...failRetryOptions });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.manager.addQueue(this);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async dispatch(
|
|
45
|
+
// biome-ignore lint/suspicious/noExplicitAny: Data is any serializable value
|
|
46
|
+
data: any,
|
|
47
|
+
options: JobOptions = {}
|
|
48
|
+
): Promise<string> {
|
|
49
|
+
if (this.client.disconnected) throw new Error("Can't dispatch: client disconnected");
|
|
50
|
+
const {
|
|
51
|
+
id = generateId(),
|
|
52
|
+
runAt = 0,
|
|
53
|
+
updateData = false,
|
|
54
|
+
updateRunAt = false,
|
|
55
|
+
resetCounts = false,
|
|
56
|
+
} = options;
|
|
57
|
+
|
|
58
|
+
await this.client.dispatch(this.key, id, runAt, data, updateData, updateRunAt, resetCounts);
|
|
59
|
+
return id;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async cancel(id: string): Promise<boolean> {
|
|
63
|
+
if (this.client.disconnected) throw new Error("Can't cancel: client disconnected");
|
|
64
|
+
return await this.client.cancel(this.key, id);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async dequeue(count: number): Promise<{ count: number; promise: Promise<unknown[]> }> {
|
|
68
|
+
const pool = this.pool!;
|
|
69
|
+
const handlerPath = this.handlerPath!;
|
|
70
|
+
const { maxRetries, maxStalls, maxBackoff, minBackoff, size, timeout } =
|
|
71
|
+
this.handlerOptions!;
|
|
72
|
+
|
|
73
|
+
const jobs = await this.client.dequeue(this.key, count);
|
|
74
|
+
|
|
75
|
+
const promise = Promise.all(
|
|
76
|
+
jobs.map(async (job: Job) => {
|
|
77
|
+
if (job.stallCount >= maxStalls) {
|
|
78
|
+
if (!this.failKey) return this.client.finish(this.key, job.id);
|
|
79
|
+
|
|
80
|
+
const failJobData = [job.id, job.data, { message: 'Max stalls exceeded' }];
|
|
81
|
+
return this.client.fail(this.key, this.failKey, job.id, failJobData);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await pool.process(handlerPath, job, size, timeout);
|
|
86
|
+
await this.client.finish(this.key, job.id);
|
|
87
|
+
} catch (message) {
|
|
88
|
+
const { error } = message as Required<DoneMessage>;
|
|
89
|
+
const { retryAt = 0, kind } = error;
|
|
90
|
+
|
|
91
|
+
if (kind === 'permanent' || job.retryCount >= maxRetries) {
|
|
92
|
+
if (!this.failKey) return this.client.finish(this.key, job.id);
|
|
93
|
+
|
|
94
|
+
const failJobData = [job.id, job.data, error];
|
|
95
|
+
return this.client.fail(this.key, this.failKey, job.id, failJobData);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const backoffUntil =
|
|
99
|
+
Date.now() + Math.min(maxBackoff, minBackoff * 2 ** job.retryCount);
|
|
100
|
+
|
|
101
|
+
await this.client.retry(this.key, job.id, Math.max(retryAt, backoffUntil));
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
return { count: jobs.length, promise };
|
|
107
|
+
}
|
|
108
|
+
}
|
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
|
*/
|
|
@@ -77,6 +92,7 @@ export interface ListenOptions extends HandlerOptions {
|
|
|
77
92
|
export type ExecMessage = {
|
|
78
93
|
op: 'exec';
|
|
79
94
|
queue: string;
|
|
95
|
+
handlerPath: string;
|
|
80
96
|
job: Job;
|
|
81
97
|
};
|
|
82
98
|
|
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Generate a random alphanumeric ID
|
|
3
|
-
* @param {number} length - Length of the ID
|
|
4
|
-
* @returns {string}
|
|
5
|
-
*/
|
|
6
|
-
export function generateId(length = 20) {
|
|
1
|
+
export function generateId(length = 20): string {
|
|
7
2
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
8
3
|
let id = '';
|
|
9
4
|
for (let i = 0; i < length; i++) {
|
|
@@ -12,25 +7,13 @@ export function generateId(length = 20) {
|
|
|
12
7
|
return id;
|
|
13
8
|
}
|
|
14
9
|
|
|
15
|
-
|
|
16
|
-
* Parse a semver string like '1.0' into { major, minor }
|
|
17
|
-
* Used by compareSemver
|
|
18
|
-
* @param {string?} version
|
|
19
|
-
* @returns {number[]}
|
|
20
|
-
*/
|
|
21
|
-
export function parseVersion(version) {
|
|
10
|
+
export function parseVersion(version: string | null | undefined): number[] {
|
|
22
11
|
const parsed = String(version).split('.').map(Number);
|
|
23
12
|
if (parsed.some((n) => Number.isNaN(n))) return [0];
|
|
24
13
|
return parsed;
|
|
25
14
|
}
|
|
26
15
|
|
|
27
|
-
|
|
28
|
-
* Compare two semver strings. Returns -1 if a < b, 0 if equal, 1 if a > b.
|
|
29
|
-
* @param {number[]} a
|
|
30
|
-
* @param {number[]} b
|
|
31
|
-
* @returns {-1 | 0 | 1}
|
|
32
|
-
*/
|
|
33
|
-
export function compareSemver(a, b) {
|
|
16
|
+
export function compareSemver(a: number[], b: number[]): -1 | 0 | 1 {
|
|
34
17
|
for (let i = 0; i < Math.min(a.length, b.length); i++) {
|
|
35
18
|
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
|
|
36
19
|
}
|