@maestro-js/queue 1.0.0-alpha.1 → 1.0.0-alpha.10

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 (4) hide show
  1. package/README.md +337 -179
  2. package/dist/index.d.ts +179 -215
  3. package/dist/index.js +422 -475
  4. package/package.json +10 -6
package/README.md CHANGED
@@ -1,279 +1,437 @@
1
1
  # @maestro-js/queue
2
2
 
3
- Database-backed job queue that stores messages in MySQL via a pluggable driver and processes them asynchronously. Follows the Provider pattern from `@maestro-js/service-registry` with a producer/consumer split supporting scheduled execution, concurrency, and distributed trace propagation.
3
+ Database-backed job queue with middleware, configurable retries, and backoff strategies.
4
4
 
5
5
  ## Quick Setup
6
6
 
7
7
  ```ts
8
8
  import { Queue } from '@maestro-js/queue'
9
9
  import { Db } from '@maestro-js/db'
10
- import { Log } from '@maestro-js/log'
11
-
12
- // Register the queue service with the db driver
13
- Queue.Provider.register(
14
- 'default',
15
- Queue.Provider.create({
16
- driver: Queue.drivers.db({
17
- db: Db.provider('default'),
18
- databaseTable: 'jobs'
19
- }),
20
- logger: Log
21
- })
22
- )
23
10
 
24
- // Create a named queue with a work function
25
- const orderQueue = Queue.create({
26
- name: 'fulfill-orders',
27
- workFn: async (body) => {
28
- await fulfillOrder(body.orderId)
29
- return { fulfilled: true }
30
- },
31
- concurrency: 3
11
+ // Create and register a queue service with the db driver
12
+ const service = Queue.Provider.create({
13
+ driver: Queue.drivers.db({ db: Db.provider('default'), databaseTable: 'queue' }),
14
+ logger: Log.provider('default')
32
15
  })
16
+ Queue.Provider.register('default', service)
33
17
 
34
- // Enqueue a message
35
- await orderQueue.addMessage({ orderId: 'ord_123' }, { scheduledDate: null })
18
+ // Define a queue with a handler
19
+ const emailQueue = Queue.create({
20
+ name: 'send-emails',
21
+ handle: async (body) => sendEmail(body.to, body.subject),
22
+ tries: 3,
23
+ backoffSeconds: [30, 60, 120]
24
+ })
36
25
 
37
- // Start processing
38
- orderQueue.startProcessing()
26
+ // Dispatch a message and start processing
27
+ await emailQueue.dispatch({ to: 'alice@example.com', subject: 'Welcome' })
28
+ await Queue.work({ stopWhenEmpty: true })
39
29
  ```
40
30
 
41
- ## Database Schema
42
-
43
- The db driver expects a MySQL table (name configurable via `databaseTable`). The column-to-field mapping used internally:
44
-
45
- | Column | Type | Description |
46
- | --------------- | ------------- | -------------------------------------------------------- |
47
- | `id` | int (PK, AI) | Message ID |
48
- | `batch_id` | int / NULL | Lock batch for concurrent dequeue |
49
- | `job_type` | varchar | Queue name string |
50
- | `body` | text (JSON) | Serialized message body |
51
- | `trace` | text (JSON) | Serialized `DehydratedTrace` for distributed tracing |
52
- | `status` | enum | `waiting`, `processing`, `success`, `error`, `stuck` |
53
- | `scheduledDate` | datetime | Earliest time the message can be processed |
54
- | `enqueuedDate` | datetime | When the message was inserted |
55
- | `started_ts` | datetime | When processing began |
56
- | `result` | text | JSON result on success, error stack on failure |
57
- | `recovered` | tinyint | 1 if the message was recovered from stuck state |
58
- | `running_time` | int / NULL | Seconds elapsed during processing |
59
- | `unique_key` | int / NULL | MD5-derived dedup key (nullable) |
60
- | `priority` | bigint / NULL | Lower value = higher priority (defaults to `Date.now()`) |
61
-
62
- The driver requires a `FORCE INDEX (locking_update)` index on `(batch_id, status, job_type, scheduledDate)` for efficient locking queries.
31
+ ## Provider Pattern
63
32
 
