@maestro-js/queue 1.0.0-alpha.2 → 1.0.0-alpha.20
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 +337 -179
- package/dist/index.d.ts +179 -215
- package/dist/index.js +422 -475
- 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
|
|
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
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
//
|
|
35
|
-
|
|
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
|
-
//
|
|
38
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
33
|
+
Queue follows the standard Maestro Provider pattern built on `@maestro-js/service-registry`:
|
|
65
34
|
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
84
|
-
db:
|
|
85
|
-
databaseTable:
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
102
|
+
Source: `packages/queue/src/db-queue-driver.ts`
|
|
103
|
+
|
|
104
|
+
### local (In-Memory)
|
|
94
105
|
|
|
95
|
-
|
|
106
|
+
In-memory driver for testing. Same interface as db but stores messages in a plain array:
|
|
96
107
|
|
|
97
108
|
```ts
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
+
```ts
|
|
246
|
+
handle.dispatch(body: MessageBody): Promise<void>
|
|
247
|
+
```
|
|
248
|
+
Enqueues a message for immediate processing.
|
|
170
249
|
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
###
|
|
266
|
+
### Queue.Config
|
|
182
267
|
|
|
183
|
-
|
|
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
|
-
###
|
|
277
|
+
### Queue.WorkOptions
|
|
186
278
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
###
|
|
307
|
+
### Delayed scheduling
|
|
205
308
|
|
|
206
|
-
|
|
309
|
+
```ts
|
|
310
|
+
import { instantFns } from 'iso-fns2'
|
|
207
311
|
|
|
208
|
-
|
|
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
|
-
|
|
319
|
+
### Bulk dispatch
|
|
211
320
|
|
|
212
321
|
```ts
|
|
213
|
-
|
|
214
|
-
'
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
356
|
+
Use the local driver in tests. The local driver stores messages in-memory:
|
|
237
357
|
|
|
238
358
|
```ts
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
382
|
+
Run tests:
|
|
258
383
|
|
|
259
|
-
|
|
384
|
+
```bash
|
|
385
|
+
pnpm --filter @maestro-js/queue test
|
|
386
|
+
cd packages/queue && npx beartest ./tests/**/*
|
|
387
|
+
```
|
|
260
388
|
|
|
261
|
-
|
|
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
|
-
|
|
406
|
+
## Cross-Package Integration
|
|
264
407
|
|
|
265
|
-
|
|
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
|
-
|
|
414
|
+
## Driver Interface
|
|
268
415
|
|
|
269
|
-
|
|
416
|
+
Implement `Queue.Driver` to create a custom backend:
|
|
270
417
|
|
|
271
|
-
|
|
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
|
-
|
|
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,
|
|
278
|
-
- `packages/queue/src/queue-
|
|
279
|
-
- `packages/queue/src/db-queue-driver.ts` -- MySQL
|
|
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
|