@c9up/bay 0.1.12 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +172 -1
  2. package/dist/BayProvider.d.ts +55 -33
  3. package/dist/BayProvider.d.ts.map +1 -1
  4. package/dist/BayProvider.js +81 -10
  5. package/dist/BayProvider.js.map +1 -1
  6. package/dist/Job.d.ts +99 -0
  7. package/dist/Job.d.ts.map +1 -0
  8. package/dist/Job.js +78 -0
  9. package/dist/Job.js.map +1 -0
  10. package/dist/QueueManager.d.ts +147 -22
  11. package/dist/QueueManager.d.ts.map +1 -1
  12. package/dist/QueueManager.js +290 -52
  13. package/dist/QueueManager.js.map +1 -1
  14. package/dist/adapters.d.ts +68 -0
  15. package/dist/adapters.d.ts.map +1 -0
  16. package/dist/adapters.js +56 -0
  17. package/dist/adapters.js.map +1 -0
  18. package/dist/augmentations.d.ts +28 -0
  19. package/dist/augmentations.d.ts.map +1 -0
  20. package/dist/augmentations.js +17 -0
  21. package/dist/augmentations.js.map +1 -0
  22. package/dist/configure.d.ts +19 -0
  23. package/dist/configure.d.ts.map +1 -0
  24. package/dist/configure.js +48 -0
  25. package/dist/configure.js.map +1 -0
  26. package/dist/console/contract.d.ts +60 -0
  27. package/dist/console/contract.d.ts.map +1 -0
  28. package/dist/console/contract.js +36 -0
  29. package/dist/console/contract.js.map +1 -0
  30. package/dist/console/index.d.ts +29 -0
  31. package/dist/console/index.d.ts.map +1 -0
  32. package/dist/console/index.js +45 -0
  33. package/dist/console/index.js.map +1 -0
  34. package/dist/console/makeJob.d.ts +32 -0
  35. package/dist/console/makeJob.d.ts.map +1 -0
  36. package/dist/console/makeJob.js +118 -0
  37. package/dist/console/makeJob.js.map +1 -0
  38. package/dist/console/queueWork.d.ts +18 -0
  39. package/dist/console/queueWork.d.ts.map +1 -0
  40. package/dist/console/queueWork.js +58 -0
  41. package/dist/console/queueWork.js.map +1 -0
  42. package/dist/drivers/MemoryDriver.d.ts +14 -8
  43. package/dist/drivers/MemoryDriver.d.ts.map +1 -1
  44. package/dist/drivers/MemoryDriver.js +61 -7
  45. package/dist/drivers/MemoryDriver.js.map +1 -1
  46. package/dist/drivers/RedisDriver.d.ts +66 -8
  47. package/dist/drivers/RedisDriver.d.ts.map +1 -1
  48. package/dist/drivers/RedisDriver.js +257 -45
  49. package/dist/drivers/RedisDriver.js.map +1 -1
  50. package/dist/index.d.ts +10 -5
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +8 -3
  53. package/dist/index.js.map +1 -1
  54. package/dist/jobs.d.ts +43 -0
  55. package/dist/jobs.d.ts.map +1 -0
  56. package/dist/jobs.js +105 -0
  57. package/dist/jobs.js.map +1 -0
  58. package/dist/nodeEnv.d.ts +16 -0
  59. package/dist/nodeEnv.d.ts.map +1 -0
  60. package/dist/nodeEnv.js +32 -0
  61. package/dist/nodeEnv.js.map +1 -0
  62. package/dist/quasar.d.ts +1 -1
  63. package/dist/quasar.js +1 -1
  64. package/dist/services/main.d.ts +5 -0
  65. package/dist/services/main.d.ts.map +1 -1
  66. package/dist/services/main.js +7 -0
  67. package/dist/services/main.js.map +1 -1
  68. package/dist/testing/FakeQueue.d.ts +15 -9
  69. package/dist/testing/FakeQueue.d.ts.map +1 -1
  70. package/dist/testing/FakeQueue.js +13 -3
  71. package/dist/testing/FakeQueue.js.map +1 -1
  72. package/package.json +9 -3
  73. package/src/BayProvider.ts +143 -25
  74. package/src/Job.ts +137 -0
  75. package/src/QueueManager.ts +454 -56
  76. package/src/adapters.ts +75 -0
  77. package/src/augmentations.ts +31 -0
  78. package/src/configure.ts +63 -0
  79. package/src/console/contract.ts +94 -0
  80. package/src/console/index.ts +68 -0
  81. package/src/console/makeJob.ts +139 -0
  82. package/src/console/queueWork.ts +70 -0
  83. package/src/drivers/MemoryDriver.ts +66 -14
  84. package/src/drivers/RedisDriver.ts +366 -63
  85. package/src/index.ts +35 -5
  86. package/src/jobs.ts +111 -0
  87. package/src/nodeEnv.ts +30 -0
  88. package/src/quasar.ts +1 -1
  89. package/src/services/main.ts +8 -0
  90. package/src/testing/FakeQueue.ts +25 -15
