@c9up/bay 0.1.13 → 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 (85) hide show
  1. package/README.md +122 -16
  2. package/dist/BayProvider.d.ts +39 -11
  3. package/dist/BayProvider.d.ts.map +1 -1
  4. package/dist/BayProvider.js +35 -13
  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 +140 -21
  11. package/dist/QueueManager.d.ts.map +1 -1
  12. package/dist/QueueManager.js +247 -53
  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 +1 -0
  23. package/dist/configure.d.ts.map +1 -1
  24. package/dist/configure.js +24 -7
  25. package/dist/configure.js.map +1 -1
  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 +60 -8
  47. package/dist/drivers/RedisDriver.d.ts.map +1 -1
  48. package/dist/drivers/RedisDriver.js +209 -29
  49. package/dist/drivers/RedisDriver.js.map +1 -1
  50. package/dist/index.d.ts +10 -6
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +8 -4
  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/quasar.d.ts +1 -1
  59. package/dist/quasar.js +1 -1
  60. package/dist/testing/FakeQueue.d.ts +15 -9
  61. package/dist/testing/FakeQueue.d.ts.map +1 -1
  62. package/dist/testing/FakeQueue.js +13 -3
  63. package/dist/testing/FakeQueue.js.map +1 -1
  64. package/package.json +5 -3
  65. package/src/BayProvider.ts +79 -26
  66. package/src/Job.ts +137 -0
  67. package/src/QueueManager.ts +411 -56
  68. package/src/adapters.ts +75 -0
  69. package/src/augmentations.ts +31 -0
  70. package/src/configure.ts +25 -7
  71. package/src/console/contract.ts +94 -0
  72. package/src/console/index.ts +68 -0
  73. package/src/console/makeJob.ts +139 -0
  74. package/src/console/queueWork.ts +70 -0
  75. package/src/drivers/MemoryDriver.ts +66 -14
  76. package/src/drivers/RedisDriver.ts +298 -42
  77. package/src/index.ts +35 -6
  78. package/src/jobs.ts +111 -0
  79. package/src/quasar.ts +1 -1
  80. package/src/testing/FakeQueue.ts +25 -15
  81. package/dist/stores.d.ts +0 -41
  82. package/dist/stores.d.ts.map +0 -1
  83. package/dist/stores.js +0 -46
  84. package/dist/stores.js.map +0 -1
  85. package/src/stores.ts +0 -59
@@ -18,8 +18,9 @@
18
18
  * needs a thin adapter.
19
19
  */
20
20
 
21
+ import { DEFAULT_QUEUE } from "../Job.js";
21
22
  import { inProduction } from "../nodeEnv.js";
22
- import type { Job, QueueDriver } from "../QueueManager.js";
23
+ import { type JobRecord, type QueueDriver, queueOf } from "../QueueManager.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 Job {
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 j.id === "string" &&
46
- typeof j.name === "string" &&
47
- typeof j.attempts === "number" &&
48
- typeof j.maxAttempts === "number" &&
49
- typeof j.status === "string"
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:
@@ -157,6 +213,23 @@ export class RedisDriver implements QueueDriver {
157
213
  * deployment makes, not one a version check makes for it.
158
214
  */
159
215
  allowNonAtomicPop?: boolean;
216
+ /**
217
+ * How many times a job may be reclaimed from a stalled worker before
218
+ * it is filed as failed instead of pushed round again. Default `1`,
219
+ * upstream's default for the same setting.
220
+ *
221
+ * Unbounded recovery is a job that kills its worker taking the whole
222
+ * queue down with it, forever: the crash never reaches the failure
223
+ * path, so `attempts` never moves and `maxAttempts` never applies.
224
+ */
225
+ maxStalledCount?: number;
226
+ /**
227
+ * How many failed jobs to keep. Default `1000` — the ceiling the
228
+ * memory driver already had. `0` keeps every one of them.
229
+ *
230
+ * Only enforced when the client answers `ltrim`.
231
+ */
232
+ maxFailedJobs?: number;
160
233
  },
161
234
  ) {
162
235
  this.#source = source;
@@ -183,33 +256,91 @@ export class RedisDriver implements QueueDriver {
183
256
  );
184
257
  }
185
258
  this.#visibilityTimeout = visibilityTimeout;
