@maestro-js/queue 1.0.0-alpha.1 → 1.0.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +337 -179
- package/dist/index.d.ts +179 -215
- package/dist/index.js +422 -475
- package/package.json +10 -6
package/dist/index.js
CHANGED
|
@@ -1,544 +1,491 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
slowestPollingRateMs = 1e4,
|
|
16
|
-
pollingBackoffRate = 1.1
|
|
12
|
+
queue: queueFilter,
|
|
13
|
+
batchingMaxWaitSeconds
|
|
17
14
|
}) {
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
33
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
+
|
|
231
|
+
// src/index.ts
|
|
232
|
+
function combineLoggers(...loggers) {
|
|
233
|
+
const active = loggers.filter((l) => !!l);
|
|
312
234
|
return {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
344
|
-
|
|
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
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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:
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
413
|
-
|
|
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
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
437
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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 {
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
538
|
-
|
|
481
|
+
work: facade.work,
|
|
482
|
+
stopAllWork: facade.stopAllWork,
|
|
483
|
+
processMessage: facade.processMessage,
|
|
539
484
|
create: facade.create,
|
|
540
|
-
|
|
541
|
-
|
|
485
|
+
drivers: {
|
|
486
|
+
db: DbQueueDriver,
|
|
487
|
+
local: localQueueDriver
|
|
488
|
+
}
|
|
542
489
|
};
|
|
543
490
|
export {
|
|
544
491
|
Queue
|