@blamejs/core 0.6.25 → 0.6.27
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/README.md +3 -2
- package/lib/cli.js +343 -2
- package/lib/framework-error.js +8 -0
- package/lib/queue-redis.js +604 -0
- package/lib/queue.js +47 -2
- package/lib/redis-client.js +427 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Redis-protocol queue adapter — backs b.queue with Redis instead of
|
|
4
|
+
* the framework's main DB. Lets operators run multiple app nodes that
|
|
5
|
+
* share a single queue without each needing to be cluster leader,
|
|
6
|
+
* since Redis itself is the coordination point.
|
|
7
|
+
*
|
|
8
|
+
* Storage layout (operator-overridable prefix, default "blamejs:queue"):
|
|
9
|
+
* <prefix>:job:<jobId> HASH — full job record (sealed payload + lastError)
|
|
10
|
+
* <prefix>:q:<queue>:ready ZSET — member=jobId, score=availableAtMs (lease index)
|
|
11
|
+
* <prefix>:q:<queue>:inflight ZSET — member=jobId, score=leaseExpiresAtMs (sweep index)
|
|
12
|
+
* <prefix>:q:<queue>:dlq ZSET — member=jobId, score=finishedAtMs (failed jobs)
|
|
13
|
+
* <prefix>:q:<queue>:queues SET — registry of known queue names (for purge/size scans)
|
|
14
|
+
*
|
|
15
|
+
* Atomicity: lease / sweep / fail / complete all run as Lua scripts so
|
|
16
|
+
* the inflight-zset / ready-zset / job-hash mutations land in a single
|
|
17
|
+
* Redis op without a window for concurrent consumers to double-lease
|
|
18
|
+
* or for sweep to race a complete.
|
|
19
|
+
*
|
|
20
|
+
* Field-crypto integration: payload + lastError seal/unseal go through
|
|
21
|
+
* cryptoField.sealRow("_blamejs_jobs", row) and unsealRow(...) — the
|
|
22
|
+
* SAME crypto-field config the local backend uses, keyed by the
|
|
23
|
+
* "_blamejs_jobs" table name. Operators configuring sealedFields on the
|
|
24
|
+
* jobs table get the same protection on Redis as on SQLite.
|
|
25
|
+
*
|
|
26
|
+
* Cron-repeat: handled at complete()-time in JS (not Lua) — re-enqueues
|
|
27
|
+
* the next firing as a fresh jobId with availableAt = next-cron-fire.
|
|
28
|
+
*
|
|
29
|
+
* Out of scope (defer to follow-up patches):
|
|
30
|
+
* - Redis Cluster (slot-routing across multi-node Redis)
|
|
31
|
+
* - Sentinel (managed primary failover)
|
|
32
|
+
* - Job priority (queue-local supports `priority` opt; Redis backend
|
|
33
|
+
* orders strictly by availableAt for v1 — re-introduce when a real
|
|
34
|
+
* operator demand surfaces with a clean Lua-side ordering scheme)
|
|
35
|
+
* - Flow children with dependsOn (queue-local's _maybeReleaseFlowChildren
|
|
36
|
+
* coordination — orthogonal to backend choice; ships when flow primitive
|
|
37
|
+
* itself becomes backend-agnostic)
|
|
38
|
+
*/
|
|
39
|
+
var C = require("./constants");
|
|
40
|
+
var cryptoField = require("./crypto-field");
|
|
41
|
+
var { generateToken } = require("./crypto");
|
|
42
|
+
var lazyRequire = require("./lazy-require");
|
|
43
|
+
var redisClient = require("./redis-client");
|
|
44
|
+
var safeJson = require("./safe-json");
|
|
45
|
+
var scheduler = require("./scheduler");
|
|
46
|
+
var { QueueError } = require("./framework-error");
|
|
47
|
+
|
|
48
|
+
var _err = QueueError.factory;
|
|
49
|
+
|
|
50
|
+
// vault is lazy-required because some flows (sealed lastError) only
|
|
51
|
+
// touch it on retry-with-error paths, and the import order
|
|
52
|
+
// (queue-redis → vault → db → audit) tolerates the late bind.
|
|
53
|
+
var vault = lazyRequire(function () { return require("./vault"); });
|
|
54
|
+
|
|
55
|
+
var DEFAULT_PREFIX = "blamejs:queue";
|
|
56
|
+
|
|
57
|
+
// ---- Lua scripts ----
|
|
58
|
+
//
|
|
59
|
+
// LEASE_LUA — atomically pull up to maxRows jobs from the ready zset
|
|
60
|
+
// whose score (availableAt) is <= nowMs, move them to the inflight
|
|
61
|
+
// zset with score = leaseExpiresAt, increment attempts, flip status,
|
|
62
|
+
// and return the jobIds. The JS side then HGETALLs each id.
|
|
63
|
+
//
|
|
64
|
+
// KEYS[1] = ready zset
|
|
65
|
+
// KEYS[2] = inflight zset
|
|
66
|
+
// ARGV[1] = nowMs
|
|
67
|
+
// ARGV[2] = leaseExpiresAt
|
|
68
|
+
// ARGV[3] = maxRows
|
|
69
|
+
// ARGV[4] = job-key prefix (e.g. "blamejs:queue:job:")
|
|
70
|
+
var LEASE_LUA = [
|
|
71
|
+
'local readyKey = KEYS[1]',
|
|
72
|
+
'local inflightKey = KEYS[2]',
|
|
73
|
+
'local nowMs = tonumber(ARGV[1])',
|
|
74
|
+
'local leaseExpiresAt = tonumber(ARGV[2])',
|
|
75
|
+
'local maxRows = tonumber(ARGV[3])',
|
|
76
|
+
'local jobKeyPrefix = ARGV[4]',
|
|
77
|
+
'local jobIds = redis.call("ZRANGEBYSCORE", readyKey, 0, nowMs, "LIMIT", 0, maxRows)',
|
|
78
|
+
'if #jobIds == 0 then return {} end',
|
|
79
|
+
'for i = 1, #jobIds do',
|
|
80
|
+
' local jobId = jobIds[i]',
|
|
81
|
+
' redis.call("ZREM", readyKey, jobId)',
|
|
82
|
+
' redis.call("ZADD", inflightKey, leaseExpiresAt, jobId)',
|
|
83
|
+
' redis.call("HINCRBY", jobKeyPrefix..jobId, "attempts", 1)',
|
|
84
|
+
' redis.call("HSET", jobKeyPrefix..jobId,',
|
|
85
|
+
' "status", "inflight",',
|
|
86
|
+
' "leasedAt", nowMs,',
|
|
87
|
+
' "leaseExpiresAt", leaseExpiresAt)',
|
|
88
|
+
'end',
|
|
89
|
+
'return jobIds',
|
|
90
|
+
].join("\n");
|
|
91
|
+
|
|
92
|
+
// SWEEP_LUA — find jobs in inflight whose lease expired, push back to
|
|
93
|
+
// ready with score=nowMs (so they're immediately leasable again).
|
|
94
|
+
//
|
|
95
|
+
// KEYS[1] = inflight zset
|
|
96
|
+
// KEYS[2] = ready zset
|
|
97
|
+
// ARGV[1] = nowMs
|
|
98
|
+
// ARGV[2] = job-key prefix
|
|
99
|
+
var SWEEP_LUA = [
|
|
100
|
+
'local inflightKey = KEYS[1]',
|
|
101
|
+
'local readyKey = KEYS[2]',
|
|
102
|
+
'local nowMs = tonumber(ARGV[1])',
|
|
103
|
+
'local jobKeyPrefix = ARGV[2]',
|
|
104
|
+
'local expired = redis.call("ZRANGEBYSCORE", inflightKey, 0, nowMs)',
|
|
105
|
+
'local count = 0',
|
|
106
|
+
'for i = 1, #expired do',
|
|
107
|
+
' local jobId = expired[i]',
|
|
108
|
+
' redis.call("ZREM", inflightKey, jobId)',
|
|
109
|
+
' redis.call("ZADD", readyKey, nowMs, jobId)',
|
|
110
|
+
' redis.call("HSET", jobKeyPrefix..jobId, "status", "pending", "leaseExpiresAt", "")',
|
|
111
|
+
' count = count + 1',
|
|
112
|
+
'end',
|
|
113
|
+
'return count',
|
|
114
|
+
].join("\n");
|
|
115
|
+
|
|
116
|
+
// COMPLETE_LUA — atomically remove from inflight zset, flip status to
|
|
117
|
+
// done, set finishedAt. Returns 1 if the job was inflight, 0 otherwise.
|
|
118
|
+
//
|
|
119
|
+
// KEYS[1] = inflight zset
|
|
120
|
+
// KEYS[2] = job hash key
|
|
121
|
+
// ARGV[1] = jobId (member to ZREM)
|
|
122
|
+
// ARGV[2] = nowMs
|
|
123
|
+
var COMPLETE_LUA = [
|
|
124
|
+
'local inflightKey = KEYS[1]',
|
|
125
|
+
'local jobKey = KEYS[2]',
|
|
126
|
+
'local jobId = ARGV[1]',
|
|
127
|
+
'local nowMs = tonumber(ARGV[2])',
|
|
128
|
+
'local removed = redis.call("ZREM", inflightKey, jobId)',
|
|
129
|
+
'if removed == 1 then',
|
|
130
|
+
' redis.call("HSET", jobKey, "status", "done", "finishedAt", nowMs, "leaseExpiresAt", "")',
|
|
131
|
+
'end',
|
|
132
|
+
'return removed',
|
|
133
|
+
].join("\n");
|
|
134
|
+
|
|
135
|
+
// FAIL_LUA — decide retry vs DLQ based on the row's current attempts
|
|
136
|
+
// vs maxAttempts (read from HASH for race-freedom). Retry: ZADD ready
|
|
137
|
+
// at score=nextAvailableAt, status=pending. DLQ: ZADD dlq at
|
|
138
|
+
// score=nowMs, status=failed.
|
|
139
|
+
//
|
|
140
|
+
// KEYS[1] = inflight zset
|
|
141
|
+
// KEYS[2] = ready zset
|
|
142
|
+
// KEYS[3] = dlq zset
|
|
143
|
+
// KEYS[4] = job hash key
|
|
144
|
+
// ARGV[1] = jobId
|
|
145
|
+
// ARGV[2] = nowMs
|
|
146
|
+
// ARGV[3] = sealedErr (string; "" if no error)
|
|
147
|
+
// ARGV[4] = nextAvailableAt
|
|
148
|
+
var FAIL_LUA = [
|
|
149
|
+
'local inflightKey = KEYS[1]',
|
|
150
|
+
'local readyKey = KEYS[2]',
|
|
151
|
+
'local dlqKey = KEYS[3]',
|
|
152
|
+
'local jobKey = KEYS[4]',
|
|
153
|
+
'local jobId = ARGV[1]',
|
|
154
|
+
'local nowMs = tonumber(ARGV[2])',
|
|
155
|
+
'local sealedErr = ARGV[3]',
|
|
156
|
+
'local nextAvailableAt = tonumber(ARGV[4])',
|
|
157
|
+
'local attempts = tonumber(redis.call("HGET", jobKey, "attempts")) or 0',
|
|
158
|
+
'local maxAttempts = tonumber(redis.call("HGET", jobKey, "maxAttempts")) or 5',
|
|
159
|
+
'redis.call("ZREM", inflightKey, jobId)',
|
|
160
|
+
'if sealedErr ~= "" then redis.call("HSET", jobKey, "lastError", sealedErr) end',
|
|
161
|
+
'redis.call("HSET", jobKey, "leaseExpiresAt", "")',
|
|
162
|
+
'if attempts < maxAttempts then',
|
|
163
|
+
' redis.call("HSET", jobKey, "status", "pending", "availableAt", nextAvailableAt)',
|
|
164
|
+
' redis.call("ZADD", readyKey, nextAvailableAt, jobId)',
|
|
165
|
+
' return 0', // retried
|
|
166
|
+
'else',
|
|
167
|
+
' redis.call("HSET", jobKey, "status", "failed", "finishedAt", nowMs, "availableAt", "")',
|
|
168
|
+
' redis.call("ZADD", dlqKey, nowMs, jobId)',
|
|
169
|
+
' return 1', // landed in dlq
|
|
170
|
+
'end',
|
|
171
|
+
].join("\n");
|
|
172
|
+
|
|
173
|
+
// EXTEND_LUA — push leaseExpiresAt forward iff the job is still inflight.
|
|
174
|
+
//
|
|
175
|
+
// KEYS[1] = inflight zset
|
|
176
|
+
// KEYS[2] = job hash key
|
|
177
|
+
// ARGV[1] = jobId
|
|
178
|
+
// ARGV[2] = newExpiry
|
|
179
|
+
var EXTEND_LUA = [
|
|
180
|
+
'local inflightKey = KEYS[1]',
|
|
181
|
+
'local jobKey = KEYS[2]',
|
|
182
|
+
'local jobId = ARGV[1]',
|
|
183
|
+
'local newExpiry = tonumber(ARGV[2])',
|
|
184
|
+
'local score = redis.call("ZSCORE", inflightKey, jobId)',
|
|
185
|
+
'if score == false then return 0 end',
|
|
186
|
+
'redis.call("ZADD", inflightKey, newExpiry, jobId)',
|
|
187
|
+
'redis.call("HSET", jobKey, "leaseExpiresAt", newExpiry)',
|
|
188
|
+
'return 1',
|
|
189
|
+
].join("\n");
|
|
190
|
+
|
|
191
|
+
// DLQ_RETRY_LUA — pull a job out of dlq, reset attempts, ZADD ready.
|
|
192
|
+
//
|
|
193
|
+
// KEYS[1] = dlq zset
|
|
194
|
+
// KEYS[2] = ready zset
|
|
195
|
+
// KEYS[3] = job hash key
|
|
196
|
+
// ARGV[1] = jobId
|
|
197
|
+
// ARGV[2] = nowMs
|
|
198
|
+
var DLQ_RETRY_LUA = [
|
|
199
|
+
'local dlqKey = KEYS[1]',
|
|
200
|
+
'local readyKey = KEYS[2]',
|
|
201
|
+
'local jobKey = KEYS[3]',
|
|
202
|
+
'local jobId = ARGV[1]',
|
|
203
|
+
'local nowMs = tonumber(ARGV[2])',
|
|
204
|
+
'local removed = redis.call("ZREM", dlqKey, jobId)',
|
|
205
|
+
'if removed == 0 then return 0 end',
|
|
206
|
+
'redis.call("HSET", jobKey,',
|
|
207
|
+
' "status", "pending",',
|
|
208
|
+
' "attempts", 0,',
|
|
209
|
+
' "availableAt", nowMs,',
|
|
210
|
+
' "lastError", "",',
|
|
211
|
+
' "finishedAt", "",',
|
|
212
|
+
' "leasedAt", "",',
|
|
213
|
+
' "leaseExpiresAt", "")',
|
|
214
|
+
'redis.call("ZADD", readyKey, nowMs, jobId)',
|
|
215
|
+
'return 1',
|
|
216
|
+
].join("\n");
|
|
217
|
+
|
|
218
|
+
// ---- Adapter ----
|
|
219
|
+
|
|
220
|
+
function create(opts) {
|
|
221
|
+
opts = opts || {};
|
|
222
|
+
if (typeof opts.url !== "string" || opts.url.length === 0) {
|
|
223
|
+
throw _err("INVALID_CONFIG",
|
|
224
|
+
"queue-redis: opts.url is required (e.g. redis://localhost:6379/0)", true);
|
|
225
|
+
}
|
|
226
|
+
var prefix = typeof opts.keyPrefix === "string" && opts.keyPrefix.length > 0
|
|
227
|
+
? opts.keyPrefix : DEFAULT_PREFIX;
|
|
228
|
+
|
|
229
|
+
var client = redisClient.create({
|
|
230
|
+
url: opts.url,
|
|
231
|
+
password: opts.password,
|
|
232
|
+
username: opts.username,
|
|
233
|
+
tls: opts.tls,
|
|
234
|
+
connectTimeoutMs: opts.connectTimeoutMs,
|
|
235
|
+
commandTimeoutMs: opts.commandTimeoutMs,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Lazy connect — defer first connect until the first operation so
|
|
239
|
+
// queue.init({ backends }) doesn't have to be async.
|
|
240
|
+
var connectPromise = null;
|
|
241
|
+
function _ensureConnected() {
|
|
242
|
+
if (client.isOpen()) return Promise.resolve();
|
|
243
|
+
if (!connectPromise) connectPromise = client.connect();
|
|
244
|
+
return connectPromise;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---- Key helpers ----
|
|
248
|
+
function _jobKey(jobId) { return prefix + ":job:" + jobId; }
|
|
249
|
+
function _readyKey(queueName) { return prefix + ":q:" + queueName + ":ready"; }
|
|
250
|
+
function _inflightKey(queueName){ return prefix + ":q:" + queueName + ":inflight"; }
|
|
251
|
+
function _dlqKey(queueName) { return prefix + ":q:" + queueName + ":dlq"; }
|
|
252
|
+
function _queuesKey() { return prefix + ":queues"; }
|
|
253
|
+
function _jobKeyPrefix() { return prefix + ":job:"; }
|
|
254
|
+
|
|
255
|
+
// ---- Row encoding ----
|
|
256
|
+
//
|
|
257
|
+
// Redis HSET fields are flat string-or-binary. Encode a JS object
|
|
258
|
+
// into HSET-friendly args while preserving null/undefined as missing
|
|
259
|
+
// (HDEL on update; never sent on insert) and boolean/number/buffer
|
|
260
|
+
// as their natural string form.
|
|
261
|
+
function _hsetArgs(jobId, fieldsObj) {
|
|
262
|
+
var args = ["HSET", _jobKey(jobId)];
|
|
263
|
+
Object.keys(fieldsObj).forEach(function (k) {
|
|
264
|
+
var v = fieldsObj[k];
|
|
265
|
+
if (v === null || v === undefined) return; // skip
|
|
266
|
+
args.push(k);
|
|
267
|
+
if (Buffer.isBuffer(v)) args.push(v);
|
|
268
|
+
else if (v === true || v === false) args.push(v ? "1" : "0");
|
|
269
|
+
else args.push(String(v));
|
|
270
|
+
});
|
|
271
|
+
return args;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Decode an HGETALL reply (alternating field/value Buffers) into a
|
|
275
|
+
// plain object with Buffer/string values as appropriate. Returns
|
|
276
|
+
// null when the hash didn't exist (HGETALL on missing key returns []).
|
|
277
|
+
function _decodeHash(hashArr) {
|
|
278
|
+
if (!hashArr || hashArr.length === 0) return null;
|
|
279
|
+
var out = {};
|
|
280
|
+
for (var i = 0; i + 1 < hashArr.length; i += 2) {
|
|
281
|
+
var k = Buffer.isBuffer(hashArr[i]) ? hashArr[i].toString("utf8") : String(hashArr[i]);
|
|
282
|
+
out[k] = Buffer.isBuffer(hashArr[i + 1]) ? hashArr[i + 1].toString("utf8") : hashArr[i + 1];
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Shape a leased row into the same { jobId, queueName, payload, ... }
|
|
288
|
+
// contract queue-local returns from _shapeLeasedRow.
|
|
289
|
+
function _shapeLeasedRow(jobId, raw) {
|
|
290
|
+
if (!raw) return null;
|
|
291
|
+
// Pretend it's a "_blamejs_jobs" row so cryptoField unseals correctly.
|
|
292
|
+
var unsealed = cryptoField.unsealRow("_blamejs_jobs", raw);
|
|
293
|
+
return {
|
|
294
|
+
jobId: jobId,
|
|
295
|
+
queueName: unsealed.queueName,
|
|
296
|
+
payload: unsealed.payload ? safeJson.parse(unsealed.payload) : null,
|
|
297
|
+
attempts: Number(unsealed.attempts),
|
|
298
|
+
maxAttempts: Number(unsealed.maxAttempts),
|
|
299
|
+
traceId: unsealed.traceId || null,
|
|
300
|
+
classification: unsealed.classification || null,
|
|
301
|
+
enqueuedAt: Number(unsealed.enqueuedAt),
|
|
302
|
+
leaseExpiresAt: Number(unsealed.leaseExpiresAt),
|
|
303
|
+
repeatCron: unsealed.repeatCron || null,
|
|
304
|
+
repeatTimezone: unsealed.repeatTimezone || null,
|
|
305
|
+
flowId: unsealed.flowId || null,
|
|
306
|
+
flowChildName: unsealed.flowChildName || null,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---- Public adapter ops ----
|
|
311
|
+
|
|
312
|
+
async function enqueue(queueName, payload, opts2) {
|
|
313
|
+
await _ensureConnected();
|
|
314
|
+
opts2 = opts2 || {};
|
|
315
|
+
var nowMs = Date.now();
|
|
316
|
+
// Same SCHEDULING PRECEDENCE rule as queue-local: opts.availableAt
|
|
317
|
+
// wins when finite; relative form is shorthand only.
|
|
318
|
+
var availableAt;
|
|
319
|
+
if (typeof opts2.availableAt === "number" && isFinite(opts2.availableAt)) {
|
|
320
|
+
availableAt = opts2.availableAt;
|
|
321
|
+
} else {
|
|
322
|
+
availableAt = nowMs + (opts2.delaySeconds ? C.TIME.seconds(opts2.delaySeconds) : 0);
|
|
323
|
+
}
|
|
324
|
+
var jobId = generateToken(16);
|
|
325
|
+
var row = {
|
|
326
|
+
_id: jobId,
|
|
327
|
+
queueName: queueName,
|
|
328
|
+
payload: payload === undefined ? null : JSON.stringify(payload),
|
|
329
|
+
status: "pending",
|
|
330
|
+
enqueuedAt: nowMs,
|
|
331
|
+
availableAt: availableAt,
|
|
332
|
+
attempts: 0,
|
|
333
|
+
maxAttempts: opts2.maxAttempts != null ? opts2.maxAttempts : 5,
|
|
334
|
+
lastError: null,
|
|
335
|
+
finishedAt: null,
|
|
336
|
+
traceId: opts2.traceId || null,
|
|
337
|
+
classification: opts2.classification || null,
|
|
338
|
+
priority: (typeof opts2.priority === "number" && isFinite(opts2.priority))
|
|
339
|
+
? Math.floor(opts2.priority) : 0,
|
|
340
|
+
repeatCron: opts2.repeat && typeof opts2.repeat.cron === "string"
|
|
341
|
+
? opts2.repeat.cron : null,
|
|
342
|
+
repeatTimezone: opts2.repeat && typeof opts2.repeat.timezone === "string"
|
|
343
|
+
? opts2.repeat.timezone : null,
|
|
344
|
+
flowId: typeof opts2.flowId === "string" ? opts2.flowId : null,
|
|
345
|
+
flowChildName: typeof opts2.flowChildName === "string" ? opts2.flowChildName : null,
|
|
346
|
+
dependsOn: Array.isArray(opts2.dependsOn) && opts2.dependsOn.length > 0
|
|
347
|
+
? JSON.stringify(opts2.dependsOn) : null,
|
|
348
|
+
};
|
|
349
|
+
var sealed = cryptoField.sealRow("_blamejs_jobs", row);
|
|
350
|
+
|
|
351
|
+
// Pipeline: HSET job + ZADD ready + SADD queues. Pipelined writes
|
|
352
|
+
// hit Redis without round-trips between them.
|
|
353
|
+
var hsetArgs = _hsetArgs(jobId, sealed);
|
|
354
|
+
var p1 = client.command.apply(null, hsetArgs);
|
|
355
|
+
var p2 = client.command("ZADD", _readyKey(queueName), String(availableAt), jobId);
|
|
356
|
+
var p3 = client.command("SADD", _queuesKey(), queueName);
|
|
357
|
+
await Promise.all([p1, p2, p3]);
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
jobId: jobId,
|
|
361
|
+
queueName: queueName,
|
|
362
|
+
enqueuedAt: nowMs,
|
|
363
|
+
availableAt: availableAt,
|
|
364
|
+
classification: row.classification,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function lease(queueName, leaseMs, count) {
|
|
369
|
+
await _ensureConnected();
|
|
370
|
+
var nowMs = Date.now();
|
|
371
|
+
var leaseExpiresAt = nowMs + leaseMs;
|
|
372
|
+
var maxRows = count != null ? count : 1;
|
|
373
|
+
|
|
374
|
+
var jobIdsRaw = await client.runScript(
|
|
375
|
+
LEASE_LUA, 2,
|
|
376
|
+
_readyKey(queueName), _inflightKey(queueName),
|
|
377
|
+
String(nowMs), String(leaseExpiresAt), String(maxRows), _jobKeyPrefix()
|
|
378
|
+
);
|
|
379
|
+
if (!jobIdsRaw || jobIdsRaw.length === 0) return [];
|
|
380
|
+
|
|
381
|
+
var jobIds = jobIdsRaw.map(function (x) {
|
|
382
|
+
return Buffer.isBuffer(x) ? x.toString("utf8") : String(x);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// Fetch each job's full record. Pipelined HGETALLs.
|
|
386
|
+
var hashes = await Promise.all(jobIds.map(function (id) {
|
|
387
|
+
return client.command("HGETALL", _jobKey(id));
|
|
388
|
+
}));
|
|
389
|
+
var leased = [];
|
|
390
|
+
for (var i = 0; i < jobIds.length; i++) {
|
|
391
|
+
var raw = _decodeHash(hashes[i]);
|
|
392
|
+
var shaped = _shapeLeasedRow(jobIds[i], raw);
|
|
393
|
+
if (shaped) leased.push(shaped);
|
|
394
|
+
}
|
|
395
|
+
return leased;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function extendLease(jobId, additionalMs) {
|
|
399
|
+
await _ensureConnected();
|
|
400
|
+
if (typeof additionalMs !== "number" || additionalMs <= 0) {
|
|
401
|
+
throw _err("INVALID_LEASE_EXTENSION",
|
|
402
|
+
"extendLease: additionalMs must be a positive number", true);
|
|
403
|
+
}
|
|
404
|
+
var newExpiry = Date.now() + additionalMs;
|
|
405
|
+
// We don't know which queue the job belongs to without a HGET, so
|
|
406
|
+
// fetch queueName first (avoids storing inflight by queue, which
|
|
407
|
+
// would otherwise need a global secondary index).
|
|
408
|
+
var qBuf = await client.command("HGET", _jobKey(jobId), "queueName");
|
|
409
|
+
if (qBuf === null || qBuf === undefined) return false;
|
|
410
|
+
var queueName = Buffer.isBuffer(qBuf) ? qBuf.toString("utf8") : String(qBuf);
|
|
411
|
+
var rv = await client.runScript(
|
|
412
|
+
EXTEND_LUA, 2,
|
|
413
|
+
_inflightKey(queueName), _jobKey(jobId),
|
|
414
|
+
jobId, String(newExpiry)
|
|
415
|
+
);
|
|
416
|
+
return rv === 1;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function complete(jobId) {
|
|
420
|
+
await _ensureConnected();
|
|
421
|
+
var nowMs = Date.now();
|
|
422
|
+
// Read row first to act on cron-repeat metadata. Same shape as
|
|
423
|
+
// queue-local: SELECT row → flip status → if repeatCron, enqueue
|
|
424
|
+
// next firing.
|
|
425
|
+
var rawArr = await client.command("HGETALL", _jobKey(jobId));
|
|
426
|
+
var raw = _decodeHash(rawArr);
|
|
427
|
+
if (!raw) return false;
|
|
428
|
+
var queueName = raw.queueName || "unknown";
|
|
429
|
+
|
|
430
|
+
await client.runScript(
|
|
431
|
+
COMPLETE_LUA, 2,
|
|
432
|
+
_inflightKey(queueName), _jobKey(jobId),
|
|
433
|
+
jobId, String(nowMs)
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
if (raw.repeatCron) {
|
|
437
|
+
try {
|
|
438
|
+
var unsealed = cryptoField.unsealRow("_blamejs_jobs", raw);
|
|
439
|
+
var cron = scheduler.parseCron(unsealed.repeatCron);
|
|
440
|
+
var nextMs = scheduler.nextCronFire(
|
|
441
|
+
cron, new Date(nowMs), unsealed.repeatTimezone || null);
|
|
442
|
+
await enqueue(unsealed.queueName,
|
|
443
|
+
unsealed.payload ? safeJson.parse(unsealed.payload) : null,
|
|
444
|
+
{
|
|
445
|
+
availableAt: nextMs,
|
|
446
|
+
repeat: { cron: unsealed.repeatCron, timezone: unsealed.repeatTimezone },
|
|
447
|
+
priority: Number(unsealed.priority) || 0,
|
|
448
|
+
classification: unsealed.classification || null,
|
|
449
|
+
traceId: unsealed.traceId || null,
|
|
450
|
+
});
|
|
451
|
+
} catch (_e) { /* best-effort — cron resumes next tick if op fixes the issue */ }
|
|
452
|
+
}
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function fail(jobId, errorMessage, retryDelayMs) {
|
|
457
|
+
await _ensureConnected();
|
|
458
|
+
var nowMs = Date.now();
|
|
459
|
+
if (typeof retryDelayMs !== "number" || !isFinite(retryDelayMs) || retryDelayMs < 0) {
|
|
460
|
+
retryDelayMs = 0;
|
|
461
|
+
}
|
|
462
|
+
var nextAvailableAt = nowMs + retryDelayMs;
|
|
463
|
+
|
|
464
|
+
var queueBuf = await client.command("HGET", _jobKey(jobId), "queueName");
|
|
465
|
+
if (queueBuf === null || queueBuf === undefined) return false;
|
|
466
|
+
var queueName = Buffer.isBuffer(queueBuf) ? queueBuf.toString("utf8") : String(queueBuf);
|
|
467
|
+
|
|
468
|
+
var sealedErr = errorMessage ? vault().seal(String(errorMessage)) : "";
|
|
469
|
+
|
|
470
|
+
await client.runScript(
|
|
471
|
+
FAIL_LUA, 4,
|
|
472
|
+
_inflightKey(queueName), _readyKey(queueName), _dlqKey(queueName), _jobKey(jobId),
|
|
473
|
+
jobId, String(nowMs), sealedErr, String(nextAvailableAt)
|
|
474
|
+
);
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function sweepExpired() {
|
|
479
|
+
await _ensureConnected();
|
|
480
|
+
// Walk every known queue; the queues SET keeps the list current
|
|
481
|
+
// (enqueue SADDs the name).
|
|
482
|
+
var qs = await client.command("SMEMBERS", _queuesKey());
|
|
483
|
+
if (!qs || qs.length === 0) return 0;
|
|
484
|
+
var nowMs = Date.now();
|
|
485
|
+
var totals = await Promise.all(qs.map(function (qBuf) {
|
|
486
|
+
var queueName = Buffer.isBuffer(qBuf) ? qBuf.toString("utf8") : String(qBuf);
|
|
487
|
+
return client.runScript(
|
|
488
|
+
SWEEP_LUA, 2,
|
|
489
|
+
_inflightKey(queueName), _readyKey(queueName),
|
|
490
|
+
String(nowMs), _jobKeyPrefix());
|
|
491
|
+
}));
|
|
492
|
+
return totals.reduce(function (acc, n) { return acc + Number(n || 0); }, 0);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function size(queueName) {
|
|
496
|
+
await _ensureConnected();
|
|
497
|
+
var [r, i] = await Promise.all([
|
|
498
|
+
client.command("ZCARD", _readyKey(queueName)),
|
|
499
|
+
client.command("ZCARD", _inflightKey(queueName)),
|
|
500
|
+
]);
|
|
501
|
+
return Number(r || 0) + Number(i || 0);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function purge(queueName) {
|
|
505
|
+
await _ensureConnected();
|
|
506
|
+
// Walk the ready + inflight + dlq zsets, delete the per-job
|
|
507
|
+
// hashes, then drop the zsets and the queues-set membership.
|
|
508
|
+
var [readyMembers, inflightMembers, dlqMembers] = await Promise.all([
|
|
509
|
+
client.command("ZRANGE", _readyKey(queueName), "0", "-1"),
|
|
510
|
+
client.command("ZRANGE", _inflightKey(queueName), "0", "-1"),
|
|
511
|
+
client.command("ZRANGE", _dlqKey(queueName), "0", "-1"),
|
|
512
|
+
]);
|
|
513
|
+
var allIds = [].concat(readyMembers || [], inflightMembers || [], dlqMembers || [])
|
|
514
|
+
.map(function (b) { return Buffer.isBuffer(b) ? b.toString("utf8") : String(b); });
|
|
515
|
+
var dels = allIds.map(function (id) { return client.command("DEL", _jobKey(id)); });
|
|
516
|
+
var zdrops = [
|
|
517
|
+
client.command("DEL", _readyKey(queueName)),
|
|
518
|
+
client.command("DEL", _inflightKey(queueName)),
|
|
519
|
+
client.command("DEL", _dlqKey(queueName)),
|
|
520
|
+
client.command("SREM", _queuesKey(), queueName),
|
|
521
|
+
];
|
|
522
|
+
await Promise.all(dels.concat(zdrops));
|
|
523
|
+
return allIds.length;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function dlqList(queueName, opts2) {
|
|
527
|
+
await _ensureConnected();
|
|
528
|
+
opts2 = opts2 || {};
|
|
529
|
+
var limit = (typeof opts2.limit === "number" && opts2.limit > 0) ? opts2.limit : 100;
|
|
530
|
+
// Newest failures first — score is finishedAtMs, so ZREVRANGE.
|
|
531
|
+
var ids = await client.command(
|
|
532
|
+
"ZREVRANGE", _dlqKey(queueName), "0", String(limit - 1));
|
|
533
|
+
if (!ids || ids.length === 0) return [];
|
|
534
|
+
var idStrs = ids.map(function (b) { return Buffer.isBuffer(b) ? b.toString("utf8") : String(b); });
|
|
535
|
+
var hashes = await Promise.all(idStrs.map(function (id) {
|
|
536
|
+
return client.command("HGETALL", _jobKey(id));
|
|
537
|
+
}));
|
|
538
|
+
var out = [];
|
|
539
|
+
for (var i = 0; i < idStrs.length; i++) {
|
|
540
|
+
var raw = _decodeHash(hashes[i]);
|
|
541
|
+
if (!raw) continue;
|
|
542
|
+
var unsealed = cryptoField.unsealRow("_blamejs_jobs", raw);
|
|
543
|
+
out.push({
|
|
544
|
+
jobId: idStrs[i],
|
|
545
|
+
queueName: unsealed.queueName,
|
|
546
|
+
payload: unsealed.payload ? safeJson.parse(unsealed.payload) : null,
|
|
547
|
+
status: unsealed.status,
|
|
548
|
+
enqueuedAt: Number(unsealed.enqueuedAt),
|
|
549
|
+
finishedAt: unsealed.finishedAt ? Number(unsealed.finishedAt) : null,
|
|
550
|
+
attempts: Number(unsealed.attempts),
|
|
551
|
+
maxAttempts: Number(unsealed.maxAttempts),
|
|
552
|
+
lastError: unsealed.lastError || null,
|
|
553
|
+
traceId: unsealed.traceId || null,
|
|
554
|
+
classification: unsealed.classification || null,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
return out;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function dlqRetry(jobId) {
|
|
561
|
+
await _ensureConnected();
|
|
562
|
+
var nowMs = Date.now();
|
|
563
|
+
var queueBuf = await client.command("HGET", _jobKey(jobId), "queueName");
|
|
564
|
+
if (queueBuf === null || queueBuf === undefined) return false;
|
|
565
|
+
var queueName = Buffer.isBuffer(queueBuf) ? queueBuf.toString("utf8") : String(queueBuf);
|
|
566
|
+
var rv = await client.runScript(
|
|
567
|
+
DLQ_RETRY_LUA, 3,
|
|
568
|
+
_dlqKey(queueName), _readyKey(queueName), _jobKey(jobId),
|
|
569
|
+
jobId, String(nowMs)
|
|
570
|
+
);
|
|
571
|
+
return rv === 1;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function dlqSize(queueName) {
|
|
575
|
+
await _ensureConnected();
|
|
576
|
+
var n = await client.command("ZCARD", _dlqKey(queueName));
|
|
577
|
+
return Number(n || 0);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function shutdown() {
|
|
581
|
+
try { await client.close(); } catch (_e) { /* best effort */ }
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return {
|
|
585
|
+
protocol: "redis",
|
|
586
|
+
enqueue: enqueue,
|
|
587
|
+
lease: lease,
|
|
588
|
+
extendLease: extendLease,
|
|
589
|
+
complete: complete,
|
|
590
|
+
fail: fail,
|
|
591
|
+
sweepExpired: sweepExpired,
|
|
592
|
+
size: size,
|
|
593
|
+
purge: purge,
|
|
594
|
+
dlqList: dlqList,
|
|
595
|
+
dlqRetry: dlqRetry,
|
|
596
|
+
dlqSize: dlqSize,
|
|
597
|
+
shutdown: shutdown,
|
|
598
|
+
// Diagnostic — exposed for tests + ops dashboards
|
|
599
|
+
_client: client,
|
|
600
|
+
_prefix: function () { return prefix; },
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
module.exports = { create: create };
|
package/lib/queue.js
CHANGED
|
@@ -38,6 +38,7 @@ var numericChecks = require("./numeric-checks");
|
|
|
38
38
|
var observability = require("./observability");
|
|
39
39
|
var protocolDispatcher = require("./protocol-dispatcher");
|
|
40
40
|
var localProto = require("./queue-local");
|
|
41
|
+
var redisProto = require("./queue-redis");
|
|
41
42
|
var retryHelper = require("./retry");
|
|
42
43
|
var safeAsync = require("./safe-async");
|
|
43
44
|
var { QueueError } = require("./framework-error");
|
|
@@ -45,9 +46,8 @@ var { QueueError } = require("./framework-error");
|
|
|
45
46
|
var dispatcher = protocolDispatcher.create({
|
|
46
47
|
name: "queue",
|
|
47
48
|
errorClass: QueueError,
|
|
48
|
-
protocols: { "local": localProto },
|
|
49
|
+
protocols: { "local": localProto, "redis": redisProto },
|
|
49
50
|
deferred: {
|
|
50
|
-
"redis": { description: "Redis Streams (XADD/XREADGROUP/XACK/XCLAIM)" },
|
|
51
51
|
"sqs": { description: "AWS SQS (and S3-compatible queue endpoints) via SigV4" },
|
|
52
52
|
"amqp": { description: "AMQP 0-9-1 (RabbitMQ etc.)" },
|
|
53
53
|
"nats": { description: "NATS JetStream" },
|
|
@@ -628,8 +628,53 @@ function enqueueFlow(spec) {
|
|
|
628
628
|
);
|
|
629
629
|
}
|
|
630
630
|
|
|
631
|
+
// bootFromEnv — env-driven init mirroring b.network.bootFromEnv and
|
|
632
|
+
// b.logStream.bootFromEnv. Reads the BLAMEJS_QUEUE_* env vars and
|
|
633
|
+
// calls queue.init({ backends }) accordingly. Operators get a working
|
|
634
|
+
// queue backend without writing build-app code.
|
|
635
|
+
//
|
|
636
|
+
// BLAMEJS_QUEUE_PROTOCOL local | redis (default: local)
|
|
637
|
+
// BLAMEJS_QUEUE_REDIS_URL redis://host:port/db (required when protocol=redis)
|
|
638
|
+
// BLAMEJS_QUEUE_REDIS_PASSWORD auth password
|
|
639
|
+
// BLAMEJS_QUEUE_REDIS_USERNAME ACL username (optional)
|
|
640
|
+
// BLAMEJS_QUEUE_REDIS_TLS "1"/"true" forces TLS (else inferred from rediss://)
|
|
641
|
+
// BLAMEJS_QUEUE_REDIS_KEY_PREFIX key prefix (default "blamejs:queue")
|
|
642
|
+
function bootFromEnv(opts) {
|
|
643
|
+
opts = opts || {};
|
|
644
|
+
var env = opts.env || process.env;
|
|
645
|
+
if (initialized) return;
|
|
646
|
+
var protocol = env.BLAMEJS_QUEUE_PROTOCOL || "local";
|
|
647
|
+
var backendCfg;
|
|
648
|
+
if (protocol === "local") {
|
|
649
|
+
backendCfg = { protocol: "local" };
|
|
650
|
+
} else if (protocol === "redis") {
|
|
651
|
+
var url = env.BLAMEJS_QUEUE_REDIS_URL;
|
|
652
|
+
if (!url) {
|
|
653
|
+
throw _err("INVALID_CONFIG",
|
|
654
|
+
"queue.bootFromEnv: BLAMEJS_QUEUE_REDIS_URL is required when BLAMEJS_QUEUE_PROTOCOL=redis",
|
|
655
|
+
true);
|
|
656
|
+
}
|
|
657
|
+
var tlsRaw = env.BLAMEJS_QUEUE_REDIS_TLS;
|
|
658
|
+
var tls = tlsRaw === "1" || tlsRaw === "true";
|
|
659
|
+
backendCfg = {
|
|
660
|
+
protocol: "redis",
|
|
661
|
+
url: url,
|
|
662
|
+
password: env.BLAMEJS_QUEUE_REDIS_PASSWORD || null,
|
|
663
|
+
username: env.BLAMEJS_QUEUE_REDIS_USERNAME || null,
|
|
664
|
+
tls: tlsRaw !== undefined ? tls : undefined, // undefined → inferred from rediss://
|
|
665
|
+
keyPrefix: env.BLAMEJS_QUEUE_REDIS_KEY_PREFIX || undefined,
|
|
666
|
+
};
|
|
667
|
+
} else {
|
|
668
|
+
throw _err("INVALID_CONFIG",
|
|
669
|
+
"queue.bootFromEnv: BLAMEJS_QUEUE_PROTOCOL must be 'local' or 'redis', got '" + protocol + "'",
|
|
670
|
+
true);
|
|
671
|
+
}
|
|
672
|
+
init({ backends: { default: backendCfg }, defaultBackend: "default" });
|
|
673
|
+
}
|
|
674
|
+
|
|
631
675
|
module.exports = {
|
|
632
676
|
init: init,
|
|
677
|
+
bootFromEnv: bootFromEnv,
|
|
633
678
|
enqueue: enqueue,
|
|
634
679
|
enqueueFlow: enqueueFlow,
|
|
635
680
|
consume: consume,
|