@blamejs/core 0.4.19 → 0.4.21
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 +14 -0
- package/lib/framework-schema.js +9 -1
- package/lib/queue-local.js +116 -4
- package/lib/queue.js +210 -2
- 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.20** (2026-04-30) — b.queue + b.jobs: priority, rate-limit, progress
|
|
12
|
+
- **0.4.19** (2026-04-30) — b.router: schema-validated routes + OpenAPI gen
|
|
11
13
|
- **0.4.18** (2026-04-30) — cookieJar forensic-test strengthening (real crypto, replay, nonce)
|
|
12
14
|
- **0.4.17** (2026-04-30) — b.httpClient.cookieJar (encrypted) + wiki catch-up sweep
|
|
13
15
|
- **0.4.16** (2026-04-30) — b.httpClient: interceptors + progress events
|
package/lib/db.js
CHANGED
|
@@ -350,9 +350,23 @@ var FRAMEWORK_SCHEMA = [
|
|
|
350
350
|
finishedAt: "INTEGER",
|
|
351
351
|
traceId: "TEXT",
|
|
352
352
|
classification: "TEXT",
|
|
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",
|
|
353
365
|
},
|
|
354
366
|
indexes: [
|
|
355
367
|
{ name: "idx_jobs_lease", columns: ["queueName", "status", "availableAt"] },
|
|
368
|
+
{ name: "idx_jobs_priority", columns: ["queueName", "status", "priority", "availableAt"] },
|
|
369
|
+
{ name: "idx_jobs_flow", columns: ["flowId"] },
|
|
356
370
|
"leaseExpiresAt",
|
|
357
371
|
"finishedAt",
|
|
358
372
|
],
|
package/lib/framework-schema.js
CHANGED
|
@@ -476,10 +476,18 @@ function _jobsDDL(dialect) {
|
|
|
476
476
|
" lastError TEXT," +
|
|
477
477
|
" finishedAt " + t.INT + "," +
|
|
478
478
|
" traceId TEXT," +
|
|
479
|
-
" classification TEXT" +
|
|
479
|
+
" classification TEXT," +
|
|
480
|
+
" priority " + t.INT + " NOT NULL DEFAULT 0," +
|
|
481
|
+
" repeatCron TEXT," +
|
|
482
|
+
" repeatTimezone TEXT," +
|
|
483
|
+
" flowId TEXT," +
|
|
484
|
+
" flowChildName TEXT," +
|
|
485
|
+
" dependsOn TEXT" +
|
|
480
486
|
")",
|
|
481
487
|
indexes: [
|
|
482
488
|
"CREATE INDEX IF NOT EXISTS idx_" + name + "_lease ON " + name + " (queueName, status, availableAt)",
|
|
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)",
|
|
483
491
|
"CREATE INDEX IF NOT EXISTS idx_" + name + "_leaseExpiresAt ON " + name + " (leaseExpiresAt)",
|
|
484
492
|
"CREATE INDEX IF NOT EXISTS idx_" + name + "_finishedAt ON " + name + " (finishedAt)",
|
|
485
493
|
],
|
package/lib/queue-local.js
CHANGED
|
@@ -50,15 +50,23 @@ var JOB_COLS = [
|
|
|
50
50
|
"_id", "queueName", "payload", "status",
|
|
51
51
|
"enqueuedAt", "availableAt", "leasedAt", "leaseExpiresAt",
|
|
52
52
|
"attempts", "maxAttempts", "lastError", "finishedAt",
|
|
53
|
-
"traceId", "classification",
|
|
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
|
|
|
@@ -97,13 +109,27 @@ function create(_config) {
|
|
|
97
109
|
var nowMs = Date.now();
|
|
98
110
|
var availableAt = nowMs + (opts.delaySeconds ? opts.delaySeconds * 1000 : 0);
|
|
99
111
|
|
|
112
|
+
var priority = (typeof opts.priority === "number" && isFinite(opts.priority))
|
|
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
|
+
|
|
100
126
|
var row = {
|
|
101
127
|
_id: generateToken(16),
|
|
102
128
|
queueName: queueName,
|
|
103
129
|
payload: payload === undefined ? null : JSON.stringify(payload),
|
|
104
130
|
status: "pending",
|
|
105
131
|
enqueuedAt: nowMs,
|
|
106
|
-
availableAt:
|
|
132
|
+
availableAt: effectiveAvailableAt,
|
|
107
133
|
leasedAt: null,
|
|
108
134
|
leaseExpiresAt: null,
|
|
109
135
|
attempts: 0,
|
|
@@ -112,6 +138,12 @@ function create(_config) {
|
|
|
112
138
|
finishedAt: null,
|
|
113
139
|
traceId: opts.traceId || null,
|
|
114
140
|
classification: opts.classification || null,
|
|
141
|
+
priority: priority,
|
|
142
|
+
repeatCron: repeatCron,
|
|
143
|
+
repeatTimezone: repeatTimezone,
|
|
144
|
+
flowId: flowId,
|
|
145
|
+
flowChildName: flowChildName,
|
|
146
|
+
dependsOn: dependsOn,
|
|
115
147
|
};
|
|
116
148
|
var sealed = cryptoField.sealRow("_blamejs_jobs", row);
|
|
117
149
|
var values = JOB_COLS.map(function (c) { return c in sealed ? sealed[c] : null; });
|
|
@@ -148,7 +180,7 @@ function create(_config) {
|
|
|
148
180
|
"WHERE _id IN (" +
|
|
149
181
|
" SELECT _id FROM _blamejs_jobs " +
|
|
150
182
|
" WHERE queueName = ? AND status = 'pending' AND availableAt <= ? " +
|
|
151
|
-
" ORDER BY availableAt ASC, enqueuedAt ASC " +
|
|
183
|
+
" ORDER BY priority DESC, availableAt ASC, enqueuedAt ASC " +
|
|
152
184
|
" LIMIT ?" +
|
|
153
185
|
") " +
|
|
154
186
|
"RETURNING " + _quotedList(LEASE_RETURN_COLS);
|
|
@@ -184,14 +216,94 @@ function create(_config) {
|
|
|
184
216
|
|
|
185
217
|
async function complete(jobId) {
|
|
186
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
|
+
|
|
187
232
|
await clusterStorage.execute(
|
|
188
233
|
"UPDATE _blamejs_jobs SET status = 'done', finishedAt = ?, leaseExpiresAt = NULL " +
|
|
189
234
|
"WHERE _id = ? AND status = 'inflight'",
|
|
190
|
-
[
|
|
235
|
+
[nowMs, jobId]
|
|
191
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
|
+
}
|
|
192
266
|
return true;
|
|
193
267
|
}
|
|
194
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
|
+
|
|
195
307
|
async function fail(jobId, errorMessage, opts) {
|
|
196
308
|
cluster.requireLeader();
|
|
197
309
|
opts = opts || {};
|
package/lib/queue.js
CHANGED
|
@@ -177,6 +177,42 @@ function consume(queueName, handler, opts) {
|
|
|
177
177
|
var pollIntervalMs = opts.pollIntervalMs || 1000;
|
|
178
178
|
var fastPollMs = opts.fastPollMs || 50;
|
|
179
179
|
|
|
180
|
+
// Rate-limit: { max, perSeconds } caps how many handler INVOCATIONS
|
|
181
|
+
// start within any rolling perSeconds window. Token-bucket-style
|
|
182
|
+
// accounting keeps it cheap (just a sliding deque of timestamps).
|
|
183
|
+
var rateLimit = null;
|
|
184
|
+
if (opts.rateLimit) {
|
|
185
|
+
if (!opts.rateLimit.max || !opts.rateLimit.perSeconds ||
|
|
186
|
+
typeof opts.rateLimit.max !== "number" ||
|
|
187
|
+
typeof opts.rateLimit.perSeconds !== "number") {
|
|
188
|
+
throw _err("BAD_RATE_LIMIT",
|
|
189
|
+
"consume({ rateLimit }): expected { max: number, perSeconds: number }, got " +
|
|
190
|
+
JSON.stringify(opts.rateLimit), true);
|
|
191
|
+
}
|
|
192
|
+
rateLimit = {
|
|
193
|
+
max: opts.rateLimit.max,
|
|
194
|
+
windowMs: opts.rateLimit.perSeconds * 1000,
|
|
195
|
+
timestamps: [],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function _rateLimitWaitMs() {
|
|
199
|
+
if (!rateLimit) return 0;
|
|
200
|
+
var now = Date.now();
|
|
201
|
+
var cutoff = now - rateLimit.windowMs;
|
|
202
|
+
while (rateLimit.timestamps.length > 0 && rateLimit.timestamps[0] <= cutoff) {
|
|
203
|
+
rateLimit.timestamps.shift();
|
|
204
|
+
}
|
|
205
|
+
if (rateLimit.timestamps.length < rateLimit.max) return 0;
|
|
206
|
+
return rateLimit.timestamps[0] + rateLimit.windowMs - now + 1;
|
|
207
|
+
}
|
|
208
|
+
function _rateLimitConsume() {
|
|
209
|
+
if (rateLimit) rateLimit.timestamps.push(Date.now());
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Progress audit-emit rate-limit — protect the audit chain from a
|
|
213
|
+
// chatty handler that calls progress() every loop iteration.
|
|
214
|
+
var PROGRESS_MIN_INTERVAL_MS = 250;
|
|
215
|
+
|
|
180
216
|
// Each consumer has its own AbortController so cancel() unblocks any
|
|
181
217
|
// in-flight poll-sleep immediately rather than waiting up to
|
|
182
218
|
// pollIntervalMs (default 1s) for the next while-loop iteration.
|
|
@@ -209,6 +245,18 @@ function consume(queueName, handler, opts) {
|
|
|
209
245
|
await _pollSleep(fastPollMs);
|
|
210
246
|
continue;
|
|
211
247
|
}
|
|
248
|
+
// If rate-limited and we'd exceed the budget, sleep until the
|
|
249
|
+
// next slot opens. We lease at most `max - currentTokens` jobs to
|
|
250
|
+
// stay under the cap.
|
|
251
|
+
if (rateLimit) {
|
|
252
|
+
var wait = _rateLimitWaitMs();
|
|
253
|
+
if (wait > 0) {
|
|
254
|
+
await _pollSleep(Math.min(wait, pollIntervalMs));
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
var remainingTokens = rateLimit.max - rateLimit.timestamps.length;
|
|
258
|
+
if (remainingTokens < slots) slots = Math.max(1, remainingTokens);
|
|
259
|
+
}
|
|
212
260
|
var jobs;
|
|
213
261
|
try { jobs = await b.lease(queueName, leaseDurationMs, slots); }
|
|
214
262
|
catch {
|
|
@@ -227,9 +275,17 @@ function consume(queueName, handler, opts) {
|
|
|
227
275
|
_emit("system.queue.consume.start", {
|
|
228
276
|
metadata: { queue: queueName, backend: b.name, jobId: job.jobId, attempt: job.attempts, traceId: job.traceId },
|
|
229
277
|
});
|
|
278
|
+
// Consume a rate-limit slot at handler-start so the budget
|
|
279
|
+
// tracks invocation rate, not lease rate (a single lease that
|
|
280
|
+
// splits work across many sub-units doesn't double-count).
|
|
281
|
+
_rateLimitConsume();
|
|
282
|
+
|
|
230
283
|
// Handler context — second arg to handler. Carries
|
|
231
|
-
// ctx.extendLease(ms) for long-running handlers
|
|
232
|
-
//
|
|
284
|
+
// ctx.extendLease(ms) for long-running handlers and
|
|
285
|
+
// ctx.progress(0..100) for surfacing job progress to the
|
|
286
|
+
// audit chain (rate-limited so chatty handlers don't drown it).
|
|
287
|
+
var lastProgressEmitAt = 0;
|
|
288
|
+
var lastProgressValue = -1;
|
|
233
289
|
var ctx = {
|
|
234
290
|
extendLease: function (additionalMs) {
|
|
235
291
|
if (typeof b.extendLease !== "function") {
|
|
@@ -246,6 +302,25 @@ function consume(queueName, handler, opts) {
|
|
|
246
302
|
return ok;
|
|
247
303
|
});
|
|
248
304
|
},
|
|
305
|
+
progress: function (pct) {
|
|
306
|
+
if (typeof pct !== "number" || !isFinite(pct)) return;
|
|
307
|
+
var clamped = Math.max(0, Math.min(100, Math.floor(pct)));
|
|
308
|
+
var now = Date.now();
|
|
309
|
+
// Always emit 0 and 100 (start/done markers); throttle the rest.
|
|
310
|
+
var isMarker = clamped === 0 || clamped === 100;
|
|
311
|
+
if (!isMarker && (now - lastProgressEmitAt) < PROGRESS_MIN_INTERVAL_MS) return;
|
|
312
|
+
if (clamped === lastProgressValue && !isMarker) return;
|
|
313
|
+
lastProgressEmitAt = now;
|
|
314
|
+
lastProgressValue = clamped;
|
|
315
|
+
observability.event("queue.progress", clamped, { queueName: queueName });
|
|
316
|
+
_emit("system.queue.progress", {
|
|
317
|
+
metadata: {
|
|
318
|
+
queue: queueName, backend: b.name, jobId: job.jobId,
|
|
319
|
+
attempt: job.attempts, traceId: job.traceId,
|
|
320
|
+
percent: clamped,
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
},
|
|
249
324
|
};
|
|
250
325
|
observability.tap("queue.consume",
|
|
251
326
|
{ queueName: queueName, backend: b.name, jobId: job.jobId, attempt: job.attempts },
|
|
@@ -413,9 +488,142 @@ function _resetForTest() {
|
|
|
413
488
|
audit.reset();
|
|
414
489
|
}
|
|
415
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
|
+
|
|
416
623
|
module.exports = {
|
|
417
624
|
init: init,
|
|
418
625
|
enqueue: enqueue,
|
|
626
|
+
enqueueFlow: enqueueFlow,
|
|
419
627
|
consume: consume,
|
|
420
628
|
size: size,
|
|
421
629
|
purge: purge,
|