@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
package/src/QueueManager.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_QUEUE,
|
|
3
|
+
isJobClass,
|
|
4
|
+
type JobClass,
|
|
5
|
+
type JobOptions,
|
|
6
|
+
toMilliseconds,
|
|
7
|
+
} from "./Job.js";
|
|
8
|
+
|
|
1
9
|
/**
|
|
2
10
|
* QueueManager — dispatch and process background jobs.
|
|
3
11
|
*
|
|
@@ -7,7 +15,7 @@
|
|
|
7
15
|
* queue.work()
|
|
8
16
|
*/
|
|
9
17
|
|
|
10
|
-
export interface
|
|
18
|
+
export interface JobRecord {
|
|
11
19
|
id: string;
|
|
12
20
|
name: string;
|
|
13
21
|
payload: unknown;
|
|
@@ -17,19 +25,137 @@ export interface Job {
|
|
|
17
25
|
error?: string;
|
|
18
26
|
createdAt: number;
|
|
19
27
|
processedAt?: number;
|
|
28
|
+
/**
|
|
29
|
+
* How many times this job has been recovered from a stalled worker.
|
|
30
|
+
*
|
|
31
|
+
* Separate from `attempts`, which counts times a handler RAN. A worker that
|
|
32
|
+
* dies mid-job never reaches the failure path, so `attempts` cannot see it —
|
|
33
|
+
* upstream carries the same two counters side by side for the same reason
|
|
34
|
+
* (`JobData.stalledCount` in `@boringnode/queue`).
|
|
35
|
+
*/
|
|
36
|
+
stalledCount?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Named queue this job waits in. A worker is told which queues to serve, so
|
|
39
|
+
* a slow queue cannot starve a fast one sharing the same process.
|
|
40
|
+
*
|
|
41
|
+
* Optional on the wire: a job written by a version that had no queues
|
|
42
|
+
* parses without it and reads as `default`.
|
|
43
|
+
*/
|
|
44
|
+
queue?: string;
|
|
45
|
+
/** Epoch ms before which no worker may take the job (`delay`). */
|
|
46
|
+
runAt?: number;
|
|
47
|
+
/** Milliseconds the handler gets before the attempt counts as failed. */
|
|
48
|
+
timeout?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The queue a record belongs to, for a record written before named queues. */
|
|
52
|
+
export function queueOf(job: JobRecord): string {
|
|
53
|
+
return job.queue ?? DEFAULT_QUEUE;
|
|
20
54
|
}
|
|
21
55
|
|
|
22
56
|
export interface JobHandler {
|
|
23
57
|
handle(payload: unknown): Promise<void>;
|
|
24
58
|
}
|
|
25
59
|
|
|
60
|
+
/**
|
|
61
|
+
* One attempt, whichever way the job was declared.
|
|
62
|
+
*
|
|
63
|
+
* A registered handler and a job class do the same two things — run, and maybe
|
|
64
|
+
* be told it finally failed — so `processOne` deals with this and not with two
|
|
65
|
+
* shapes.
|
|
66
|
+
*/
|
|
67
|
+
interface JobRunner {
|
|
68
|
+
run(payload: unknown): Promise<void> | void;
|
|
69
|
+
onFailed?(error: Error): Promise<void> | void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Reject once `ms` has passed, without touching the work.
|
|
74
|
+
*
|
|
75
|
+
* Nothing in Node can interrupt a running promise, so a job that ignores its
|
|
76
|
+
* timeout goes on burning CPU. What this buys is that the WORKER stops waiting
|
|
77
|
+
* for it — otherwise one stuck job costs the whole worker, which never picks
|
|
78
|
+
* anything up again.
|
|
79
|
+
*/
|
|
80
|
+
function withTimeout(
|
|
81
|
+
work: Promise<void> | void,
|
|
82
|
+
ms: number | undefined,
|
|
83
|
+
name: string,
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
const settled = Promise.resolve(work);
|
|
86
|
+
if (ms === undefined || ms <= 0) return settled;
|
|
87
|
+
return new Promise<void>((resolve, reject) => {
|
|
88
|
+
const timer = setTimeout(() => {
|
|
89
|
+
reject(new Error(`Job '${name}' exceeded its ${ms}ms timeout`));
|
|
90
|
+
}, ms);
|
|
91
|
+
// Unreffed: a pending timeout must not be the reason the process stays up.
|
|
92
|
+
timer.unref();
|
|
93
|
+
settled.then(
|
|
94
|
+
(value) => {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
resolve(value);
|
|
97
|
+
},
|
|
98
|
+
(err) => {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
reject(err);
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* What a worker is told before it starts, by upstream's names for it
|
|
108
|
+
* (`WorkerConfig.idleDelay`, `WorkerConfig.stalledInterval`).
|
|
109
|
+
*/
|
|
110
|
+
export interface WorkerOptions {
|
|
111
|
+
/** Milliseconds to wait after finding nothing to do. Default `2000`. */
|
|
112
|
+
idleDelay?: number;
|
|
113
|
+
/** Milliseconds between stalled-job sweeps. Default `30_000`. */
|
|
114
|
+
stalledInterval?: number;
|
|
115
|
+
/**
|
|
116
|
+
* How many jobs this worker runs at once. Default `1`.
|
|
117
|
+
*
|
|
118
|
+
* One job at a time is the safe default and a poor one for anything that
|
|
119
|
+
* waits on the network: a worker sending mail spends nearly all of its time
|
|
120
|
+
* idle with a queue behind it.
|
|
121
|
+
*/
|
|
122
|
+
concurrency?: number;
|
|
123
|
+
/**
|
|
124
|
+
* Which named queues to serve, in order. Default: the `default` queue.
|
|
125
|
+
*
|
|
126
|
+
* Naming them is how a slow queue is kept from starving a fast one — run
|
|
127
|
+
* one worker for `emails` and another for `default`, rather than one worker
|
|
128
|
+
* taking whatever comes.
|
|
129
|
+
*/
|
|
130
|
+
queues?: readonly string[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* What a single `dispatch` may override.
|
|
135
|
+
*
|
|
136
|
+
* Everything a job class declares, plus `maxAttempts` — the name this method
|
|
137
|
+
* took before job classes existed, kept because it is what every existing call
|
|
138
|
+
* site passes. `maxRetries` is the class's name for the same number.
|
|
139
|
+
*/
|
|
140
|
+
export interface DispatchOptions extends JobOptions {
|
|
141
|
+
/** The older spelling of `maxRetries`. Wins when both are given. */
|
|
142
|
+
maxAttempts?: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
26
145
|
export interface QueueDriver {
|
|
27
|
-
push(job:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
146
|
+
push(job: JobRecord): Promise<void>;
|
|
147
|
+
/**
|
|
148
|
+
* Take the next job from one of `queues`, or from the default queue when
|
|
149
|
+
* the caller names none.
|
|
150
|
+
*
|
|
151
|
+
* A driver written before named queues takes no argument and keeps working:
|
|
152
|
+
* it serves the one queue it has, which is the default one.
|
|
153
|
+
*/
|
|
154
|
+
pop(queues?: readonly string[]): Promise<JobRecord | null>;
|
|
155
|
+
fail(job: JobRecord, error: string): Promise<void>;
|
|
156
|
+
complete(job: JobRecord): Promise<void>;
|
|
157
|
+
retry(job: JobRecord): Promise<void>;
|
|
158
|
+
failed(): Promise<JobRecord[]>;
|
|
33
159
|
size(): Promise<number>;
|
|
34
160
|
/**
|
|
35
161
|
* Optional crash recovery: move jobs orphaned in the driver's 'processing'
|
|
@@ -37,57 +163,144 @@ export interface QueueDriver {
|
|
|
37
163
|
* recovered. In-memory drivers omit this — their jobs don't survive a crash.
|
|
38
164
|
*/
|
|
39
165
|
recoverStale?(): Promise<number>;
|
|
166
|
+
/**
|
|
167
|
+
* Optional lease renewal: tell the driver this job is still being worked on,
|
|
168
|
+
* answering `false` when the claim is gone (already recovered, or now held
|
|
169
|
+
* by another worker). Drivers with no lease omit it.
|
|
170
|
+
*/
|
|
171
|
+
renew?(job: JobRecord): Promise<boolean>;
|
|
172
|
+
/**
|
|
173
|
+
* How often the worker should call `renew` while a handler runs. The driver
|
|
174
|
+
* sets the cadence because the driver owns the deadline. Absent means no
|
|
175
|
+
* renewal.
|
|
176
|
+
*/
|
|
177
|
+
readonly renewIntervalMs?: number;
|
|
40
178
|
}
|
|
41
179
|
|
|
42
180
|
export class QueueManager {
|
|
43
181
|
#driver: QueueDriver;
|
|
44
182
|
#handlers: Map<string, JobHandler | (new () => JobHandler)> = new Map();
|
|
183
|
+
#jobs: Map<string, JobClass> = new Map();
|
|
45
184
|
#running = false;
|
|
46
185
|
/** The running loop, so `stop()` can wait for it to finish. */
|
|
47
186
|
#loopPromise: Promise<void> | undefined;
|
|
48
187
|
/** Cuts the sleep between polls short. */
|
|
49
188
|
#wake: (() => void) | undefined;
|
|
50
|
-
|
|
189
|
+
/** Every attempt currently in flight, so `drain()` can wait for all of them. */
|
|
190
|
+
#inflight: Set<Promise<boolean>> = new Set();
|
|
51
191
|
|
|
52
|
-
|
|
192
|
+
/** Defaults for `work()`, from the config's `worker` block. */
|
|
193
|
+
readonly #workerDefaults: WorkerOptions;
|
|
194
|
+
|
|
195
|
+
constructor(driver: QueueDriver, workerDefaults?: WorkerOptions) {
|
|
53
196
|
this.#driver = driver;
|
|
197
|
+
this.#workerDefaults = workerDefaults ?? {};
|
|
54
198
|
}
|
|
55
199
|
|
|
56
|
-
/** Register a job handler. */
|
|
200
|
+
/** Register a job handler under a name. */
|
|
57
201
|
register(name: string, handler: JobHandler | (new () => JobHandler)): void {
|
|
58
202
|
this.#handlers.set(name, handler);
|
|
59
203
|
}
|
|
60
204
|
|
|
61
|
-
/**
|
|
205
|
+
/**
|
|
206
|
+
* Register a job class under its own name, so a worker in another process
|
|
207
|
+
* can find it from what the record carries.
|
|
208
|
+
*
|
|
209
|
+
* `dispatch(SomeJob, …)` does this on its own; call it directly when the
|
|
210
|
+
* worker never dispatches — which is the ordinary case, since a worker
|
|
211
|
+
* process runs jobs and an HTTP process queues them.
|
|
212
|
+
*/
|
|
213
|
+
registerJob(job: JobClass): void {
|
|
214
|
+
this.#jobs.set(job.name, job);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Every job class this manager knows, by name. */
|
|
218
|
+
registeredJobs(): ReadonlyMap<string, JobClass> {
|
|
219
|
+
return this.#jobs;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Queue a job.
|
|
224
|
+
*
|
|
225
|
+
* Takes a job class — the payload is then typed by the class's own
|
|
226
|
+
* parameter, so a field the handler reads cannot be one the dispatcher
|
|
227
|
+
* never sent:
|
|
228
|
+
*
|
|
229
|
+
* await queue.dispatch(SendEmail, { to: 'user@example.com' })
|
|
230
|
+
*
|
|
231
|
+
* A registered name still works, and is what a job whose name is computed
|
|
232
|
+
* at runtime needs:
|
|
233
|
+
*
|
|
234
|
+
* await queue.dispatch('send-email', { to: '…' })
|
|
235
|
+
*/
|
|
236
|
+
async dispatch<Payload>(
|
|
237
|
+
job: JobClass<Payload>,
|
|
238
|
+
payload: Payload,
|
|
239
|
+
options?: DispatchOptions,
|
|
240
|
+
): Promise<string>;
|
|
62
241
|
async dispatch(
|
|
63
242
|
name: string,
|
|
64
243
|
payload: unknown,
|
|
65
|
-
options?:
|
|
244
|
+
options?: DispatchOptions,
|
|
245
|
+
): Promise<string>;
|
|
246
|
+
async dispatch(
|
|
247
|
+
job: string | JobClass,
|
|
248
|
+
payload: unknown,
|
|
249
|
+
options: DispatchOptions = {},
|
|
66
250
|
): Promise<string> {
|
|
67
|
-
|
|
251
|
+
let name: string;
|
|
252
|
+
let declared: JobOptions = {};
|
|
253
|
+
if (isJobClass(job)) {
|
|
254
|
+
name = job.name;
|
|
255
|
+
declared = job.options ?? {};
|
|
256
|
+
// So a worker that never dispatches still resolves it by name.
|
|
257
|
+
this.#jobs.set(name, job);
|
|
258
|
+
} else {
|
|
259
|
+
name = job;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The call site wins over the class, and the class over the defaults —
|
|
263
|
+
// the same order `work()` reads its own options in.
|
|
264
|
+
const maxAttempts =
|
|
265
|
+
options.maxAttempts ?? options.maxRetries ?? declared.maxRetries ?? 3;
|
|
266
|
+
if (maxAttempts < 1) {
|
|
68
267
|
throw new Error("maxAttempts must be >= 1");
|
|
69
268
|
}
|
|
269
|
+
const delay = options.delay ?? declared.delay;
|
|
270
|
+
const delayMs = delay === undefined ? 0 : toMilliseconds(delay, "delay");
|
|
271
|
+
const timeout = options.timeout ?? declared.timeout;
|
|
272
|
+
|
|
70
273
|
const id = `job_${crypto.randomUUID()}`;
|
|
71
|
-
const
|
|
274
|
+
const record: JobRecord = {
|
|
72
275
|
id,
|
|
73
276
|
name,
|
|
74
277
|
payload,
|
|
75
278
|
attempts: 0,
|
|
76
|
-
maxAttempts
|
|
279
|
+
maxAttempts,
|
|
77
280
|
status: "pending",
|
|
78
281
|
createdAt: Date.now(),
|
|
282
|
+
queue: options.queue ?? declared.queue ?? DEFAULT_QUEUE,
|
|
79
283
|
};
|
|
80
|
-
|
|
284
|
+
if (delayMs > 0) record.runAt = Date.now() + delayMs;
|
|
285
|
+
if (timeout !== undefined) {
|
|
286
|
+
record.timeout = toMilliseconds(timeout, "timeout");
|
|
287
|
+
}
|
|
288
|
+
await this.#driver.push(record);
|
|
81
289
|
return id;
|
|
82
290
|
}
|
|
83
291
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
292
|
+
/**
|
|
293
|
+
* Process the next job, from `queues` when the caller names any.
|
|
294
|
+
*
|
|
295
|
+
* Returns whether there was one — the loop uses that to decide between
|
|
296
|
+
* asking again and sleeping.
|
|
297
|
+
*/
|
|
298
|
+
async processOne(queues?: readonly string[]): Promise<boolean> {
|
|
299
|
+
const job = await this.#driver.pop(queues);
|
|
87
300
|
if (!job) return false;
|
|
88
301
|
|
|
89
|
-
const
|
|
90
|
-
if (!
|
|
302
|
+
const run = this.#resolveRunner(job.name);
|
|
303
|
+
if (!run) {
|
|
91
304
|
process.stderr.write(
|
|
92
305
|
`QueueManager: no handler registered for job '${job.name}'\n`,
|
|
93
306
|
);
|
|
@@ -98,20 +311,25 @@ export class QueueManager {
|
|
|
98
311
|
return true;
|
|
99
312
|
}
|
|
100
313
|
|
|
101
|
-
const handler =
|
|
102
|
-
typeof handlerOrClass === "function"
|
|
103
|
-
? new handlerOrClass()
|
|
104
|
-
: handlerOrClass;
|
|
314
|
+
const handler = run;
|
|
105
315
|
job.attempts++;
|
|
106
316
|
job.status = "processing";
|
|
107
317
|
job.processedAt = Date.now();
|
|
108
318
|
|
|
319
|
+
// The lease a driver takes at pop() has a deadline, and a handler slower
|
|
320
|
+
// than that deadline was being recovered and re-delivered WHILE IT WAS
|
|
321
|
+
// STILL RUNNING — a second worker picked the job up, and the first one's
|
|
322
|
+
// completion then removed an entry the second one owned. Upstream calls
|
|
323
|
+
// the same mechanism a heartbeat (`Adapter.renewJobs`); a driver without
|
|
324
|
+
// a lease supplies no cadence and nothing is scheduled.
|
|
325
|
+
const stopRenewing = this.#startRenewing(job);
|
|
326
|
+
let handled = false;
|
|
109
327
|
try {
|
|
110
|
-
await handler.
|
|
111
|
-
|
|
112
|
-
await this.#driver.complete(job);
|
|
328
|
+
await withTimeout(handler.run(job.payload), job.timeout, job.name);
|
|
329
|
+
handled = true;
|
|
113
330
|
} catch (err) {
|
|
114
|
-
const
|
|
331
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
332
|
+
const errorMsg = error.message;
|
|
115
333
|
if (job.attempts < job.maxAttempts) {
|
|
116
334
|
job.status = "pending";
|
|
117
335
|
await this.#driver.retry(job);
|
|
@@ -119,30 +337,150 @@ export class QueueManager {
|
|
|
119
337
|
job.status = "failed";
|
|
120
338
|
job.error = errorMsg;
|
|
121
339
|
await this.#driver.fail(job, errorMsg);
|
|
340
|
+
// After the last attempt, not after each one. A throw here is
|
|
341
|
+
// reported and swallowed: the job has already failed, and
|
|
342
|
+
// failing to say so must not be read as a second failure.
|
|
343
|
+
try {
|
|
344
|
+
await handler.onFailed?.(error);
|
|
345
|
+
} catch (hookErr) {
|
|
346
|
+
process.stderr.write(
|
|
347
|
+
`QueueManager: failed() hook of '${job.name}' threw: ${
|
|
348
|
+
hookErr instanceof Error ? hookErr.message : String(hookErr)
|
|
349
|
+
}\n`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
122
352
|
}
|
|
353
|
+
} finally {
|
|
354
|
+
stopRenewing();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Outside the catch, and deliberately. Marking the job done is a write to
|
|
358
|
+
// the driver, and a write can fail on its own — a Redis blip, a closed
|
|
359
|
+
// connection. Inside, that failure was read as the HANDLER having failed:
|
|
360
|
+
// the job went round again and the handler ran a second time, and once
|
|
361
|
+
// `attempts` ran out the job was filed as failed with the driver's error
|
|
362
|
+
// on it. A job that succeeded, in the failed list. The completion is
|
|
363
|
+
// allowed to throw now; the job keeps its lease, and the ordinary stall
|
|
364
|
+
// recovery is what re-delivers it.
|
|
365
|
+
if (handled) {
|
|
366
|
+
job.status = "completed";
|
|
367
|
+
await this.#driver.complete(job);
|
|
123
368
|
}
|
|
124
369
|
|
|
125
370
|
return true;
|
|
126
371
|
}
|
|
127
372
|
|
|
373
|
+
/**
|
|
374
|
+
* The runner for `name`: a registered handler, or a job class.
|
|
375
|
+
*
|
|
376
|
+
* A handler registered under the name wins — an application that registers
|
|
377
|
+
* one deliberately is overriding whatever else answers to it.
|
|
378
|
+
*/
|
|
379
|
+
#resolveRunner(name: string): JobRunner | undefined {
|
|
380
|
+
const handlerOrClass = this.#handlers.get(name);
|
|
381
|
+
if (handlerOrClass !== undefined) {
|
|
382
|
+
const handler =
|
|
383
|
+
typeof handlerOrClass === "function"
|
|
384
|
+
? new handlerOrClass()
|
|
385
|
+
: handlerOrClass;
|
|
386
|
+
return { run: (payload) => handler.handle(payload) };
|
|
387
|
+
}
|
|
388
|
+
const JobConstructor = this.#jobs.get(name);
|
|
389
|
+
if (JobConstructor === undefined) return undefined;
|
|
390
|
+
const instance = new JobConstructor();
|
|
391
|
+
return {
|
|
392
|
+
run: (payload) => {
|
|
393
|
+
// `payload` is declared readonly on the class so a handler cannot
|
|
394
|
+
// rewrite what it was sent; it is assigned once, here.
|
|
395
|
+
Object.defineProperty(instance, "payload", {
|
|
396
|
+
value: payload,
|
|
397
|
+
configurable: true,
|
|
398
|
+
enumerable: true,
|
|
399
|
+
});
|
|
400
|
+
return instance.execute();
|
|
401
|
+
},
|
|
402
|
+
onFailed: instance.failed?.bind(instance),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Keep the driver's claim on `job` alive for as long as the handler runs.
|
|
408
|
+
* Returns the function that stops it — always called, including when the
|
|
409
|
+
* handler throws, so a finished job never keeps extending a lease.
|
|
410
|
+
*/
|
|
411
|
+
#startRenewing(job: JobRecord): () => void {
|
|
412
|
+
const driver = this.#driver;
|
|
413
|
+
const every = driver.renewIntervalMs;
|
|
414
|
+
if (!driver.renew || every === undefined || every <= 0) {
|
|
415
|
+
return () => {};
|
|
416
|
+
}
|
|
417
|
+
const timer = setInterval(() => {
|
|
418
|
+
// A renewal that fails is not a reason to interrupt the handler: the
|
|
419
|
+
// job may already have been recovered, and the handler finishing is
|
|
420
|
+
// still the best outcome available.
|
|
421
|
+
void driver.renew?.(job).catch(() => {});
|
|
422
|
+
}, every);
|
|
423
|
+
// Unreffed: the handler's own promise is what holds the process open.
|
|
424
|
+
timer.unref();
|
|
425
|
+
return () => {
|
|
426
|
+
clearInterval(timer);
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
128
430
|
/**
|
|
129
431
|
* Start processing jobs continuously. Reclaims crash-orphaned jobs at
|
|
130
|
-
* startup and every `
|
|
131
|
-
* without recoverStale) — otherwise a job left in 'processing' by a
|
|
132
|
-
* worker would sit there forever.
|
|
432
|
+
* startup and every `stalledInterval` thereafter (no-op for in-memory
|
|
433
|
+
* drivers without recoverStale) — otherwise a job left in 'processing' by a
|
|
434
|
+
* crashed worker would sit there forever.
|
|
435
|
+
*
|
|
436
|
+
* The options are upstream's `worker` block, by the names it gives them:
|
|
437
|
+
*
|
|
438
|
+
* queue.work({ idleDelay: 2000, stalledInterval: 30_000 })
|
|
439
|
+
*
|
|
440
|
+
* `idleDelay` defaults to 2 s, which is upstream's default too — a worker
|
|
441
|
+
* that finds nothing waits before asking again, and asking every second was
|
|
442
|
+
* bay's own number rather than the framework's.
|
|
443
|
+
*
|
|
444
|
+
* The positional form is the one this method had before it took the
|
|
445
|
+
* framework's names, and still works: `work(idleDelay, stalledInterval)`.
|
|
133
446
|
*/
|
|
134
|
-
async work(
|
|
135
|
-
|
|
136
|
-
|
|
447
|
+
async work(
|
|
448
|
+
options?: WorkerOptions | number,
|
|
449
|
+
stalledIntervalArg = 30_000,
|
|
450
|
+
): Promise<void> {
|
|
451
|
+
const asOptions = typeof options === "number" ? undefined : options;
|
|
452
|
+
// An argument beats the config's `worker` block, which beats the
|
|
453
|
+
// framework's own defaults.
|
|
454
|
+
const defaults = this.#workerDefaults;
|
|
455
|
+
const idleDelay =
|
|
456
|
+
typeof options === "number"
|
|
457
|
+
? options
|
|
458
|
+
: (options?.idleDelay ?? defaults.idleDelay ?? 2000);
|
|
459
|
+
const stalledInterval =
|
|
460
|
+
typeof options === "number"
|
|
461
|
+
? stalledIntervalArg
|
|
462
|
+
: (options?.stalledInterval ??
|
|
463
|
+
defaults.stalledInterval ??
|
|
464
|
+
stalledIntervalArg);
|
|
465
|
+
|
|
466
|
+
if (idleDelay <= 0) {
|
|
467
|
+
throw new Error("idleDelay must be positive");
|
|
137
468
|
}
|
|
138
|
-
if (
|
|
139
|
-
throw new Error("
|
|
469
|
+
if (stalledInterval <= 0) {
|
|
470
|
+
throw new Error("stalledInterval must be positive");
|
|
140
471
|
}
|
|
472
|
+
const concurrency =
|
|
473
|
+
asOptions?.concurrency ?? this.#workerDefaults.concurrency ?? 1;
|
|
474
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
475
|
+
throw new Error("concurrency must be a whole number >= 1");
|
|
476
|
+
}
|
|
477
|
+
const queues = asOptions?.queues ?? this.#workerDefaults.queues;
|
|
478
|
+
|
|
141
479
|
if (this.#running) {
|
|
142
480
|
throw new Error("QueueManager is already running");
|
|
143
481
|
}
|
|
144
482
|
this.#running = true;
|
|
145
|
-
const loop = this.#loop(
|
|
483
|
+
const loop = this.#loop(idleDelay, stalledInterval, concurrency, queues);
|
|
146
484
|
this.#loopPromise = loop;
|
|
147
485
|
try {
|
|
148
486
|
await loop;
|
|
@@ -159,23 +497,41 @@ export class QueueManager {
|
|
|
159
497
|
* while the loop was still pending — up to a full poll interval of a worker
|
|
160
498
|
* that was supposed to be gone, and a timer holding the process open.
|
|
161
499
|
*/
|
|
162
|
-
async #loop(
|
|
500
|
+
async #loop(
|
|
501
|
+
idleDelay: number,
|
|
502
|
+
stalledInterval: number,
|
|
503
|
+
concurrency: number,
|
|
504
|
+
queues: readonly string[] | undefined,
|
|
505
|
+
): Promise<void> {
|
|
163
506
|
await this.#tryRecoverStale();
|
|
164
507
|
let lastRecover = Date.now();
|
|
165
508
|
while (this.#running) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
509
|
+
// One round of up to `concurrency` jobs. `allSettled`, not `all`: a
|
|
510
|
+
// driver that throws for one job must not abandon the others
|
|
511
|
+
// mid-flight, and each attempt already reports its own failure.
|
|
512
|
+
const round = Array.from({ length: concurrency }, () =>
|
|
513
|
+
this.processOne(queues),
|
|
514
|
+
);
|
|
515
|
+
for (const attempt of round) this.#inflight.add(attempt);
|
|
516
|
+
const outcomes = await Promise.allSettled(round);
|
|
517
|
+
for (const attempt of round) this.#inflight.delete(attempt);
|
|
518
|
+
|
|
519
|
+
let processed = false;
|
|
520
|
+
for (const outcome of outcomes) {
|
|
521
|
+
if (outcome.status === "fulfilled") {
|
|
522
|
+
processed = processed || outcome.value;
|
|
523
|
+
} else {
|
|
524
|
+
const err = outcome.reason;
|
|
525
|
+
process.stderr.write(
|
|
526
|
+
`QueueManager processOne error: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
177
529
|
}
|
|
178
|
-
|
|
530
|
+
// Nothing anywhere means the queues are empty; anything at all means
|
|
531
|
+
// there may be more behind it, so ask again without waiting.
|
|
532
|
+
if (!processed) await this.#sleep(idleDelay);
|
|
533
|
+
|
|
534
|
+
if (this.#running && Date.now() - lastRecover >= stalledInterval) {
|
|
179
535
|
await this.#tryRecoverStale();
|
|
180
536
|
lastRecover = Date.now();
|
|
181
537
|
}
|
|
@@ -218,11 +574,10 @@ export class QueueManager {
|
|
|
218
574
|
return (await this.#driver.recoverStale?.()) ?? 0;
|
|
219
575
|
}
|
|
220
576
|
|
|
221
|
-
/** Await
|
|
577
|
+
/** Await every in-flight attempt, if any. */
|
|
222
578
|
async drain(): Promise<void> {
|
|
223
|
-
if (this.#
|
|
224
|
-
|
|
225
|
-
}
|
|
579
|
+
if (this.#inflight.size === 0) return;
|
|
580
|
+
await Promise.allSettled([...this.#inflight]);
|
|
226
581
|
}
|
|
227
582
|
|
|
228
583
|
/**
|
|
@@ -240,7 +595,7 @@ export class QueueManager {
|
|
|
240
595
|
}
|
|
241
596
|
|
|
242
597
|
/** Get failed jobs. */
|
|
243
|
-
async failedJobs(): Promise<
|
|
598
|
+
async failedJobs(): Promise<JobRecord[]> {
|
|
244
599
|
return this.#driver.failed();
|
|
245
600
|
}
|
|
246
601
|
|
package/src/adapters.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The queue adapter factories a config file names — `{ default, adapters }`.
|
|
3
|
+
*
|
|
4
|
+
* The shape the framework gives a package with several backends and one
|
|
5
|
+
* selected: a `drivers` namespace imported beside `defineConfig`, each entry
|
|
6
|
+
* that namespace's result, and the selection read from the environment. Bay had
|
|
7
|
+
* a single `driver: "memory"` and told everyone else to build the manager by
|
|
8
|
+
* hand, which meant Redis could not be reached from a config file at all.
|
|
9
|
+
*
|
|
10
|
+
* import { defineConfig, drivers } from '@c9up/bay'
|
|
11
|
+
*
|
|
12
|
+
* export default defineConfig({
|
|
13
|
+
* default: env.get('QUEUE_DRIVER'),
|
|
14
|
+
* adapters: {
|
|
15
|
+
* memory: drivers.memory(),
|
|
16
|
+
* redis: drivers.redis({ connection: 'main' }),
|
|
17
|
+
* },
|
|
18
|
+
* })
|
|
19
|
+
*
|
|
20
|
+
* The names are upstream's: `@adonisjs/queue` reads `default` + `adapters` out
|
|
21
|
+
* of `config/queue.ts`, fills them from a `drivers` namespace, and takes the
|
|
22
|
+
* selection from `QUEUE_DRIVER`. Bay said `stores` / `QUEUE_STORE`, which is
|
|
23
|
+
* the vocabulary of a different package. Both still work — see `stores` below.
|
|
24
|
+
*
|
|
25
|
+
* Factories are lazy: only the adapter an application actually uses is built,
|
|
26
|
+
* so naming a Redis queue in a config that runs in memory opens no connection.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { MemoryDriver } from "./drivers/MemoryDriver.js";
|
|
30
|
+
import type { RedisClientSource } from "./drivers/RedisDriver.js";
|
|
31
|
+
import { RedisDriver } from "./drivers/RedisDriver.js";
|
|
32
|
+
import type { QueueDriver } from "./QueueManager.js";
|
|
33
|
+
import { quasarConnection } from "./quasar.js";
|
|
34
|
+
|
|
35
|
+
/** A driver, built on first use. */
|
|
36
|
+
export type AdapterFactory = () => QueueDriver;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The older name for {@link AdapterFactory}, kept for configs typed against it.
|
|
40
|
+
*/
|
|
41
|
+
export type QueueStoreFactory = AdapterFactory;
|
|
42
|
+
|
|
43
|
+
export const drivers = {
|
|
44
|
+
/** In memory. Jobs do not survive a restart — for tests and dev. */
|
|
45
|
+
memory(): AdapterFactory {
|
|
46
|
+
return () => new MemoryDriver();
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Redis. `connection` takes an ioredis-shaped client, a function answering
|
|
51
|
+
* one, or the NAME of a `@c9up/quasar` connection — the last resolved at
|
|
52
|
+
* first use, without bay importing quasar, which stays an optional peer.
|
|
53
|
+
*/
|
|
54
|
+
redis(options: {
|
|
55
|
+
connection: RedisClientSource | string;
|
|
56
|
+
prefix?: string;
|
|
57
|
+
visibilityTimeoutMs?: number;
|
|
58
|
+
}): AdapterFactory {
|
|
59
|
+
const source: RedisClientSource =
|
|
60
|
+
typeof options.connection === "string"
|
|
61
|
+
? quasarConnection(options.connection)
|
|
62
|
+
: options.connection;
|
|
63
|
+
return () =>
|
|
64
|
+
new RedisDriver(source, {
|
|
65
|
+
prefix: options.prefix,
|
|
66
|
+
visibilityTimeoutMs: options.visibilityTimeoutMs,
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The older name for {@link drivers}, kept for configs written against it. The
|
|
73
|
+
* same object: `stores.redis(...)` and `drivers.redis(...)` are one call.
|
|
74
|
+
*/
|
|
75
|
+
export const stores = drivers;
|