64
- ## API Reference
33
+ Queue follows the standard Maestro Provider pattern built on `@maestro-js/service-registry`:
65
34
 
66
- ### Provider Setup
35
+ 1. **`Queue.Provider.create(config)`** -- factory that instantiates a queue service with a driver
36
+ 2. **`Queue.Provider.register(name, service)`** -- register the instance in a named registry
37
+ 3. **`Queue.provider(key)`** -- resolve a named instance (deferred via Proxy)
38
+ 4. **Facade** -- `Queue.create()`, `Queue.work()`, etc. spread from `provider('default')`
39
+ 5. **`Queue.drivers`** -- factory functions for pluggable backends (`db`, `local`)
67
40
 
68
- ```ts
69
- Queue.Provider.create(config: {
70
- driver: Queue.Driver
71
- logger: Log.Logger<[Queue.LogMessage]>
72
- tracing?: Queue.Tracing // optional distributed tracing adapter
73
- }): Queue.QueueService
74
- ```
41
+ Extend provider keys via declaration merging:
75
42
 
76
43
  ```ts
77
- Queue.Provider.register(name: Queue.Provider.Key, service: Queue.QueueService): void
44
+ declare module '@maestro-js/queue' {
45
+ namespace Queue.Provider {
46
+ interface Keys {
47
+ default: unknown
48
+ highPriority: unknown
49
+ }
50
+ }
51
+ }
78
52
  ```
79
53
 
80
- ### Drivers
54
+ Source: `packages/queue/src/index.ts`
55
+
56
+ ## Drivers
57
+
58
+ ### db (MySQL)
59
+
60
+ Production driver backed by MySQL. Uses optimistic locking with `UPDATE ... INNER JOIN` for
61
+ concurrent pop operations.
81
62
 
82
63
  ```ts
83
- Queue.drivers.db(config: {
84
- db: { update, select, insert } // Db provider interface
85
- databaseTable: string
86
- extraFields?: string[] // additional columns to insert
87
- fastestPollingRateMs?: number // default 100
88
- slowestPollingRateMs?: number // default 10_000
89
- pollingBackoffRate?: number // default 1.1
90
- }): Queue.Driver
64
+ Queue.drivers.db({
65
+ db: Db.provider('default'), // must implement select/insert/update
66
+ databaseTable: 'queue', // table name (alphanumeric + underscores only)
67
+ queue: 'send-emails', // optional: filter to specific queue name(s)
68
+ batchingMaxWaitSeconds: 0.5 // optional: batch pop requests over this window
69
+ })
70
+ ```
71
+
72
+ Config interface:
73
+
74
+ | Field | Type | Default | Description |
75
+ |--------------------------|----------------------|---------|------------------------------------------------|
76
+ | `db` | `{ select, insert, update }` | -- | Database connection with query methods |
77
+ | `databaseTable` | `string` | -- | MySQL table name |
78
+ | `queue` | `string \| string[]` | -- | Optional queue name filter |
79
+ | `batchingMaxWaitSeconds` | `number` | `0` | Batch pop requests over this window (seconds) |
80
+
81
+ Required database schema:
82
+
83
+ ```sql
84
+ CREATE TABLE queue (
85
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
86
+ attempt INT UNSIGNED NOT NULL DEFAULT 0,
87
+ queue VARCHAR(255) NOT NULL,
88
+ status ENUM('waiting', 'processing', 'succeeded', 'failed', 'failedPermanently') NOT NULL DEFAULT 'waiting',
89
+ scheduledDate DATETIME NOT NULL,
90
+ enqueuedDate DATETIME NOT NULL DEFAULT NOW(),
91
+ startedDate DATETIME,
92
+ completedDate DATETIME,
93
+ body JSON,
94
+ result TEXT,
95
+ lockId BIGINT UNSIGNED DEFAULT NULL,
96
+ PRIMARY KEY (id, attempt),
97
+ INDEX locking_pop (lockId, status, scheduledDate),
98
+ INDEX queue (queue)
99
+ );
91
100
  ```
92
101
 
93
- ### Queue Instance Methods
102
+ Source: `packages/queue/src/db-queue-driver.ts`
103
+
104
+ ### local (In-Memory)
94
105
 