259
+
260
+ const maxStalled = options?.maxStalledCount ?? 1;
261
+ if (!Number.isInteger(maxStalled) || maxStalled < 0) {
262
+ throw new Error(
263
+ `[bay] RedisDriver maxStalledCount must be a non-negative integer, got ${maxStalled}`,
264
+ );
265
+ }
266
+ this.#maxStalledCount = maxStalled;
267
+
268
+ const maxFailed = options?.maxFailedJobs ?? 1000;
269
+ if (!Number.isInteger(maxFailed) || maxFailed < 0) {
270
+ throw new Error(
271
+ `[bay] RedisDriver maxFailedJobs must be a non-negative integer, got ${maxFailed}`,
272
+ );
273
+ }
274
+ this.#maxFailedJobs = maxFailed;
275
+ }
276
+
277
+ /**
278
+ * Renew a lease at half its length: two chances to be heard before the
279
+ * deadline, so one slow round-trip does not hand a running job to somebody
280
+ * else. Read by `QueueManager` while a handler runs.
281
+ */
282
+ get renewIntervalMs(): number {
283
+ return Math.max(1, Math.floor(this.#visibilityTimeout / 2));
186
284
  }
187
285
 
188
- #pendingKey = () => `${this.#prefix}pending`;
286
+ /**
287
+ * Where one queue's jobs wait.
288
+ *
289
+ * The default queue keeps the key it always had. Naming it
290
+ * `queue:default:pending` would have been tidier and would have orphaned
291
+ * every job already sitting in `queue:pending` at the moment of the upgrade
292
+ * — a silent loss, since nothing reads the old key afterwards.
293
+ */
294
+ #pendingKey = (queue: string = DEFAULT_QUEUE) =>
295
+ queue === DEFAULT_QUEUE
296
+ ? `${this.#prefix}pending`
297
+ : `${this.#prefix}q:${queue}:pending`;
298
+ #delayedKey = (queue: string = DEFAULT_QUEUE) =>
299
+ queue === DEFAULT_QUEUE
300
+ ? `${this.#prefix}delayed`
301
+ : `${this.#prefix}q:${queue}:delayed`;
189
302
  #processingKey = () => `${this.#prefix}processing`;
190
303
  #allowNonAtomicPop = false;
304
+ #maxStalledCount = 1;
305
+ #maxFailedJobs = 1000;
306
+ /** This driver instance, as a lease owner. */
307
+ #workerId = crypto.randomUUID();
191
308
  #failedKey = () => `${this.#prefix}failed`;
192
309
  #leaseKey = (jobId: string) => `${this.#prefix}lease:${jobId}`;
193
310
 
194
- async push(job: Job): Promise<void> {
311
+ async push(job: JobRecord): Promise<void> {
195
312
  const client = await this.#client();
196
- await client.rpush(this.#pendingKey(), JSON.stringify(job));
313
+ const queue = queueOf(job);
314
+ if (job.runAt !== undefined && job.runAt > Date.now()) {
315
+ if (!client.zadd) {
316
+ // Pushing it to the list instead would run it now, which is the
317
+ // one thing a delay exists to prevent.
318
+ throw new Error(
319
+ "This Redis client cannot hold a delayed job: it has no ZADD. " +
320
+ "Use a client with sorted-set commands (ioredis has them), or dispatch without `delay`.",
321
+ );
322
+ }
323
+ await client.zadd(
324
+ this.#delayedKey(queue),
325
+ job.runAt,
326
+ JSON.stringify(job),
327
+ );
328
+ return;
329
+ }
330
+ await client.rpush(this.#pendingKey(queue), JSON.stringify(job));
197
331
  }
198
332
 
199
- async pop(): Promise<Job | null> {
333
+ async pop(
334
+ queues: readonly string[] = [DEFAULT_QUEUE],
335
+ ): Promise<JobRecord | null> {
200
336
  const client = await this.#client();
201
337
  let raw: string | null = null;
202
338
 
203
- if (client.lmove) {
204
- raw = await client.lmove(
205
- this.#pendingKey(),
206
- this.#processingKey(),
207
- "LEFT",
208
- "RIGHT",
209
- );
210
- } else {
211
- raw = await client.lpop(this.#pendingKey());
212
- if (raw) await client.rpush(this.#processingKey(), raw);
339
+ // In the order given, so a worker can say which queue it drains first.
340
+ for (const queue of queues) {
341
+ await this.#promoteDue(client, queue);
342
+ raw = await this.#take(client, queue);
343
+ if (raw !== null) break;
213
344
  }
214
345
 
215
346
  if (!raw) return null;
@@ -239,34 +370,54 @@ export class RedisDriver implements QueueDriver {
239
370
  // pending — so the delivery guarantee survives the blip.
240
371
  await client.set(
241
372
  this.#leaseKey(parsed.id),
242
- raw,
373
+ JSON.stringify({ owner: this.#workerId, raw } satisfies Lease),
243
374
  "PX",
244
375
  String(this.#visibilityTimeout),
245
376
  );
246
377
  return parsed;
247
378
  }
248
379
 
249
- async complete(job: Job): Promise<void> {
380
+ /**
381
+ * Say the job is still being worked on, and push its deadline back.
382
+ *
383
+ * Answers `false` when there is nothing left to renew — the lease expired
384
+ * and the job was recovered, or it was recovered and re-popped by another
385
+ * worker, whose claim this one must not extend.
386
+ */
387
+ async renew(job: JobRecord): Promise<boolean> {
388
+ const client = await this.#client();
389
+ const key = this.#leaseKey(job.id);
390
+ const stored = await client.get(key);
391
+ if (stored === null) return false;
392
+ const lease = readLease(stored);
393
+ if (lease.owner !== undefined && lease.owner !== this.#workerId) {
394
+ return false;
395
+ }
396
+ await client.set(key, stored, "PX", String(this.#visibilityTimeout));
397
+ return true;
398
+ }
399
+
400
+ async complete(job: JobRecord): Promise<void> {
250
401
  const client = await this.#client();
251
402
  await this.#removeFromProcessing(job);
252
403
  await client.del(this.#leaseKey(job.id));
253
404
  }
254
405
 
255
- async fail(job: Job, error: string): Promise<void> {
406
+ async fail(job: JobRecord, error: string): Promise<void> {
256
407
  const client = await this.#client();
257
408
  await this.#removeFromProcessing(job);
258
409
  await client.del(this.#leaseKey(job.id));
259
410
  job.error = error;
260
411
  job.status = "failed";
261
- await client.rpush(this.#failedKey(), JSON.stringify(job));
412
+ await this.#pushFailed(client, job);
262
413
  }
263
414
 
264
- async retry(job: Job): Promise<void> {
415
+ async retry(job: JobRecord): Promise<void> {
265
416
  const client = await this.#client();
266
417
  await this.#removeFromProcessing(job);
267
418
  await client.del(this.#leaseKey(job.id));
268
419
  job.status = "pending";
269
- await client.rpush(this.#pendingKey(), JSON.stringify(job));
420
+ await client.rpush(this.#pendingKey(queueOf(job)), JSON.stringify(job));
270
421
  }
271
422
 
272
423
  async recoverStale(): Promise<number> {
@@ -288,30 +439,77 @@ export class RedisDriver implements QueueDriver {
288
439
  continue;
289
440
  }
290
441
  const lease = await client.get(this.#leaseKey(parsed.id));
291
- if (lease === null) {
292
- await client.lrem(this.#processingKey(), 1, raw);
293
- parsed.status = "pending";
294
- await client.rpush(this.#pendingKey(), JSON.stringify(parsed));
295
- recovered++;
442
+ if (lease !== null) continue;
443
+
444
+ // The LREM is the claim, and its RESULT decides who acts. Two
445
+ // recovery passes overlapping — two workers, or one worker whose
446
+ // pass ran long — both read the same expired entry, and both used to
447
+ // push it back to pending: one job, delivered twice, from the
448
+ // mechanism that exists to make delivery reliable. Exactly one LREM
449
+ // can remove a given element, so exactly one pass continues past
450
+ // here. (A crash between this and the RPUSH below still loses the
451
+ // entry; closing that needs the whole pass in one server-side script,
452
+ // which is how upstream does it.)
453
+ const claimed = await client.lrem(this.#processingKey(), 1, raw);
454
+ if (claimed === 0) continue;
455
+
456
+ // A stall is not an attempt: the worker died before the handler
457
+ // could fail, so `attempts` never moved and `maxAttempts` never
458
+ // applied. A job that kills whatever picks it up was therefore
459
+ // recovered forever, taking the queue with it. Counted separately,
460
+ // and bounded — upstream bounds the same thing with the same
461
+ // default, failing the job once it is exceeded.
462
+ const stalled = (parsed.stalledCount ?? 0) + 1;
463
+ if (stalled > this.#maxStalledCount) {
464
+ parsed.stalledCount = stalled;
465
+ parsed.status = "failed";
466
+ parsed.error = `Stalled ${stalled} time(s) without completing (maxStalledCount ${this.#maxStalledCount})`;
467
+ await this.#pushFailed(client, parsed);
468
+ continue;
296
469
  }
470
+
471
+ parsed.stalledCount = stalled;
472
+ parsed.status = "pending";
473
+ // Back to the queue it came from, not to the default one: a recovered
474
+ // job whose queue nobody serves would never run again.
475
+ await client.rpush(
476
+ this.#pendingKey(queueOf(parsed)),
477
+ JSON.stringify(parsed),
478
+ );
479
+ recovered++;
297
480
  }
298
481
  return recovered;
299
482
  }
300
483
 
484
+ /**
485
+ * File a job as failed, keeping the list to `maxFailedJobs`.
486
+ *
487
+ * Unbounded, the failed list is a leak with no ceiling and no owner: nothing
488
+ * trims it, and `failed()` reads all of it in one LRANGE. The memory driver
489
+ * has capped its own at a thousand from the start; this is the same cap on
490
+ * the driver where the list actually survives a restart.
491
+ */
492
+ async #pushFailed(client: RedisClient, job: JobRecord): Promise<void> {
493
+ await client.rpush(this.#failedKey(), JSON.stringify(job));
494
+ if (this.#maxFailedJobs === 0 || !client.ltrim) return;
495
+ await client.ltrim(this.#failedKey(), -this.#maxFailedJobs, -1);
496
+ }
497
+
301
498
  /**
302
499
  * Remove the entry for `job` from the processing list. The string in
303
500
  * Redis is whatever pop() pushed, but QueueManager mutates `job` after
304
501
  * pop returns (attempts++, status="processing", processedAt, then
305
502
  * completed/failed/pending). LREM-ing on `JSON.stringify(job)` would
306
- * therefore miss every real-world entry. Use the lease — set to the
307
- * exact raw string at pop() time — and fall back to a list scan when
503
+ * therefore miss every real-world entry. Use the lease — which carries
504
+ * the exact raw string pop() moved — and fall back to a list scan when
308
505
  * the lease has expired (e.g. recoverStale already handled it).
309
506
  */
310
- async #removeFromProcessing(job: Job): Promise<void> {
507
+ async #removeFromProcessing(job: JobRecord): Promise<void> {
311
508
  const client = await this.#client();
312
509
  const stored = await client.get(this.#leaseKey(job.id));
313
510
  if (stored !== null) {
314
- const removed = await client.lrem(this.#processingKey(), 1, stored);
511
+ const { raw } = readLease(stored);
512
+ const removed = await client.lrem(this.#processingKey(), 1, raw);
315
513
  if (removed > 0) return;
316
514
  }
317
515
  // Lease missing or already-LREM'd entry not found — best-effort scan
@@ -324,14 +522,14 @@ export class RedisDriver implements QueueDriver {
324
522
  } catch {
325
523
  continue;
326
524
  }
327
- if (isValidJob(parsed) && (parsed as { id: string }).id === job.id) {
525
+ if (isValidJob(parsed) && parsed.id === job.id) {
328
526
  await client.lrem(this.#processingKey(), 1, item);
329
527
  return;
330
528
  }
331
529
  }
332
530
  }
333
531
 
334
- async failed(): Promise<Job[]> {
532
+ async failed(): Promise<JobRecord[]> {
335
533
  const client = await this.#client();
336
534
  const raws = await client.lrange(this.#failedKey(), 0, -1);
337
535
  return raws
@@ -343,11 +541,69 @@ export class RedisDriver implements QueueDriver {
343
541
  return null;
344
542
  }
345
543
  })
346
- .filter((j): j is Job => j !== null);
544
+ .filter((j): j is JobRecord => j !== null);
347
545
  }
348
546
 
349
- async size(): Promise<number> {
547
+ /**
548
+ * How many jobs are waiting on `queue` — its list plus its delayed set.
549
+ *
550
+ * A delayed job is queued; it is simply not due. Counting only the list
551
+ * reported an empty queue to anything draining one before shutdown.
552
+ */
553
+ async size(queue: string = DEFAULT_QUEUE): Promise<number> {
350
554
  const client = await this.#client();
351
- return client.llen(this.#pendingKey());
555
+ const waiting = await client.llen(this.#pendingKey(queue));
556
+ const delayed = client.zcard
557
+ ? await client.zcard(this.#delayedKey(queue))
558
+ : 0;
559
+ return waiting + delayed;
560
+ }
561
+
562
+ /**
563
+ * Move `queue`'s due jobs out of the delayed set and onto its list.
564
+ *
565
+ * `ZREM` is the claim: two workers can read the same due entry, and only
566
+ * the one whose removal returns 1 owns it. Without that the job is pushed
567
+ * onto the list once per worker that saw it.
568
+ */
569
+ async #promoteDue(client: RedisClient, queue: string): Promise<void> {
570
+ if (!client.zrangebyscore || !client.zrem) return;
571
+ const due = await client.zrangebyscore(
572
+ this.#delayedKey(queue),
573
+ 0,
574
+ Date.now(),
575
+ "LIMIT",
576
+ "0",
577
+ "100",
578
+ );
579
+ for (const raw of due) {
580
+ const claimed = await client.zrem(this.#delayedKey(queue), raw);
581
+ if (claimed !== 1) continue;
582
+ let parsed: unknown;
583
+ try {
584
+ parsed = JSON.parse(raw);
585
+ } catch {
586
+ // Already removed from the set; there is nothing runnable to push.
587
+ continue;
588
+ }
589
+ if (!isValidJob(parsed)) continue;
590
+ parsed.runAt = undefined;
591
+ await client.rpush(this.#pendingKey(queue), JSON.stringify(parsed));
592
+ }
593
+ }
594
+
595
+ /** Take the head of one queue, claiming it in `processing`. */
596
+ async #take(client: RedisClient, queue: string): Promise<string | null> {
597
+ if (client.lmove) {
598
+ return client.lmove(
599
+ this.#pendingKey(queue),
600
+ this.#processingKey(),
601
+ "LEFT",
602
+ "RIGHT",
603
+ );
604
+ }
605
+ const raw = await client.lpop(this.#pendingKey(queue));
606
+ if (raw) await client.rpush(this.#processingKey(), raw);
607
+ return raw;
352
608
  }
353
609
  }
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
- * Dispatch/process/retry/fail pattern with pluggable drivers (Memory, Redis).
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
- * @implements MISS-11
8
+ * Dispatch/process/retry/fail, with pluggable drivers (Memory, Redis).
7
9
  */
8
10
 
9
- export type { BayProviderConfig } from "./BayProvider.js";
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 type { Job, JobHandler, QueueDriver } from "./QueueManager.js";
14
- export { QueueManager } from "./QueueManager.js";
15
- export { type QueueStoreFactory, stores } from "./stores.js";
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,111 @@
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 {
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
+ return [];
66
+ }
67
+ const found: string[] = [];
68
+ for (const entry of entries) {
69
+ const full = path.join(dir, entry.name);
70
+ if (entry.isDirectory()) {
71
+ found.push(...(await walk(full, depth + 1)));
72
+ continue;
73
+ }
74
+ // `.d.ts` is a declaration, not a module with a job in it.
75
+ if (/\.d\.[cm]?ts$/.test(entry.name)) continue;
76
+ if (/\.[cm]?[jt]s$/.test(entry.name)) found.push(full);
77
+ }
78
+ return found.sort();
79
+ }
80
+
81
+ /**
82
+ * Import every module under `locations` and return the job classes they
83
+ * default-export.
84
+ *
85
+ * A module that throws on import is reported and skipped: one unfinished job
86
+ * file must not stop the worker from running every other job.
87
+ */
88
+ export async function discoverJobs(
89
+ locations: readonly string[],
90
+ ): Promise<JobClass[]> {
91
+ const found: JobClass[] = [];
92
+ for (const location of locations) {
93
+ for (const file of await walk(directoryOf(location))) {
94
+ let module: unknown;
95
+ try {
96
+ module = await import(pathToFileURL(path.resolve(file)).href);
97
+ } catch (err) {
98
+ process.stderr.write(
99
+ `[bay] could not load '${file}': ${
100
+ err instanceof Error ? err.message : String(err)
101
+ }\n`,
102
+ );
103
+ continue;
104
+ }
105
+ if (typeof module !== "object" || module === null) continue;
106
+ const exported = Reflect.get(module, "default");
107
+ if (isJobClass(exported)) found.push(exported);
108
+ }
109
+ }
110
+ return found;
111
+ }
package/src/quasar.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Bay does not depend on quasar: it is an optional peer, and this module
5
5
  * never imports it statically. The specifier is built at runtime so the
6
- * TypeScript build stays free of it too — a hard type import would make echo
6
+ * TypeScript build stays free of it too — a hard type import would make bay
7
7
  * unbuildable for anyone who queues in memory.
8
8
  *
9
9
  * The shape is checked before use rather than asserted, the same way bay