@basaltkit/queue 1.3.0 → 1.4.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.
package/README.md CHANGED
@@ -119,7 +119,10 @@ await GenerateInvoice.dispatch({ orderId: 'o-1' }, { delay: '10m' })
119
119
  await GenerateInvoice.dispatch({ orderId: 'o-2' }, { priority: 1 })
120
120
  ```
121
121
 
122
- Note: with the sync driver, `delay` is ignored — the job runs immediately.
122
+ Note: with the sync driver, `delay` and `priority` are ignored — the job runs immediately.
123
+ That is not silent: the sync driver declares `delayed: false` / `priority: false`, so the
124
+ `onUnsupported` policy fires (by default a one-off `console.warn` per job+feature). Set
125
+ `onUnsupported: 'throw'` in production if a delay is load-bearing.
123
126
 
124
127
  ### Context propagation (tenant, requestId…)
125
128
 
@@ -232,8 +235,15 @@ console.log(driver.executed) // [{ queue: 'default', jobName: 'demo', attempts:
232
235
  | `queue` | `string` | No | `'default'` | Name of the queue the job goes into. |
233
236
  | `attempts` | `number` | No | `1` | Maximum number of attempts on failure. |
234
237
  | `backoff` | `JobBackoff` | No | — | Wait strategy between attempts. |
238
+ | `removeOnComplete` | `JobRetention` | No | the `queuePlugin` default | Redis retention for this job once it completes — overrides the plugin-wide default. |
239
+ | `removeOnFail` | `JobRetention` | No | the `queuePlugin` default | Redis retention for this job once it fails permanently — overrides the plugin-wide default. |
235
240
  | `handle` | `(payload: T) => void \| Promise<void>` | Yes | — | The function that does the work (runs on the worker). |
236
241
 
242
+ `JobRetention` = `boolean | number | { age?: DurationInput; count?: number }`. `true` removes
243
+ the job as soon as it finishes, `false` keeps it forever, a number keeps that many most-recent,
244
+ and `{ age, count }` caps by both. Only the BullMQ driver stores finished jobs, so only it
245
+ honours retention; the sync driver stores nothing and ignores it.
246
+
237
247
  The returned object (`JobDefinition<T>`) exposes:
238
248
 
239
249
  - `dispatch(payload, options?)` — puts the job on the queue. Throws `JobNotRegisteredError` if the job hasn't yet been registered with a `QueueManager`.
@@ -264,6 +274,16 @@ Basalt plugin that registers a `QueueManager` in the container under the `QUEUE`
264
274
  | `connection` | `string \| ConnectionOptions` | No | — | Redis URL (`redis://…` or `rediss://…`) or ioredis options. With a value → BullMQ driver; without one → sync driver. |
265
275
  | `driver` | `QueueDriver` | No | — | Custom driver — takes precedence over `connection`. |
266
276
  | `workers` | `{ queue: string; concurrency?: number }[]` | No | `[]` | Queues whose workers start in this process on boot. |
