@maestro-js/queue 1.0.0-alpha.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 +279 -0
- package/dist/index.d.ts +300 -0
- package/dist/index.js +545 -0
- package/package.json +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# @maestro-js/queue
|
|
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.
|
|
4
|
+
|
|
5
|
+
## Quick Setup
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { Queue } from '@maestro-js/queue'
|
|
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
|
+
|
|
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
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Enqueue a message
|
|
35
|
+
await orderQueue.addMessage({ orderId: 'ord_123' }, { scheduledDate: null })
|
|
36
|
+
|
|
37
|
+
// Start processing
|
|
38
|
+
orderQueue.startProcessing()
|
|
39
|
+
```
|
|
40
|
+
|
|
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.
|
|
63
|
+
|
|
64
|
+
## API Reference
|
|
65
|
+
|
|
66
|
+
### Provider Setup
|
|
67
|
+
|
|
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
|
+
```
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
Queue.Provider.register(name: Queue.Provider.Key, service: Queue.QueueService): void
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Drivers
|
|
81
|
+
|
|
82
|
+
```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
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Queue Instance Methods
|
|
94
|
+
|
|
95
|
+
Create a queue:
|
|
96
|
+
|
|
97
|
+
```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
|
|
103
|
+
})
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Dispatch a message:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
await queue.addMessage(body, {
|
|
110
|
+
scheduledDate: Iso.Instant | null, // null = process immediately
|
|
111
|
+
tracingContext?: DehydratedTrace | null
|
|
112
|
+
})
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Update a waiting message:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
await queue.updateMessage({
|
|
119
|
+
messageId: number,
|
|
120
|
+
scheduledDate: Iso.Instant,
|
|
121
|
+
body: MessageBody
|
|
122
|
+
})
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
List messages (paginated):
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const messages = await queue.listMessages({ pageSize: 50, pageNumber: 0 })
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Processing control:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
queue.startProcessing() // begin polling and processing
|
|
135
|
+
queue.stopProcessing() // stop polling
|
|
136
|
+
await queue.processUntilEmpty({ pollingMs?: number }) // process then resolve when empty
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Service-Level Methods
|
|
140
|
+
|
|
141
|
+
```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
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Message Statuses
|
|
149
|
+
|
|
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 |
|
|
157
|
+
|
|
158
|
+
### Log Events
|
|
159
|
+
|
|
160
|
+
The queue emits structured log messages with these `event` values:
|
|
161
|
+
|
|
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
|
|
168
|
+
|
|
169
|
+
## Common Patterns
|
|
170
|
+
|
|
171
|
+
### Schedule a Future Job
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import { instantFns } from 'iso-fns2'
|
|
175
|
+
|
|
176
|
+
// Schedule 1 hour from now
|
|
177
|
+
const oneHourLater = instantFns.add(instantFns.now(), { hours: 1 })
|
|
178
|
+
await queue.addMessage({ task: 'reminder' }, { scheduledDate: oneHourLater })
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Deduplication with Unique Keys
|
|
182
|
+
|
|
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).
|
|
184
|
+
|
|
185
|
+
### Process Until Empty (Worker Scripts)
|
|
186
|
+
|
|
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
|
+
```
|
|
192
|
+
|
|
193
|
+
### Concurrency Control
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
// High-concurrency queue for lightweight jobs
|
|
197
|
+
const emailQueue = Queue.create({
|
|
198
|
+
name: 'send-emails',
|
|
199
|
+
workFn: async (body) => sendEmail(body),
|
|
200
|
+
concurrency: 10
|
|
201
|
+
})
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Stuck Job Recovery
|
|
205
|
+
|
|
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.
|
|
207
|
+
|
|
208
|
+
### Distributed Tracing
|
|
209
|
+
|
|
210
|
+
Pass a `tracing` adapter when creating the service to propagate trace context across producer/consumer boundaries:
|
|
211
|
+
|
|
212
|
+
```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
|
+
)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
The trace data is serialized as `DehydratedTrace` (traceId, spanId, traceFlags, traceState) and stored in the `trace` column.
|
|
233
|
+
|
|
234
|
+
## Testing
|
|
235
|
+
|
|
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:
|
|
237
|
+
|
|
238
|
+
```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
|
+
}
|
|
247
|
+
|
|
248
|
+
Queue.Provider.register(
|
|
249
|
+
'default',
|
|
250
|
+
Queue.Provider.create({
|
|
251
|
+
driver: mockDriver,
|
|
252
|
+
logger: Log
|
|
253
|
+
})
|
|
254
|
+
)
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## Cross-Package Integration
|
|
258
|
+
|
|
259
|
+
### Mail (async email sending)
|
|
260
|
+
|
|
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.
|
|
262
|
+
|
|
263
|
+
### Recurring Jobs (cron scheduling)
|
|
264
|
+
|
|
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.
|
|
266
|
+
|
|
267
|
+
### Db (database driver)
|
|
268
|
+
|
|
269
|
+
The db driver requires a `Db` provider that exposes `select`, `update`, and `insert` methods. Pass `Db.provider('default')` as the `db` config option.
|
|
270
|
+
|
|
271
|
+
### Tracing
|
|
272
|
+
|
|
273
|
+
Integrate with `@maestro-js/tracing` by implementing the `Queue.Tracing` interface to propagate OpenTelemetry-compatible trace context across the producer/consumer boundary.
|
|
274
|
+
|
|
275
|
+
## Source Files
|
|
276
|
+
|
|
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
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { Iso } from 'iso-fns2';
|
|
2
|
+
import { Log } from '@maestro-js/log';
|
|
3
|
+
|
|
4
|
+
interface DehydratedTrace {
|
|
5
|
+
traceId: string;
|
|
6
|
+
spanId: string;
|
|
7
|
+
traceFlags: number;
|
|
8
|
+
traceState: string | undefined;
|
|
9
|
+
}
|
|
10
|
+
interface QueueTracing {
|
|
11
|
+
hydrate: <T>(trace: DehydratedTrace | null, callback: () => T) => T;
|
|
12
|
+
dehydrate: () => DehydratedTrace | null;
|
|
13
|
+
startActiveSpan: <R>(name: string | {
|
|
14
|
+
name: string;
|
|
15
|
+
attributes?: Record<string, unknown>;
|
|
16
|
+
}, fn: () => R) => R;
|
|
17
|
+
getActiveSpan: () => {
|
|
18
|
+
setAttribute(key: string, value: unknown): void;
|
|
19
|
+
setStatus(status: {
|
|
20
|
+
code: number;
|
|
21
|
+
message: string;
|
|
22
|
+
}): void;
|
|
23
|
+
} | undefined;
|
|
24
|
+
}
|
|
25
|
+
interface QueueMessage<MessageBody extends Record<string, any> = Record<string, any>, Result = any> {
|
|
26
|
+
id: number;
|
|
27
|
+
batchId: number | null;
|
|
28
|
+
queue: string;
|
|
29
|
+
scheduledDate: Iso.Instant;
|
|
30
|
+
enqueuedDate: Iso.Instant;
|
|
31
|
+
startedDate: Iso.Instant | null;
|
|
32
|
+
body: MessageBody;
|
|
33
|
+
trace: DehydratedTrace | null;
|
|
34
|
+
status: 'waiting' | 'processing' | 'success' | 'error' | 'stuck';
|
|
35
|
+
result: Result;
|
|
36
|
+
recovered: number;
|
|
37
|
+
runningTimeSeconds: number | null;
|
|
38
|
+
uniqueKey: number | null;
|
|
39
|
+
priority: number | null;
|
|
40
|
+
}
|
|
41
|
+
type QueueWorkFunction<MessageBody extends Record<string, any> = Record<string, any>> = (args: {
|
|
42
|
+
body: MessageBody;
|
|
43
|
+
id: number;
|
|
44
|
+
trace: DehydratedTrace | null;
|
|
45
|
+
scheduledDate: Iso.Instant;
|
|
46
|
+
}) => any;
|
|
47
|
+
interface QueueDriver {
|
|
48
|
+
start<MessageBody extends Record<string, any> = Record<string, any>>(options: {
|
|
49
|
+
queueName: string;
|
|
50
|
+
workFn: QueueWorkFunction<MessageBody>;
|
|
51
|
+
concurrency?: number;
|
|
52
|
+
}): void;
|
|
53
|
+
stop(options: {
|
|
54
|
+
queueName: string;
|
|
55
|
+
}): void;
|
|
56
|
+
listMessages<MessageBody extends Record<string, any> = Record<string, any>, Result = any>(options: {
|
|
57
|
+
pageSize: number;
|
|
58
|
+
pageNumber: number;
|
|
59
|
+
queueName: string;
|
|
60
|
+
}): Promise<QueueMessage<MessageBody, Result>[]>;
|
|
61
|
+
addMessage<MessageBody extends Record<string, any> = Record<string, any>>(options: {
|
|
62
|
+
body: MessageBody;
|
|
63
|
+
scheduledDate: Iso.Instant | null;
|
|
64
|
+
queueName: string;
|
|
65
|
+
traceData: DehydratedTrace | null;
|
|
66
|
+
}): Promise<{
|
|
67
|
+
id: number;
|
|
68
|
+
}>;
|
|
69
|
+
updateMessage<MessageBody extends Record<string, any> = Record<string, any>>(options: {
|
|
70
|
+
queueName: string;
|
|
71
|
+
messageId: number;
|
|
72
|
+
scheduledDate: Iso.Instant;
|
|
73
|
+
body: MessageBody;
|
|
74
|
+
}): Promise<void>;
|
|
75
|
+
isQueueEmpty(options: {
|
|
76
|
+
queueName: string;
|
|
77
|
+
}): Promise<boolean>;
|
|
78
|
+
}
|
|
79
|
+
type QueueLogMessage = {
|
|
80
|
+
event: 'messageEnqueued';
|
|
81
|
+
queue: string;
|
|
82
|
+
driver: QueueDriver;
|
|
83
|
+
scheduledDate: Iso.Instant | null;
|
|
84
|
+
body: Record<string, any>;
|
|
85
|
+
messageId: number;
|
|
86
|
+
} | {
|
|
87
|
+
event: 'messageDequeued';
|
|
88
|
+
queue: string;
|
|
89
|
+
driver: QueueDriver;
|
|
90
|
+
body: Record<string, any>;
|
|
91
|
+
messageId: number;
|
|
92
|
+
} | {
|
|
93
|
+
event: 'messageProcessed';
|
|
94
|
+
queue: string;
|
|
95
|
+
driver: QueueDriver;
|
|
96
|
+
body: Record<string, any>;
|
|
97
|
+
result: unknown;
|
|
98
|
+
messageId: number;
|
|
99
|
+
} | {
|
|
100
|
+
event: 'messageFailed';
|
|
101
|
+
queue: string;
|
|
102
|
+
driver: QueueDriver;
|
|
103
|
+
body: Record<string, any>;
|
|
104
|
+
error: unknown;
|
|
105
|
+
messageId: number;
|
|
106
|
+
} | {
|
|
107
|
+
event: 'queueStarted';
|
|
108
|
+
queue: string;
|
|
109
|
+
driver: QueueDriver;
|
|
110
|
+
} | {
|
|
111
|
+
event: 'queueStopped';
|
|
112
|
+
queue: string;
|
|
113
|
+
driver: QueueDriver;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
interface DbQueueDriverConfig {
|
|
117
|
+
db: {
|
|
118
|
+
update(query: string, params?: any[]): Promise<{
|
|
119
|
+
changedRows: number;
|
|
120
|
+
}>;
|
|
121
|
+
select(query: string, params?: any[]): Promise<Array<Record<string, any>>>;
|
|
122
|
+
insert(query: string, params?: any[]): Promise<{
|
|
123
|
+
insertId: number;
|
|
124
|
+
}>;
|
|
125
|
+
};
|
|
126
|
+
databaseTable: string;
|
|
127
|
+
extraFields?: string[];
|
|
128
|
+
fastestPollingRateMs?: number;
|
|
129
|
+
slowestPollingRateMs?: number;
|
|
130
|
+
pollingBackoffRate?: number;
|
|
131
|
+
}
|
|
132
|
+
declare function DbQueueDriver({ db, databaseTable, extraFields, fastestPollingRateMs, slowestPollingRateMs, pollingBackoffRate }: DbQueueDriverConfig): QueueDriver;
|
|
133
|
+
|
|
134
|
+
declare function createService(config: Queue.Provider.QueueServiceConfig): {
|
|
135
|
+
list: () => string[];
|
|
136
|
+
get: <MessageBody extends Record<string, any>, Result = any>(name: string) => Queue.Queue<MessageBody, Result> | undefined;
|
|
137
|
+
create: <MessageBody extends Record<string, any>, Result = any>(options: Queue.QueueOptions<MessageBody, Result>) => Queue.Queue<MessageBody, Result>;
|
|
138
|
+
startProcessing: () => void;
|
|
139
|
+
stopProcessing: () => void;
|
|
140
|
+
};
|
|
141
|
+
declare function provider(key: Queue.Provider.Key): {
|
|
142
|
+
list: () => string[];
|
|
143
|
+
get: <MessageBody extends Record<string, any>, Result = any>(name: string) => Queue.Queue<MessageBody, Result> | undefined;
|
|
144
|
+
create: <MessageBody extends Record<string, any>, Result = any>(options: Queue.QueueOptions<MessageBody, Result>) => Queue.Queue<MessageBody, Result>;
|
|
145
|
+
startProcessing: () => void;
|
|
146
|
+
stopProcessing: () => void;
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* Job queue for deferring work beyond the current request.
|
|
150
|
+
*
|
|
151
|
+
* Messages are stored by a pluggable driver and processed asynchronously by a
|
|
152
|
+
* worker — either in the same process or a dedicated consumer. Each message
|
|
153
|
+
* tracks its full lifecycle (waiting → processing → success/error/stuck) and
|
|
154
|
+
* supports scheduled future execution, concurrent processing, and distributed
|
|
155
|
+
* trace propagation across the producer/consumer boundary.
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* ```ts
|
|
159
|
+
* // Create and register a queue service
|
|
160
|
+
* Queue.Provider.register('default', Queue.Provider.create({
|
|
161
|
+
* driver: Queue.drivers.db({ db: Db.provider('default'), databaseTable: 'jobs' }),
|
|
162
|
+
* logger: Log.provider('default')
|
|
163
|
+
* }))
|
|
164
|
+
*
|
|
165
|
+
* // Create a queue with a work function
|
|
166
|
+
* const orderQueue = Queue.create({
|
|
167
|
+
* name: 'fulfill-orders',
|
|
168
|
+
* workFn: async (body) => {
|
|
169
|
+
* await fulfillOrder(body.orderId)
|
|
170
|
+
* return { fulfilled: true }
|
|
171
|
+
* }
|
|
172
|
+
* })
|
|
173
|
+
*
|
|
174
|
+
* // Enqueue a message and start processing
|
|
175
|
+
* await orderQueue.addMessage({ orderId: 'ord_123' }, { scheduledDate: null })
|
|
176
|
+
* orderQueue.startProcessing()
|
|
177
|
+
* ```
|
|
178
|
+
*/
|
|
179
|
+
declare namespace Queue {
|
|
180
|
+
/** Per-queue configuration passed to {@link QueueService.create} */
|
|
181
|
+
interface QueueOptions<T extends Record<string, any> = Record<string, any>, Result = any> {
|
|
182
|
+
name: string;
|
|
183
|
+
workFn(body: T): Promise<Result>;
|
|
184
|
+
logger?: Log.LogFunctions<[QueueLogMessage]>;
|
|
185
|
+
concurrency?: number;
|
|
186
|
+
}
|
|
187
|
+
/** Pluggable storage backend that handles message persistence, polling, and locking */
|
|
188
|
+
type Driver = QueueDriver;
|
|
189
|
+
/** A single message stored in the queue with its full lifecycle metadata */
|
|
190
|
+
type QueueMessage<MessageBody extends Record<string, any> = Record<string, any>, Result = any> = QueueMessage<MessageBody, Result>;
|
|
191
|
+
/** Union of all queue lifecycle log event shapes */
|
|
192
|
+
type LogMessage = QueueLogMessage;
|
|
193
|
+
/** Distributed tracing adapter for propagating trace context across producer/consumer boundaries */
|
|
194
|
+
type Tracing = QueueTracing;
|
|
195
|
+
/**
|
|
196
|
+
* A named queue instance with methods for adding messages and controlling processing.
|
|
197
|
+
*
|
|
198
|
+
* Each queue has its own work function, concurrency setting, and independent
|
|
199
|
+
* start/stop lifecycle. Messages are typed via the `MessageBody` and `Result`
|
|
200
|
+
* generics — the body is what the producer enqueues, the result is what the
|
|
201
|
+
* work function returns on success.
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* ```ts
|
|
205
|
+
* const queue = Queue.create({
|
|
206
|
+
* name: 'send-emails',
|
|
207
|
+
* workFn: async (body) => sendEmail(body.to, body.subject),
|
|
208
|
+
* concurrency: 3
|
|
209
|
+
* })
|
|
210
|
+
* await queue.addMessage({ to: 'alice@example.com', subject: 'Welcome' }, { scheduledDate: null })
|
|
211
|
+
* queue.startProcessing()
|
|
212
|
+
* ```
|
|
213
|
+
*/
|
|
214
|
+
type Queue<MessageBody extends Record<string, any> = Record<string, any>, Result = any> = {
|
|
215
|
+
/** Starts polling for and processing messages on this queue */
|
|
216
|
+
startProcessing(): void;
|
|
217
|
+
/** Starts processing and resolves when no waiting or in-progress messages remain */
|
|
218
|
+
processUntilEmpty(options?: {
|
|
219
|
+
pollingMs?: number;
|
|
220
|
+
}): Promise<void>;
|
|
221
|
+
/** Stops polling for new messages on this queue */
|
|
222
|
+
stopProcessing(): void;
|
|
223
|
+
name: string;
|
|
224
|
+
/** Returns a paginated list of messages in this queue */
|
|
225
|
+
listMessages(options: {
|
|
226
|
+
pageSize: number;
|
|
227
|
+
pageNumber: number;
|
|
228
|
+
}): Promise<QueueMessage<MessageBody, Result>[]>;
|
|
229
|
+
/** Adds a message to this queue for immediate or scheduled processing */
|
|
230
|
+
addMessage(body: MessageBody, options: {
|
|
231
|
+
scheduledDate: Iso.Instant | null;
|
|
232
|
+
tracingContext?: DehydratedTrace | null;
|
|
233
|
+
}): Promise<void>;
|
|
234
|
+
/** Updates the body and scheduled date of a waiting message */
|
|
235
|
+
updateMessage(options: {
|
|
236
|
+
messageId: number;
|
|
237
|
+
scheduledDate: Iso.Instant;
|
|
238
|
+
body: MessageBody;
|
|
239
|
+
}): Promise<void>;
|
|
240
|
+
};
|
|
241
|
+
namespace Provider {
|
|
242
|
+
interface QueueServiceConfig {
|
|
243
|
+
driver: Driver;
|
|
244
|
+
logger: Log.Logger<[QueueLogMessage]>;
|
|
245
|
+
tracing?: QueueTracing;
|
|
246
|
+
}
|
|
247
|
+
type Key = keyof Keys;
|
|
248
|
+
interface Keys {
|
|
249
|
+
default: unknown;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Container that manages multiple named queues sharing the same driver and logger.
|
|
254
|
+
*
|
|
255
|
+
* Create individual queues with {@link create}, then start or stop processing
|
|
256
|
+
* across all queues at once or individually. Each queue has its own work function,
|
|
257
|
+
* concurrency, and message lifecycle.
|
|
258
|
+
*
|
|
259
|
+
* @example
|
|
260
|
+
* ```ts
|
|
261
|
+
* const orders = Queue.create({ name: 'orders', workFn: processOrder })
|
|
262
|
+
* const emails = Queue.create({ name: 'emails', workFn: sendEmail, concurrency: 5 })
|
|
263
|
+
* Queue.startProcessing() // starts both queues
|
|
264
|
+
* ```
|
|
265
|
+
*/
|
|
266
|
+
interface QueueService {
|
|
267
|
+
/** Returns the names of all queues registered with this service */
|
|
268
|
+
list(): string[];
|
|
269
|
+
/** Returns the queue with the given name, or `undefined` if not found */
|
|
270
|
+
get<MessageBody extends Record<string, any>, Result = any>(name: string): Queue.Queue<MessageBody, Result> | undefined;
|
|
271
|
+
/** Creates a new named queue with the given work function and options */
|
|
272
|
+
create<MessageBody extends Record<string, any>, Result = any>(options: Queue.QueueOptions<MessageBody, Result>): Queue<MessageBody, Result>;
|
|
273
|
+
/** Stops processing messages on all queues */
|
|
274
|
+
stopProcessing(): void;
|
|
275
|
+
/** Starts processing messages on all queues */
|
|
276
|
+
startProcessing(): void;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Job queue for deferring work beyond the current request.
|
|
281
|
+
* Call `Queue.Provider.create()` with a driver, register it, then use `Queue.create()` to define queues.
|
|
282
|
+
*/
|
|
283
|
+
declare const Queue: {
|
|
284
|
+
drivers: {
|
|
285
|
+
db: typeof DbQueueDriver;
|
|
286
|
+
};
|
|
287
|
+
Provider: {
|
|
288
|
+
create: typeof createService;
|
|
289
|
+
register: (name: Queue.Provider.Key, item: Queue.QueueService) => void;
|
|
290
|
+
list: () => "default"[];
|
|
291
|
+
};
|
|
292
|
+
provider: typeof provider;
|
|
293
|
+
list: () => string[];
|
|
294
|
+
get: <MessageBody extends Record<string, any>, Result = any>(name: string) => Queue.Queue<MessageBody, Result> | undefined;
|
|
295
|
+
create: <MessageBody extends Record<string, any>, Result = any>(options: Queue.QueueOptions<MessageBody, Result>) => Queue.Queue<MessageBody, Result>;
|
|
296
|
+
startProcessing: () => void;
|
|
297
|
+
stopProcessing: () => void;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
export { Queue };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { instantFns as instantFns2 } from "iso-fns2";
|
|
3
|
+
import { Log } from "@maestro-js/log";
|
|
4
|
+
import { ServiceRegistry } from "@maestro-js/service-registry";
|
|
5
|
+
|
|
6
|
+
// src/db-queue-driver.ts
|
|
7
|
+
import crypto from "crypto";
|
|
8
|
+
import { instantFns } from "iso-fns2";
|
|
9
|
+
import assert from "assert";
|
|
10
|
+
function DbQueueDriver({
|
|
11
|
+
db,
|
|
12
|
+
databaseTable,
|
|
13
|
+
extraFields,
|
|
14
|
+
fastestPollingRateMs = 100,
|
|
15
|
+
slowestPollingRateMs = 1e4,
|
|
16
|
+
pollingBackoffRate = 1.1
|
|
17
|
+
}) {
|
|
18
|
+
function logError(msg) {
|
|
19
|
+
console.error(`Oxen Queue: ${msg}`);
|
|
20
|
+
}
|
|
21
|
+
const processingMap = /* @__PURE__ */ new Map();
|
|
22
|
+
function getIsProcessing(queueName) {
|
|
23
|
+
return !!processingMap.get(queueName);
|
|
24
|
+
}
|
|
25
|
+
function setIsProcessing(queueName, processing) {
|
|
26
|
+
processingMap.set(queueName, processing);
|
|
27
|
+
}
|
|
28
|
+
const jobRecoveryIntervalMap = /* @__PURE__ */ new Map();
|
|
29
|
+
function getJobRecoveryInterval(queueName) {
|
|
30
|
+
return jobRecoveryIntervalMap.get(queueName) ?? null;
|
|
31
|
+
}
|
|
32
|
+
function setJobRecoveryInterval(queueName, jobRecoveryInterval) {
|
|
33
|
+
jobRecoveryIntervalMap.set(queueName, jobRecoveryInterval);
|
|
34
|
+
}
|
|
35
|
+
async function start({
|
|
36
|
+
workFn,
|
|
37
|
+
concurrency = 3,
|
|
38
|
+
timeout = 60 * 45,
|
|
39
|
+
shouldRecoverStuckJobs = true,
|
|
40
|
+
queueName
|
|
41
|
+
}) {
|
|
42
|
+
if (getIsProcessing(queueName)) {
|
|
43
|
+
throw new Error(`This queue is already processing`);
|
|
44
|
+
}
|
|
45
|
+
if (!workFn) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Missing work_fn argument, nothing to do! Remember that the process() function takes an object as its single argument.`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (typeof concurrency !== "number") {
|
|
51
|
+
throw new Error(`The concurrency argument must be a number`);
|
|
52
|
+
}
|
|
53
|
+
let jobTimeoutSeconds = Math.floor(timeout);
|
|
54
|
+
let inProcess = 0;
|
|
55
|
+
let pollingRate = fastestPollingRateMs;
|
|
56
|
+
let currentlyFetching = false;
|
|
57
|
+
const workingJobBatch = [];
|
|
58
|
+
setIsProcessing(queueName, true);
|
|
59
|
+
async function fillJobBatch() {
|
|
60
|
+
const batchId = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
|
|
61
|
+
const lockedBatch = await db.update(
|
|
62
|
+
`
|
|
63
|
+
UPDATE ${databaseTable} AS main
|
|
64
|
+
INNER JOIN (
|
|
65
|
+
SELECT id FROM ${databaseTable} FORCE INDEX (locking_update)
|
|
66
|
+
WHERE batchId IS NULL
|
|
67
|
+
AND STATUS = "waiting"
|
|
68
|
+
AND jobType = ?
|
|
69
|
+
AND scheduledDate <= NOW()
|
|
70
|
+
ORDER BY priority ASC LIMIT ${concurrency}
|
|
71
|
+
) sub
|
|
72
|
+
ON sub.id = main.id
|
|
73
|
+
SET batchId = ?, STATUS = "processing", startedDate = NOW()`,
|
|
74
|
+
[queueName, batchId]
|
|
75
|
+
);
|
|
76
|
+
if (lockedBatch.changedRows === 0) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const nextJobs = await db.select(
|
|
80
|
+
`SELECT id, scheduledDate, body, trace FROM ${databaseTable} WHERE batchId = ? ORDER BY priority ASC LIMIT ${concurrency}`,
|
|
81
|
+
[batchId]
|
|
82
|
+
);
|
|
83
|
+
if (nextJobs.length === 0) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
nextJobs.map((db2) => ({
|
|
87
|
+
id: db2.id,
|
|
88
|
+
scheduledDate: new Date(db2.scheduledDate).toISOString(),
|
|
89
|
+
body: db2.body ? JSON.parse(db2.body) : db2.body,
|
|
90
|
+
trace: db2.trace ? JSON.parse(db2.trace) : db2.trace
|
|
91
|
+
})).forEach((job) => {
|
|
92
|
+
workingJobBatch.push(job);
|
|
93
|
+
});
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
async function getNextJob() {
|
|
97
|
+
if (!currentlyFetching && workingJobBatch.length < concurrency) {
|
|
98
|
+
currentlyFetching = true;
|
|
99
|
+
await fillJobBatch().catch((error) => {
|
|
100
|
+
logError("There was an error while trying to get the next set of jobs:");
|
|
101
|
+
logError(error);
|
|
102
|
+
});
|
|
103
|
+
currentlyFetching = false;
|
|
104
|
+
}
|
|
105
|
+
return workingJobBatch.shift();
|
|
106
|
+
}
|
|
107
|
+
async function doWork() {
|
|
108
|
+
const job = await getNextJob();
|
|
109
|
+
if (!job) {
|
|
110
|
+
if (pollingRate < slowestPollingRateMs) {
|
|
111
|
+
pollingRate *= pollingBackoffRate;
|
|
112
|
+
}
|
|
113
|
+
if (pollingRate >= slowestPollingRateMs) {
|
|
114
|
+
pollingRate = slowestPollingRateMs;
|
|
115
|
+
}
|
|
116
|
+
return Promise.resolve("no_job");
|
|
117
|
+
}
|
|
118
|
+
pollingRate = fastestPollingRateMs;
|
|
119
|
+
const promises = [
|
|
120
|
+
workFn({ body: job.body, id: job.id, trace: job.trace, scheduledDate: job.scheduledDate }),
|
|
121
|
+
new Promise((_, reject) => {
|
|
122
|
+
setTimeout(
|
|
123
|
+
() => reject(new Error(`timeout for job_id ${job.id} (over ${jobTimeoutSeconds} seconds)`)),
|
|
124
|
+
typeof jobTimeoutSeconds === "number" ? jobTimeoutSeconds * 1e3 : 0
|
|
125
|
+
);
|
|
126
|
+
})
|
|
127
|
+
];
|
|
128
|
+
return Promise.race(promises).then(async (jobResult) => {
|
|
129
|
+
return db.update(
|
|
130
|
+
`
|
|
131
|
+
update ${databaseTable}
|
|
132
|
+
set
|
|
133
|
+
result = ?,
|
|
134
|
+
uniqueKey = NULL,
|
|
135
|
+
status="success",
|
|
136
|
+
runningTimeSeconds = TIMESTAMPDIFF(SECOND,startedDate,NOW())
|
|
137
|
+
where id =?
|
|
138
|
+
LIMIT 1`,
|
|
139
|
+
[JSON.stringify(jobResult), job.id]
|
|
140
|
+
);
|
|
141
|
+
}).catch(async (error) => {
|
|
142
|
+
return db.update(
|
|
143
|
+
`
|
|
144
|
+
update ${databaseTable}
|
|
145
|
+
set
|
|
146
|
+
result = ?,
|
|
147
|
+
uniqueKey = NULL,
|
|
148
|
+
status="error",
|
|
149
|
+
runningTimeSeconds = TIMESTAMPDIFF(SECOND,startedDate,NOW())
|
|
150
|
+
where id = ?
|
|
151
|
+
LIMIT 1`,
|
|
152
|
+
[error.stack, job.id]
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
async function loop() {
|
|
157
|
+
if (inProcess < concurrency) {
|
|
158
|
+
inProcess++;
|
|
159
|
+
doWork().then(() => {
|
|
160
|
+
inProcess--;
|
|
161
|
+
}).catch((error) => {
|
|
162
|
+
inProcess--;
|
|
163
|
+
logError("Unhandled Oxen Queue error:");
|
|
164
|
+
logError(error);
|
|
165
|
+
});
|
|
166
|
+
} else if (pollingRate < slowestPollingRateMs) {
|
|
167
|
+
pollingRate *= pollingBackoffRate;
|
|
168
|
+
if (pollingRate >= slowestPollingRateMs) {
|
|
169
|
+
pollingRate = slowestPollingRateMs;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (!getIsProcessing(queueName)) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
setTimeout(function() {
|
|
176
|
+
if (!getIsProcessing(queueName)) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
loop();
|
|
180
|
+
}, pollingRate ?? 0);
|
|
181
|
+
}
|
|
182
|
+
async function recoverStuckJobs() {
|
|
183
|
+
return db.update(
|
|
184
|
+
`
|
|
185
|
+
UPDATE ${databaseTable}
|
|
186
|
+
SET
|
|
187
|
+
STATUS="waiting",
|
|
188
|
+
batchId = NULL,
|
|
189
|
+
startedDate = NULL,
|
|
190
|
+
recovered = 1
|
|
191
|
+
WHERE
|
|
192
|
+
STATUS="processing" AND
|
|
193
|
+
startedDate < (NOW() - INTERVAL ${jobTimeoutSeconds} SECOND) AND
|
|
194
|
+
jobType = ?
|
|
195
|
+
`,
|
|
196
|
+
[queueName]
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
async function markStuckJobs() {
|
|
200
|
+
return db.update(
|
|
201
|
+
`
|
|
202
|
+
UPDATE ${databaseTable}
|
|
203
|
+
SET
|
|
204
|
+
STATUS="stuck",
|
|
205
|
+
uniqueKey = NULL,
|
|
206
|
+
recovered = 1
|
|
207
|
+
WHERE
|
|
208
|
+
STATUS="processing" AND
|
|
209
|
+
startedDate < (NOW() - INTERVAL ${jobTimeoutSeconds} SECOND) AND
|
|
210
|
+
jobType = ?
|
|
211
|
+
`,
|
|
212
|
+
[queueName]
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
loop();
|
|
216
|
+
setJobRecoveryInterval(
|
|
217
|
+
queueName,
|
|
218
|
+
setInterval(function() {
|
|
219
|
+
if (shouldRecoverStuckJobs) {
|
|
220
|
+
recoverStuckJobs().catch(function(error) {
|
|
221
|
+
logError("Unable to recover stuck jobs:");
|
|
222
|
+
logError(error);
|
|
223
|
+
});
|
|
224
|
+
} else {
|
|
225
|
+
markStuckJobs().catch(function(error) {
|
|
226
|
+
logError("Unable to mark stuck jobs:");
|
|
227
|
+
logError(error);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}, 1e3 * 60)
|
|
231
|
+
//every minute
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
function stop(options) {
|
|
235
|
+
setIsProcessing(options.queueName, false);
|
|
236
|
+
const jobRecoveryInterval = getJobRecoveryInterval(options.queueName);
|
|
237
|
+
if (jobRecoveryInterval) clearInterval(jobRecoveryInterval);
|
|
238
|
+
}
|
|
239
|
+
async function listMessages({
|
|
240
|
+
pageNumber,
|
|
241
|
+
pageSize,
|
|
242
|
+
queueName
|
|
243
|
+
}) {
|
|
244
|
+
const skip = pageNumber * pageSize;
|
|
245
|
+
const results = await db.select(`SELECT * FROM ${databaseTable} WHERE jobType = ? ORDER BY id DESC LIMIT ? OFFSET ?`, [
|
|
246
|
+
queueName,
|
|
247
|
+
pageSize,
|
|
248
|
+
skip
|
|
249
|
+
]);
|
|
250
|
+
return results.map((r) => dbToJob(r));
|
|
251
|
+
}
|
|
252
|
+
async function addMessage(job) {
|
|
253
|
+
const uniqueKey = job.uniqueKey === void 0 ? null : parseInt(crypto.createHash("md5").update(`${job.uniqueKey}|${job.queueName}`).digest("hex").slice(0, 8), 16);
|
|
254
|
+
const inserts = [
|
|
255
|
+
JSON.stringify(job.body),
|
|
256
|
+
job.traceData ? JSON.stringify(job.traceData) : null,
|
|
257
|
+
job.queueName,
|
|
258
|
+
uniqueKey,
|
|
259
|
+
typeof job.priority === "number" ? job.priority : Date.now()
|
|
260
|
+
];
|
|
261
|
+
extraFields?.forEach((field) => {
|
|
262
|
+
inserts.push(job[field]);
|
|
263
|
+
});
|
|
264
|
+
const fields = ["body", "trace", "jobType", "uniqueKey", "priority", ...extraFields ?? []];
|
|
265
|
+
const { insertId } = await db.insert(
|
|
266
|
+
`
|
|
267
|
+
insert into ${databaseTable} (
|
|
268
|
+
${fields.join(",")}, scheduledDate
|
|
269
|
+
)
|
|
270
|
+
VALUES (?, GREATEST(?, NOW()))
|
|
271
|
+
ON DUPLICATE KEY UPDATE
|
|
272
|
+
priority = IF(priority > VALUES(priority), VALUES(priority), priority)
|
|
273
|
+
`,
|
|
274
|
+
[inserts, instantFns.formatISO9075(job.scheduledDate ?? instantFns.now())]
|
|
275
|
+
);
|
|
276
|
+
if (!insertId) {
|
|
277
|
+
const results = await db.select(`SELECT id FROM ${databaseTable} WHERE uniqueKey = ?`, [uniqueKey]);
|
|
278
|
+
const row = results[0];
|
|
279
|
+
assert.ok(row, "Could not find inserted row");
|
|
280
|
+
return { id: row["id"] };
|
|
281
|
+
} else {
|
|
282
|
+
return { id: insertId };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
async function updateMessage({
|
|
286
|
+
queueName,
|
|
287
|
+
messageId,
|
|
288
|
+
scheduledDate,
|
|
289
|
+
body
|
|
290
|
+
}) {
|
|
291
|
+
const result = instantFns.formatISO9075(scheduledDate);
|
|
292
|
+
await db.update(
|
|
293
|
+
`UPDATE ${databaseTable} SET scheduledDate = GREATEST(?, NOW()), body = ? WHERE jobType = ? AND id = ? AND status = "waiting"`,
|
|
294
|
+
[result, JSON.stringify(body), queueName, messageId]
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
async function isQueueEmpty({ queueName }) {
|
|
298
|
+
const results = await db.select(
|
|
299
|
+
`
|
|
300
|
+
SELECT id FROM ${databaseTable} FORCE INDEX (locking_update)
|
|
301
|
+
WHERE ((batchId IS NULL AND STATUS = "waiting") OR STATUS = "processing")
|
|
302
|
+
AND jobType = ?
|
|
303
|
+
AND scheduledDate <= NOW()
|
|
304
|
+
ORDER BY priority ASC LIMIT 1`,
|
|
305
|
+
[queueName]
|
|
306
|
+
);
|
|
307
|
+
return !results.length;
|
|
308
|
+
}
|
|
309
|
+
return { start, stop, listMessages, addMessage, updateMessage, isQueueEmpty };
|
|
310
|
+
}
|
|
311
|
+
function dbToJob(db) {
|
|
312
|
+
return {
|
|
313
|
+
id: db.id,
|
|
314
|
+
batchId: db.batchId,
|
|
315
|
+
queue: db.jobType,
|
|
316
|
+
scheduledDate: new Date(db.scheduledDate).toISOString(),
|
|
317
|
+
enqueuedDate: new Date(db.enqueuedDate).toISOString(),
|
|
318
|
+
startedDate: db.startedDate ? new Date(db.startedDate).toISOString() : null,
|
|
319
|
+
body: db.body ? JSON.parse(db.body) : db.body,
|
|
320
|
+
trace: db.trace ? JSON.parse(db.trace) : db.trace,
|
|
321
|
+
status: db.status,
|
|
322
|
+
result: db.result,
|
|
323
|
+
recovered: db.recovered,
|
|
324
|
+
runningTimeSeconds: db.runningTimeSeconds,
|
|
325
|
+
uniqueKey: db.uniqueKey,
|
|
326
|
+
priority: db.priority
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/index.ts
|
|
331
|
+
var noopTracing = {
|
|
332
|
+
hydrate: (_trace, callback) => callback(),
|
|
333
|
+
dehydrate: () => null,
|
|
334
|
+
startActiveSpan: (_name, fn) => fn(),
|
|
335
|
+
getActiveSpan: () => void 0
|
|
336
|
+
};
|
|
337
|
+
function createService(config) {
|
|
338
|
+
const queues = [];
|
|
339
|
+
const tracing = config.tracing ?? noopTracing;
|
|
340
|
+
function list() {
|
|
341
|
+
return queues.map((q) => q.name);
|
|
342
|
+
}
|
|
343
|
+
function get(name) {
|
|
344
|
+
return queues.find((q) => q.name === name);
|
|
345
|
+
}
|
|
346
|
+
function create(options) {
|
|
347
|
+
const queueName = options.name;
|
|
348
|
+
const concurrency = options.concurrency ?? 1;
|
|
349
|
+
const logger = Log.create({
|
|
350
|
+
transports: [config.logger, ...options.logger ? [options.logger] : []]
|
|
351
|
+
});
|
|
352
|
+
async function workFn({
|
|
353
|
+
body,
|
|
354
|
+
id,
|
|
355
|
+
trace,
|
|
356
|
+
scheduledDate
|
|
357
|
+
}) {
|
|
358
|
+
try {
|
|
359
|
+
logger.info({
|
|
360
|
+
event: "messageDequeued",
|
|
361
|
+
queue: queueName,
|
|
362
|
+
driver: config.driver,
|
|
363
|
+
body,
|
|
364
|
+
messageId: id
|
|
365
|
+
});
|
|
366
|
+
const result = await tracing.hydrate(trace, async () => {
|
|
367
|
+
return tracing.startActiveSpan(
|
|
368
|
+
{ name: "queue_consumer_transaction", attributes: { "messaging.destination.name": options.name } },
|
|
369
|
+
() => {
|
|
370
|
+
const parent = tracing.getActiveSpan();
|
|
371
|
+
return tracing.startActiveSpan(
|
|
372
|
+
{
|
|
373
|
+
name: "queue_consumer",
|
|
374
|
+
attributes: {
|
|
375
|
+
op: "queue.process",
|
|
376
|
+
"sentry.op": "queue.process",
|
|
377
|
+
"messaging.message.id": id,
|
|
378
|
+
"messaging.destination.name": options.name,
|
|
379
|
+
"messaging.message.receive.latency": instantFns2.getEpochMilliseconds(instantFns2.now()) - instantFns2.getEpochMilliseconds(scheduledDate)
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
async () => {
|
|
383
|
+
const res = await options.workFn(body);
|
|
384
|
+
parent?.setStatus({ code: 1, message: "ok" });
|
|
385
|
+
return res;
|
|
386
|
+
}
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
);
|
|
390
|
+
});
|
|
391
|
+
logger.info({
|
|
392
|
+
event: "messageProcessed",
|
|
393
|
+
queue: queueName,
|
|
394
|
+
driver: config.driver,
|
|
395
|
+
body,
|
|
396
|
+
result,
|
|
397
|
+
messageId: id
|
|
398
|
+
});
|
|
399
|
+
return result;
|
|
400
|
+
} catch (e) {
|
|
401
|
+
logger.error({
|
|
402
|
+
event: "messageFailed",
|
|
403
|
+
queue: queueName,
|
|
404
|
+
driver: config.driver,
|
|
405
|
+
body,
|
|
406
|
+
error: e,
|
|
407
|
+
messageId: id
|
|
408
|
+
});
|
|
409
|
+
throw e;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
async function listMessages({ pageSize, pageNumber }) {
|
|
413
|
+
return config.driver.listMessages({ pageSize, pageNumber, queueName });
|
|
414
|
+
}
|
|
415
|
+
async function addMessage(body, options2) {
|
|
416
|
+
const { id } = await tracing.hydrate(
|
|
417
|
+
options2.tracingContext === void 0 ? tracing.dehydrate() : options2.tracingContext,
|
|
418
|
+
async () => {
|
|
419
|
+
return tracing.startActiveSpan(
|
|
420
|
+
{
|
|
421
|
+
name: "queue_producer",
|
|
422
|
+
attributes: {
|
|
423
|
+
op: "queue.publish",
|
|
424
|
+
"sentry.op": "queue.publish",
|
|
425
|
+
"messaging.destination.name": queueName
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
async () => {
|
|
429
|
+
const span = tracing.getActiveSpan();
|
|
430
|
+
const { id: id2 } = await config.driver.addMessage({
|
|
431
|
+
body,
|
|
432
|
+
scheduledDate: options2.scheduledDate,
|
|
433
|
+
queueName,
|
|
434
|
+
traceData: tracing.dehydrate()
|
|
435
|
+
});
|
|
436
|
+
span?.setAttribute("messaging.message.id", id2);
|
|
437
|
+
return { id: id2 };
|
|
438
|
+
}
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
);
|
|
442
|
+
logger.info({
|
|
443
|
+
event: "messageEnqueued",
|
|
444
|
+
queue: queueName,
|
|
445
|
+
body,
|
|
446
|
+
scheduledDate: options2.scheduledDate ?? null,
|
|
447
|
+
driver: config.driver,
|
|
448
|
+
messageId: id
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
async function updateMessage({
|
|
452
|
+
messageId,
|
|
453
|
+
scheduledDate,
|
|
454
|
+
body
|
|
455
|
+
}) {
|
|
456
|
+
return config.driver.updateMessage({ messageId, body, scheduledDate, queueName });
|
|
457
|
+
}
|
|
458
|
+
function processUntilEmpty(options2) {
|
|
459
|
+
const interval = setInterval(() => {
|
|
460
|
+
config.driver.isQueueEmpty({ queueName }).then((isEmpty2) => {
|
|
461
|
+
if (isEmpty2) {
|
|
462
|
+
declareEmpty(null);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
}, options2?.pollingMs ?? 3e3);
|
|
466
|
+
let declareEmpty = (value) => {
|
|
467
|
+
};
|
|
468
|
+
const isEmpty = new Promise((resolve) => {
|
|
469
|
+
declareEmpty = resolve;
|
|
470
|
+
}).then(() => clearInterval(interval)).then(() => {
|
|
471
|
+
return stopProcessing2();
|
|
472
|
+
});
|
|
473
|
+
startProcessing2();
|
|
474
|
+
return isEmpty;
|
|
475
|
+
}
|
|
476
|
+
function startProcessing2() {
|
|
477
|
+
config.driver.start({ queueName, workFn, concurrency });
|
|
478
|
+
logger.info({
|
|
479
|
+
event: "queueStarted",
|
|
480
|
+
queue: queueName,
|
|
481
|
+
driver: config.driver
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
function stopProcessing2() {
|
|
485
|
+
config.driver.stop({ queueName });
|
|
486
|
+
logger.info({
|
|
487
|
+
event: "queueStopped",
|
|
488
|
+
queue: queueName,
|
|
489
|
+
driver: config.driver
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
const queue = {
|
|
493
|
+
listMessages,
|
|
494
|
+
addMessage,
|
|
495
|
+
updateMessage,
|
|
496
|
+
startProcessing: startProcessing2,
|
|
497
|
+
processUntilEmpty,
|
|
498
|
+
stopProcessing: stopProcessing2,
|
|
499
|
+
name: options.name
|
|
500
|
+
};
|
|
501
|
+
queues.push(queue);
|
|
502
|
+
return queue;
|
|
503
|
+
}
|
|
504
|
+
function startProcessing() {
|
|
505
|
+
queues.forEach((q) => q.startProcessing());
|
|
506
|
+
}
|
|
507
|
+
function stopProcessing() {
|
|
508
|
+
queues.forEach((q) => q.stopProcessing());
|
|
509
|
+
}
|
|
510
|
+
return { list, get, create, startProcessing, stopProcessing };
|
|
511
|
+
}
|
|
512
|
+
var registry = ServiceRegistry.createRegistry(
|
|
513
|
+
ServiceRegistry.proxyFunctionsForObject
|
|
514
|
+
);
|
|
515
|
+
var Provider = {
|
|
516
|
+
create: createService,
|
|
517
|
+
register: registry.register,
|
|
518
|
+
list: registry.list
|
|
519
|
+
};
|
|
520
|
+
function provider(key) {
|
|
521
|
+
const service = registry.resolve(key);
|
|
522
|
+
return {
|
|
523
|
+
list: service.list,
|
|
524
|
+
get: service.get,
|
|
525
|
+
create: service.create,
|
|
526
|
+
startProcessing: service.startProcessing,
|
|
527
|
+
stopProcessing: service.stopProcessing
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
var facade = provider("default");
|
|
531
|
+
var Queue = {
|
|
532
|
+
drivers: {
|
|
533
|
+
db: DbQueueDriver
|
|
534
|
+
},
|
|
535
|
+
Provider,
|
|
536
|
+
provider,
|
|
537
|
+
list: facade.list,
|
|
538
|
+
get: facade.get,
|
|
539
|
+
create: facade.create,
|
|
540
|
+
startProcessing: facade.startProcessing,
|
|
541
|
+
stopProcessing: facade.stopProcessing
|
|
542
|
+
};
|
|
543
|
+
export {
|
|
544
|
+
Queue
|
|
545
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@maestro-js/queue",
|
|
3
|
+
"description": "Database-backed job queue for deferring work beyond the current request with scheduled execution, concurrency control, and distributed tracing. Use when working with @maestro-js/queue, including dispatching jobs, processing queues, configuring the db driver, writing queue consumers, handling retries/stuck jobs, integrating with mail or recurring-jobs, and understanding the queue database schema.",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"default": "./dist/index.js"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"iso-fns2": "npm:iso-fns@2.0.0-alpha.26",
|
|
13
|
+
"@maestro-js/log": "1.0.0-alpha.0",
|
|
14
|
+
"@maestro-js/service-registry": "1.0.0-alpha.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/node": "^22.19.11"
|
|
18
|
+
},
|
|
19
|
+
"version": "1.0.0-alpha.0",
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "restricted"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"license": "UNLICENSED",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22.18.0"
|
|
29
|
+
},
|
|
30
|
+
"repository": "https://github.com/Marcato-Partners/maestro-js",
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsup --config ../../tsup.config.ts",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"test": "echo 'No tests yet'",
|
|
35
|
+
"format": "prettier --write src/",
|
|
36
|
+
"lint": "prettier --check src/"
|
|
37
|
+
}
|
|
38
|
+
}
|