95
- Create a queue:
106
+ In-memory driver for testing. Same interface as db but stores messages in a plain array:
96
107
 
97
108
  ```ts
98
- const queue = Queue.create<MessageBody, Result>({
99
- name: string,
100
- workFn: (body: MessageBody) => Promise<Result>,
101
- logger?: Log.LogFunctions<[Queue.LogMessage]>, // additional per-queue logger
102
- concurrency?: number // default 1
109
+ Queue.drivers.local({
110
+ queue: 'send-emails' // optional: filter to specific queue name(s)
103
111
  })
104
112
  ```
105
113
 
106
- Dispatch a message:
114
+ Source: `packages/queue/src/local-queue-driver.ts`
115
+
116
+ ## Core Concepts
117
+
118
+ ### Retry and Backoff
119
+
120
+ Configure retries per queue with `tries` and `backoffSeconds`:
107
121
 
108
122
  ```ts
109
- await queue.addMessage(body, {
110
- scheduledDate: Iso.Instant | null, // null = process immediately
111
- tracingContext?: DehydratedTrace | null
123
+ const q = Queue.create({
124
+ name: 'flaky-api',
125
+ handle: async (body) => callExternalApi(body),
126
+ tries: 4, // total attempts (1 initial + 3 retries)
127
+ backoffSeconds: [10, 30, 120] // delay before retry 1, 2, 3
112
128
  })
113
129
  ```
114
130
 
115
- Update a waiting message:
131
+ - `tries` defaults to `1` (no retries)
132
+ - `backoffSeconds` can be a single `number` (constant delay) or an `number[]` (per-attempt delay)
133
+ - If the array is shorter than the number of retries, the last value is reused
134
+ - If omitted entirely, defaults to `60` seconds between retries
135
+ - `attempts` on the message is 0-indexed -- the first attempt is `0`, the first retry is `1`
136
+
137
+ ### Middleware
138
+
139
+ Wrap message handling with middleware functions for cross-cutting concerns:
116
140
 
117
141
  ```ts
118
- await queue.updateMessage({
119
- messageId: number,
120
- scheduledDate: Iso.Instant,
121
- body: MessageBody
142
+ const loggingMiddleware: Queue.Middleware = async (message, next) => {
143
+ console.log('Processing:', message)
144
+ const result = await next()
145
+ console.log('Done:', result)
146
+ return result
147
+ }
148
+
149
+ const q = Queue.create({
150
+ name: 'with-logging',
151
+ handle: async (body) => processBody(body),
152
+ middleware: [loggingMiddleware]
122
153
  })
123
154
  ```
124
155
 
125
- List messages (paginated):
156
+ Middleware is composed right-to-left (`reduceRight`), so the first middleware in the array is the
157
+ outermost wrapper.
158
+
159
+ ### Fail and Release
160
+
161
+ Handlers receive `fail` and `release` callbacks as a second argument for explicit message control:
126
162
 
127
163
  ```ts
128
- const messages = await queue.listMessages({ pageSize: 50, pageNumber: 0 })
164
+ const q = Queue.create({
165
+ name: 'validate-first',
166
+ tries: 3,
167
+ handle: async (body, { fail, release }) => {
168
+ if (!body.email) {
169
+ fail(new Error('Missing email -- will never succeed')) // permanently fail, skip retries
170
+ }
171
+ const result = await sendEmail(body.email)
172
+ if (result.rateLimited) {
173
+ release(new Error('Rate limited'), 300) // retry in 5 minutes
174
+ }
175
+ }
176
+ })
129
177
  ```
130
178
 
131
- Processing control:
179
+ - **`fail(error)`** -- permanently fails the message, regardless of remaining retries
180
+ - **`release(error, delaySeconds?)`** -- releases the message for retry. Pass `delaySeconds` to override the configured backoff, or omit it to use the default.
181
+
182
+ Both return `never` -- they halt handler execution at the call site.
183
+
184
+ Any other thrown error also triggers a retry (or permanent failure on the last attempt), using the
185
+ configured `backoffSeconds`. Middleware also receives these callbacks as a third argument.
186
+
187
+ ### Work Loop
188
+
189
+ `Queue.work()` starts a polling loop that pops messages from the driver and dispatches them to the
190
+ appropriate handler:
132
191
 
