@blamejs/core 0.4.20 → 0.4.22
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/CHANGELOG.md +2 -0
- package/lib/db.js +12 -2
- package/lib/framework-schema.js +7 -1
- package/lib/mail.js +156 -19
- package/lib/queue-local.js +111 -2
- package/lib/queue.js +133 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- **0.4.21** (2026-04-30) — b.queue: repeat-in-queue (cron) + parent-child Flows
|
|
12
|
+
- **0.4.20** (2026-04-30) — b.queue + b.jobs: priority, rate-limit, progress
|
|
11
13
|
- **0.4.19** (2026-04-30) — b.router: schema-validated routes + OpenAPI gen
|
|
12
14
|
- **0.4.18** (2026-04-30) — cookieJar forensic-test strengthening (real crypto, replay, nonce)
|
|
13
15
|
- **0.4.17** (2026-04-30) — b.httpClient.cookieJar (encrypted) + wiki catch-up sweep
|
package/lib/db.js
CHANGED
|
@@ -351,12 +351,22 @@ var FRAMEWORK_SCHEMA = [
|
|
|
351
351
|
traceId: "TEXT",
|
|
352
352
|
classification: "TEXT",
|
|
353
353
|
priority: "INTEGER NOT NULL DEFAULT 0",
|
|
354
|
+
// Repeat-in-queue: cron-shaped recurring jobs re-enqueue themselves
|
|
355
|
+
// after each successful completion. NULL = one-shot (no repeat).
|
|
356
|
+
repeatCron: "TEXT",
|
|
357
|
+
repeatTimezone: "TEXT",
|
|
358
|
+
// Flows: parent-child job graphs with dependency edges.
|
|
359
|
+
// flowId groups jobs in the same flow; dependsOn is a JSON array
|
|
360
|
+
// of jobIds this row waits for; flowChildName is the human-readable
|
|
361
|
+
// label inside the flow used by dependsOn resolution.
|
|
362
|
+
flowId: "TEXT",
|
|
363
|
+
flowChildName: "TEXT",
|
|
364
|
+
dependsOn: "TEXT",
|
|
354
365
|
},
|
|
355
366
|
indexes: [
|
|
356
367
|
{ name: "idx_jobs_lease", columns: ["queueName", "status", "availableAt"] },
|
|
357
|
-
// Priority lease index — pickers walk this when ORDER BY priority DESC,
|
|
358
|
-
// availableAt ASC, enqueuedAt ASC; matches the queue.lease ordering.
|
|
359
368
|
{ name: "idx_jobs_priority", columns: ["queueName", "status", "priority", "availableAt"] },
|
|
369
|
+
{ name: "idx_jobs_flow", columns: ["flowId"] },
|
|
360
370
|
"leaseExpiresAt",
|
|
361
371
|
"finishedAt",
|
|
362
372
|
],
|
package/lib/framework-schema.js
CHANGED
|
@@ -477,11 +477,17 @@ function _jobsDDL(dialect) {
|
|
|
477
477
|
" finishedAt " + t.INT + "," +
|
|
478
478
|
" traceId TEXT," +
|
|
479
479
|
" classification TEXT," +
|
|
480
|
-
" priority " + t.INT + " NOT NULL DEFAULT 0" +
|
|
480
|
+
" priority " + t.INT + " NOT NULL DEFAULT 0," +
|
|
481
|
+
" repeatCron TEXT," +
|
|
482
|
+
" repeatTimezone TEXT," +
|
|
483
|
+
" flowId TEXT," +
|
|
484
|
+
" flowChildName TEXT," +
|
|
485
|
+
" dependsOn TEXT" +
|
|
481
486
|
")",
|
|
482
487
|
indexes: [
|
|
483
488
|
"CREATE INDEX IF NOT EXISTS idx_" + name + "_lease ON " + name + " (queueName, status, availableAt)",
|
|
484
489
|
"CREATE INDEX IF NOT EXISTS idx_" + name + "_priority ON " + name + " (queueName, status, priority, availableAt)",
|
|
490
|
+
"CREATE INDEX IF NOT EXISTS idx_" + name + "_flow ON " + name + " (flowId)",
|
|
485
491
|
"CREATE INDEX IF NOT EXISTS idx_" + name + "_leaseExpiresAt ON " + name + " (leaseExpiresAt)",
|
|
486
492
|
"CREATE INDEX IF NOT EXISTS idx_" + name + "_finishedAt ON " + name + " (finishedAt)",
|
|
487
493
|
],
|
package/lib/mail.js
CHANGED
|
@@ -38,9 +38,24 @@
|
|
|
38
38
|
* text: "plain body" (at least one of text/html)
|
|
39
39
|
* html: "<p>...</p>"
|
|
40
40
|
* headers: { "X-Custom": "v" } (merged with defaults)
|
|
41
|
+
* attachments: [{
|
|
42
|
+
* filename: "report.pdf", // required
|
|
43
|
+
* content: buf, // Buffer or string
|
|
44
|
+
* contentType: "application/pdf", // default application/octet-stream
|
|
45
|
+
* contentDisposition: "attachment", // or "inline"
|
|
46
|
+
* cid: "logo-1", // for inline images:
|
|
47
|
+
* // <img src="cid:logo-1">
|
|
48
|
+
* }, ...]
|
|
41
49
|
* }
|
|
42
50
|
* → whatever the transport returned
|
|
43
51
|
*
|
|
52
|
+
* When attachments are present the SMTP transport wraps the body in
|
|
53
|
+
* multipart/mixed; text+html bodies still use multipart/alternative
|
|
54
|
+
* inside. Resend's http preset forwards attachments via the Resend API
|
|
55
|
+
* shape (base64 content + content_id for inline). Operators wiring
|
|
56
|
+
* other vendors against httpTransport include attachments in their
|
|
57
|
+
* own serialize() per-vendor.
|
|
58
|
+
*
|
|
44
59
|
* Validation surface uses MailError (FrameworkError subclass) with
|
|
45
60
|
* permanent flag. Distinct codes per failure: missing-to, missing-from,
|
|
46
61
|
* missing-body, invalid-recipient, transport-failed, smtp-*, http-*,
|
|
@@ -128,6 +143,52 @@ function _validateMessage(message) {
|
|
|
128
143
|
throw new MailError("mail/missing-body",
|
|
129
144
|
"message must include at least one of text or html", true);
|
|
130
145
|
}
|
|
146
|
+
|
|
147
|
+
if (message.attachments !== undefined) {
|
|
148
|
+
if (!Array.isArray(message.attachments)) {
|
|
149
|
+
throw new MailError("mail/invalid-attachments",
|
|
150
|
+
"message.attachments must be an array", true);
|
|
151
|
+
}
|
|
152
|
+
for (var i = 0; i < message.attachments.length; i++) {
|
|
153
|
+
var att = message.attachments[i];
|
|
154
|
+
if (!att || typeof att !== "object") {
|
|
155
|
+
throw new MailError("mail/invalid-attachment",
|
|
156
|
+
"attachments[" + i + "] must be an object", true);
|
|
157
|
+
}
|
|
158
|
+
if (typeof att.filename !== "string" || att.filename.length === 0) {
|
|
159
|
+
throw new MailError("mail/invalid-attachment",
|
|
160
|
+
"attachments[" + i + "].filename must be a non-empty string", true);
|
|
161
|
+
}
|
|
162
|
+
if (/[\r\n\0]/.test(att.filename)) {
|
|
163
|
+
throw new MailError("mail/invalid-attachment",
|
|
164
|
+
"attachments[" + i + "].filename contains forbidden control characters", true);
|
|
165
|
+
}
|
|
166
|
+
if (att.content === undefined || att.content === null) {
|
|
167
|
+
throw new MailError("mail/invalid-attachment",
|
|
168
|
+
"attachments[" + i + "].content is required (Buffer or string)", true);
|
|
169
|
+
}
|
|
170
|
+
if (!Buffer.isBuffer(att.content) && typeof att.content !== "string") {
|
|
171
|
+
throw new MailError("mail/invalid-attachment",
|
|
172
|
+
"attachments[" + i + "].content must be a Buffer or string", true);
|
|
173
|
+
}
|
|
174
|
+
if (att.contentType !== undefined &&
|
|
175
|
+
(typeof att.contentType !== "string" || /[\r\n\0]/.test(att.contentType))) {
|
|
176
|
+
throw new MailError("mail/invalid-attachment",
|
|
177
|
+
"attachments[" + i + "].contentType must be a clean string", true);
|
|
178
|
+
}
|
|
179
|
+
if (att.contentDisposition !== undefined &&
|
|
180
|
+
att.contentDisposition !== "attachment" &&
|
|
181
|
+
att.contentDisposition !== "inline") {
|
|
182
|
+
throw new MailError("mail/invalid-attachment",
|
|
183
|
+
"attachments[" + i + "].contentDisposition must be 'attachment' or 'inline'", true);
|
|
184
|
+
}
|
|
185
|
+
if (att.cid !== undefined &&
|
|
186
|
+
(typeof att.cid !== "string" || /[\r\n\0<>]/.test(att.cid))) {
|
|
187
|
+
throw new MailError("mail/invalid-attachment",
|
|
188
|
+
"attachments[" + i + "].cid must be a clean string (no <>)", true);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
131
192
|
}
|
|
132
193
|
|
|
133
194
|
function _mergeMessage(defaults, message) {
|
|
@@ -201,6 +262,59 @@ function memoryTransport() {
|
|
|
201
262
|
// cleartext port the transport always issues STARTTLS and refuses
|
|
202
263
|
// to send AUTH or DATA in cleartext if the upgrade is rejected.
|
|
203
264
|
|
|
265
|
+
function _newBoundary(label) {
|
|
266
|
+
return "blamejs-" + label + "-" + Date.now() + "-" + Math.floor(Math.random() * 1e9);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// base64-encode the buffer with line wrapping at 76 chars (RFC 2045
|
|
270
|
+
// §6.8). Most clients tolerate longer lines but the spec maximum is
|
|
271
|
+
// 998 octets per line; sticking to 76 keeps everyone happy.
|
|
272
|
+
function _base64Wrap(buf) {
|
|
273
|
+
var b64 = buf.toString("base64");
|
|
274
|
+
var lines = [];
|
|
275
|
+
for (var i = 0; i < b64.length; i += 76) lines.push(b64.slice(i, i + 76));
|
|
276
|
+
return lines.join("\r\n");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function _buildAttachmentPart(att) {
|
|
280
|
+
var content = Buffer.isBuffer(att.content) ? att.content : Buffer.from(String(att.content), "utf8");
|
|
281
|
+
var contentType = att.contentType || "application/octet-stream";
|
|
282
|
+
var disposition = att.contentDisposition || (att.cid ? "inline" : "attachment");
|
|
283
|
+
var lines = [];
|
|
284
|
+
lines.push("Content-Type: " + contentType + '; name="' + att.filename + '"');
|
|
285
|
+
lines.push("Content-Transfer-Encoding: base64");
|
|
286
|
+
lines.push("Content-Disposition: " + disposition + '; filename="' + att.filename + '"');
|
|
287
|
+
if (att.cid) lines.push("Content-ID: <" + att.cid + ">");
|
|
288
|
+
lines.push("");
|
|
289
|
+
lines.push(_base64Wrap(content));
|
|
290
|
+
return lines.join("\r\n");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function _buildBodyPart(message) {
|
|
294
|
+
// Text + html → multipart/alternative; otherwise single-part.
|
|
295
|
+
if (message.text && message.html) {
|
|
296
|
+
var altBoundary = _newBoundary("alt");
|
|
297
|
+
return {
|
|
298
|
+
contentType: 'multipart/alternative; boundary="' + altBoundary + '"',
|
|
299
|
+
body: [
|
|
300
|
+
"--" + altBoundary,
|
|
301
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
302
|
+
"",
|
|
303
|
+
message.text,
|
|
304
|
+
"--" + altBoundary,
|
|
305
|
+
"Content-Type: text/html; charset=utf-8",
|
|
306
|
+
"",
|
|
307
|
+
message.html,
|
|
308
|
+
"--" + altBoundary + "--",
|
|
309
|
+
].join("\r\n"),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (message.html) {
|
|
313
|
+
return { contentType: "text/html; charset=utf-8", body: message.html };
|
|
314
|
+
}
|
|
315
|
+
return { contentType: "text/plain; charset=utf-8", body: message.text || "" };
|
|
316
|
+
}
|
|
317
|
+
|
|
204
318
|
function _buildRfc822(message) {
|
|
205
319
|
var headers = [];
|
|
206
320
|
headers.push("From: " + message.from);
|
|
@@ -221,27 +335,32 @@ function _buildRfc822(message) {
|
|
|
221
335
|
}
|
|
222
336
|
}
|
|
223
337
|
|
|
338
|
+
var attachments = Array.isArray(message.attachments) ? message.attachments : [];
|
|
339
|
+
var inner = _buildBodyPart(message);
|
|
224
340
|
var body;
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
headers.push(
|
|
228
|
-
body =
|
|
229
|
-
"--" + boundary,
|
|
230
|
-
"Content-Type: text/plain; charset=utf-8",
|
|
231
|
-
"",
|
|
232
|
-
message.text,
|
|
233
|
-
"--" + boundary,
|
|
234
|
-
"Content-Type: text/html; charset=utf-8",
|
|
235
|
-
"",
|
|
236
|
-
message.html,
|
|
237
|
-
"--" + boundary + "--",
|
|
238
|
-
].join("\r\n");
|
|
239
|
-
} else if (message.html) {
|
|
240
|
-
headers.push("Content-Type: text/html; charset=utf-8");
|
|
241
|
-
body = message.html;
|
|
341
|
+
|
|
342
|
+
if (attachments.length === 0) {
|
|
343
|
+
headers.push("Content-Type: " + inner.contentType);
|
|
344
|
+
body = inner.body;
|
|
242
345
|
} else {
|
|
243
|
-
|
|
244
|
-
|
|
346
|
+
// multipart/mixed: first part is the body (single or alternative),
|
|
347
|
+
// subsequent parts are the attachments. Inline disposition + Content-ID
|
|
348
|
+
// is interpreted correctly by every major client even inside mixed;
|
|
349
|
+
// operators with strict-RFC-2387 multipart/related needs subscribe
|
|
350
|
+
// to a future patch when demand surfaces.
|
|
351
|
+
var mixedBoundary = _newBoundary("mixed");
|
|
352
|
+
headers.push('Content-Type: multipart/mixed; boundary="' + mixedBoundary + '"');
|
|
353
|
+
var parts = [];
|
|
354
|
+
parts.push("--" + mixedBoundary);
|
|
355
|
+
parts.push("Content-Type: " + inner.contentType);
|
|
356
|
+
parts.push("");
|
|
357
|
+
parts.push(inner.body);
|
|
358
|
+
for (var ai = 0; ai < attachments.length; ai++) {
|
|
359
|
+
parts.push("--" + mixedBoundary);
|
|
360
|
+
parts.push(_buildAttachmentPart(attachments[ai]));
|
|
361
|
+
}
|
|
362
|
+
parts.push("--" + mixedBoundary + "--");
|
|
363
|
+
body = parts.join("\r\n");
|
|
245
364
|
}
|
|
246
365
|
|
|
247
366
|
// Normalize line endings then dot-stuff per SMTP transparency.
|
|
@@ -587,6 +706,21 @@ function resendTransport(opts) {
|
|
|
587
706
|
if (message.html) payload.html = message.html;
|
|
588
707
|
if (message.text) payload.text = message.text;
|
|
589
708
|
if (message.headers) payload.headers = message.headers;
|
|
709
|
+
// Resend attachments shape: [{ filename, content (base64 string),
|
|
710
|
+
// contentType?, content_id? }]. Inline images via cid go through
|
|
711
|
+
// the content_id field (Resend renders <img src="cid:...">).
|
|
712
|
+
if (Array.isArray(message.attachments) && message.attachments.length > 0) {
|
|
713
|
+
payload.attachments = message.attachments.map(function (att) {
|
|
714
|
+
var buf = Buffer.isBuffer(att.content) ? att.content : Buffer.from(String(att.content), "utf8");
|
|
715
|
+
var entry = {
|
|
716
|
+
filename: att.filename,
|
|
717
|
+
content: buf.toString("base64"),
|
|
718
|
+
};
|
|
719
|
+
if (att.contentType) entry.contentType = att.contentType;
|
|
720
|
+
if (att.cid) entry.content_id = att.cid;
|
|
721
|
+
return entry;
|
|
722
|
+
});
|
|
723
|
+
}
|
|
590
724
|
return { body: JSON.stringify(payload) };
|
|
591
725
|
},
|
|
592
726
|
interpret: function (res) {
|
|
@@ -694,6 +828,9 @@ function create(opts) {
|
|
|
694
828
|
module.exports = {
|
|
695
829
|
create: create,
|
|
696
830
|
MailError: MailError,
|
|
831
|
+
// Test-only export: lets unit tests inspect the wire format without
|
|
832
|
+
// standing up a TLS-capable SMTP fixture. Operators don't call this.
|
|
833
|
+
_buildRfc822ForTest: _buildRfc822,
|
|
697
834
|
transports: {
|
|
698
835
|
console: consoleTransport,
|
|
699
836
|
memory: memoryTransport,
|
package/lib/queue-local.js
CHANGED
|
@@ -51,14 +51,22 @@ var JOB_COLS = [
|
|
|
51
51
|
"enqueuedAt", "availableAt", "leasedAt", "leaseExpiresAt",
|
|
52
52
|
"attempts", "maxAttempts", "lastError", "finishedAt",
|
|
53
53
|
"traceId", "classification", "priority",
|
|
54
|
+
"repeatCron", "repeatTimezone",
|
|
55
|
+
"flowId", "flowChildName", "dependsOn",
|
|
54
56
|
];
|
|
55
57
|
|
|
58
|
+
// Sentinel availableAt for flow children that haven't yet had their
|
|
59
|
+
// dependencies satisfied — far future so the lease query never picks
|
|
60
|
+
// them. Parent-completion sets a real availableAt when all deps complete.
|
|
61
|
+
var FLOW_BLOCKED_AVAILABLE_AT = Number.MAX_SAFE_INTEGER;
|
|
62
|
+
|
|
56
63
|
// Columns returned by lease() / used by RETURNING. Subset of JOB_COLS
|
|
57
64
|
// — only what callers need; fewer bytes over the wire in cluster mode.
|
|
58
65
|
var LEASE_RETURN_COLS = [
|
|
59
66
|
"_id", "queueName", "payload",
|
|
60
67
|
"attempts", "maxAttempts", "traceId", "classification",
|
|
61
68
|
"enqueuedAt", "leaseExpiresAt",
|
|
69
|
+
"repeatCron", "repeatTimezone", "flowId", "flowChildName",
|
|
62
70
|
];
|
|
63
71
|
|
|
64
72
|
function _quotedList(cols) {
|
|
@@ -84,6 +92,10 @@ function _shapeLeasedRow(raw) {
|
|
|
84
92
|
classification: unsealed.classification,
|
|
85
93
|
enqueuedAt: Number(unsealed.enqueuedAt),
|
|
86
94
|
leaseExpiresAt: Number(unsealed.leaseExpiresAt),
|
|
95
|
+
repeatCron: unsealed.repeatCron || null,
|
|
96
|
+
repeatTimezone: unsealed.repeatTimezone || null,
|
|
97
|
+
flowId: unsealed.flowId || null,
|
|
98
|
+
flowChildName: unsealed.flowChildName || null,
|
|
87
99
|
};
|
|
88
100
|
}
|
|
89
101
|
|
|
@@ -99,13 +111,25 @@ function create(_config) {
|
|
|
99
111
|
|
|
100
112
|
var priority = (typeof opts.priority === "number" && isFinite(opts.priority))
|
|
101
113
|
? Math.floor(opts.priority) : 0;
|
|
114
|
+
var repeatCron = opts.repeat && typeof opts.repeat.cron === "string"
|
|
115
|
+
? opts.repeat.cron : null;
|
|
116
|
+
var repeatTimezone = opts.repeat && typeof opts.repeat.timezone === "string"
|
|
117
|
+
? opts.repeat.timezone : null;
|
|
118
|
+
var flowId = typeof opts.flowId === "string" ? opts.flowId : null;
|
|
119
|
+
var flowChildName = typeof opts.flowChildName === "string" ? opts.flowChildName : null;
|
|
120
|
+
var dependsOn = Array.isArray(opts.dependsOn) && opts.dependsOn.length > 0
|
|
121
|
+
? JSON.stringify(opts.dependsOn) : null;
|
|
122
|
+
// Flow children with deps wait at MAX_SAFE_INTEGER until parent
|
|
123
|
+
// completion bumps availableAt — keeps them out of the lease index.
|
|
124
|
+
var effectiveAvailableAt = (dependsOn ? FLOW_BLOCKED_AVAILABLE_AT : availableAt);
|
|
125
|
+
|
|
102
126
|
var row = {
|
|
103
127
|
_id: generateToken(16),
|
|
104
128
|
queueName: queueName,
|
|
105
129
|
payload: payload === undefined ? null : JSON.stringify(payload),
|
|
106
130
|
status: "pending",
|
|
107
131
|
enqueuedAt: nowMs,
|
|
108
|
-
availableAt:
|
|
132
|
+
availableAt: effectiveAvailableAt,
|
|
109
133
|
leasedAt: null,
|
|
110
134
|
leaseExpiresAt: null,
|
|
111
135
|
attempts: 0,
|
|
@@ -115,6 +139,11 @@ function create(_config) {
|
|
|
115
139
|
traceId: opts.traceId || null,
|
|
116
140
|
classification: opts.classification || null,
|
|
117
141
|
priority: priority,
|
|
142
|
+
repeatCron: repeatCron,
|
|
143
|
+
repeatTimezone: repeatTimezone,
|
|
144
|
+
flowId: flowId,
|
|
145
|
+
flowChildName: flowChildName,
|
|
146
|
+
dependsOn: dependsOn,
|
|
118
147
|
};
|
|
119
148
|
var sealed = cryptoField.sealRow("_blamejs_jobs", row);
|
|
120
149
|
var values = JOB_COLS.map(function (c) { return c in sealed ? sealed[c] : null; });
|
|
@@ -187,14 +216,94 @@ function create(_config) {
|
|
|
187
216
|
|
|
188
217
|
async function complete(jobId) {
|
|
189
218
|
cluster.requireLeader();
|
|
219
|
+
var nowMs = Date.now();
|
|
220
|
+
// Read the row first so we can act on repeat / flow metadata after
|
|
221
|
+
// the status flip. Single SELECT + UPDATE pair under the same
|
|
222
|
+
// jobId — race-free under SQLite (single-writer); cluster-storage
|
|
223
|
+
// dispatches both calls to the same backend.
|
|
224
|
+
var rowRes = await clusterStorage.execute(
|
|
225
|
+
"SELECT _id, queueName, payload, repeatCron, repeatTimezone, " +
|
|
226
|
+
" flowId, flowChildName, priority, classification, traceId " +
|
|
227
|
+
"FROM _blamejs_jobs WHERE _id = ?",
|
|
228
|
+
[jobId]
|
|
229
|
+
);
|
|
230
|
+
var row = (rowRes && rowRes.rows && rowRes.rows[0]) || null;
|
|
231
|
+
|
|
190
232
|
await clusterStorage.execute(
|
|
191
233
|
"UPDATE _blamejs_jobs SET status = 'done', finishedAt = ?, leaseExpiresAt = NULL " +
|
|
192
234
|
"WHERE _id = ? AND status = 'inflight'",
|
|
193
|
-
[
|
|
235
|
+
[nowMs, jobId]
|
|
194
236
|
);
|
|
237
|
+
|
|
238
|
+
// Repeat-in-queue: cron-recurring job re-enqueues itself for the
|
|
239
|
+
// next firing time. Failures (which take the fail() path) don't
|
|
240
|
+
// re-enqueue — operators investigate before the cron resumes.
|
|
241
|
+
if (row && row.repeatCron) {
|
|
242
|
+
try {
|
|
243
|
+
var unsealedRow = cryptoField.unsealRow("_blamejs_jobs", row);
|
|
244
|
+
var scheduler = require("./scheduler");
|
|
245
|
+
var cron = scheduler.parseCron(unsealedRow.repeatCron);
|
|
246
|
+
var nextMs = scheduler.nextCronFire(cron, new Date(nowMs), unsealedRow.repeatTimezone || null);
|
|
247
|
+
await enqueue(unsealedRow.queueName,
|
|
248
|
+
unsealedRow.payload ? safeJson.parse(unsealedRow.payload) : null,
|
|
249
|
+
{
|
|
250
|
+
availableAt: nextMs,
|
|
251
|
+
delaySeconds: Math.max(0, Math.floor((nextMs - nowMs) / 1000)),
|
|
252
|
+
repeat: { cron: unsealedRow.repeatCron, timezone: unsealedRow.repeatTimezone },
|
|
253
|
+
priority: Number(unsealedRow.priority) || 0,
|
|
254
|
+
classification: unsealedRow.classification || null,
|
|
255
|
+
traceId: unsealedRow.traceId || null,
|
|
256
|
+
});
|
|
257
|
+
} catch (_e) { /* repeat re-enqueue best-effort — cron resumes next tick if op fixes the issue */ }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Flow propagation: walk siblings whose dependsOn includes this
|
|
261
|
+
// jobId (or this job's flowChildName) and bump availableAt to now
|
|
262
|
+
// if ALL their deps are now complete.
|
|
263
|
+
if (row && row.flowId) {
|
|
264
|
+
await _maybeReleaseFlowChildren(row.flowId, jobId, row.flowChildName, nowMs);
|
|
265
|
+
}
|
|
195
266
|
return true;
|
|
196
267
|
}
|
|
197
268
|
|
|
269
|
+
async function _maybeReleaseFlowChildren(flowId, completedJobId, completedChildName, nowMs) {
|
|
270
|
+
var siblingsRes = await clusterStorage.execute(
|
|
271
|
+
"SELECT _id, dependsOn, flowChildName, status, availableAt FROM _blamejs_jobs " +
|
|
272
|
+
"WHERE flowId = ? AND status = 'pending' AND availableAt > ?",
|
|
273
|
+
[flowId, nowMs]
|
|
274
|
+
);
|
|
275
|
+
var siblings = (siblingsRes && siblingsRes.rows) || [];
|
|
276
|
+
for (var i = 0; i < siblings.length; i++) {
|
|
277
|
+
var sib = siblings[i];
|
|
278
|
+
if (!sib.dependsOn) continue;
|
|
279
|
+
var deps;
|
|
280
|
+
try { deps = JSON.parse(sib.dependsOn); }
|
|
281
|
+
catch (_e) { continue; }
|
|
282
|
+
if (!Array.isArray(deps) || deps.length === 0) continue;
|
|
283
|
+
// Resolve which deps are satisfied. Each dep is either a jobId
|
|
284
|
+
// or a flowChildName; we accept both shapes against the flow.
|
|
285
|
+
var allDone = true;
|
|
286
|
+
for (var d = 0; d < deps.length; d++) {
|
|
287
|
+
var dep = deps[d];
|
|
288
|
+
// Quick path: just-completed job matches by id or child name.
|
|
289
|
+
if (dep === completedJobId || (completedChildName && dep === completedChildName)) continue;
|
|
290
|
+
// Otherwise SELECT to confirm done.
|
|
291
|
+
var depRes = await clusterStorage.execute(
|
|
292
|
+
"SELECT 1 FROM _blamejs_jobs WHERE flowId = ? AND status = 'done' AND " +
|
|
293
|
+
" (_id = ? OR flowChildName = ?) LIMIT 1",
|
|
294
|
+
[flowId, dep, dep]
|
|
295
|
+
);
|
|
296
|
+
if (!depRes || !depRes.rows || depRes.rows.length === 0) { allDone = false; break; }
|
|
297
|
+
}
|
|
298
|
+
if (allDone) {
|
|
299
|
+
await clusterStorage.execute(
|
|
300
|
+
"UPDATE _blamejs_jobs SET availableAt = ? WHERE _id = ?",
|
|
301
|
+
[nowMs, sib._id]
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
198
307
|
async function fail(jobId, errorMessage, opts) {
|
|
199
308
|
cluster.requireLeader();
|
|
200
309
|
opts = opts || {};
|
package/lib/queue.js
CHANGED
|
@@ -488,9 +488,142 @@ function _resetForTest() {
|
|
|
488
488
|
audit.reset();
|
|
489
489
|
}
|
|
490
490
|
|
|
491
|
+
// enqueueFlow — atomic registration of a parent-child job graph.
|
|
492
|
+
//
|
|
493
|
+
// await b.queue.enqueueFlow({
|
|
494
|
+
// queueName: "ingest",
|
|
495
|
+
// children: [
|
|
496
|
+
// { name: "fetch", payload: { url } },
|
|
497
|
+
// { name: "transform", payload: { ... }, dependsOn: ["fetch"] },
|
|
498
|
+
// { name: "publish", payload: { ... }, dependsOn: ["transform"] },
|
|
499
|
+
// ],
|
|
500
|
+
// });
|
|
501
|
+
//
|
|
502
|
+
// Cycle detection runs at registration (Tier-A throw). Each child enters
|
|
503
|
+
// the queue with availableAt = MAX_SAFE_INTEGER until parent completion
|
|
504
|
+
// bumps it. Returns { flowId, jobs: [{ name, jobId }, ...] }.
|
|
505
|
+
function enqueueFlow(spec) {
|
|
506
|
+
_requireInit();
|
|
507
|
+
if (!spec || typeof spec !== "object") {
|
|
508
|
+
return Promise.reject(_err("BAD_FLOW", "enqueueFlow requires an opts object", true));
|
|
509
|
+
}
|
|
510
|
+
if (typeof spec.queueName !== "string" || !spec.queueName) {
|
|
511
|
+
return Promise.reject(_err("BAD_FLOW", "enqueueFlow requires queueName", true));
|
|
512
|
+
}
|
|
513
|
+
if (!Array.isArray(spec.children) || spec.children.length === 0) {
|
|
514
|
+
return Promise.reject(_err("BAD_FLOW", "enqueueFlow requires children: [...]", true));
|
|
515
|
+
}
|
|
516
|
+
// Validate each child's shape.
|
|
517
|
+
var byName = {};
|
|
518
|
+
for (var i = 0; i < spec.children.length; i++) {
|
|
519
|
+
var c = spec.children[i];
|
|
520
|
+
if (!c || typeof c !== "object") {
|
|
521
|
+
return Promise.reject(_err("BAD_FLOW", "children[" + i + "] must be an object", true));
|
|
522
|
+
}
|
|
523
|
+
if (typeof c.name !== "string" || !c.name) {
|
|
524
|
+
return Promise.reject(_err("BAD_FLOW", "children[" + i + "].name must be a non-empty string", true));
|
|
525
|
+
}
|
|
526
|
+
if (byName[c.name]) {
|
|
527
|
+
return Promise.reject(_err("BAD_FLOW", "duplicate child name '" + c.name + "'", true));
|
|
528
|
+
}
|
|
529
|
+
byName[c.name] = c;
|
|
530
|
+
if (c.dependsOn !== undefined) {
|
|
531
|
+
if (!Array.isArray(c.dependsOn)) {
|
|
532
|
+
return Promise.reject(_err("BAD_FLOW",
|
|
533
|
+
"children[" + i + "].dependsOn must be an array of names", true));
|
|
534
|
+
}
|
|
535
|
+
for (var di = 0; di < c.dependsOn.length; di++) {
|
|
536
|
+
if (typeof c.dependsOn[di] !== "string") {
|
|
537
|
+
return Promise.reject(_err("BAD_FLOW",
|
|
538
|
+
"children[" + i + "].dependsOn[" + di + "] must be a string name", true));
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// Cycle detection — depth-first traversal with visited set.
|
|
544
|
+
function _visit(name, stack) {
|
|
545
|
+
if (stack.indexOf(name) !== -1) {
|
|
546
|
+
throw _err("FLOW_CYCLE", "flow cycle detected: " +
|
|
547
|
+
stack.concat([name]).join(" → "), true);
|
|
548
|
+
}
|
|
549
|
+
var child = byName[name];
|
|
550
|
+
if (!child || !child.dependsOn) return;
|
|
551
|
+
var nextStack = stack.concat([name]);
|
|
552
|
+
for (var k = 0; k < child.dependsOn.length; k++) {
|
|
553
|
+
var dep = child.dependsOn[k];
|
|
554
|
+
if (!byName[dep]) {
|
|
555
|
+
throw _err("FLOW_UNKNOWN_DEP",
|
|
556
|
+
"child '" + name + "' dependsOn unknown name '" + dep + "'", true);
|
|
557
|
+
}
|
|
558
|
+
_visit(dep, nextStack);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
var names = Object.keys(byName);
|
|
563
|
+
for (var n = 0; n < names.length; n++) _visit(names[n], []);
|
|
564
|
+
} catch (e) {
|
|
565
|
+
return Promise.reject(e);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
var nodeCrypto = require("node:crypto");
|
|
569
|
+
var flowId = "flow-" + nodeCrypto.randomBytes(8).toString("hex");
|
|
570
|
+
|
|
571
|
+
return observability.tap("queue.enqueueFlow",
|
|
572
|
+
{ queueName: spec.queueName, flowId: flowId, childCount: spec.children.length },
|
|
573
|
+
async function () {
|
|
574
|
+
var jobs = [];
|
|
575
|
+
// Two-pass insert: first pass enqueues all children with their
|
|
576
|
+
// names attached so the second pass can write dependsOn jobIds
|
|
577
|
+
// resolved by name. Children with deps land at MAX_SAFE_INTEGER
|
|
578
|
+
// availableAt automatically (see queue-local enqueue logic).
|
|
579
|
+
var nameToJobId = {};
|
|
580
|
+
for (var p = 0; p < spec.children.length; p++) {
|
|
581
|
+
var ch = spec.children[p];
|
|
582
|
+
// Hold off setting dependsOn until we know all sibling jobIds.
|
|
583
|
+
var enqOpts = {
|
|
584
|
+
flowId: flowId,
|
|
585
|
+
flowChildName: ch.name,
|
|
586
|
+
priority: ch.priority || 0,
|
|
587
|
+
classification: ch.classification || null,
|
|
588
|
+
traceId: ch.traceId || null,
|
|
589
|
+
maxAttempts: ch.maxAttempts,
|
|
590
|
+
// dependsOn intentionally omitted on first pass — will be patched
|
|
591
|
+
// in via direct UPDATE after all jobIds are known. This means
|
|
592
|
+
// root children (no deps) are immediately leaseable; deps-bearing
|
|
593
|
+
// children get patched to MAX_SAFE_INTEGER via second pass.
|
|
594
|
+
};
|
|
595
|
+
var result = await enqueue(spec.queueName, ch.payload, enqOpts);
|
|
596
|
+
nameToJobId[ch.name] = result.jobId;
|
|
597
|
+
jobs.push({ name: ch.name, jobId: result.jobId, dependsOn: ch.dependsOn || [] });
|
|
598
|
+
}
|
|
599
|
+
// Second pass: write dependsOn (translated to jobIds) for children
|
|
600
|
+
// that need it, and parking-lot their availableAt to MAX_SAFE_INTEGER.
|
|
601
|
+
var clusterStorage = require("./cluster-storage");
|
|
602
|
+
for (var q = 0; q < jobs.length; q++) {
|
|
603
|
+
var j = jobs[q];
|
|
604
|
+
if (j.dependsOn.length === 0) continue;
|
|
605
|
+
var depIds = j.dependsOn.map(function (n2) { return nameToJobId[n2]; });
|
|
606
|
+
await clusterStorage.execute(
|
|
607
|
+
"UPDATE _blamejs_jobs SET dependsOn = ?, availableAt = ? WHERE _id = ?",
|
|
608
|
+
[JSON.stringify(depIds), Number.MAX_SAFE_INTEGER, j.jobId]
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
_emit("system.queue.flow.enqueue", {
|
|
612
|
+
metadata: {
|
|
613
|
+
queue: spec.queueName,
|
|
614
|
+
flowId: flowId,
|
|
615
|
+
childCount: spec.children.length,
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
return { flowId: flowId, jobs: jobs.map(function (j) { return { name: j.name, jobId: j.jobId }; }) };
|
|
619
|
+
}
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
|
|
491
623
|
module.exports = {
|
|
492
624
|
init: init,
|
|
493
625
|
enqueue: enqueue,
|
|
626
|
+
enqueueFlow: enqueueFlow,
|
|
494
627
|
consume: consume,
|
|
495
628
|
size: size,
|
|
496
629
|
purge: purge,
|