@maestro-js/queue 1.0.0-alpha.18 → 1.0.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +179 -337
  2. package/dist/index.d.ts +215 -179
  3. package/dist/index.js +475 -422
  4. package/package.json +6 -10
package/dist/index.js CHANGED
@@ -1,491 +1,544 @@
1
1
  // src/index.ts
2
- import { randomUUID } from "crypto";
3
- import { instantFns as instantFns3 } from "iso-fns2";
2
+ import { instantFns as instantFns2 } from "iso-fns2";
3
+ import { Log } from "@maestro-js/log";
4
4
  import { ServiceRegistry } from "@maestro-js/service-registry";
5
5
 
6
6
  // src/db-queue-driver.ts
7
- import { randomInt } from "crypto";
7
+ import crypto from "crypto";
8
8
  import { instantFns } from "iso-fns2";
9
+ import assert from "assert";
9
10
  function DbQueueDriver({
10
11
  db,
11
12
  databaseTable,
12
- queue: queueFilter,
13
- batchingMaxWaitSeconds
13
+ extraFields,
14
+ fastestPollingRateMs = 100,
15
+ slowestPollingRateMs = 1e4,
16
+ pollingBackoffRate = 1.1
14
17
  }) {
15
- if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(databaseTable)) {
16
- throw new Error(`Invalid databaseTable name: ${databaseTable}`);
18
+ function logError(msg) {
19
+ console.error(`Oxen Queue: ${msg}`);
17
20
  }
18
- const queueFilterClause = queueFilter ? Array.isArray(queueFilter) ? `AND queue IN (${queueFilter.map(() => "?").join(", ")})` : "AND queue = ?" : "";
19
- const queueFilterParams = queueFilter ? Array.isArray(queueFilter) ? queueFilter : [queueFilter] : [];
20
- async function size() {
21
- const rows = await db.select(
22
- `SELECT COUNT(*) AS count FROM ${databaseTable} WHERE status = 'waiting' AND scheduledDate <= NOW() ${queueFilterClause}`,
23
- queueFilterParams
24
- );
25
- return Number(rows[0]?.["count"] ?? 0);
21
+ const processingMap = /* @__PURE__ */ new Map();
22
+ function getIsProcessing(queueName) {
23
+ return !!processingMap.get(queueName);
26
24
  }
27
- async function push(message) {
28
- const { insertId } = await db.insert(
29
- `INSERT INTO ${databaseTable} (queue, body, scheduledDate) VALUES (?, ?, GREATEST(?, NOW()))`,
30
- [message.queue, JSON.stringify(message.body), instantFns.formatISO9075(message.scheduledDate)]
31
- );
32
- return String(insertId);
25
+ function setIsProcessing(queueName, processing) {
26
+ processingMap.set(queueName, processing);
33
27
  }
34
- async function bulk(messages) {
35
- if (messages.length === 0) return;
36
- const placeholders = messages.map(() => "(?, ?, GREATEST(?, NOW()))").join(", ");
37
- const params = [];
38
- for (const message of messages) {
39
- params.push(message.queue, JSON.stringify(message.body), instantFns.formatISO9075(message.scheduledDate));
40
- }
41
- await db.insert(`INSERT INTO ${databaseTable} (queue, body, scheduledDate) VALUES ${placeholders}`, params);
28
+ const jobRecoveryIntervalMap = /* @__PURE__ */ new Map();
29
+ function getJobRecoveryInterval(queueName) {
30
+ return jobRecoveryIntervalMap.get(queueName) ?? null;
42
31
  }
43
- async function batchPop(count) {
44
- const waiting = await db.select(
45
- `SELECT 1 FROM ${databaseTable} WHERE lockId IS NULL AND status = 'waiting' AND scheduledDate <= NOW() ${queueFilterClause} LIMIT 1`,
46
- queueFilterParams
47
- );
48
- if (waiting.length === 0) {
49
- return [];
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`);
50
44
  }
51
- const lockId = randomInt(0, 2 ** 48 - 1);
52
- const locked = await db.update(
53
- `UPDATE ${databaseTable}
54
- INNER JOIN (
55
- SELECT id, attempt FROM ${databaseTable} FORCE INDEX (locking_pop)
56
- WHERE lockId IS NULL
57
- AND status = 'waiting'
58
- AND scheduledDate <= NOW()
59
- ${queueFilterClause}
60
- ORDER BY scheduledDate ASC
61
- LIMIT ?
62
- ) AS sub ON sub.id = ${databaseTable}.id AND sub.attempt = ${databaseTable}.attempt
63
- SET lockId = ?, status = 'processing', startedDate = NOW()`,
64
- [...queueFilterParams, count, lockId]
65
- );
66
- if (locked.changedRows === 0) {
67
- return [];
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
+ );
68
49
  }
69
- const rows = await db.select(`SELECT id, queue, body, attempt FROM ${databaseTable} WHERE lockId = ?`, [lockId]);
70
- return rows.map(dbRowToMessage);
71
- }
72
- const pop = makePop({ batchPop, maxWaitSeconds: batchingMaxWaitSeconds });
73
- async function fail({ id, attempts, error }) {
74
- await db.update(
75
- `UPDATE ${databaseTable} SET status = 'failedPermanently', completedDate = NOW(), result = ? WHERE id = ? AND attempt = ? LIMIT 1`,
76
- [error.stack, id, attempts]
77
- );
78
- }
79
- async function success(options) {
80
- await db.update(
81
- `UPDATE ${databaseTable} SET status = 'succeeded', completedDate = NOW(), result = ? WHERE id = ? AND attempt = ? LIMIT 1`,
82
- [JSON.stringify(options.result), options.id, options.attempts]
83
- );
84
- }
85
- async function release(options) {
86
- await db.update(`UPDATE ${databaseTable} SET status = 'failed', result = ? WHERE id = ? AND attempt = ? LIMIT 1`, [
87
- options.error.stack,
88
- options.id,
89
- options.attempts
90
- ]);
91
- await db.insert(
92
- `INSERT INTO ${databaseTable} (id, attempt, queue, body, scheduledDate)
93
- SELECT id, ?, queue, body, DATE_ADD(NOW(), INTERVAL ? SECOND)
94
- FROM ${databaseTable}
95
- WHERE id = ? AND attempt = ? LIMIT 1`,
96
- [options.attempts + 1, options.delaySeconds, options.id, options.attempts]
97
- );
98
- }
99
- return { size, push, bulk, pop, fail, success, release };
100
- }
101
- function dbRowToMessage(row) {
102
- return {
103
- id: String(row["id"]),
104
- queue: row["queue"],
105
- body: typeof row["body"] === "string" ? JSON.parse(row["body"]) : row["body"],
106
- attempts: row["attempt"]
107
- };
108
- }
109
- function makePop({
110
- batchPop,
111
- maxWaitSeconds = 0
112
- }) {
113
- let waiting = [];
114
- function processBatch() {
115
- const batch = waiting;
116
- waiting = [];
117
- batchPop(batch.length).then((messages) => {
118
- batch.forEach((b, index) => {
119
- if (index < messages.length) {
120
- b[0](messages[index]);
121
- } else {
122
- b[0](null);
123
- }
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);
124
93
  });
125
- }).catch((e) => batch.forEach((b) => b[1](e)));
126
- }
127
- function pop() {
128
- if (maxWaitSeconds <= 0) {
129
- return batchPop(1).then((m) => m.length ? m[0] : null);
130
- } else {
131
- const promise = new Promise((resolve, reject) => waiting.push([resolve, reject]));
132
- if (waiting.length === 1) {
133
- setTimeout(processBatch, maxWaitSeconds * 1e3);
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;
134
104
  }
135
- return promise;
105
+ return workingJobBatch.shift();
136
106
  }
137
- }
138
- return pop;
139
- }
140
-
141
- // src/local-queue-driver.ts
142
- import { instantFns as instantFns2 } from "iso-fns2";
143
- function localQueueDriver({ queue: queueFilter } = {}) {
144
- const filterSet = queueFilter ? new Set(Array.isArray(queueFilter) ? queueFilter : [queueFilter]) : null;
145
- const messages = [];
146
- let nextId = 1;
147
- function matchesFilter(queue) {
148
- return !filterSet || filterSet.has(queue);
149
- }
150
- function eligible(msg) {
151
- return msg.status === "waiting" && msg.scheduledDate <= instantFns2.now() && matchesFilter(msg.queue);
152
- }
153
- function find(id, attempts) {
154
- return messages.find((m) => m.id === Number(id) && m.attempt === attempts);
155
- }
156
- async function size() {
157
- return messages.filter(eligible).length;
158
- }
159
- async function push(message) {
160
- const id = nextId++;
161
- const now = instantFns2.now();
162
- messages.push({
163
- id,
164
- attempt: 0,
165
- queue: message.queue,
166
- status: "waiting",
167
- scheduledDate: message.scheduledDate > now ? message.scheduledDate : now,
168
- body: structuredClone(message.body)
169
- });
170
- return String(id);
171
- }
172
- async function bulk(input) {
173
- if (input.length === 0) return;
174
- const now = instantFns2.now();
175
- for (const message of input) {
176
- const id = nextId++;
177
- messages.push({
178
- id,
179
- attempt: 0,
180
- queue: message.queue,
181
- status: "waiting",
182
- scheduledDate: message.scheduledDate > now ? message.scheduledDate : now,
183
- body: structuredClone(message.body)
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
+ );
184
154
  });
185
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
+ );
186
233
  }
187
- async function pop() {
188
- const ready = messages.filter(eligible).sort((a, b) => a.scheduledDate < b.scheduledDate ? -1 : 1);
189
- const msg = ready[0];
190
- if (!msg) return null;
191
- msg.status = "processing";
192
- return { id: String(msg.id), queue: msg.queue, body: structuredClone(msg.body), attempts: msg.attempt };
234
+ function stop(options) {
235
+ setIsProcessing(options.queueName, false);
236
+ const jobRecoveryInterval = getJobRecoveryInterval(options.queueName);
237
+ if (jobRecoveryInterval) clearInterval(jobRecoveryInterval);
193
238
  }
194
- async function fail({ id, attempts, error }) {
195
- const msg = find(id, attempts);
196
- if (msg) {
197
- msg.status = "failedPermanently";
198
- msg.result = error.stack;
199
- }
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));
200
251
  }
201
- async function success({ id, attempts, result }) {
202
- const msg = find(id, attempts);
203
- if (msg) {
204
- msg.status = "succeeded";
205
- msg.result = JSON.stringify(result);
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 };
206
283
  }
207
284
  }
208
- async function release({
209
- id,
210
- attempts,
211
- error,
212
- delaySeconds
285
+ async function updateMessage({
286
+ queueName,
287
+ messageId,
288
+ scheduledDate,
289
+ body
213
290
  }) {
214
- const msg = find(id, attempts);
215
- if (msg) {
216
- msg.status = "failed";
217
- msg.result = error.stack;
218
- messages.push({
219
- id: msg.id,
220
- attempt: attempts + 1,
221
- queue: msg.queue,
222
- status: "waiting",
223
- scheduledDate: instantFns2.add(instantFns2.now(), { seconds: delaySeconds }),
224
- body: structuredClone(msg.body)
225
- });
226
- }
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;
227
308
  }
228
- return { size, push, bulk, pop, fail, success, release };
309
+ return { start, stop, listMessages, addMessage, updateMessage, isQueueEmpty };
229
310
  }
230
-
231
- // src/index.ts
232
- function combineLoggers(...loggers) {
233
- const active = loggers.filter((l) => !!l);
311
+ function dbToJob(db) {
234
312
  return {
235
- info(msg) {
236
- for (const l of active) l.info(msg);
237
- },
238
- warn(msg) {
239
- for (const l of active) l.warn(msg);
240
- },
241
- error(msg) {
242
- for (const l of active) l.error(msg);
243
- }
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
244
327
  };
245
328
  }
246
- function createService({ driver, logger: serviceLogger }) {
247
- const allQueues = /* @__PURE__ */ new Map();
248
- function getDefaultDelaySeconds({
249
- queueConfig,
250
- queueMessage
251
- }) {
252
- if (typeof queueConfig.backoffSeconds === "number") {
253
- return queueConfig.backoffSeconds;
254
- } else if (!Array.isArray(queueConfig.backoffSeconds) || !queueConfig.backoffSeconds.length) {
255
- return 60;
256
- } else {
257
- return queueConfig.backoffSeconds.length > queueMessage.attempts ? queueConfig.backoffSeconds[queueMessage.attempts] : queueConfig.backoffSeconds.at(-1);
258
- }
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);
259
342
  }
260
- async function execute(options) {
261
- const actions = {
262
- fail(error) {
263
- throw new FailQueueMessageError(error);
264
- },
265
- release(error, delaySeconds) {
266
- throw new ReleaseQueueMessageError(error, delaySeconds);
267
- }
268
- };
269
- const middleware = options.queueConfig.middleware ?? [];
270
- const executeFn = middleware.reduceRight(
271
- (acc, cur) => {
272
- return (body) => cur(body, { ...actions, next: () => acc(body) });
273
- },
274
- (body) => options.queueConfig.handle(body, actions)
275
- );
276
- return executeFn(options.queueMessage.body);
343
+ function get(name) {
344
+ return queues.find((q) => q.name === name);
277
345
  }
278
- async function processMessage(queueMessage) {
279
- const queueConfig = allQueues.get(queueMessage.queue);
280
- if (!queueConfig) {
281
- throw new Error(`Queue not found with name "${queueMessage.queue}"`);
282
- }
283
- const logger = combineLoggers(serviceLogger, queueConfig.logger);
284
- logger.info({ event: "messageDequeued", queue: queueMessage.queue, body: queueMessage.body, messageId: queueMessage.id });
285
- try {
286
- const result = await execute({ queueConfig, queueMessage });
287
- await driver.success({ id: queueMessage.id, attempts: queueMessage.attempts, result });
288
- logger.info({
289
- event: "messageProcessed",
290
- queue: queueMessage.queue,
291
- body: queueMessage.body,
292
- messageId: queueMessage.id,
293
- result
294
- });
295
- return result;
296
- } catch (e) {
297
- if (e instanceof FailQueueMessageError) {
298
- await driver.fail({ id: queueMessage.id, attempts: queueMessage.attempts, error: e.cause });
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) {
299
401
  logger.error({
300
402
  event: "messageFailed",
301
- queue: queueMessage.queue,
302
- body: queueMessage.body,
303
- messageId: queueMessage.id,
304
- error: e.cause
403
+ queue: queueName,
404
+ driver: config.driver,
405
+ body,
406
+ error: e,
407
+ messageId: id
305
408
  });
306
409
  throw e;
307
- } else {
308
- const isLastAttempt = queueMessage.attempts + 1 >= (queueConfig.tries ?? 1);
309
- const error = e instanceof ReleaseQueueMessageError ? e.cause : e instanceof Error ? e : new Error(String(e));
310
- if (isLastAttempt) {
311
- await driver.fail({ id: queueMessage.id, attempts: queueMessage.attempts, error });
312
- logger.error({
313
- event: "messageFailed",
314
- queue: queueMessage.queue,
315
- body: queueMessage.body,
316
- messageId: queueMessage.id,
317
- error
318
- });
319
- } else {
320
- await driver.release({
321
- id: queueMessage.id,
322
- attempts: queueMessage.attempts,
323
- error,
324
- delaySeconds: e instanceof ReleaseQueueMessageError ? e.delaySeconds ?? getDefaultDelaySeconds({ queueConfig, queueMessage }) : getDefaultDelaySeconds({ queueConfig, queueMessage })
325
- });
326
- logger.warn({
327
- event: "messageReleased",
328
- queue: queueMessage.queue,
329
- body: queueMessage.body,
330
- messageId: queueMessage.id,
331
- error,
332
- attempt: queueMessage.attempts + 1
333
- });
334
- }
335
- throw e;
336
410
  }
337
411
  }
338
- }
339
- let workPromises = [];
340
- function work({ maxJobs, maxTimeSeconds, stopWhenEmpty, sleepSeconds, concurrency = 1 }) {
341
- const workAbortController = new AbortController();
342
- let messagesProcessed = 0;
343
- let messagesPopping = 0;
344
- let timeoutToClear = null;
345
- if (maxTimeSeconds) {
346
- timeoutToClear = setTimeout(() => workAbortController.abort("max time exceeded"), maxTimeSeconds * 1e3);
412
+ async function listMessages({ pageSize, pageNumber }) {
413
+ return config.driver.listMessages({ pageSize, pageNumber, queueName });
347
414
  }
348
- async function pollForWork() {
349
- let didThrowDuringPop = false;
350
- while (!workAbortController.signal.aborted) {
351
- try {
352
- if (didThrowDuringPop) {
353
- await new Promise((r) => setTimeout(r, 1e3));
354
- didThrowDuringPop = false;
355
- }
356
- if (typeof maxJobs === "number" && messagesProcessed >= maxJobs) {
357
- workAbortController.abort("max jobs reached");
358
- } else {
359
- if (typeof maxJobs === "number" && messagesProcessed + messagesPopping >= maxJobs) {
360
- await new Promise((r) => setTimeout(r, 1e3 * (sleepSeconds || 1)));
361
- } else {
362
- messagesPopping++;
363
- const message = await driver.pop().catch((e) => {
364
- didThrowDuringPop = true;
365
- messagesPopping--;
366
- throw e;
367
- });
368
- messagesPopping--;
369
- if (!message && stopWhenEmpty) {
370
- workAbortController.abort("queue empty");
371
- } else if (!message) {
372
- await new Promise((r) => setTimeout(r, 1e3 * (sleepSeconds || 1)));
373
- } else {
374
- messagesProcessed++;
375
- await processMessage(message);
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
376
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 };
377
438
  }
378
- }
379
- await new Promise((r) => setTimeout(r, 0));
380
- } catch (e) {
439
+ );
381
440
  }
382
- }
383
- }
384
- const promise = Promise.all(Array.from({ length: concurrency }).map(() => pollForWork())).then(() => {
385
- });
386
- const result = Object.assign(promise, {
387
- abort: (reason) => {
388
- workAbortController.abort(reason);
389
- if (timeoutToClear) clearTimeout(timeoutToClear);
390
- }
391
- });
392
- workPromises.push(result);
393
- result.finally(() => {
394
- workPromises = workPromises.filter((w) => w !== result);
395
- if (timeoutToClear) {
396
- clearTimeout(timeoutToClear);
397
- }
398
- });
399
- return result;
400
- }
401
- async function stopAllWork() {
402
- const current = [...workPromises];
403
- current.forEach((w) => w.abort());
404
- await Promise.all(current);
405
- }
406
- function create(queueConfig) {
407
- if (allQueues.has(queueConfig.name)) {
408
- throw new Error(`Cannot have multiple queues with name "${queueConfig.name}"`);
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
+ });
409
450
  }
410
- allQueues.set(queueConfig.name, queueConfig);
411
- const logger = combineLoggers(serviceLogger, queueConfig.logger);
412
- async function dispatch(body) {
413
- await driver.push({ queue: queueConfig.name, body: body ?? {}, scheduledDate: instantFns3.now() });
414
- logger.info({ event: "messageEnqueued", queue: queueConfig.name, body });
451
+ async function updateMessage({
452
+ messageId,
453
+ scheduledDate,
454
+ body
455
+ }) {
456
+ return config.driver.updateMessage({ messageId, body, scheduledDate, queueName });
415
457
  }
416
- async function dispatchWithDelay(...params) {
417
- const body = typeof params[0] === "string" ? {} : params[0];
418
- const scheduledDate = typeof params[0] === "string" ? params[0] : params[1];
419
- await driver.push({ queue: queueConfig.name, body: params, scheduledDate });
420
- logger.info({ event: "messageEnqueued", queue: queueConfig.name, body });
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;
421
475
  }
422
- async function dispatchSync(body) {
423
- logger.info({ event: "messageEnqueued", queue: queueConfig.name, body });
424
- const queueMessage = { id: randomUUID(), body, queue: queueConfig.name, attempts: 0 };
425
- const result = await execute({ queueConfig, queueMessage });
426
- logger.info({ event: "messageProcessed", queue: queueConfig.name, body, messageId: queueMessage.id, result });
427
- return result;
476
+ function startProcessing2() {
477
+ config.driver.start({ queueName, workFn, concurrency });
478
+ logger.info({
479
+ event: "queueStarted",
480
+ queue: queueName,
481
+ driver: config.driver
482
+ });
428
483
  }
429
- async function bulk(messages) {
430
- await driver.bulk(
431
- messages.map(({ body, scheduledDate }) => ({
432
- queue: queueConfig.name,
433
- body,
434
- scheduledDate: scheduledDate ?? instantFns3.now()
435
- }))
436
- );
437
- for (const { body } of messages) {
438
- logger.info({ event: "messageEnqueued", queue: queueConfig.name, body });
439
- }
484
+ function stopProcessing2() {
485
+ config.driver.stop({ queueName });
486
+ logger.info({
487
+ event: "queueStopped",
488
+ queue: queueName,
489
+ driver: config.driver
490
+ });
440
491
  }
441
- return { dispatch, dispatchWithDelay, dispatchSync, bulk };
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;
442
503
  }
443
- return { work, stopAllWork, create, processMessage };
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 };
444
511
  }
445
512
  var registry = ServiceRegistry.createRegistry(
446
513
  ServiceRegistry.proxyFunctionsForObject
447
514
  );
448
515
  var Provider = {
449
516
  create: createService,
450
- register: registry.register
517
+ register: registry.register,
518
+ list: registry.list
451
519
  };
452
520
  function provider(key) {
453
521
  const service = registry.resolve(key);
454
522
  return {
455
- work: service.work,
456
- stopAllWork: service.stopAllWork,
457
- processMessage: service.processMessage,
458
- create: service.create
523
+ list: service.list,
524
+ get: service.get,
525
+ create: service.create,
526
+ startProcessing: service.startProcessing,
527
+ stopProcessing: service.stopProcessing
459
528
  };
460
529
  }
461
530
  var facade = provider("default");
462
- var FailQueueMessageError = class extends Error {
463
- cause;
464
- constructor(cause) {
465
- super(void 0, { cause });
466
- this.cause = cause;
467
- }
468
- };
469
- var ReleaseQueueMessageError = class extends Error {
470
- cause;
471
- delaySeconds = void 0;
472
- constructor(cause, delaySeconds) {
473
- super(void 0, { cause });
474
- this.delaySeconds = delaySeconds;
475
- this.cause = cause;
476
- }
477
- };
478
531
  var Queue = {
532
+ drivers: {
533
+ db: DbQueueDriver
534
+ },
479
535
  Provider,
480
536
  provider,
481
- work: facade.work,
482
- stopAllWork: facade.stopAllWork,
483
- processMessage: facade.processMessage,
537
+ list: facade.list,
538
+ get: facade.get,
484
539
  create: facade.create,
485
- drivers: {
486
- db: DbQueueDriver,
487
- local: localQueueDriver
488
- }
540
+ startProcessing: facade.startProcessing,
541
+ stopProcessing: facade.stopProcessing
489
542
  };
490
543
  export {
491
544
  Queue