133
192
  ```ts
134
- queue.startProcessing() // begin polling and processing
135
- queue.stopProcessing() // stop polling
136
- await queue.processUntilEmpty({ pollingMs?: number }) // process then resolve when empty
193
+ const workHandle = Queue.work({
194
+ maxJobs: 100, // stop after processing 100 messages
195
+ stopWhenEmpty: false, // keep polling even when queue is empty
196
+ maxTimeSeconds: 3600, // stop after 1 hour
197
+ sleepSeconds: 1, // pause between polls when idle (default 1)
198
+ concurrency: 4 // number of parallel polling workers
199
+ })
200
+
201
+ // Later: gracefully stop
202
+ workHandle.abort('shutting down')
203
+
204
+ // Or stop all active work loops
205
+ await Queue.stopAllWork()
137
206
  ```
138
207
 
139
- ### Service-Level Methods
208
+ The work loop swallows handler errors -- failed messages are logged and released/failed via the
209
+ driver, but the loop continues.
210
+
211
+ ## API Reference
212
+
213
+ ### QueueService Methods
140
214
 
141
215
  ```ts
142
- Queue.list() // names of all registered queues
143
- Queue.get<Body, Result>(name) // retrieve a queue by name
144
- Queue.startProcessing() // start all queues
145
- Queue.stopProcessing() // stop all queues
216
+ Queue.Provider.create(config: { driver: Queue.Driver, logger?: Log.Logger }): Queue.QueueService
146
217
  ```
218
+ Creates a new queue service with the given driver and optional logger.
147
219
 
148
- ### Message Statuses
220
+ ```ts
221
+ Queue.create<MessageBody, Result>(config: Queue.Config): Queue.QueueHandle
222
+ ```
223
+ Creates a named queue bound to the default service. Throws if a queue with the same name already
224
+ exists.
149
225
 
150
- | Status | Meaning |
151
- | ------------ | -------------------------------------------------------------- |
152
- | `waiting` | Queued and eligible for processing when scheduled time arrives |
153
- | `processing` | Locked by a worker and currently executing |
154
- | `success` | Work function completed without error |
155
- | `error` | Work function threw or timed out |
156
- | `stuck` | Processing exceeded timeout; recovered or marked stuck |
226
+ ```ts
227
+ Queue.work(options: Queue.WorkOptions): Promise<void> & { abort(reason?: string): void }
228
+ ```
229
+ Starts a polling work loop. Returns a promise that resolves when the loop stops, with an `abort()`
230
+ method to stop it.
157
231
 
158
- ### Log Events
232
+ ```ts
233
+ Queue.stopAllWork(): Promise<void>
234
+ ```
235
+ Aborts all active work loops and waits for in-flight messages to finish.
159
236
 
160
- The queue emits structured log messages with these `event` values:
237
+ ```ts
238
+ Queue.processMessage(queueMessage: Queue.Message): Promise<unknown>
239
+ ```
240
+ Processes a single message directly by looking up the queue handler by name. Used by
241
+ `@maestro-js/recurring-jobs` to process messages outside the normal work loop.
161
242
 
162
- - `messageEnqueued` -- message added to the queue
163
- - `messageDequeued` -- message picked up by a worker
164
- - `messageProcessed` -- work function completed successfully
165
- - `messageFailed` -- work function threw an error
166
- - `queueStarted` -- queue processing started
167
- - `queueStopped` -- queue processing stopped
243
+ ### QueueHandle Methods
168
244
 
169
- ## Common Patterns
245
+ ```ts
246
+ handle.dispatch(body: MessageBody): Promise<void>
247
+ ```
248
+ Enqueues a message for immediate processing.
170
249
 
171
- ### Schedule a Future Job
250
+ ```ts
251
+ handle.dispatchWithDelay(body: MessageBody, scheduledDate: Iso.Instant): Promise<void>
252
+ ```
253
+ Enqueues a message scheduled for a future date.
172
254
 
173
255
  ```ts
174
- import { instantFns } from 'iso-fns2'
256
+ handle.dispatchSync(body: MessageBody): Promise<Result>
257
+ ```
258
+ Runs the handler synchronously without going through the driver. Useful for testing. Middleware
259
+ still executes.
175
260
 
