@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 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
@@ -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
+ }