@c9up/bay 0.1.13 → 0.2.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/README.md +122 -16
- package/dist/BayProvider.d.ts +51 -11
- package/dist/BayProvider.d.ts.map +1 -1
- package/dist/BayProvider.js +36 -13
- package/dist/BayProvider.js.map +1 -1
- package/dist/Job.d.ts +99 -0
- package/dist/Job.d.ts.map +1 -0
- package/dist/Job.js +78 -0
- package/dist/Job.js.map +1 -0
- package/dist/QueueManager.d.ts +140 -21
- package/dist/QueueManager.d.ts.map +1 -1
- package/dist/QueueManager.js +247 -53
- package/dist/QueueManager.js.map +1 -1
- package/dist/adapters.d.ts +68 -0
- package/dist/adapters.d.ts.map +1 -0
- package/dist/adapters.js +56 -0
- package/dist/adapters.js.map +1 -0
- package/dist/augmentations.d.ts +28 -0
- package/dist/augmentations.d.ts.map +1 -0
- package/dist/augmentations.js +17 -0
- package/dist/augmentations.js.map +1 -0
- package/dist/configure.d.ts +1 -0
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +24 -7
- package/dist/configure.js.map +1 -1
- package/dist/console/contract.d.ts +60 -0
- package/dist/console/contract.d.ts.map +1 -0
- package/dist/console/contract.js +36 -0
- package/dist/console/contract.js.map +1 -0
- package/dist/console/index.d.ts +29 -0
- package/dist/console/index.d.ts.map +1 -0
- package/dist/console/index.js +45 -0
- package/dist/console/index.js.map +1 -0
- package/dist/console/makeJob.d.ts +32 -0
- package/dist/console/makeJob.d.ts.map +1 -0
- package/dist/console/makeJob.js +118 -0
- package/dist/console/makeJob.js.map +1 -0
- package/dist/console/queueWork.d.ts +18 -0
- package/dist/console/queueWork.d.ts.map +1 -0
- package/dist/console/queueWork.js +58 -0
- package/dist/console/queueWork.js.map +1 -0
- package/dist/drivers/MemoryDriver.d.ts +14 -8
- package/dist/drivers/MemoryDriver.d.ts.map +1 -1
- package/dist/drivers/MemoryDriver.js +61 -7
- package/dist/drivers/MemoryDriver.js.map +1 -1
- package/dist/drivers/RedisDriver.d.ts +60 -8
- package/dist/drivers/RedisDriver.d.ts.map +1 -1
- package/dist/drivers/RedisDriver.js +214 -30
- package/dist/drivers/RedisDriver.js.map +1 -1
- package/dist/index.d.ts +10 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -4
- package/dist/index.js.map +1 -1
- package/dist/jobs.d.ts +55 -0
- package/dist/jobs.d.ts.map +1 -0
- package/dist/jobs.js +135 -0
- package/dist/jobs.js.map +1 -0
- package/dist/quasar.d.ts +6 -9
- package/dist/quasar.d.ts.map +1 -1
- package/dist/quasar.js +27 -55
- package/dist/quasar.js.map +1 -1
- package/dist/testing/FakeQueue.d.ts +15 -9
- package/dist/testing/FakeQueue.d.ts.map +1 -1
- package/dist/testing/FakeQueue.js +13 -3
- package/dist/testing/FakeQueue.js.map +1 -1
- package/dist/vendor/nodeEnv.d.ts +5 -0
- package/dist/vendor/nodeEnv.d.ts.map +1 -0
- package/dist/{nodeEnv.js → vendor/nodeEnv.js} +13 -7
- package/dist/vendor/nodeEnv.js.map +1 -0
- package/dist/vendor/quasarConnection.d.ts +35 -0
- package/dist/vendor/quasarConnection.d.ts.map +1 -0
- package/dist/vendor/quasarConnection.js +62 -0
- package/dist/vendor/quasarConnection.js.map +1 -0
- package/package.json +5 -3
- package/src/BayProvider.ts +92 -26
- package/src/Job.ts +137 -0
- package/src/QueueManager.ts +411 -56
- package/src/adapters.ts +75 -0
- package/src/augmentations.ts +31 -0
- package/src/configure.ts +25 -7
- package/src/console/contract.ts +94 -0
- package/src/console/index.ts +68 -0
- package/src/console/makeJob.ts +139 -0
- package/src/console/queueWork.ts +70 -0
- package/src/drivers/MemoryDriver.ts +66 -14
- package/src/drivers/RedisDriver.ts +303 -43
- package/src/index.ts +35 -6
- package/src/jobs.ts +144 -0
- package/src/quasar.ts +28 -72
- package/src/testing/FakeQueue.ts +25 -15
- package/src/{nodeEnv.ts → vendor/nodeEnv.ts} +14 -7
- package/src/vendor/quasarConnection.ts +129 -0
- package/dist/nodeEnv.d.ts +0 -16
- package/dist/nodeEnv.d.ts.map +0 -1
- package/dist/nodeEnv.js.map +0 -1
- package/dist/stores.d.ts +0 -41
- package/dist/stores.d.ts.map +0 -1
- package/dist/stores.js +0 -46
- package/dist/stores.js.map +0 -1
- package/src/stores.ts +0 -59
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
* needs a thin adapter.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import {
|
|
22
|
-
import type
|
|
21
|
+
import { DEFAULT_QUEUE } from "../Job.js";
|
|
22
|
+
import { type JobRecord, type QueueDriver, queueOf } from "../QueueManager.js";
|
|
23
|
+
import { inProduction } from "../vendor/nodeEnv.js";
|
|
23
24
|
|
|
24
25
|
export interface RedisClient {
|
|
25
26
|
rpush(key: string, ...values: string[]): Promise<number>;
|
|
@@ -31,25 +32,80 @@ export interface RedisClient {
|
|
|
31
32
|
to: "LEFT" | "RIGHT",
|
|
32
33
|
): Promise<string | null>;
|
|
33
34
|
lrem(key: string, count: number, element: string): Promise<number>;
|
|
35
|
+
/**
|
|
36
|
+
* Optional, like `lmove`. Present on ioredis; without it the failed list
|
|
37
|
+
* simply keeps its entries, which is what this driver did before.
|
|
38
|
+
*/
|
|
39
|
+
ltrim?(key: string, start: number, stop: number): Promise<string>;
|
|
34
40
|
llen(key: string): Promise<number>;
|
|
35
41
|
lrange(key: string, start: number, stop: number): Promise<string[]>;
|
|
36
42
|
del(key: string): Promise<number>;
|
|
37
43
|
set(key: string, value: string, ...args: string[]): Promise<string | null>;
|
|
38
44
|
get(key: string): Promise<string | null>;
|
|
45
|
+
/**
|
|
46
|
+
* Sorted-set commands, for delayed jobs. Optional like `lmove`: a client
|
|
47
|
+
* without them can still run a queue, and `push` refuses a job carrying a
|
|
48
|
+
* `delay` rather than running it early — which is the one thing a delay
|
|
49
|
+
* must not do.
|
|
50
|
+
*/
|
|
51
|
+
zadd?(key: string, score: number, member: string): Promise<number | string>;
|
|
52
|
+
zrangebyscore?(
|
|
53
|
+
key: string,
|
|
54
|
+
min: number | string,
|
|
55
|
+
max: number | string,
|
|
56
|
+
...args: string[]
|
|
57
|
+
): Promise<string[]>;
|
|
58
|
+
zrem?(key: string, ...members: string[]): Promise<number>;
|
|
59
|
+
zcard?(key: string): Promise<number>;
|
|
39
60
|
}
|
|
40
61
|
|
|
41
|
-
function isValidJob(obj: unknown): obj is
|
|
62
|
+
function isValidJob(obj: unknown): obj is JobRecord {
|
|
42
63
|
if (typeof obj !== "object" || obj === null) return false;
|
|
43
|
-
const j = obj as Record<string, unknown>;
|
|
44
64
|
return (
|
|
45
|
-
typeof
|
|
46
|
-
typeof
|
|
47
|
-
typeof
|
|
48
|
-
typeof
|
|
49
|
-
typeof
|
|
65
|
+
typeof Reflect.get(obj, "id") === "string" &&
|
|
66
|
+
typeof Reflect.get(obj, "name") === "string" &&
|
|
67
|
+
typeof Reflect.get(obj, "attempts") === "number" &&
|
|
68
|
+
typeof Reflect.get(obj, "maxAttempts") === "number" &&
|
|
69
|
+
typeof Reflect.get(obj, "status") === "string"
|
|
50
70
|
);
|
|
51
71
|
}
|
|
52
72
|
|
|
73
|
+
/**
|
|
74
|
+
* What a lease holds: the exact string pop() moved into `processing`, and the
|
|
75
|
+
* worker that moved it.
|
|
76
|
+
*
|
|
77
|
+
* The owner is what makes renewal safe. Without it a worker whose lease had
|
|
78
|
+
* already expired — its job recovered, re-popped by somebody else — would go on
|
|
79
|
+
* extending the deadline of a job it no longer had any claim on. Upstream draws
|
|
80
|
+
* the same line inside its renewal script: "Only the worker that currently owns
|
|
81
|
+
* the lease may renew it."
|
|
82
|
+
*/
|
|
83
|
+
interface Lease {
|
|
84
|
+
owner: string;
|
|
85
|
+
raw: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read a lease back. A value written by an older version of this driver is the
|
|
90
|
+
* raw job string on its own, with no owner — still usable for the one thing
|
|
91
|
+
* `#removeFromProcessing` needs it for.
|
|
92
|
+
*/
|
|
93
|
+
function readLease(stored: string): { owner?: string; raw: string } {
|
|
94
|
+
let parsed: unknown;
|
|
95
|
+
try {
|
|
96
|
+
parsed = JSON.parse(stored);
|
|
97
|
+
} catch {
|
|
98
|
+
return { raw: stored };
|
|
99
|
+
}
|
|
100
|
+
if (typeof parsed !== "object" || parsed === null) return { raw: stored };
|
|
101
|
+
const raw = Reflect.get(parsed, "raw");
|
|
102
|
+
const owner = Reflect.get(parsed, "owner");
|
|
103
|
+
if (typeof raw !== "string" || typeof owner !== "string") {
|
|
104
|
+
return { raw: stored };
|
|
105
|
+
}
|
|
106
|
+
return { owner, raw };
|
|
107
|
+
}
|
|
108
|
+
|
|
53
109
|
/**
|
|
54
110
|
* Where the client comes from. A resolver is what lets a queue name its
|
|
55
111
|
* connection (`quasarConnection("jobs")`) instead of being handed a client:
|
|
@@ -74,6 +130,10 @@ function checkLmove(client: RedisClient, allowNonAtomicPop: boolean): void {
|
|
|
74
130
|
// job from pending before it reaches processing: nothing recovers it,
|
|
75
131
|
// because nothing knows it existed. That is a different product, and in
|
|
76
132
|
// production it must be asked for rather than fallen into.
|
|
133
|
+
// Read through `inProduction()` rather than `NODE_ENV === "production"`:
|
|
134
|
+
// `NODE_ENV=prod` is ordinary in a Dockerfile, and taken verbatim it answers
|
|
135
|
+
// "not production" — so this queue would silently accept a delivery
|
|
136
|
+
// guarantee weaker than the one it advertises.
|
|
77
137
|
if (inProduction() && !allowNonAtomicPop) {
|
|
78
138
|
throw new Error(
|
|
79
139
|
"[bay] this Redis client has no LMOVE (Redis < 6.2), so pop() would be a non-atomic lpop+rpush — " +
|
|
@@ -157,6 +217,23 @@ export class RedisDriver implements QueueDriver {
|
|
|
157
217
|
* deployment makes, not one a version check makes for it.
|
|
158
218
|
*/
|
|
159
219
|
allowNonAtomicPop?: boolean;
|
|
220
|
+
/**
|
|
221
|
+
* How many times a job may be reclaimed from a stalled worker before
|
|
222
|
+
* it is filed as failed instead of pushed round again. Default `1`,
|
|
223
|
+
* upstream's default for the same setting.
|
|
224
|
+
*
|
|
225
|
+
* Unbounded recovery is a job that kills its worker taking the whole
|
|
226
|
+
* queue down with it, forever: the crash never reaches the failure
|
|
227
|
+
* path, so `attempts` never moves and `maxAttempts` never applies.
|
|
228
|
+
*/
|
|
229
|
+
maxStalledCount?: number;
|
|
230
|
+
/**
|
|
231
|
+
* How many failed jobs to keep. Default `1000` — the ceiling the
|
|
232
|
+
* memory driver already had. `0` keeps every one of them.
|
|
233
|
+
*
|
|
234
|
+
* Only enforced when the client answers `ltrim`.
|
|
235
|
+
*/
|
|
236
|
+
maxFailedJobs?: number;
|
|
160
237
|
},
|
|
161
238
|
) {
|
|
162
239
|
this.#source = source;
|
|
@@ -183,33 +260,91 @@ export class RedisDriver implements QueueDriver {
|
|
|
183
260
|
);
|
|
184
261
|
}
|
|
185
262
|
this.#visibilityTimeout = visibilityTimeout;
|
|
263
|
+
|
|
264
|
+
const maxStalled = options?.maxStalledCount ?? 1;
|
|
265
|
+
if (!Number.isInteger(maxStalled) || maxStalled < 0) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
`[bay] RedisDriver maxStalledCount must be a non-negative integer, got ${maxStalled}`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
this.#maxStalledCount = maxStalled;
|
|
271
|
+
|
|
272
|
+
const maxFailed = options?.maxFailedJobs ?? 1000;
|
|
273
|
+
if (!Number.isInteger(maxFailed) || maxFailed < 0) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`[bay] RedisDriver maxFailedJobs must be a non-negative integer, got ${maxFailed}`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
this.#maxFailedJobs = maxFailed;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Renew a lease at half its length: two chances to be heard before the
|
|
283
|
+
* deadline, so one slow round-trip does not hand a running job to somebody
|
|
284
|
+
* else. Read by `QueueManager` while a handler runs.
|
|
285
|
+
*/
|
|
286
|
+
get renewIntervalMs(): number {
|
|
287
|
+
return Math.max(1, Math.floor(this.#visibilityTimeout / 2));
|
|
186
288
|
}
|
|
187
289
|
|
|
188
|
-
|
|
290
|
+
/**
|
|
291
|
+
* Where one queue's jobs wait.
|
|
292
|
+
*
|
|
293
|
+
* The default queue keeps the key it always had. Naming it
|
|
294
|
+
* `queue:default:pending` would have been tidier and would have orphaned
|
|
295
|
+
* every job already sitting in `queue:pending` at the moment of the upgrade
|
|
296
|
+
* — a silent loss, since nothing reads the old key afterwards.
|
|
297
|
+
*/
|
|
298
|
+
#pendingKey = (queue: string = DEFAULT_QUEUE) =>
|
|
299
|
+
queue === DEFAULT_QUEUE
|
|
300
|
+
? `${this.#prefix}pending`
|
|
301
|
+
: `${this.#prefix}q:${queue}:pending`;
|
|
302
|
+
#delayedKey = (queue: string = DEFAULT_QUEUE) =>
|
|
303
|
+
queue === DEFAULT_QUEUE
|
|
304
|
+
? `${this.#prefix}delayed`
|
|
305
|
+
: `${this.#prefix}q:${queue}:delayed`;
|
|
189
306
|
#processingKey = () => `${this.#prefix}processing`;
|
|
190
307
|
#allowNonAtomicPop = false;
|
|
308
|
+
#maxStalledCount = 1;
|
|
309
|
+
#maxFailedJobs = 1000;
|
|
310
|
+
/** This driver instance, as a lease owner. */
|
|
311
|
+
#workerId = crypto.randomUUID();
|
|
191
312
|
#failedKey = () => `${this.#prefix}failed`;
|
|
192
313
|
#leaseKey = (jobId: string) => `${this.#prefix}lease:${jobId}`;
|
|
193
314
|
|
|
194
|
-
async push(job:
|
|
315
|
+
async push(job: JobRecord): Promise<void> {
|
|
195
316
|
const client = await this.#client();
|
|
196
|
-
|
|
317
|
+
const queue = queueOf(job);
|
|
318
|
+
if (job.runAt !== undefined && job.runAt > Date.now()) {
|
|
319
|
+
if (!client.zadd) {
|
|
320
|
+
// Pushing it to the list instead would run it now, which is the
|
|
321
|
+
// one thing a delay exists to prevent.
|
|
322
|
+
throw new Error(
|
|
323
|
+
"This Redis client cannot hold a delayed job: it has no ZADD. " +
|
|
324
|
+
"Use a client with sorted-set commands (ioredis has them), or dispatch without `delay`.",
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
await client.zadd(
|
|
328
|
+
this.#delayedKey(queue),
|
|
329
|
+
job.runAt,
|
|
330
|
+
JSON.stringify(job),
|
|
331
|
+
);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
await client.rpush(this.#pendingKey(queue), JSON.stringify(job));
|
|
197
335
|
}
|
|
198
336
|
|
|
199
|
-
async pop(
|
|
337
|
+
async pop(
|
|
338
|
+
queues: readonly string[] = [DEFAULT_QUEUE],
|
|
339
|
+
): Promise<JobRecord | null> {
|
|
200
340
|
const client = await this.#client();
|
|
201
341
|
let raw: string | null = null;
|
|
202
342
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
"RIGHT",
|
|
209
|
-
);
|
|
210
|
-
} else {
|
|
211
|
-
raw = await client.lpop(this.#pendingKey());
|
|
212
|
-
if (raw) await client.rpush(this.#processingKey(), raw);
|
|
343
|
+
// In the order given, so a worker can say which queue it drains first.
|
|
344
|
+
for (const queue of queues) {
|
|
345
|
+
await this.#promoteDue(client, queue);
|
|
346
|
+
raw = await this.#take(client, queue);
|
|
347
|
+
if (raw !== null) break;
|
|
213
348
|
}
|
|
214
349
|
|
|
215
350
|
if (!raw) return null;
|
|
@@ -239,34 +374,54 @@ export class RedisDriver implements QueueDriver {
|
|
|
239
374
|
// pending — so the delivery guarantee survives the blip.
|
|
240
375
|
await client.set(
|
|
241
376
|
this.#leaseKey(parsed.id),
|
|
242
|
-
raw,
|
|
377
|
+
JSON.stringify({ owner: this.#workerId, raw } satisfies Lease),
|
|
243
378
|
"PX",
|
|
244
379
|
String(this.#visibilityTimeout),
|
|
245
380
|
);
|
|
246
381
|
return parsed;
|
|
247
382
|
}
|
|
248
383
|
|
|
249
|
-
|
|
384
|
+
/**
|
|
385
|
+
* Say the job is still being worked on, and push its deadline back.
|
|
386
|
+
*
|
|
387
|
+
* Answers `false` when there is nothing left to renew — the lease expired
|
|
388
|
+
* and the job was recovered, or it was recovered and re-popped by another
|
|
389
|
+
* worker, whose claim this one must not extend.
|
|
390
|
+
*/
|
|
391
|
+
async renew(job: JobRecord): Promise<boolean> {
|
|
392
|
+
const client = await this.#client();
|
|
393
|
+
const key = this.#leaseKey(job.id);
|
|
394
|
+
const stored = await client.get(key);
|
|
395
|
+
if (stored === null) return false;
|
|
396
|
+
const lease = readLease(stored);
|
|
397
|
+
if (lease.owner !== undefined && lease.owner !== this.#workerId) {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
await client.set(key, stored, "PX", String(this.#visibilityTimeout));
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async complete(job: JobRecord): Promise<void> {
|
|
250
405
|
const client = await this.#client();
|
|
251
406
|
await this.#removeFromProcessing(job);
|
|
252
407
|
await client.del(this.#leaseKey(job.id));
|
|
253
408
|
}
|
|
254
409
|
|
|
255
|
-
async fail(job:
|
|
410
|
+
async fail(job: JobRecord, error: string): Promise<void> {
|
|
256
411
|
const client = await this.#client();
|
|
257
412
|
await this.#removeFromProcessing(job);
|
|
258
413
|
await client.del(this.#leaseKey(job.id));
|
|
259
414
|
job.error = error;
|
|
260
415
|
job.status = "failed";
|
|
261
|
-
await
|
|
416
|
+
await this.#pushFailed(client, job);
|
|
262
417
|
}
|
|
263
418
|
|
|
264
|
-
async retry(job:
|
|
419
|
+
async retry(job: JobRecord): Promise<void> {
|
|
265
420
|
const client = await this.#client();
|
|
266
421
|
await this.#removeFromProcessing(job);
|
|
267
422
|
await client.del(this.#leaseKey(job.id));
|
|
268
423
|
job.status = "pending";
|
|
269
|
-
await client.rpush(this.#pendingKey(), JSON.stringify(job));
|
|
424
|
+
await client.rpush(this.#pendingKey(queueOf(job)), JSON.stringify(job));
|
|
270
425
|
}
|
|
271
426
|
|
|
272
427
|
async recoverStale(): Promise<number> {
|
|
@@ -288,30 +443,77 @@ export class RedisDriver implements QueueDriver {
|
|
|
288
443
|
continue;
|
|
289
444
|
}
|
|
290
445
|
const lease = await client.get(this.#leaseKey(parsed.id));
|
|
291
|
-
if (lease
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
446
|
+
if (lease !== null) continue;
|
|
447
|
+
|
|
448
|
+
// The LREM is the claim, and its RESULT decides who acts. Two
|
|
449
|
+
// recovery passes overlapping — two workers, or one worker whose
|
|
450
|
+
// pass ran long — both read the same expired entry, and both used to
|
|
451
|
+
// push it back to pending: one job, delivered twice, from the
|
|
452
|
+
// mechanism that exists to make delivery reliable. Exactly one LREM
|
|
453
|
+
// can remove a given element, so exactly one pass continues past
|
|
454
|
+
// here. (A crash between this and the RPUSH below still loses the
|
|
455
|
+
// entry; closing that needs the whole pass in one server-side script,
|
|
456
|
+
// which is how upstream does it.)
|
|
457
|
+
const claimed = await client.lrem(this.#processingKey(), 1, raw);
|
|
458
|
+
if (claimed === 0) continue;
|
|
459
|
+
|
|
460
|
+
// A stall is not an attempt: the worker died before the handler
|
|
461
|
+
// could fail, so `attempts` never moved and `maxAttempts` never
|
|
462
|
+
// applied. A job that kills whatever picks it up was therefore
|
|
463
|
+
// recovered forever, taking the queue with it. Counted separately,
|
|
464
|
+
// and bounded — upstream bounds the same thing with the same
|
|
465
|
+
// default, failing the job once it is exceeded.
|
|
466
|
+
const stalled = (parsed.stalledCount ?? 0) + 1;
|
|
467
|
+
if (stalled > this.#maxStalledCount) {
|
|
468
|
+
parsed.stalledCount = stalled;
|
|
469
|
+
parsed.status = "failed";
|
|
470
|
+
parsed.error = `Stalled ${stalled} time(s) without completing (maxStalledCount ${this.#maxStalledCount})`;
|
|
471
|
+
await this.#pushFailed(client, parsed);
|
|
472
|
+
continue;
|
|
296
473
|
}
|
|
474
|
+
|
|
475
|
+
parsed.stalledCount = stalled;
|
|
476
|
+
parsed.status = "pending";
|
|
477
|
+
// Back to the queue it came from, not to the default one: a recovered
|
|
478
|
+
// job whose queue nobody serves would never run again.
|
|
479
|
+
await client.rpush(
|
|
480
|
+
this.#pendingKey(queueOf(parsed)),
|
|
481
|
+
JSON.stringify(parsed),
|
|
482
|
+
);
|
|
483
|
+
recovered++;
|
|
297
484
|
}
|
|
298
485
|
return recovered;
|
|
299
486
|
}
|
|
300
487
|
|
|
488
|
+
/**
|
|
489
|
+
* File a job as failed, keeping the list to `maxFailedJobs`.
|
|
490
|
+
*
|
|
491
|
+
* Unbounded, the failed list is a leak with no ceiling and no owner: nothing
|
|
492
|
+
* trims it, and `failed()` reads all of it in one LRANGE. The memory driver
|
|
493
|
+
* has capped its own at a thousand from the start; this is the same cap on
|
|
494
|
+
* the driver where the list actually survives a restart.
|
|
495
|
+
*/
|
|
496
|
+
async #pushFailed(client: RedisClient, job: JobRecord): Promise<void> {
|
|
497
|
+
await client.rpush(this.#failedKey(), JSON.stringify(job));
|
|
498
|
+
if (this.#maxFailedJobs === 0 || !client.ltrim) return;
|
|
499
|
+
await client.ltrim(this.#failedKey(), -this.#maxFailedJobs, -1);
|
|
500
|
+
}
|
|
501
|
+
|
|
301
502
|
/**
|
|
302
503
|
* Remove the entry for `job` from the processing list. The string in
|
|
303
504
|
* Redis is whatever pop() pushed, but QueueManager mutates `job` after
|
|
304
505
|
* pop returns (attempts++, status="processing", processedAt, then
|
|
305
506
|
* completed/failed/pending). LREM-ing on `JSON.stringify(job)` would
|
|
306
|
-
* therefore miss every real-world entry. Use the lease —
|
|
307
|
-
* exact raw string
|
|
507
|
+
* therefore miss every real-world entry. Use the lease — which carries
|
|
508
|
+
* the exact raw string pop() moved — and fall back to a list scan when
|
|
308
509
|
* the lease has expired (e.g. recoverStale already handled it).
|
|
309
510
|
*/
|
|
310
|
-
async #removeFromProcessing(job:
|
|
511
|
+
async #removeFromProcessing(job: JobRecord): Promise<void> {
|
|
311
512
|
const client = await this.#client();
|
|
312
513
|
const stored = await client.get(this.#leaseKey(job.id));
|
|
313
514
|
if (stored !== null) {
|
|
314
|
-
const
|
|
515
|
+
const { raw } = readLease(stored);
|
|
516
|
+
const removed = await client.lrem(this.#processingKey(), 1, raw);
|
|
315
517
|
if (removed > 0) return;
|
|
316
518
|
}
|
|
317
519
|
// Lease missing or already-LREM'd entry not found — best-effort scan
|
|
@@ -324,14 +526,14 @@ export class RedisDriver implements QueueDriver {
|
|
|
324
526
|
} catch {
|
|
325
527
|
continue;
|
|
326
528
|
}
|
|
327
|
-
if (isValidJob(parsed) &&
|
|
529
|
+
if (isValidJob(parsed) && parsed.id === job.id) {
|
|
328
530
|
await client.lrem(this.#processingKey(), 1, item);
|
|
329
531
|
return;
|
|
330
532
|
}
|
|
331
533
|
}
|
|
332
534
|
}
|
|
333
535
|
|
|
334
|
-
async failed(): Promise<
|
|
536
|
+
async failed(): Promise<JobRecord[]> {
|
|
335
537
|
const client = await this.#client();
|
|
336
538
|
const raws = await client.lrange(this.#failedKey(), 0, -1);
|
|
337
539
|
return raws
|
|
@@ -343,11 +545,69 @@ export class RedisDriver implements QueueDriver {
|
|
|
343
545
|
return null;
|
|
344
546
|
}
|
|
345
547
|
})
|
|
346
|
-
.filter((j): j is
|
|
548
|
+
.filter((j): j is JobRecord => j !== null);
|
|
347
549
|
}
|
|
348
550
|
|
|
349
|
-
|
|
551
|
+
/**
|
|
552
|
+
* How many jobs are waiting on `queue` — its list plus its delayed set.
|
|
553
|
+
*
|
|
554
|
+
* A delayed job is queued; it is simply not due. Counting only the list
|
|
555
|
+
* reported an empty queue to anything draining one before shutdown.
|
|
556
|
+
*/
|
|
557
|
+
async size(queue: string = DEFAULT_QUEUE): Promise<number> {
|
|
350
558
|
const client = await this.#client();
|
|
351
|
-
|
|
559
|
+
const waiting = await client.llen(this.#pendingKey(queue));
|
|
560
|
+
const delayed = client.zcard
|
|
561
|
+
? await client.zcard(this.#delayedKey(queue))
|
|
562
|
+
: 0;
|
|
563
|
+
return waiting + delayed;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Move `queue`'s due jobs out of the delayed set and onto its list.
|
|
568
|
+
*
|
|
569
|
+
* `ZREM` is the claim: two workers can read the same due entry, and only
|
|
570
|
+
* the one whose removal returns 1 owns it. Without that the job is pushed
|
|
571
|
+
* onto the list once per worker that saw it.
|
|
572
|
+
*/
|
|
573
|
+
async #promoteDue(client: RedisClient, queue: string): Promise<void> {
|
|
574
|
+
if (!client.zrangebyscore || !client.zrem) return;
|
|
575
|
+
const due = await client.zrangebyscore(
|
|
576
|
+
this.#delayedKey(queue),
|
|
577
|
+
0,
|
|
578
|
+
Date.now(),
|
|
579
|
+
"LIMIT",
|
|
580
|
+
"0",
|
|
581
|
+
"100",
|
|
582
|
+
);
|
|
583
|
+
for (const raw of due) {
|
|
584
|
+
const claimed = await client.zrem(this.#delayedKey(queue), raw);
|
|
585
|
+
if (claimed !== 1) continue;
|
|
586
|
+
let parsed: unknown;
|
|
587
|
+
try {
|
|
588
|
+
parsed = JSON.parse(raw);
|
|
589
|
+
} catch {
|
|
590
|
+
// Already removed from the set; there is nothing runnable to push.
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (!isValidJob(parsed)) continue;
|
|
594
|
+
parsed.runAt = undefined;
|
|
595
|
+
await client.rpush(this.#pendingKey(queue), JSON.stringify(parsed));
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Take the head of one queue, claiming it in `processing`. */
|
|
600
|
+
async #take(client: RedisClient, queue: string): Promise<string | null> {
|
|
601
|
+
if (client.lmove) {
|
|
602
|
+
return client.lmove(
|
|
603
|
+
this.#pendingKey(queue),
|
|
604
|
+
this.#processingKey(),
|
|
605
|
+
"LEFT",
|
|
606
|
+
"RIGHT",
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
const raw = await client.lpop(this.#pendingKey(queue));
|
|
610
|
+
if (raw) await client.rpush(this.#processingKey(), raw);
|
|
611
|
+
return raw;
|
|
352
612
|
}
|
|
353
613
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,18 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @c9up/bay — Background job queue for the Ream framework.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* A job is a class: it carries its own name, its options and the type of the
|
|
5
|
+
* payload it reads, and `dispatch(SomeJob, payload)` takes it. Named queues,
|
|
6
|
+
* `delay`, `timeout` and worker `concurrency` come from the same declaration.
|
|
5
7
|
*
|
|
6
|
-
*
|
|
8
|
+
* Dispatch/process/retry/fail, with pluggable drivers (Memory, Redis).
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
|
-
export
|
|
11
|
+
export {
|
|
12
|
+
type AdapterFactory,
|
|
13
|
+
drivers,
|
|
14
|
+
type QueueStoreFactory,
|
|
15
|
+
stores,
|
|
16
|
+
} from "./adapters.js";
|
|
17
|
+
|
|
18
|
+
import "./augmentations.js";
|
|
19
|
+
|
|
20
|
+
export type {
|
|
21
|
+
BayAppContext,
|
|
22
|
+
BayConfigStore,
|
|
23
|
+
BayContainer,
|
|
24
|
+
BayProviderConfig,
|
|
25
|
+
} from "./BayProvider.js";
|
|
10
26
|
export { MemoryDriver } from "./drivers/MemoryDriver.js";
|
|
11
27
|
export type { RedisClient } from "./drivers/RedisDriver.js";
|
|
12
28
|
export { RedisDriver } from "./drivers/RedisDriver.js";
|
|
13
|
-
export
|
|
14
|
-
|
|
15
|
-
|
|
29
|
+
export {
|
|
30
|
+
DEFAULT_QUEUE,
|
|
31
|
+
type Duration,
|
|
32
|
+
isJobClass,
|
|
33
|
+
Job,
|
|
34
|
+
type JobClass,
|
|
35
|
+
type JobOptions,
|
|
36
|
+
} from "./Job.js";
|
|
37
|
+
export type {
|
|
38
|
+
DispatchOptions,
|
|
39
|
+
JobHandler,
|
|
40
|
+
JobRecord,
|
|
41
|
+
QueueDriver,
|
|
42
|
+
WorkerOptions,
|
|
43
|
+
} from "./QueueManager.js";
|
|
44
|
+
export { QueueManager, queueOf } from "./QueueManager.js";
|
|
16
45
|
|
|
17
46
|
import type { BayProviderConfig } from "./BayProvider.js";
|
|
18
47
|
|
package/src/jobs.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding the job classes an application wrote.
|
|
3
|
+
*
|
|
4
|
+
* A job class carries its own name, and a worker resolves a queued record by
|
|
5
|
+
* that name — so the worker process has to have imported the class. Written by
|
|
6
|
+
* hand that is a registration list to keep in step with a directory:
|
|
7
|
+
*
|
|
8
|
+
* queue.registerJob(SendEmail)
|
|
9
|
+
* queue.registerJob(SendInvoice) // …and the one nobody added
|
|
10
|
+
*
|
|
11
|
+
* `locations` in `config/queue.ts` is the directory instead. Every module under
|
|
12
|
+
* it is imported once at boot, and a default export that is a job class is
|
|
13
|
+
* registered under its own name.
|
|
14
|
+
*
|
|
15
|
+
* Directories, not globs. `'./app/jobs/**\/*.{ts,js}'` — the spelling upstream's
|
|
16
|
+
* config uses — is accepted and read as the directory it starts with, so a
|
|
17
|
+
* config copied from there works; bay ships no glob engine and adding a
|
|
18
|
+
* dependency for one path shape is not worth it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as fsp from "node:fs/promises";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import { pathToFileURL } from "node:url";
|
|
24
|
+
import { isJobClass, type JobClass } from "./Job.js";
|
|
25
|
+
|
|
26
|
+
/** Where `make:job` writes, and where discovery looks when nothing is declared. */
|
|
27
|
+
export const DEFAULT_JOBS_DIR = "app/jobs";
|
|
28
|
+
|
|
29
|
+
let jobsDir = DEFAULT_JOBS_DIR;
|
|
30
|
+
|
|
31
|
+
/** @internal Told by the provider what the config declared. */
|
|
32
|
+
export function setJobsDir(dir: string): void {
|
|
33
|
+
jobsDir = dir;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Where job files live — the first `locations` entry, or the default. */
|
|
37
|
+
export function getJobsDir(): string {
|
|
38
|
+
return jobsDir;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The directory a `locations` entry names.
|
|
43
|
+
*
|
|
44
|
+
* Everything from the first glob character on is dropped: `app/jobs/** /*.ts`
|
|
45
|
+
* and `app/jobs` name the same directory, and the walk below is recursive
|
|
46
|
+
* either way.
|
|
47
|
+
*/
|
|
48
|
+
export function directoryOf(location: string): string {
|
|
49
|
+
const withoutGlob = location.split(/[*?[{]/)[0] ?? location;
|
|
50
|
+
const trimmed = withoutGlob.replace(/\/+$/, "");
|
|
51
|
+
return trimmed.replace(/^\.\//, "") || ".";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Every module file under `dir`, recursively. */
|
|
55
|
+
async function walk(dir: string, depth = 0): Promise<string[]> {
|
|
56
|
+
// A jobs directory is a flat convention with the occasional subdirectory;
|
|
57
|
+
// an unbounded walk would follow whatever happens to live under it.
|
|
58
|
+
if (depth > 8) return [];
|
|
59
|
+
let entries: import("node:fs").Dirent[];
|
|
60
|
+
try {
|
|
61
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
62
|
+
} catch (err) {
|
|
63
|
+
// A declared directory that does not exist YET is not an error: a project
|
|
64
|
+
// can name where its jobs will go before writing the first one.
|
|
65
|
+
//
|
|
66
|
+
// Anything else is. Reading every failure as "empty" meant a permission
|
|
67
|
+
// denial, a broken mount or a path pointing at a file produced a worker
|
|
68
|
+
// with no handlers and nothing said — the queue accepted jobs and
|
|
69
|
+
// processed none of them.
|
|
70
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
71
|
+
throw new Error(
|
|
72
|
+
`[bay] cannot read the jobs directory '${dir}': ${
|
|
73
|
+
err instanceof Error ? err.message : String(err)
|
|
74
|
+
}`,
|
|
75
|
+
{ cause: err },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const found: string[] = [];
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
const full = path.join(dir, entry.name);
|
|
81
|
+
if (entry.isDirectory()) {
|
|
82
|
+
found.push(...(await walk(full, depth + 1)));
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
// `.d.ts` is a declaration, not a module with a job in it.
|
|
86
|
+
if (/\.d\.[cm]?ts$/.test(entry.name)) continue;
|
|
87
|
+
if (/\.[cm]?[jt]s$/.test(entry.name)) found.push(full);
|
|
88
|
+
}
|
|
89
|
+
return found.sort();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Import every module under `locations` and return the job classes they
|
|
94
|
+
* default-export.
|
|
95
|
+
*
|
|
96
|
+
* A module that throws on import is reported and skipped: one unfinished job
|
|
97
|
+
* file must not stop the worker from running every other job.
|
|
98
|
+
*/
|
|
99
|
+
export async function discoverJobs(
|
|
100
|
+
locations: readonly string[],
|
|
101
|
+
/**
|
|
102
|
+
* Turn a configured location into an absolute path.
|
|
103
|
+
*
|
|
104
|
+
* The host supplies it — `app.makePath` on ream — because `app/jobs` means
|
|
105
|
+
* "under the application root", not "under whatever directory the process
|
|
106
|
+
* happens to have started in". Resolving against `process.cwd()` gave a
|
|
107
|
+
* worker launched from anywhere else a silent empty discovery.
|
|
108
|
+
*
|
|
109
|
+
* Absent, the old cwd-relative behaviour stands: bay is agnostic, and a host
|
|
110
|
+
* with no notion of an application root has nothing better to offer.
|
|
111
|
+
*/
|
|
112
|
+
resolveLocation: (location: string) => string = (location) =>
|
|
113
|
+
path.resolve(location),
|
|
114
|
+
): Promise<JobClass[]> {
|
|
115
|
+
const found: JobClass[] = [];
|
|
116
|
+
let scanned = 0;
|
|
117
|
+
const failures: string[] = [];
|
|
118
|
+
for (const location of locations) {
|
|
119
|
+
for (const file of await walk(resolveLocation(directoryOf(location)))) {
|
|
120
|
+
scanned += 1;
|
|
121
|
+
let module: unknown;
|
|
122
|
+
try {
|
|
123
|
+
module = await import(pathToFileURL(path.resolve(file)).href);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
126
|
+
failures.push(`${file}: ${message}`);
|
|
127
|
+
process.stderr.write(`[bay] could not load '${file}': ${message}\n`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (typeof module !== "object" || module === null) continue;
|
|
131
|
+
const exported = Reflect.get(module, "default");
|
|
132
|
+
if (isJobClass(exported)) found.push(exported);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Skipping ONE broken file so the others still run is the point of the catch
|
|
136
|
+
// above. Ending with nothing at all because every file was broken is a
|
|
137
|
+
// different thing: the worker would come up, accept jobs and process none.
|
|
138
|
+
if (found.length === 0 && scanned > 0 && failures.length === scanned) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`[bay] every job file failed to load, so the worker has no handlers:\n ${failures.join("\n ")}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return found;
|
|
144
|
+
}
|