176
- // Schedule 1 hour from now
177
- const oneHourLater = instantFns.add(instantFns.now(), { hours: 1 })
178
- await queue.addMessage({ task: 'reminder' }, { scheduledDate: oneHourLater })
261
+ ```ts
262
+ handle.bulk(messages: { body: MessageBody; scheduledDate?: Iso.Instant }[]): Promise<void>
179
263
  ```
264
+ Enqueues multiple messages in a single operation. Uses the driver's bulk method for efficiency.
180
265
 
181
- ### Deduplication with Unique Keys
266
+ ### Queue.Config
182
267
 
183
- The db driver supports unique keys via the `addMessage` options on the driver level. When a duplicate `uniqueKey` is inserted, the row is not duplicated -- instead the priority is updated if the new priority is higher (lower number).
268
+ | Field | Type | Default | Description |
269
+ |------------------|----------------------------------------|---------|------------------------------------------------------|
270
+ | `name` | `string` | -- | Unique queue name |
271
+ | `handle` | `(message: MessageBody, actions: Queue.Actions) => Promise<Result>` | -- | Handler function for each message |
272
+ | `middleware` | `Queue.Middleware[]` | `[]` | Middleware wrapping the handler |
273
+ | `tries` | `number` | `1` | Total attempts (1 = no retries) |
274
+ | `backoffSeconds` | `number \| number[]` | `60` | Seconds to wait between retries |
275
+ | `logger` | `Log.LogFunctions` | -- | Per-queue logger (in addition to service-level logger)|
184
276
 
185
- ### Process Until Empty (Worker Scripts)
277
+ ### Queue.WorkOptions
186
278
 
187
- ```ts
188
- // Useful for one-off batch processing or tests
189
- await queue.processUntilEmpty({ pollingMs: 1000 })
190
- // Resolves when no waiting/processing messages remain
191
- ```
279
+ | Field | Type | Default | Description |
280
+ |------------------|-----------|---------|----------------------------------------------|
281
+ | `maxJobs` | `number` | -- | Stop after processing this many messages |
282
+ | `stopWhenEmpty` | `boolean` | -- | Stop when no messages are available |
283
+ | `maxTimeSeconds` | `number` | -- | Stop after this many seconds |
284
+ | `sleepSeconds` | `number` | `1` | Seconds to sleep between polls when idle |
285
+ | `concurrency` | `number` | `1` | Number of parallel polling workers |
286
+
287
+ ## Common Patterns
192
288
 
193
- ### Concurrency Control
289
+ ### Email queue with retries
194
290
 
195
291
  ```ts
196
- // High-concurrency queue for lightweight jobs
197
292
  const emailQueue = Queue.create({
198
293
  name: 'send-emails',
199
- workFn: async (body) => sendEmail(body),
200
- concurrency: 10
294
+ handle: async (body, { fail }) => {
295
+ const result = await sendEmail(body.to, body.subject, body.html)
296
+ if (result.rejected) {
297
+ fail(new Error(`Rejected: ${body.to}`))
298
+ }
299
+ },
300
+ tries: 3,
301
+ backoffSeconds: [60, 300, 900]
201
302
  })
303
+
304
+ await emailQueue.dispatch({ to: 'bob@example.com', subject: 'Hello', html: '<p>Hi!</p>' })
202
305
  ```
203
306
 
204
- ### Stuck Job Recovery
307
+ ### Delayed scheduling
205
308
 
206
- The db driver automatically recovers stuck jobs every 60 seconds. Jobs in `processing` status that exceed the timeout (default 45 minutes) are reset to `waiting` and reprocessed. Set `shouldRecoverStuckJobs: false` in the driver `start` options to mark them as `stuck` instead of recovering.
309
+ ```ts
310
+ import { instantFns } from 'iso-fns2'
207
311
 
208
- ### Distributed Tracing
312
+ // Schedule a reminder for 24 hours from now
313
+ await reminderQueue.dispatchWithDelay(
314
+ { userId: 42, message: 'Check in' },
315
+ instantFns.add(instantFns.now(), { hours: 24 })
316
+ )
317
+ ```
209
318
 