@@ -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 Job {
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: Job): Promise<void>;
28
- pop(): Promise<Job | null>;
29
- fail(job: Job, error: string): Promise<void>;
30
- complete(job: Job): Promise<void>;
31
- retry(job: Job): Promise<void>;
32
- failed(): Promise<Job[]>;
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,53 +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
- #inflightPromise: Promise<boolean> | null = null;
185
+ /** The running loop, so `stop()` can wait for it to finish. */
186
+ #loopPromise: Promise<void> | undefined;
187
+ /** Cuts the sleep between polls short. */
188
+ #wake: (() => void) | undefined;
189
+ /** Every attempt currently in flight, so `drain()` can wait for all of them. */
190
+ #inflight: Set<Promise<boolean>> = new Set();
191
+
192
+ /** Defaults for `work()`, from the config's `worker` block. */
193
+ readonly #workerDefaults: WorkerOptions;
47
194
 
48
- constructor(driver: QueueDriver) {
195
+ constructor(driver: QueueDriver, workerDefaults?: WorkerOptions) {
49
196
  this.#driver = driver;
197
+ this.#workerDefaults = workerDefaults ?? {};
50
198
  }
51
199
 
52
- /** Register a job handler. */
200
+ /** Register a job handler under a name. */
53
201
  register(name: string, handler: JobHandler | (new () => JobHandler)): void {
54
202
  this.#handlers.set(name, handler);
55
203
  }
56
204
 
57
- /** Dispatch a job to the queue. */
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>;
58
241
  async dispatch(
59
242
  name: string,
60
243
  payload: unknown,
61
- options?: { maxAttempts?: number },
244
+ options?: DispatchOptions,
245
+ ): Promise<string>;
246
+ async dispatch(
247
+ job: string | JobClass,
248
+ payload: unknown,
249
+ options: DispatchOptions = {},
62
250
  ): Promise<string> {
63
- if (options?.maxAttempts !== undefined && options.maxAttempts < 1) {
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) {
64
267
  throw new Error("maxAttempts must be >= 1");
65
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
+
66
273
  const id = `job_${crypto.randomUUID()}`;
67
- const job: Job = {
274
+ const record: JobRecord = {
68
275
  id,
69
276
  name,
70
277
  payload,
71
278
  attempts: 0,
72
- maxAttempts: options?.maxAttempts ?? 3,
279
+ maxAttempts,
73
280
  status: "pending",
74
281
  createdAt: Date.now(),
282
+ queue: options.queue ?? declared.queue ?? DEFAULT_QUEUE,
75
283
  };
76
- await this.#driver.push(job);
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);
77
289
  return id;
78
290
  }
79
291
 
80
- /** Process the next job in the queue. */
81
- async processOne(): Promise<boolean> {
82
- const job = await this.#driver.pop();
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);
83
300
  if (!job) return false;
84
301
 
85
- const handlerOrClass = this.#handlers.get(job.name);
86
- if (!handlerOrClass) {
302
+ const run = this.#resolveRunner(job.name);
303
+ if (!run) {
87
304
  process.stderr.write(
88
305
  `QueueManager: no handler registered for job '${job.name}'\n`,
89
306
  );
@@ -94,20 +311,25 @@ export class QueueManager {
94
311
  return true;
95
312
  }
96
313
 
97
- const handler =
98
- typeof handlerOrClass === "function"
99
- ? new handlerOrClass()
100
- : handlerOrClass;
314
+ const handler = run;
101
315
  job.attempts++;
102
316
  job.status = "processing";
103
317
  job.processedAt = Date.now();
104
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;
105
327
  try {
106
- await handler.handle(job.payload);
107
- job.status = "completed";
108
- await this.#driver.complete(job);
328
+ await withTimeout(handler.run(job.payload), job.timeout, job.name);
329
+ handled = true;
109
330
  } catch (err) {
110
- const errorMsg = err instanceof Error ? err.message : String(err);
331
+ const error = err instanceof Error ? err : new Error(String(err));
332
+ const errorMsg = error.message;
111
333
  if (job.attempts < job.maxAttempts) {
112
334
  job.status = "pending";
113
335
  await this.#driver.retry(job);
@@ -115,53 +337,222 @@ export class QueueManager {
115
337
  job.status = "failed";
116
338
  job.error = errorMsg;
117
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
+ }
118
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);
119
368
  }
120
369
 
121
370
  return true;
122
371
  }
123
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
+
124
430
  /**
125
431
  * Start processing jobs continuously. Reclaims crash-orphaned jobs at
126
- * startup and every `recoverStaleMs` thereafter (no-op for in-memory drivers
127
- * without recoverStale) — otherwise a job left in 'processing' by a crashed
128
- * 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)`.
129
446
  */
130
- async work(pollIntervalMs = 1000, recoverStaleMs = 30_000): Promise<void> {
131
- if (pollIntervalMs <= 0) {
132
- throw new Error("pollIntervalMs must be positive");
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");
133
468
  }
134
- if (recoverStaleMs <= 0) {
135
- throw new Error("recoverStaleMs must be positive");
469
+ if (stalledInterval <= 0) {
470
+ throw new Error("stalledInterval must be positive");
136
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
+
137
479
  if (this.#running) {
138
480
  throw new Error("QueueManager is already running");
139
481
  }
140
482
  this.#running = true;
483
+ const loop = this.#loop(idleDelay, stalledInterval, concurrency, queues);
484
+ this.#loopPromise = loop;
485
+ try {
486
+ await loop;
487
+ } finally {
488
+ this.#loopPromise = undefined;
489
+ }
490
+ }
491
+
492
+ /**
493
+ * The polling loop itself.
494
+ *
495
+ * Between jobs it sleeps, and that sleep is CANCELLABLE: `stop()` wakes it
496
+ * rather than waiting out the interval. Without that, stopping returned
497
+ * while the loop was still pending — up to a full poll interval of a worker
498
+ * that was supposed to be gone, and a timer holding the process open.
499
+ */
500
+ async #loop(
501
+ idleDelay: number,
502
+ stalledInterval: number,
503
+ concurrency: number,
504
+ queues: readonly string[] | undefined,
505
+ ): Promise<void> {
141
506
  await this.#tryRecoverStale();
142
507
  let lastRecover = Date.now();
143
508
  while (this.#running) {
144
- try {
145
- this.#inflightPromise = this.processOne();
146
- const processed = await this.#inflightPromise;
147
- if (!processed) {
148
- await new Promise((r) => setTimeout(r, pollIntervalMs));
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
+ );
149
528
  }
150
- } catch (err) {
151
- process.stderr.write(
152
- `QueueManager processOne error: ${err instanceof Error ? err.message : String(err)}\n`,
153
- );
154
- await new Promise((r) => setTimeout(r, pollIntervalMs));
155
- } finally {
156
- this.#inflightPromise = null;
157
529
  }
158
- if (this.#running && Date.now() - lastRecover >= recoverStaleMs) {
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) {
159
535
  await this.#tryRecoverStale();
160
536
  lastRecover = Date.now();
161
537
  }
162
538
  }
163
539
  }
164
540
 
541
+ /** Wait, unless `stop()` says otherwise first. */
542
+ #sleep(ms: number): Promise<void> {
543
+ return new Promise((resolve) => {
544
+ const timer = setTimeout(() => {
545
+ this.#wake = undefined;
546
+ resolve();
547
+ }, ms);
548
+ this.#wake = () => {
549
+ clearTimeout(timer);
550
+ this.#wake = undefined;
551
+ resolve();
552
+ };
553
+ });
554
+ }
555
+
165
556
  /** recoverStale() wrapper that swallows driver errors — used by the work loop. */
166
557
  async #tryRecoverStale(): Promise<void> {
167
558
  try {
@@ -183,21 +574,28 @@ export class QueueManager {
183
574
  return (await this.#driver.recoverStale?.()) ?? 0;
184
575
  }
185
576
 
186
- /** Await the currently in-flight processOne, if any. */
577
+ /** Await every in-flight attempt, if any. */
187
578
  async drain(): Promise<void> {
188
- if (this.#inflightPromise) {
189
- await this.#inflightPromise.catch(() => {});
190
- }
579
+ if (this.#inflight.size === 0) return;
580
+ await Promise.allSettled([...this.#inflight]);
191
581
  }
192
582
 
193
- /** Stop the worker. */
583
+ /**
584
+ * Stop the worker and wait for it to actually be gone.
585
+ *
586
+ * Awaits the LOOP, not just the job in flight: a stop that returns while
587
+ * the loop is still sleeping leaves a worker running past the teardown that
588
+ * asked it to stop.
589
+ */
194
590
  async stop(): Promise<void> {
195
591
  this.#running = false;
592
+ this.#wake?.();
196
593
  await this.drain();
594
+ if (this.#loopPromise) await this.#loopPromise.catch(() => {});
197
595
  }
198
596
 
199
597
  /** Get failed jobs. */
200
- async failedJobs(): Promise<Job[]> {
598
+ async failedJobs(): Promise<JobRecord[]> {
201
599
  return this.#driver.failed();
202
600
  }
203
601