queasy 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.github/workflows/check.yml +3 -0
  2. package/.github/workflows/publish.yml +3 -0
  3. package/CLAUDE.md +5 -4
  4. package/Readme.md +9 -4
  5. package/biome.json +5 -1
  6. package/dist/client.d.ts +33 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +199 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/constants.d.ts +10 -0
  11. package/dist/constants.d.ts.map +1 -0
  12. package/{src → dist}/constants.js +2 -10
  13. package/dist/constants.js.map +1 -0
  14. package/dist/errors.d.ts +7 -0
  15. package/dist/errors.d.ts.map +1 -0
  16. package/{src → dist}/errors.js +1 -13
  17. package/dist/errors.js.map +1 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +3 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/manager.d.ts +19 -0
  23. package/dist/manager.d.ts.map +1 -0
  24. package/dist/manager.js +67 -0
  25. package/dist/manager.js.map +1 -0
  26. package/dist/pool.d.ts +29 -0
  27. package/dist/pool.d.ts.map +1 -0
  28. package/{src → dist}/pool.js +23 -82
  29. package/dist/pool.js.map +1 -0
  30. package/dist/queasy.lua +390 -0
  31. package/dist/queue.d.ts +22 -0
  32. package/dist/queue.d.ts.map +1 -0
  33. package/dist/queue.js +81 -0
  34. package/dist/queue.js.map +1 -0
  35. package/dist/types.d.ts +92 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +2 -0
  38. package/dist/types.js.map +1 -0
  39. package/dist/utils.d.ts +4 -0
  40. package/dist/utils.d.ts.map +1 -0
  41. package/dist/utils.js +24 -0
  42. package/dist/utils.js.map +1 -0
  43. package/dist/worker.d.ts +2 -0
  44. package/dist/worker.d.ts.map +1 -0
  45. package/dist/worker.js +42 -0
  46. package/dist/worker.js.map +1 -0
  47. package/docker-compose.yml +0 -2
  48. package/fuzztest/Readme.md +185 -0
  49. package/fuzztest/fuzz.ts +356 -0
  50. package/fuzztest/handlers/cascade-a.ts +90 -0
  51. package/fuzztest/handlers/cascade-b.ts +71 -0
  52. package/fuzztest/handlers/fail-handler.ts +47 -0
  53. package/fuzztest/handlers/periodic.ts +89 -0
  54. package/fuzztest/process.ts +100 -0
  55. package/fuzztest/shared/chaos.ts +29 -0
  56. package/fuzztest/shared/stream.ts +40 -0
  57. package/package.json +8 -7
  58. package/plans/redis-options.md +279 -0
  59. package/src/client.ts +246 -0
  60. package/src/constants.ts +33 -0
  61. package/src/errors.ts +13 -0
  62. package/src/index.ts +2 -0
  63. package/src/manager.ts +78 -0
  64. package/src/pool.ts +129 -0
  65. package/src/queasy.lua +2 -3
  66. package/src/queue.ts +108 -0
  67. package/src/types.ts +16 -0
  68. package/src/{utils.js → utils.ts} +3 -20
  69. package/src/{worker.js → worker.ts} +5 -12
  70. package/test/{client.test.js → client.test.ts} +6 -7
  71. package/test/{errors.test.js → errors.test.ts} +1 -1
  72. package/test/fixtures/always-fail-handler.ts +5 -0
  73. package/test/fixtures/data-logger-handler.ts +11 -0
  74. package/test/fixtures/failure-handler.ts +6 -0
  75. package/test/fixtures/permanent-error-handler.ts +6 -0
  76. package/test/fixtures/slow-handler.ts +6 -0
  77. package/test/fixtures/success-handler.js +0 -5
  78. package/test/fixtures/success-handler.ts +6 -0
  79. package/test/fixtures/with-failure-handler.ts +5 -0
  80. package/test/{guards.test.js → guards.test.ts} +21 -34
  81. package/test/{manager.test.js → manager.test.ts} +26 -34
  82. package/test/{pool.test.js → pool.test.ts} +14 -16
  83. package/test/{queue.test.js → queue.test.ts} +21 -21
  84. package/test/{redis-functions.test.js → redis-functions.test.ts} +14 -20
  85. package/test/{utils.test.js → utils.test.ts} +1 -1
  86. package/tsconfig.json +20 -0
  87. package/jsconfig.json +0 -17
  88. package/src/client.js +0 -258
  89. package/src/index.js +0 -2
  90. package/src/manager.js +0 -94
  91. package/src/queue.js +0 -154
  92. package/test/fixtures/always-fail-handler.js +0 -8
  93. package/test/fixtures/data-logger-handler.js +0 -19
  94. package/test/fixtures/failure-handler.js +0 -9
  95. package/test/fixtures/permanent-error-handler.js +0 -10
  96. package/test/fixtures/slow-handler.js +0 -9
  97. package/test/fixtures/with-failure-handler.js +0 -8
  98. /package/test/fixtures/{no-handle-handler.js → no-handle-handler.ts} +0 -0
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
+ }
@@ -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
@@ -0,0 +1,2 @@
1
+ export { Client } from './client.ts';
2
+ export { PermanentError, StallError } from './errors.ts';
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
- if #result > 0 then
230
- redis.call('ZADD', expiry_key, expiry, client_id)
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
  }