210
- Pass a `tracing` adapter when creating the service to propagate trace context across producer/consumer boundaries:
319
+ ### Bulk dispatch
211
320
 
212
321
  ```ts
213
- Queue.Provider.register(
214
- 'default',
215
- Queue.Provider.create({
216
- driver: Queue.drivers.db({ db: Db.provider('default'), databaseTable: 'jobs' }),
217
- logger: Log,
218
- tracing: {
219
- hydrate: (trace, callback) => {
220
- /* restore trace context */ return callback()
221
- },
222
- dehydrate: () => {
223
- /* return current trace context */ return null
224
- },
225
- startActiveSpan: (name, fn) => fn(),
226
- getActiveSpan: () => undefined
227
- }
228
- })
229
- )
322
+ const messages = users.map((user) => ({
323
+ body: { userId: user.id, type: 'weekly-digest' }
324
+ }))
325
+ await digestQueue.bulk(messages)
326
+ ```
327
+
328
+ ### Middleware (logging, tracing)
329
+
330
+ ```ts
331
+ const tracingMiddleware: Queue.Middleware = async (message, next) => {
332
+ const span = tracer.startSpan('queue.process')
333
+ try {
334
+ return await next()
335
+ } finally {
336
+ span.end()
337
+ }
338
+ }
339
+
340
+ const q = Queue.create({
341
+ name: 'traced-queue',
342
+ handle: async (body) => process(body),
343
+ middleware: [tracingMiddleware]
344
+ })
230
345
  ```
231
346
 
232
- The trace data is serialized as `DehydratedTrace` (traceId, spanId, traceFlags, traceState) and stored in the `trace` column.
347
+ ### Sync dispatch for testing
348
+
349
+ ```ts
350
+ // Bypass the driver entirely -- runs the handler (and middleware) inline
351
+ const result = await emailQueue.dispatchSync({ to: 'test@example.com', subject: 'Test' })
352
+ ```
233
353
 
234
354
  ## Testing
235
355
 
236
- This package has no tests yet (`pnpm --filter @maestro-js/queue test` prints "No tests yet"). To test queue behavior in consuming packages, mock the driver interface:
356
+ Use the local driver in tests. The local driver stores messages in-memory:
237
357
 
238
358
  ```ts
239
- const mockDriver: Queue.Driver = {
240
- start: () => {},
241
- stop: () => {},
242
- listMessages: async () => [],
243
- addMessage: async () => ({ id: 1 }),
244
- updateMessage: async () => {},
245
- isQueueEmpty: async () => true
246
- }
359
+ import { Queue } from '@maestro-js/queue'
360
+ import { test } from 'beartest-js'
361
+ import { expect } from 'expect'
247
362
 
248
- Queue.Provider.register(
249
- 'default',
250
- Queue.Provider.create({
251
- driver: mockDriver,
252
- logger: Log
253
- })
254
- )
363
+ const service = Queue.Provider.create({ driver: Queue.drivers.local() })
364
+ Queue.Provider.register('default', service)
365
+
366
+ const testQueue = Queue.create({
367
+ name: 'test-queue',
368
+ handle: async (body) => ({ processed: body.value })
369
+ })
370
+
371
+ test('dispatches and processes a message', async () => {
372
+ await testQueue.dispatch({ value: 42 })
373
+ await Queue.work({ stopWhenEmpty: true })
374
+ })
375
+
376
+ test('dispatchSync runs handler inline', async () => {
377
+ const result = await testQueue.dispatchSync({ value: 99 })
378
+ expect(result).toEqual({ processed: 99 })
379
+ })
255
380
  ```
256
381
 
257
- ## Cross-Package Integration
382
+ Run tests:
258
383
 
259
- ### Mail (async email sending)
384
+ ```bash
385
+ pnpm --filter @maestro-js/queue test
386
+ cd packages/queue && npx beartest ./tests/**/*
387
+ ```
260
388
 