277
+ | `onUnsupported` | `'throw' \| 'warn' \| 'ignore'` | No | `'warn'` | What happens when a dispatch uses an option the active driver cannot honour (a delay on Kafka, a priority on SQS). `'warn'` logs once per job+feature and proceeds; `'throw'` raises `UnsupportedJobOptionError` — set it in production when the option is load-bearing; `'ignore'` is silent. |
278
+ | `removeOnComplete` | `JobRetention` | No | driver default (BullMQ keeps the last `1000`) | Default Redis retention for completed jobs. A job's own `removeOnComplete` wins. |
279
+ | `removeOnFail` | `JobRetention` | No | driver default (BullMQ `false` — keep all) | Default retention for failed jobs. Keeping all is deliberate (inspection and `queue:retry`); set e.g. `{ age: '14d' }` so failures don't grow unbounded. |
280
+ | `onError` | `(error, { queue, source }) => void` | No | `console.error` with context | Infra faults from the driver's broker client (Redis down). Forwarded to the driver built from `connection`; ignored when you pass your own `driver`. |
281
+ | `onJobFailed` | `({ queue, job, jobId?, error }) => void` | No | `console.error` with context | A job exhausted its retries. Forwarded to the driver built from `connection`; ignored when you pass your own `driver`. |
282
+
283
+ **Failure callbacks work on the shorthand path.** `queuePlugin({ connection, onError,
284
+ onJobFailed })` forwards both to the BullMQ driver it builds. They are ignored when you
285
+ supply your own `driver` — that driver owns its own callbacks, so configure them there.
286
+ See [Failure hooks](#failure-hooks) below.
267
287
 
268
288
  ```ts
269
289
  import { QUEUE } from '@basaltkit/queue'
@@ -274,12 +294,23 @@ const manager = app.container.get(QUEUE) // get the QueueManager from the contai
274
294
 
275
295
  | Method | Signature | Description |
276
296
  |---|---|---|
277
- | `constructor` | `new QueueManager(driver: QueueDriver)` | Creates the manager over a driver. |
297
+ | `constructor` | `new QueueManager(driver: QueueDriver, options?: QueueManagerOptions)` | Creates the manager over a driver. |
278
298
  | `register` | `(job) => this` | Registers a job and wires its `dispatch`. |
279
- | `dispatch` | `<T>(job, payload: T, options?: DispatchOptions) => Promise<void>` | Validates and puts the job on the queue. Auto-registers the job if not registered yet. |
299
+ | `dispatch` | `<T>(job, payload: T, options?: DispatchOptions) => Promise<void>` | Validates, snapshots the request context, checks the driver's capabilities, and enqueues. Auto-registers the job if not registered yet. |
280
300
  | `work` | `(queue = 'default', { concurrency? }?) => void` | Starts a worker for the queue (no-op on the sync driver). |
301
+ | `stats` | `(queue = 'default') => Promise<QueueStats \| undefined>` | Job counts per state, or `undefined` when the driver can't introspect (sync). |
302
+ | `retryFailed` | `(queue = 'default', { limit? }?) => Promise<number \| undefined>` | Re-enqueues failed jobs; returns the count, or `undefined` when the driver doesn't support it. |
281
303
  | `close` | `() => Promise<void>` | Closes workers and connections. |
282
304
 
305
+ `QueueManagerOptions`:
306
+
307
+ | Option | Type | Default | Purpose |
308
+ |---|---|---|---|
309
+ | `onUnsupported` | `'throw' \| 'warn' \| 'ignore'` | `'warn'` | Same policy as the plugin option. |
310
+ | `warn` | `(message: string) => void` | `console.warn` | Where `'warn'` diagnostics go — point it at your logger. |
311
+ | `removeOnComplete` | `JobRetention` | driver default | Default retention for completed jobs. |
312
+ | `removeOnFail` | `JobRetention` | driver default | Default retention for failed jobs. |
313
+
283
314
  ### `queuedOn<T>(bus, manager, event, handler, options?): () => void`
284
315
 
285
316
  Creates the event→job bridge. Returns the subscription cancel function.
@@ -294,17 +325,94 @@ Creates the event→job bridge. Returns the subscription cancel function.
294
325
 
295
326
  ### Drivers
296
327
 
297
- - **`class SyncQueueDriver`** — runs inline on `dispatch`, honors `attempts` (immediate retry). Public property `executed: { queue, jobName, attempts }[]` with the execution history. For testing and dev without Redis.
298
- - **`class BullmqQueueDriver`** — production over Redis. `new BullmqQueueDriver({ connection })`, where `connection` is a Redis URL or ioredis options (`BullmqDriverOptions`). Completed jobs are cleaned up (keeps 1000); failed jobs are kept.
299
- - **`interface QueueDriver`** (Advanced) — contract for custom drivers: `setExecutor(executor)`, `add(queue, jobName, data, options: AddJobOptions)`, `startWorker(queue, { concurrency? })`, `close()`. Helper types: `AddJobOptions`, `JobExecutor`.
328
+ - **`class SyncQueueDriver`** — runs inline on `dispatch`, honors `attempts` (immediate retry). Public property `executed: { queue, jobName, attempts }[]` with the execution history (capped at 1000 entries). For testing and dev without Redis.
329
+ - **`class BullmqQueueDriver`** — production over Redis; see the options table below.
330
+ - **`interface QueueDriver`** (Advanced) — contract for custom drivers: `setExecutor(executor)`, `add(queue, jobName, data, options: AddJobOptions)`, `startWorker(queue, { concurrency? })`, optional `stats(queue)` / `retryFailed(queue, { limit? })`, `close()`, plus the optional `name` and `capabilities` fields. Helper types: `AddJobOptions`, `JobExecutor`, `QueueStats`, `DriverCapabilities`.
331
+
332
+ #### `new BullmqQueueDriver(options: BullmqDriverOptions)`
333
+
334
+ | Option | Type | Default | Purpose |
335
+ |---|---|---|---|
336
+ | `connection` | `string \| ConnectionOptions` | — (required) | Redis URL (`redis://…`, `rediss://…` → TLS) or ioredis options. |
337
+ | `onError` | `(error: unknown, info: { queue: string; source: 'worker' \| 'queue' }) => void` | `console.error` with the queue name and source | Infra errors from BullMQ's `Worker`/`Queue` EventEmitters (Redis down, connection reset). Node makes an unlistened `'error'` event **fatal**, so the driver always attaches a listener — override this to route the fault into your logger/alerting. |
338
+ | `onJobFailed` | `(info: { queue: string; job: string; jobId?: string; error: unknown }) => void` | `console.error` naming the job, its id and the queue | A job **exhausted its retries** (BullMQ's `'failed'` event). Without it, permanently-failed jobs are only visible by polling `queue:stats`. |
339
+
340
+ Retention defaults applied by this driver when neither the job nor the plugin sets one:
341
+ completed → keep the last `1000`; failed → keep all.
342
+
343
+ #### `DriverCapabilities`
344
+
345
+ Each driver declares what its backend honours — `{ delayed, priority, retries, backoff }`, all
346
+ `boolean`. `QueueManager.dispatch` compares a dispatch's options against them and applies
347
+ `onUnsupported`. A driver that omits `capabilities` is treated as fully capable.
348
+
349
+ | Driver | `delayed` | `priority` | `retries` | `backoff` |
350
+ |---|:---:|:---:|:---:|:---:|
351
+ | `bullmq` | ✅ | ✅ | ✅ | ✅ |
352
+ | `sync` | ❌ | ❌ | ✅ | ❌ |
353
+ | [`rabbitmq`](https://www.npmjs.com/package/@basaltkit/queue-rabbitmq) | ✅ | ✅ | ✅ | ✅ |
354
+ | [`sqs`](https://www.npmjs.com/package/@basaltkit/queue-sqs) | ✅ (≤ 15 min) | ❌ | ✅ | ✅ |
355
+ | [`kafka`](https://www.npmjs.com/package/@basaltkit/queue-kafka) | ❌ | ❌ | ✅ | ❌ |
356
+
357
+ ### Failure hooks
358
+
359
+ The queue has no hook bus of its own — failures are reported through driver callbacks, and every
360
+ one of them has a **non-silent default**, so nothing disappears if you configure nothing.
361
+
362
+ | Hook | Where it lives | Receives | Default when unset |
363
+ |---|---|---|---|
364
+ | `onError` | `QueuePluginOptions` or `BullmqDriverOptions` | `(error, { queue, source: 'worker' \| 'queue' })` | `console.error` with queue + source |
365
+ | `onJobFailed` | `QueuePluginOptions` or `BullmqDriverOptions` | `({ queue, job, jobId?, error })` | `console.error` naming the job |
366
+ | `warn` | `QueueManagerOptions` | `(message: string)` | `console.warn`, once per job+feature |
367
+ | `onError` | [`RabbitmqDriverOptions`](https://www.npmjs.com/package/@basaltkit/queue-rabbitmq) | `(error, { source: 'connection' \| 'channel' })` | `console.error` |
368
+ | `onError` | [`KafkaDriverOptions`](https://www.npmjs.com/package/@basaltkit/queue-kafka) | `(error, { source: 'consumer' \| 'producer'; queue? })` | `console.error` |
369
+ | `onError` | [`SqsDriverOptions`](https://www.npmjs.com/package/@basaltkit/queue-sqs) | `(error, { queue })` | `console.error`, then an `errorPauseMs` pause |
370
+
371
+ Only the BullMQ driver has `onJobFailed`; the broker drivers route an exhausted job to their own
372
+ dead-letter destination (`q.dead`, `<topic>.dead`, `<queue>-dead`) instead.
373
+
374
+ Override the BullMQ callbacks right on the plugin — `queuePlugin({ connection })` forwards
375
+ them to the driver it builds:
376
+
377
+ ```ts
378
+ import { queuePlugin } from '@basaltkit/queue'
379
+
380
+ queuePlugin({
381
+ connection: process.env.REDIS_URL!,
382
+ jobs: [SendWelcomeEmail],
383
+ workers: [{ queue: 'default', concurrency: 5 }],
384
+ onError: (error, { queue, source }) => logger.error({ err: error, queue, source }, 'queue infra error'),
385
+ onJobFailed: ({ queue, job, jobId, error }) =>
386
+ logger.error({ err: error, queue, job, jobId }, 'job failed permanently'),
387
+ })
388
+ ```
389
+
390
+ Passing your own `driver` bypasses that forwarding — a supplied driver owns its callbacks,
391
+ so set them in its constructor:
392
+
393
+ ```ts
394
+ import { queuePlugin, BullmqQueueDriver } from '@basaltkit/queue'
395
+
396
+ queuePlugin({
397
+ jobs: [SendWelcomeEmail],
398
+ driver: new BullmqQueueDriver({
399
+ connection: process.env.REDIS_URL!,
400
+ onError: (error, { queue, source }) => logger.error({ err: error, queue, source }, 'queue infra error'),
401
+ }),
402
+ })
403
+ ```
300
404
 
301
405
  ### Exported errors
302
406
 
303
- | Class | Code | When it occurs |
407
+ | Error | Code | When |
304
408
  |---|---|---|
305
- | `JobValidationError` | `JOB_INVALID` | Payload doesn't pass the `schema` (has `.job` and `.issues`). |
306
- | `JobNotRegisteredError` | `QUEUE_JOB_NOT_REGISTERED` | `dispatch` before registering the job with a manager. |
307
- | `UnknownJobError` | `QUEUE_UNKNOWN_JOB` | The job reached the worker but isn't registered in that process. |
409
+ | `JobValidationError` | `JOB_INVALID` | The payload failed the job's `schema` on `dispatch` and again on the worker. Carries `.job` and `.issues`. |
410
+ | `JobNotRegisteredError` | `QUEUE_JOB_NOT_REGISTERED` | `job.dispatch()` was called before the job was registered in a `QueueManager`. |
411
+ | `UnknownJobError` | `QUEUE_UNKNOWN_JOB` | A job reached the worker but is not registered in that process — producer and worker registered different job lists. |
412
+ | `UnsupportedJobOptionError` | `QUEUE_UNSUPPORTED_OPTION` | With `onUnsupported: 'throw'`, a dispatch used an option the active driver's `capabilities` do not include. `status = 500`. |
413
+
414
+ Errors thrown outside these classes come from the driver's client (ioredis, amqplib, kafkajs,
415
+ the AWS SDK) and reach you through that driver's `onError`.
308
416
 
309
417
  ### Token
310
418
 
@@ -327,9 +435,23 @@ The data passed to `dispatch` doesn't match the `schema`. The error includes `is
327
435
  **In dev, `delay` doesn't work.**
328
436
  The sync driver always runs immediately. Delays, timed backoff, and priority only have a real effect with the BullMQ driver (with `connection`).
329
437
 
438
+ **`[basalt/queue] The "sync" driver does not support …` in the logs.**
439
+ The `onUnsupported` policy caught a dispatch option the active driver can't honour. It warns once per job+feature. Either switch to a driver that supports it, drop the option, or set `onUnsupported: 'ignore'` if you accept the degradation.
440
+
441
+ **A job failed for good and nothing was logged.**
442
+ Only the BullMQ driver reports exhausted jobs, via `onJobFailed` (default `console.error`). If you replaced it with a no-op, you removed the only signal. On the broker drivers, inspect the dead-letter destination (`q.dead`, `<topic>.dead`, `<queue>-dead`).
443
+
330
444
  **Do I need Redis to run the tests?**
331
445
  No. Without `connection`, the plugin uses `SyncQueueDriver`. You can also instantiate the driver directly and inspect `driver.executed`.
332
446
 
447
+ ## Sync driver semantics
448
+
449
+ The inline sync driver (the default without a `connection`) is at-most-once:
450
+ handler errors reject `dispatch()` and an exhausted job is lost. Selecting it
451
+ implicitly in production logs a boot warning — pass `driver: new
452
+ SyncQueueDriver()` to opt in deliberately. Its `executed[]` history is capped
453
+ at 1000 entries.
454
+
333
455
  ## How it connects to other modules
334
456
 
335
457
  - **`@basaltkit/core`** — provides `createApp`/`definePlugin` (`queuePlugin` is a core plugin), the ALS context (`runWithContext`/`ctx`) propagated to workers, `parseDuration` (formats `'30s'`, `'10m'`), and the base `BasaltError` class.
@@ -3,6 +3,15 @@ import type { AddJobOptions, JobExecutor, QueueDriver } from '../driver.js';
3
3
  * Synchronous driver: executes the job inline on dispatch, honoring `attempts`
4
4
  * (immediate retry). It is the driver for tests and Redis-less dev — the
5
5
  * equivalent of Laravel's `sync` queue driver.
6
+ *
7
+ * Semantics to be aware of (they differ from a broker-backed driver):
8
+ * - **At-most-once.** A job that exhausts its inline retries is LOST — there is
9
+ * no persistence and no later redelivery.
10
+ * - **Errors propagate to the dispatcher.** `job.dispatch()` rejects when the
11
+ * handler fails (useful in tests/dev; a broker driver would return
12
+ * immediately and retry in the background).
13
+ * Deploying this driver to production is almost always a misconfiguration —
14
+ * `queuePlugin` warns when it is selected by default there.
6
15
  */
7
16
  export declare class SyncQueueDriver implements QueueDriver {
8
17
  readonly name = "sync";
@@ -22,5 +31,8 @@ export declare class SyncQueueDriver implements QueueDriver {
22
31
  setExecutor(executor: JobExecutor): void;
23
32
  add(queue: string, jobName: string, data: unknown, options: AddJobOptions): Promise<void>;
24
33
  startWorker(): void;
34
+ /** Appends to `executed[]`, evicting the oldest entries past the cap so a
35
+ * long-running process on this driver cannot leak memory unboundedly. */
36
+ private record;
25
37
  close(): Promise<void>;
26
38
  }
@@ -1,7 +1,18 @@
1
+ /** `executed[]` keeps at most this many entries (oldest evicted first). */
2
+ const EXECUTED_HISTORY_LIMIT = 1000;
1
3
  /**
2
4
  * Synchronous driver: executes the job inline on dispatch, honoring `attempts`
3
5
  * (immediate retry). It is the driver for tests and Redis-less dev — the
4
6
  * equivalent of Laravel's `sync` queue driver.
7
+ *
8
+ * Semantics to be aware of (they differ from a broker-backed driver):
9
+ * - **At-most-once.** A job that exhausts its inline retries is LOST — there is
10
+ * no persistence and no later redelivery.
11
+ * - **Errors propagate to the dispatcher.** `job.dispatch()` rejects when the
12
+ * handler fails (useful in tests/dev; a broker driver would return
13
+ * immediately and retry in the background).
14
+ * Deploying this driver to production is almost always a misconfiguration —
15
+ * `queuePlugin` warns when it is selected by default there.
5
16
  */
6
17
  export class SyncQueueDriver {
7
18
  name = 'sync';
@@ -19,18 +30,26 @@ export class SyncQueueDriver {
19
30
  for (let attempt = 1; attempt <= options.attempts; attempt++) {
20
31
  try {
21
32
  await this.executor?.(jobName, data);
22
- this.executed.push({ queue, jobName, attempts: attempt });
33
+ this.record({ queue, jobName, attempts: attempt });
23
34
  return;
24
35
  }
25
36
  catch (error) {
26
37
  lastError = error;
27
38
  }
28
39
  }
29
- this.executed.push({ queue, jobName, attempts: options.attempts });
40
+ this.record({ queue, jobName, attempts: options.attempts });
30
41
  throw lastError;
31
42
  }
32
43
  startWorker() {
33
44
  // no-op: add() already executes inline
34
45
  }
46
+ /** Appends to `executed[]`, evicting the oldest entries past the cap so a
47
+ * long-running process on this driver cannot leak memory unboundedly. */
48
+ record(entry) {
49
+ this.executed.push(entry);
50
+ if (this.executed.length > EXECUTED_HISTORY_LIMIT) {
51
+ this.executed.splice(0, this.executed.length - EXECUTED_HISTORY_LIMIT);
52
+ }
53
+ }
35
54
  async close() { }
36
55
  }
package/dist/index.d.ts CHANGED
@@ -38,5 +38,17 @@ export interface QueuePluginOptions {
38
38
  * and retries) — set e.g. `{ age: '14d' }` so failures don't grow unbounded.
39
39
  */
40
40
  removeOnFail?: JobRetention;
41
+ /**
42
+ * Infra errors from the driver's broker client (e.g. Redis down). Forwarded
43
+ * to the driver built from `connection`; default `console.error` with context.
44
+ * Ignored when you pass your own `driver` — configure it on the driver then.
45
+ */
46
+ onError?: BullmqDriverOptions['onError'];
47
+ /**
48
+ * A job exhausted its retries. Forwarded to the driver built from
49
+ * `connection`; default `console.error` with context. Ignored when you pass
50
+ * your own `driver` — configure it on the driver then.
51
+ */
52
+ onJobFailed?: BullmqDriverOptions['onJobFailed'];
41
53
  }
42
54
  export declare function queuePlugin(options?: QueuePluginOptions): import("@basaltkit/core").BasaltPlugin<unknown>;
package/dist/index.js CHANGED
@@ -14,10 +14,29 @@ export function queuePlugin(options = {}) {
14
14
  register({ container }) {
15
15
  registerQueueCommands(container);
16
16
  container.singleton(QUEUE, () => {
17
- const driver = options.driver ??
18
- (options.connection
19
- ? new BullmqQueueDriver({ connection: options.connection })
20
- : new SyncQueueDriver());
17
+ let driver = options.driver;
18
+ if (!driver) {
19
+ if (options.connection) {
20
+ driver = new BullmqQueueDriver({
21
+ connection: options.connection,
22
+ ...(options.onError !== undefined ? { onError: options.onError } : {}),
23
+ ...(options.onJobFailed !== undefined ? { onJobFailed: options.onJobFailed } : {}),
24
+ });
25
+ }
26
+ else {
27
+ driver = new SyncQueueDriver();
28
+ if (process.env['NODE_ENV'] === 'production') {
29
+ // The silent default without a Redis connection is the inline sync
30
+ // driver: at-most-once, no background retries, handler errors
31
+ // propagate into the dispatching request. Deliberate sync use in
32
+ // production stays possible — pass `driver: new SyncQueueDriver()`
33
+ // explicitly to silence this.
34
+ console.warn('[basalt:queue] No `connection` (Redis) configured — falling back to the inline sync driver. ' +
35
+ 'Jobs run at-most-once inside the dispatching request and are lost on failure. ' +
36
+ 'Configure a Redis `connection` for production, or pass `driver: new SyncQueueDriver()` to opt in explicitly.');
37
+ }
38
+ }
39
+ }
21
40
  const manager = new QueueManager(driver, {
22
41
  ...(options.onUnsupported !== undefined ? { onUnsupported: options.onUnsupported } : {}),
23
42
  ...(options.removeOnComplete !== undefined ? { removeOnComplete: options.removeOnComplete } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/queue",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Basalt queues on top of BullMQ: declarative jobs with Zod payloads, context propagation (tenant/requestId) to workers and a sync driver for tests.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -15,8 +15,8 @@
15
15
  ],
16
16
  "dependencies": {
17
17
  "bullmq": "^6.2.1",
18
- "@basaltkit/core": "^1.3.0",
19
- "@basaltkit/events": "^1.0.1"
18
+ "@basaltkit/events": "^1.1.0",
19
+ "@basaltkit/core": "^1.3.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^26.3.0",