@maestro-js/queue 1.0.0-alpha.19 → 1.0.0-alpha.2
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 +179 -337
- package/dist/index.d.ts +215 -179
- package/dist/index.js +475 -422
- package/package.json +6 -10
package/README.md
CHANGED
|
@@ -1,437 +1,279 @@
|
|
|
1
1
|
# @maestro-js/queue
|
|
2
2
|
|
|
3
|
-
Database-backed job queue with
|
|
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.
|
|
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
|
+
)
|
|
10
23
|
|
|
11
|
-
// Create
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
15
32
|
})
|
|
16
|
-
Queue.Provider.register('default', service)
|
|
17
33
|
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
name: 'send-emails',
|
|
21
|
-
handle: async (body) => sendEmail(body.to, body.subject),
|
|
22
|
-
tries: 3,
|
|
23
|
-
backoffSeconds: [30, 60, 120]
|
|
24
|
-
})
|
|
34
|
+
// Enqueue a message
|
|
35
|
+
await orderQueue.addMessage({ orderId: 'ord_123' }, { scheduledDate: null })
|
|
25
36
|
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
await Queue.work({ stopWhenEmpty: true })
|
|
37
|
+
// Start processing
|
|
38
|
+
orderQueue.startProcessing()
|
|
29
39
|
```
|
|
30
40
|
|
|
31
|
-
##
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
34
63
|
|
|
35
|
-
|
|
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`)
|
|
64
|
+
## API Reference
|
|
40
65
|
|
|
41
|
-
|
|
66
|
+
### Provider Setup
|
|
42
67
|
|
|
43
68
|
```ts
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
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
|
|
52
74
|
```
|
|
53
75
|
|
|
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.
|
|
62
|
-
|
|
63
76
|
```ts
|
|
64
|
-
Queue.
|
|
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
|
-
);
|
|
77
|
+
Queue.Provider.register(name: Queue.Provider.Key, service: Queue.QueueService): void
|
|
100
78
|
```
|
|
101
79
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
### local (In-Memory)
|
|
105
|
-
|
|
106
|
-
In-memory driver for testing. Same interface as db but stores messages in a plain array:
|
|
80
|
+
### Drivers
|
|
107
81
|
|
|
108
82
|
```ts
|
|
109
|
-
Queue.drivers.
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
112
91
|
```
|
|
113
92
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
## Core Concepts
|
|
117
|
-
|
|
118
|
-
### Retry and Backoff
|
|
93
|
+
### Queue Instance Methods
|
|
119
94
|
|
|
120
|
-
|
|
95
|
+
Create a queue:
|
|
121
96
|
|
|
122
97
|
```ts
|
|
123
|
-
const
|
|
124
|
-
name:
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
128
103
|
})
|
|
129
104
|
```
|
|
130
105
|
|
|
131
|
-
|
|
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:
|
|
106
|
+
Dispatch a message:
|
|
140
107
|
|
|
141
108
|
```ts
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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]
|
|
109
|
+
await queue.addMessage(body, {
|
|
110
|
+
scheduledDate: Iso.Instant | null, // null = process immediately
|
|
111
|
+
tracingContext?: DehydratedTrace | null
|
|
153
112
|
})
|
|
154
113
|
```
|
|
155
114
|
|
|
156
|
-
|
|
157
|
-
outermost wrapper.
|
|
158
|
-
|
|
159
|
-
### Fail and Release
|
|
160
|
-
|
|
161
|
-
Handlers receive `fail` and `release` callbacks as a second argument for explicit message control:
|
|
115
|
+
Update a waiting message:
|
|
162
116
|
|
|
163
117
|
```ts
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
}
|
|
118
|
+
await queue.updateMessage({
|
|
119
|
+
messageId: number,
|
|
120
|
+
scheduledDate: Iso.Instant,
|
|
121
|
+
body: MessageBody
|
|
176
122
|
})
|
|
177
123
|
```
|
|
178
124
|
|
|
179
|
-
|
|
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:
|
|
125
|
+
List messages (paginated):
|
|
191
126
|
|
|
192
127
|
```ts
|
|
193
|
-
const
|
|
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()
|
|
128
|
+
const messages = await queue.listMessages({ pageSize: 50, pageNumber: 0 })
|
|
206
129
|
```
|
|
207
130
|
|
|
208
|
-
|
|
209
|
-
driver, but the loop continues.
|
|
210
|
-
|
|
211
|
-
## API Reference
|
|
212
|
-
|
|
213
|
-
### QueueService Methods
|
|
131
|
+
Processing control:
|
|
214
132
|
|
|
215
133
|
```ts
|
|
216
|
-
|
|
134
|
+
queue.startProcessing() // begin polling and processing
|
|
135
|
+
queue.stopProcessing() // stop polling
|
|
136
|
+
await queue.processUntilEmpty({ pollingMs?: number }) // process then resolve when empty
|
|
217
137
|
```
|
|
218
|
-
Creates a new queue service with the given driver and optional logger.
|
|
219
138
|
|
|
220
|
-
|
|
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.
|
|
139
|
+
### Service-Level Methods
|
|
225
140
|
|
|
226
141
|
```ts
|
|
227
|
-
Queue.
|
|
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
|
|
228
146
|
```
|
|
229
|
-
Starts a polling work loop. Returns a promise that resolves when the loop stops, with an `abort()`
|
|
230
|
-
method to stop it.
|
|
231
147
|
|
|
232
|
-
|
|
233
|
-
Queue.stopAllWork(): Promise<void>
|
|
234
|
-
```
|
|
235
|
-
Aborts all active work loops and waits for in-flight messages to finish.
|
|
148
|
+
### Message Statuses
|
|
236
149
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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 |
|
|
242
157
|
|
|
243
|
-
###
|
|
158
|
+
### Log Events
|
|
244
159
|
|
|
245
|
-
|
|
246
|
-
handle.dispatch(body: MessageBody): Promise<void>
|
|
247
|
-
```
|
|
248
|
-
Enqueues a message for immediate processing.
|
|
160
|
+
The queue emits structured log messages with these `event` values:
|
|
249
161
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
254
168
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
Runs the handler synchronously without going through the driver. Useful for testing. Middleware
|
|
259
|
-
still executes.
|
|
169
|
+
## Common Patterns
|
|
170
|
+
|
|
171
|
+
### Schedule a Future Job
|
|
260
172
|
|
|
261
173
|
```ts
|
|
262
|
-
|
|
263
|
-
```
|
|
264
|
-
Enqueues multiple messages in a single operation. Uses the driver's bulk method for efficiency.
|
|
174
|
+
import { instantFns } from 'iso-fns2'
|
|
265
175
|
|
|
266
|
-
|
|
176
|
+
// Schedule 1 hour from now
|
|
177
|
+
const oneHourLater = instantFns.add(instantFns.now(), { hours: 1 })
|
|
178
|
+
await queue.addMessage({ task: 'reminder' }, { scheduledDate: oneHourLater })
|
|
179
|
+
```
|
|
267
180
|
|
|
268
|
-
|
|
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)|
|
|
181
|
+
### Deduplication with Unique Keys
|
|
276
182
|
|
|
277
|
-
|
|
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).
|
|
278
184
|
|
|
279
|
-
|
|
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 |
|
|
185
|
+
### Process Until Empty (Worker Scripts)
|
|
286
186
|
|
|
287
|
-
|
|
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
|
+
```
|
|
288
192
|
|
|
289
|
-
###
|
|
193
|
+
### Concurrency Control
|
|
290
194
|
|
|
291
195
|
```ts
|
|
196
|
+
// High-concurrency queue for lightweight jobs
|
|
292
197
|
const emailQueue = Queue.create({
|
|
293
198
|
name: 'send-emails',
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (result.rejected) {
|
|
297
|
-
fail(new Error(`Rejected: ${body.to}`))
|
|
298
|
-
}
|
|
299
|
-
},
|
|
300
|
-
tries: 3,
|
|
301
|
-
backoffSeconds: [60, 300, 900]
|
|
199
|
+
workFn: async (body) => sendEmail(body),
|
|
200
|
+
concurrency: 10
|
|
302
201
|
})
|
|
303
|
-
|
|
304
|
-
await emailQueue.dispatch({ to: 'bob@example.com', subject: 'Hello', html: '<p>Hi!</p>' })
|
|
305
202
|
```
|
|
306
203
|
|
|
307
|
-
###
|
|
204
|
+
### Stuck Job Recovery
|
|
308
205
|
|
|
309
|
-
|
|
310
|
-
import { instantFns } from 'iso-fns2'
|
|
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.
|
|
311
207
|
|
|
312
|
-
|
|
313
|
-
await reminderQueue.dispatchWithDelay(
|
|
314
|
-
{ userId: 42, message: 'Check in' },
|
|
315
|
-
instantFns.add(instantFns.now(), { hours: 24 })
|
|
316
|
-
)
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
### Bulk dispatch
|
|
320
|
-
|
|
321
|
-
```ts
|
|
322
|
-
const messages = users.map((user) => ({
|
|
323
|
-
body: { userId: user.id, type: 'weekly-digest' }
|
|
324
|
-
}))
|
|
325
|
-
await digestQueue.bulk(messages)
|
|
326
|
-
```
|
|
208
|
+
### Distributed Tracing
|
|
327
209
|
|
|
328
|
-
|
|
210
|
+
Pass a `tracing` adapter when creating the service to propagate trace context across producer/consumer boundaries:
|
|
329
211
|
|
|
330
212
|
```ts
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
+
)
|
|
345
230
|
```
|
|
346
231
|
|
|
347
|
-
|
|
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
|
-
```
|
|
232
|
+
The trace data is serialized as `DehydratedTrace` (traceId, spanId, traceFlags, traceState) and stored in the `trace` column.
|
|
353
233
|
|
|
354
234
|
## Testing
|
|
355
235
|
|
|
356
|
-
|
|
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:
|
|
357
237
|
|
|
358
238
|
```ts
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
})
|
|
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
|
+
}
|
|
375
247
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
248
|
+
Queue.Provider.register(
|
|
249
|
+
'default',
|
|
250
|
+
Queue.Provider.create({
|
|
251
|
+
driver: mockDriver,
|
|
252
|
+
logger: Log
|
|
253
|
+
})
|
|
254
|
+
)
|
|
380
255
|
```
|
|
381
256
|
|
|
382
|
-
|
|
257
|
+
## Cross-Package Integration
|
|
383
258
|
|
|
384
|
-
|
|
385
|
-
pnpm --filter @maestro-js/queue test
|
|
386
|
-
cd packages/queue && npx beartest ./tests/**/*
|
|
387
|
-
```
|
|
259
|
+
### Mail (async email sending)
|
|
388
260
|
|
|
389
|
-
|
|
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.
|
|
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.
|
|
405
262
|
|
|
406
|
-
|
|
263
|
+
### Recurring Jobs (cron scheduling)
|
|
407
264
|
|
|
408
|
-
|
|
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)
|
|
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.
|
|
413
266
|
|
|
414
|
-
|
|
267
|
+
### Db (database driver)
|
|
415
268
|
|
|
416
|
-
|
|
269
|
+
The db driver requires a `Db` provider that exposes `select`, `update`, and `insert` methods. Pass `Db.provider('default')` as the `db` config option.
|
|
417
270
|
|
|
418
|
-
|
|
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
|
-
```
|
|
271
|
+
### Tracing
|
|
429
272
|
|
|
430
|
-
|
|
273
|
+
Integrate with `@maestro-js/tracing` by implementing the `Queue.Tracing` interface to propagate OpenTelemetry-compatible trace context across the producer/consumer boundary.
|
|
431
274
|
|
|
432
|
-
##
|
|
275
|
+
## Source Files
|
|
433
276
|
|
|
434
|
-
- `packages/queue/src/index.ts` --
|
|
435
|
-
- `packages/queue/src/queue-
|
|
436
|
-
- `packages/queue/src/db-queue-driver.ts` -- MySQL driver with
|
|
437
|
-
- `packages/queue/src/local-queue-driver.ts` -- in-memory driver for testing
|
|
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
|