261
- The `@maestro-js/mail` package uses Queue to dispatch email-sending jobs asynchronously. Mail enqueues messages; a queue consumer processes them via the configured mail driver.
389
+ ## Warnings and Gotchas
390
+
391
+ - **Queue name uniqueness** -- calling `Queue.create()` with a name that's already registered
392
+ throws an error. Each queue name must be unique within a service instance.
393
+ - **`attempts` is 0-indexed** -- the first execution has `attempts: 0`, the first retry has
394
+ `attempts: 1`. The message permanently fails when `attempts + 1 >= tries`.
395
+ - **`backoffSeconds` array indexing** -- `backoffSeconds[attempts]` is used for the delay, where
396
+ `attempts` is 0-indexed. If the array is shorter than the retry count, the last element is reused.
397
+ - **`dispatchSync` bypasses the driver** -- messages sent with `dispatchSync` are never persisted.
398
+ The handler runs inline, and `work()` will never pick them up. Middleware still executes.
399
+ - **`work()` swallows handler errors** -- the work loop catches all errors from handlers. Failed
400
+ messages are released/failed via the driver and logged, but the loop continues polling.
401
+ - **Database schema must be created manually** -- the db driver does not create or migrate the
402
+ queue table. You must create it before using the driver. See the schema in the Drivers section.
403
+ - **`work()` returns a hybrid promise** -- the returned value is both a `Promise<void>` and has an
404
+ `abort()` method. Call `abort()` to stop the loop, then `await` the promise.
262
405
 
263
- ### Recurring Jobs (cron scheduling)
406
+ ## Cross-Package Integration
264
407
 
265
- The `@maestro-js/recurring-jobs` package uses Queue to execute scheduled/cron-based jobs. It dispatches queue messages on a schedule and relies on the queue's processing infrastructure.
408
+ - **Depends on**: `@maestro-js/service-registry` (Provider pattern foundation),
409
+ `@maestro-js/log` (structured logging)
410
+ - **Used by**: `@maestro-js/mail` (async email sending via queue), `@maestro-js/recurring-jobs`
411
+ (cron-scheduled jobs backed by queue)
412
+ - **Dev dependency**: `@maestro-js/db` (MySQL driver needs a db connection)
266
413
 
267
- ### Db (database driver)
414
+ ## Driver Interface
268
415
 
269
- The db driver requires a `Db` provider that exposes `select`, `update`, and `insert` methods. Pass `Db.provider('default')` as the `db` config option.
416
+ Implement `Queue.Driver` to create a custom backend:
270
417
 
271
- ### Tracing
418
+ ```ts
419
+ interface QueueDriver {
420
+ size(): Promise<number>
421
+ push(message: { queue: string; body: Record<string, any>; scheduledDate: Iso.Instant }): Promise<string>
422
+ bulk(messages: { queue: string; body: Record<string, any>; scheduledDate: Iso.Instant }[]): Promise<void>
423
+ pop(): Promise<QueueMessage | null>
424
+ fail(options: { id: string; attempts: number; error: Error }): Promise<void>
425
+ success(options: { id: string; attempts: number; result: any }): Promise<void>
426
+ release(options: { id: string; attempts: number; error: Error; delaySeconds: number }): Promise<void>
427
+ }
428
+ ```
272
429
 
273
- Integrate with `@maestro-js/tracing` by implementing the `Queue.Tracing` interface to propagate OpenTelemetry-compatible trace context across the producer/consumer boundary.
430
+ Source: `packages/queue/src/queue-driver.ts`
274
431
 
275
- ## Source Files
432
+ ## Key Source Files
276
433
 
277
- - `packages/queue/src/index.ts` -- Provider pattern, facade, queue creation and lifecycle
278
- - `packages/queue/src/queue-types.ts` -- TypeScript interfaces for QueueMessage, QueueDriver, QueueTracing, log events
279
- - `packages/queue/src/db-queue-driver.ts` -- MySQL-backed driver with polling, batched locking, stuck job recovery
434
+ - `packages/queue/src/index.ts` -- main export, Provider pattern, `createService()`, queue handles
435
+ - `packages/queue/src/queue-driver.ts` -- `QueueDriver` and `QueueMessage` interfaces
436
+ - `packages/queue/src/db-queue-driver.ts` -- MySQL driver with optimistic locking
437
+ - `packages/queue/src/local-queue-driver.ts` -- in-memory driver for testing