@maestro-js/queue 1.0.0-alpha.1 → 1.0.0-alpha.11

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 +337 -179
  2. package/dist/index.d.ts +179 -215
  3. package/dist/index.js +422 -475
  4. package/package.json +10 -6
package/dist/index.js CHANGED
@@ -1,544 +1,491 @@
1
1
  // src/index.ts
2
- import { instantFns as instantFns2 } from "iso-fns2";
3
- import { Log } from "@maestro-js/log";
2
+ import { randomUUID } from "crypto";
3
+ import { instantFns as instantFns3 } from "iso-fns2";
4
4
  import { ServiceRegistry } from "@maestro-js/service-registry";
5
5
 
6
6
  // src/db-queue-driver.ts
7
- import crypto from "crypto";
7
+ import { randomInt } from "crypto";
8
8
  import { instantFns } from "iso-fns2";
9
- import assert from "assert";
10
9
  function DbQueueDriver({
11
10
  db,
12
11
  databaseTable,
13
- extraFields,
14
- fastestPollingRateMs = 100,
15
- slowestPollingRateMs = 1e4,
16
- pollingBackoffRate = 1.1
12
+ queue: queueFilter,
13
+ batchingMaxWaitSeconds
17
14
  }) {
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);
15
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(databaseTable)) {
16
+ throw new Error(`Invalid databaseTable name: ${databaseTable}`);
27
17
  }
28
- const jobRecoveryIntervalMap = /* @__PURE__ */ new Map();
29
- function getJobRecoveryInterval(queueName) {
30
- return jobRecoveryIntervalMap.get(queueName) ?? null;
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);
31
26
  }
32
- function setJobRecoveryInterval(queueName, jobRecoveryInterval) {
33
- jobRecoveryIntervalMap.set(queueName, jobRecoveryInterval);
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);
34
33
  }
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`);
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));
52
40
  }
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;
41
+ await db.insert(`INSERT INTO ${databaseTable} (queue, body, scheduledDate) VALUES ${placeholders}`, params);
42
+ }
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 [];
95
50
  }
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();
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 [];
106
68
  }
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;
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);
115
123
  }
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
124
  });
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;
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);
174
134
  }
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
- );
135
+ return promise;
214
136
  }
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
137
  }
234
- function stop(options) {
235
- setIsProcessing(options.queueName, false);
236
- const jobRecoveryInterval = getJobRecoveryInterval(options.queueName);
237
- if (jobRecoveryInterval) clearInterval(jobRecoveryInterval);
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);
238
152
  }
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));
153
+ function find(id, attempts) {
154
+ return messages.find((m) => m.id === Number(id) && m.attempt === attempts);
251
155
  }
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]);
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)
263
169
  });
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 };
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)
184
+ });
283
185
  }
284
186
  }
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
- );
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 };
296
193
  }
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;
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
+ }
308
200
  }
309
- return { start, stop, listMessages, addMessage, updateMessage, isQueueEmpty };
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);
206
+ }
207
+ }
208
+ async function release({
209
+ id,
210
+ attempts,
211
+ error,
212
+ delaySeconds
213
+ }) {
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
+ }
227
+ }
228
+ return { size, push, bulk, pop, fail, success, release };
310
229
  }
311
- function dbToJob(db) {
230
+
231
+ // src/index.ts
232
+ function combineLoggers(...loggers) {
233
+ const active = loggers.filter((l) => !!l);
312
234
  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
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
+ }
327
244
  };
328
245
  }
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);
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
+ }
342
259
  }
343
- function get(name) {
344
- return queues.find((q) => q.name === name);
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);
345
277
  }
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) {
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 });
401
299
  logger.error({
402
300
  event: "messageFailed",
403
- queue: queueName,
404
- driver: config.driver,
405
- body,
406
- error: e,
407
- messageId: id
301
+ queue: queueMessage.queue,
302
+ body: queueMessage.body,
303
+ messageId: queueMessage.id,
304
+ error: e.cause
408
305
  });
409
306
  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;
410
336
  }
411
337
  }
412
- async function listMessages({ pageSize, pageNumber }) {
413
- return config.driver.listMessages({ pageSize, pageNumber, queueName });
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);
414
347
  }
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()
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;
435
367
  });
436
- span?.setAttribute("messaging.message.id", id2);
437
- return { id: id2 };
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);
376
+ }
438
377
  }
439
- );
378
+ }
379
+ await new Promise((r) => setTimeout(r, 0));
380
+ } catch (e) {
440
381
  }
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
- });
382
+ }
450
383
  }
451
- async function updateMessage({
452
- messageId,
453
- scheduledDate,
454
- body
455
- }) {
456
- return config.driver.updateMessage({ messageId, body, scheduledDate, queueName });
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}"`);
457
409
  }
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;
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 });
475
415
  }
476
- function startProcessing2() {
477
- config.driver.start({ queueName, workFn, concurrency });
478
- logger.info({
479
- event: "queueStarted",
480
- queue: queueName,
481
- driver: config.driver
482
- });
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 });
483
421
  }
484
- function stopProcessing2() {
485
- config.driver.stop({ queueName });
486
- logger.info({
487
- event: "queueStopped",
488
- queue: queueName,
489
- driver: config.driver
490
- });
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;
491
428
  }
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());
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
+ }
440
+ }
441
+ return { dispatch, dispatchWithDelay, dispatchSync, bulk };
509
442
  }
510
- return { list, get, create, startProcessing, stopProcessing };
443
+ return { work, stopAllWork, create, processMessage };
511
444
  }
512
445
  var registry = ServiceRegistry.createRegistry(
513
446
  ServiceRegistry.proxyFunctionsForObject
514
447
  );
515
448
  var Provider = {
516
449
  create: createService,
517
- register: registry.register,
518
- list: registry.list
450
+ register: registry.register
519
451
  };
520
452
  function provider(key) {
521
453
  const service = registry.resolve(key);
522
454
  return {
523
- list: service.list,
524
- get: service.get,
525
- create: service.create,
526
- startProcessing: service.startProcessing,
527
- stopProcessing: service.stopProcessing
455
+ work: service.work,
456
+ stopAllWork: service.stopAllWork,
457
+ processMessage: service.processMessage,
458
+ create: service.create
528
459
  };
529
460
  }
530
461
  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
+ };
531
478
  var Queue = {
532
- drivers: {
533
- db: DbQueueDriver
534
- },
535
479
  Provider,
536
480
  provider,
537
- list: facade.list,
538
- get: facade.get,
481
+ work: facade.work,
482
+ stopAllWork: facade.stopAllWork,
483
+ processMessage: facade.processMessage,
539
484
  create: facade.create,
540
- startProcessing: facade.startProcessing,
541
- stopProcessing: facade.stopProcessing
485
+ drivers: {
486
+ db: DbQueueDriver,
487
+ local: localQueueDriver
488
+ }
542
489
  };
543
490
  export {
544
491
  Queue