@blamejs/core 0.7.1 → 0.7.18
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 +423 -389
- package/README.md +150 -149
- package/bin/blamejs.js +0 -0
- package/index.js +308 -282
- package/lib/api-key.js +660 -672
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt.js +311 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +594 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -266
- package/lib/cache.js +1206 -1211
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/codepoint-class.js +196 -0
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -286
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -421
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/file-upload.js +1136 -0
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -202
- package/lib/framework-schema.js +717 -717
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -362
- package/lib/mail.js +981 -962
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -573
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/body-parser.js +1170 -1091
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -269
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +79 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -614
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -91
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -451
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -479
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -855
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -177
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -752
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -489
- package/lib/request-helpers.js +340 -336
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -630
- package/lib/session.js +478 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +879 -114
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -270
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -598
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/queue-redis.js
CHANGED
|
@@ -1,752 +1,745 @@
|
|
|
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
|
-
*/
|
|
33
|
-
var C = require("./constants");
|
|
34
|
-
var cryptoField = require("./crypto-field");
|
|
35
|
-
var { generateToken } = require("./crypto");
|
|
36
|
-
var lazyRequire = require("./lazy-require");
|
|
37
|
-
var redisClient = require("./redis-client");
|
|
38
|
-
var safeJson = require("./safe-json");
|
|
39
|
-
var scheduler = require("./scheduler");
|
|
40
|
-
var { QueueError } = require("./framework-error");
|
|
41
|
-
|
|
42
|
-
var _err = QueueError.factory;
|
|
43
|
-
|
|
44
|
-
// vault is lazy-required because some flows (sealed lastError) only
|
|
45
|
-
// touch it on retry-with-error paths, and the import order
|
|
46
|
-
// (queue-redis → vault → db → audit) tolerates the late bind.
|
|
47
|
-
var vault = lazyRequire(function () { return require("./vault"); });
|
|
48
|
-
|
|
49
|
-
var DEFAULT_PREFIX = "blamejs:queue";
|
|
50
|
-
|
|
51
|
-
// ---- Lua scripts ----
|
|
52
|
-
//
|
|
53
|
-
// LEASE_LUA — atomically pull up to maxRows jobs from the ready zset
|
|
54
|
-
// whose availableAt is <= nowMs, move them to the inflight zset with
|
|
55
|
-
// score = leaseExpiresAt, increment attempts, flip status, and return
|
|
56
|
-
// the jobIds in priority order. The JS side then HGETALLs each id.
|
|
57
|
-
//
|
|
58
|
-
// Priority semantics — matches queue-local's `ORDER BY priority DESC,
|
|
59
|
-
// availableAt ASC, enqueuedAt ASC` over the same `WHERE availableAt
|
|
60
|
-
// <= nowMs` filter. The Redis ZSET orders by availableAt only (the
|
|
61
|
-
// score), which is enough to filter "ready vs not-yet-ready" via
|
|
62
|
-
// ZRANGEBYSCORE 0..nowMs but NOT enough to surface a high-priority
|
|
63
|
-
// job ahead of an earlier-availableAt low-priority job. So we
|
|
64
|
-
// over-fetch maxRows*5 candidates by score, HMGET the priority +
|
|
65
|
-
// availableAt for each, sort priority-DESC / availableAt-ASC server-
|
|
66
|
-
// side in Lua, and lease the top maxRows. The over-fetch factor is
|
|
67
|
-
// chosen so a queue with a typical priority distribution surfaces
|
|
68
|
-
// the right jobs without scanning the entire ready set; queues with
|
|
69
|
-
// >5x as many priority-0 jobs as priority-N jobs would still pull
|
|
70
|
-
// the top-priority ones first.
|
|
71
|
-
//
|
|
72
|
-
// KEYS[1] = ready zset
|
|
73
|
-
// KEYS[2] = inflight zset
|
|
74
|
-
// ARGV[1] = nowMs
|
|
75
|
-
// ARGV[2] = leaseExpiresAt
|
|
76
|
-
// ARGV[3] = maxRows
|
|
77
|
-
// ARGV[4] = job-key prefix (e.g. "blamejs:queue:job:")
|
|
78
|
-
var LEASE_LUA = [
|
|
79
|
-
'local readyKey = KEYS[1]',
|
|
80
|
-
'local inflightKey = KEYS[2]',
|
|
81
|
-
'local nowMs = tonumber(ARGV[1])',
|
|
82
|
-
'local leaseExpiresAt = tonumber(ARGV[2])',
|
|
83
|
-
'local maxRows = tonumber(ARGV[3])',
|
|
84
|
-
'local jobKeyPrefix = ARGV[4]',
|
|
85
|
-
'local oversample = maxRows * 5',
|
|
86
|
-
'local jobIds = redis.call("ZRANGEBYSCORE", readyKey, 0, nowMs, "LIMIT", 0, oversample)',
|
|
87
|
-
'if #jobIds == 0 then return {} end',
|
|
88
|
-
// Pull priority + availableAt + enqueuedAt for each candidate so the
|
|
89
|
-
// sort uses the same triple queue-local sorts on. enqueuedAt is the
|
|
90
|
-
// tiebreaker among same-priority same-availableAt jobs (FIFO within
|
|
91
|
-
// a priority lane).
|
|
92
|
-
'local rows = {}',
|
|
93
|
-
'for i = 1, #jobIds do',
|
|
94
|
-
' local jobId = jobIds[i]',
|
|
95
|
-
' local h = redis.call("HMGET", jobKeyPrefix..jobId, "priority", "availableAt", "enqueuedAt")',
|
|
96
|
-
' rows[i] = { jobId, tonumber(h[1] or "0") or 0, tonumber(h[2] or "0") or 0, tonumber(h[3] or "0") or 0 }',
|
|
97
|
-
'end',
|
|
98
|
-
// priority DESC, availableAt ASC, enqueuedAt ASC.
|
|
99
|
-
'table.sort(rows, function(a, b)',
|
|
100
|
-
' if a[2] ~= b[2] then return a[2] > b[2] end',
|
|
101
|
-
' if a[3] ~= b[3] then return a[3] < b[3] end',
|
|
102
|
-
' return a[4] < b[4]',
|
|
103
|
-
'end)',
|
|
104
|
-
'local picked = {}',
|
|
105
|
-
'local n = math.min(maxRows, #rows)',
|
|
106
|
-
'for i = 1, n do',
|
|
107
|
-
' local jobId = rows[i][1]',
|
|
108
|
-
' redis.call("ZREM", readyKey, jobId)',
|
|
109
|
-
' redis.call("ZADD", inflightKey, leaseExpiresAt, jobId)',
|
|
110
|
-
' redis.call("HINCRBY", jobKeyPrefix..jobId, "attempts", 1)',
|
|
111
|
-
' redis.call("HSET", jobKeyPrefix..jobId,',
|
|
112
|
-
' "status", "inflight",',
|
|
113
|
-
' "leasedAt", nowMs,',
|
|
114
|
-
' "leaseExpiresAt", leaseExpiresAt)',
|
|
115
|
-
' picked[i] = jobId',
|
|
116
|
-
'end',
|
|
117
|
-
'return picked',
|
|
118
|
-
].join("\n");
|
|
119
|
-
|
|
120
|
-
// SWEEP_LUA — find jobs in inflight whose lease expired, push back to
|
|
121
|
-
// ready with score=nowMs (so they're immediately leasable again).
|
|
122
|
-
//
|
|
123
|
-
// KEYS[1] = inflight zset
|
|
124
|
-
// KEYS[2] = ready zset
|
|
125
|
-
// ARGV[1] = nowMs
|
|
126
|
-
// ARGV[2] = job-key prefix
|
|
127
|
-
var SWEEP_LUA = [
|
|
128
|
-
'local inflightKey = KEYS[1]',
|
|
129
|
-
'local readyKey = KEYS[2]',
|
|
130
|
-
'local nowMs = tonumber(ARGV[1])',
|
|
131
|
-
'local jobKeyPrefix = ARGV[2]',
|
|
132
|
-
'local expired = redis.call("ZRANGEBYSCORE", inflightKey, 0, nowMs)',
|
|
133
|
-
'local count = 0',
|
|
134
|
-
'for i = 1, #expired do',
|
|
135
|
-
' local jobId = expired[i]',
|
|
136
|
-
' redis.call("ZREM", inflightKey, jobId)',
|
|
137
|
-
' redis.call("ZADD", readyKey, nowMs, jobId)',
|
|
138
|
-
' redis.call("HSET", jobKeyPrefix..jobId, "status", "pending", "leaseExpiresAt", "")',
|
|
139
|
-
' count = count + 1',
|
|
140
|
-
'end',
|
|
141
|
-
'return count',
|
|
142
|
-
].join("\n");
|
|
143
|
-
|
|
144
|
-
// COMPLETE_LUA — atomically remove from inflight zset, flip status to
|
|
145
|
-
// done, set finishedAt. Returns 1 if the job was inflight, 0 otherwise.
|
|
146
|
-
//
|
|
147
|
-
// KEYS[1] = inflight zset
|
|
148
|
-
// KEYS[2] = job hash key
|
|
149
|
-
// ARGV[1] = jobId (member to ZREM)
|
|
150
|
-
// ARGV[2] = nowMs
|
|
151
|
-
var COMPLETE_LUA = [
|
|
152
|
-
'local inflightKey = KEYS[1]',
|
|
153
|
-
'local jobKey = KEYS[2]',
|
|
154
|
-
'local jobId = ARGV[1]',
|
|
155
|
-
'local nowMs = tonumber(ARGV[2])',
|
|
156
|
-
'local removed = redis.call("ZREM", inflightKey, jobId)',
|
|
157
|
-
'if removed == 1 then',
|
|
158
|
-
' redis.call("HSET", jobKey, "status", "done", "finishedAt", nowMs, "leaseExpiresAt", "")',
|
|
159
|
-
'end',
|
|
160
|
-
'return removed',
|
|
161
|
-
].join("\n");
|
|
162
|
-
|
|
163
|
-
// FAIL_LUA — decide retry vs DLQ based on the row's current attempts
|
|
164
|
-
// vs maxAttempts (read from HASH for race-freedom). Retry: ZADD ready
|
|
165
|
-
// at score=nextAvailableAt, status=pending. DLQ: ZADD dlq at
|
|
166
|
-
// score=nowMs, status=failed.
|
|
167
|
-
//
|
|
168
|
-
// KEYS[1] = inflight zset
|
|
169
|
-
// KEYS[2] = ready zset
|
|
170
|
-
// KEYS[3] = dlq zset
|
|
171
|
-
// KEYS[4] = job hash key
|
|
172
|
-
// ARGV[1] = jobId
|
|
173
|
-
// ARGV[2] = nowMs
|
|
174
|
-
// ARGV[3] = sealedErr (string; "" if no error)
|
|
175
|
-
// ARGV[4] = nextAvailableAt
|
|
176
|
-
var FAIL_LUA = [
|
|
177
|
-
'local inflightKey = KEYS[1]',
|
|
178
|
-
'local readyKey = KEYS[2]',
|
|
179
|
-
'local dlqKey = KEYS[3]',
|
|
180
|
-
'local jobKey = KEYS[4]',
|
|
181
|
-
'local jobId = ARGV[1]',
|
|
182
|
-
'local nowMs = tonumber(ARGV[2])',
|
|
183
|
-
'local sealedErr = ARGV[3]',
|
|
184
|
-
'local nextAvailableAt = tonumber(ARGV[4])',
|
|
185
|
-
'local attempts = tonumber(redis.call("HGET", jobKey, "attempts")) or 0',
|
|
186
|
-
'local maxAttempts = tonumber(redis.call("HGET", jobKey, "maxAttempts")) or 5',
|
|
187
|
-
'redis.call("ZREM", inflightKey, jobId)',
|
|
188
|
-
'if sealedErr ~= "" then redis.call("HSET", jobKey, "lastError", sealedErr) end',
|
|
189
|
-
'redis.call("HSET", jobKey, "leaseExpiresAt", "")',
|
|
190
|
-
'if attempts < maxAttempts then',
|
|
191
|
-
' redis.call("HSET", jobKey, "status", "pending", "availableAt", nextAvailableAt)',
|
|
192
|
-
' redis.call("ZADD", readyKey, nextAvailableAt, jobId)',
|
|
193
|
-
' return 0', // retried
|
|
194
|
-
'else',
|
|
195
|
-
' redis.call("HSET", jobKey, "status", "failed", "finishedAt", nowMs, "availableAt", "")',
|
|
196
|
-
' redis.call("ZADD", dlqKey, nowMs, jobId)',
|
|
197
|
-
' return 1', // landed in dlq
|
|
198
|
-
'end',
|
|
199
|
-
].join("\n");
|
|
200
|
-
|
|
201
|
-
// EXTEND_LUA — push leaseExpiresAt forward iff the job is still inflight.
|
|
202
|
-
//
|
|
203
|
-
// KEYS[1] = inflight zset
|
|
204
|
-
// KEYS[2] = job hash key
|
|
205
|
-
// ARGV[1] = jobId
|
|
206
|
-
// ARGV[2] = newExpiry
|
|
207
|
-
var EXTEND_LUA = [
|
|
208
|
-
'local inflightKey = KEYS[1]',
|
|
209
|
-
'local jobKey = KEYS[2]',
|
|
210
|
-
'local jobId = ARGV[1]',
|
|
211
|
-
'local newExpiry = tonumber(ARGV[2])',
|
|
212
|
-
'local score = redis.call("ZSCORE", inflightKey, jobId)',
|
|
213
|
-
'if score == false then return 0 end',
|
|
214
|
-
'redis.call("ZADD", inflightKey, newExpiry, jobId)',
|
|
215
|
-
'redis.call("HSET", jobKey, "leaseExpiresAt", newExpiry)',
|
|
216
|
-
'return 1',
|
|
217
|
-
].join("\n");
|
|
218
|
-
|
|
219
|
-
// DLQ_RETRY_LUA — pull a job out of dlq, reset attempts, ZADD ready.
|
|
220
|
-
//
|
|
221
|
-
// KEYS[1] = dlq zset
|
|
222
|
-
// KEYS[2] = ready zset
|
|
223
|
-
// KEYS[3] = job hash key
|
|
224
|
-
// ARGV[1] = jobId
|
|
225
|
-
// ARGV[2] = nowMs
|
|
226
|
-
var DLQ_RETRY_LUA = [
|
|
227
|
-
'local dlqKey = KEYS[1]',
|
|
228
|
-
'local readyKey = KEYS[2]',
|
|
229
|
-
'local jobKey = KEYS[3]',
|
|
230
|
-
'local jobId = ARGV[1]',
|
|
231
|
-
'local nowMs = tonumber(ARGV[2])',
|
|
232
|
-
'local removed = redis.call("ZREM", dlqKey, jobId)',
|
|
233
|
-
'if removed == 0 then return 0 end',
|
|
234
|
-
'redis.call("HSET", jobKey,',
|
|
235
|
-
' "status", "pending",',
|
|
236
|
-
' "attempts", 0,',
|
|
237
|
-
' "availableAt", nowMs,',
|
|
238
|
-
' "lastError", "",',
|
|
239
|
-
' "finishedAt", "",',
|
|
240
|
-
' "leasedAt", "",',
|
|
241
|
-
' "leaseExpiresAt", "")',
|
|
242
|
-
'redis.call("ZADD", readyKey, nowMs, jobId)',
|
|
243
|
-
'return 1',
|
|
244
|
-
].join("\n");
|
|
245
|
-
|
|
246
|
-
// ---- Adapter ----
|
|
247
|
-
|
|
248
|
-
function create(opts) {
|
|
249
|
-
opts = opts || {};
|
|
250
|
-
if (typeof opts.url !== "string" || opts.url.length === 0) {
|
|
251
|
-
throw _err("INVALID_CONFIG",
|
|
252
|
-
"queue-redis: opts.url is required (e.g. redis://localhost:6379/0)", true);
|
|
253
|
-
}
|
|
254
|
-
var prefix = typeof opts.keyPrefix === "string" && opts.keyPrefix.length > 0
|
|
255
|
-
? opts.keyPrefix : DEFAULT_PREFIX;
|
|
256
|
-
|
|
257
|
-
var client = redisClient.create(
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
function
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
return
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
};
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
var
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
var
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
var
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
var
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
if (
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
if (!
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
var
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
// flow set for a
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
await
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
)
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
client.command("
|
|
648
|
-
|
|
649
|
-
var
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
}
|
|
656
|
-
var
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
var
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
return
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
);
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
// Diagnostic — exposed for tests + ops dashboards
|
|
747
|
-
_client: client,
|
|
748
|
-
_prefix: function () { return prefix; },
|
|
749
|
-
};
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
module.exports = { create: create };
|
|
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
|
+
*/
|
|
33
|
+
var C = require("./constants");
|
|
34
|
+
var cryptoField = require("./crypto-field");
|
|
35
|
+
var { generateToken } = require("./crypto");
|
|
36
|
+
var lazyRequire = require("./lazy-require");
|
|
37
|
+
var redisClient = require("./redis-client");
|
|
38
|
+
var safeJson = require("./safe-json");
|
|
39
|
+
var scheduler = require("./scheduler");
|
|
40
|
+
var { QueueError } = require("./framework-error");
|
|
41
|
+
|
|
42
|
+
var _err = QueueError.factory;
|
|
43
|
+
|
|
44
|
+
// vault is lazy-required because some flows (sealed lastError) only
|
|
45
|
+
// touch it on retry-with-error paths, and the import order
|
|
46
|
+
// (queue-redis → vault → db → audit) tolerates the late bind.
|
|
47
|
+
var vault = lazyRequire(function () { return require("./vault"); });
|
|
48
|
+
|
|
49
|
+
var DEFAULT_PREFIX = "blamejs:queue";
|
|
50
|
+
|
|
51
|
+
// ---- Lua scripts ----
|
|
52
|
+
//
|
|
53
|
+
// LEASE_LUA — atomically pull up to maxRows jobs from the ready zset
|
|
54
|
+
// whose availableAt is <= nowMs, move them to the inflight zset with
|
|
55
|
+
// score = leaseExpiresAt, increment attempts, flip status, and return
|
|
56
|
+
// the jobIds in priority order. The JS side then HGETALLs each id.
|
|
57
|
+
//
|
|
58
|
+
// Priority semantics — matches queue-local's `ORDER BY priority DESC,
|
|
59
|
+
// availableAt ASC, enqueuedAt ASC` over the same `WHERE availableAt
|
|
60
|
+
// <= nowMs` filter. The Redis ZSET orders by availableAt only (the
|
|
61
|
+
// score), which is enough to filter "ready vs not-yet-ready" via
|
|
62
|
+
// ZRANGEBYSCORE 0..nowMs but NOT enough to surface a high-priority
|
|
63
|
+
// job ahead of an earlier-availableAt low-priority job. So we
|
|
64
|
+
// over-fetch maxRows*5 candidates by score, HMGET the priority +
|
|
65
|
+
// availableAt for each, sort priority-DESC / availableAt-ASC server-
|
|
66
|
+
// side in Lua, and lease the top maxRows. The over-fetch factor is
|
|
67
|
+
// chosen so a queue with a typical priority distribution surfaces
|
|
68
|
+
// the right jobs without scanning the entire ready set; queues with
|
|
69
|
+
// >5x as many priority-0 jobs as priority-N jobs would still pull
|
|
70
|
+
// the top-priority ones first.
|
|
71
|
+
//
|
|
72
|
+
// KEYS[1] = ready zset
|
|
73
|
+
// KEYS[2] = inflight zset
|
|
74
|
+
// ARGV[1] = nowMs
|
|
75
|
+
// ARGV[2] = leaseExpiresAt
|
|
76
|
+
// ARGV[3] = maxRows
|
|
77
|
+
// ARGV[4] = job-key prefix (e.g. "blamejs:queue:job:")
|
|
78
|
+
var LEASE_LUA = [
|
|
79
|
+
'local readyKey = KEYS[1]',
|
|
80
|
+
'local inflightKey = KEYS[2]',
|
|
81
|
+
'local nowMs = tonumber(ARGV[1])',
|
|
82
|
+
'local leaseExpiresAt = tonumber(ARGV[2])',
|
|
83
|
+
'local maxRows = tonumber(ARGV[3])',
|
|
84
|
+
'local jobKeyPrefix = ARGV[4]',
|
|
85
|
+
'local oversample = maxRows * 5',
|
|
86
|
+
'local jobIds = redis.call("ZRANGEBYSCORE", readyKey, 0, nowMs, "LIMIT", 0, oversample)',
|
|
87
|
+
'if #jobIds == 0 then return {} end',
|
|
88
|
+
// Pull priority + availableAt + enqueuedAt for each candidate so the
|
|
89
|
+
// sort uses the same triple queue-local sorts on. enqueuedAt is the
|
|
90
|
+
// tiebreaker among same-priority same-availableAt jobs (FIFO within
|
|
91
|
+
// a priority lane).
|
|
92
|
+
'local rows = {}',
|
|
93
|
+
'for i = 1, #jobIds do',
|
|
94
|
+
' local jobId = jobIds[i]',
|
|
95
|
+
' local h = redis.call("HMGET", jobKeyPrefix..jobId, "priority", "availableAt", "enqueuedAt")',
|
|
96
|
+
' rows[i] = { jobId, tonumber(h[1] or "0") or 0, tonumber(h[2] or "0") or 0, tonumber(h[3] or "0") or 0 }',
|
|
97
|
+
'end',
|
|
98
|
+
// priority DESC, availableAt ASC, enqueuedAt ASC.
|
|
99
|
+
'table.sort(rows, function(a, b)',
|
|
100
|
+
' if a[2] ~= b[2] then return a[2] > b[2] end',
|
|
101
|
+
' if a[3] ~= b[3] then return a[3] < b[3] end',
|
|
102
|
+
' return a[4] < b[4]',
|
|
103
|
+
'end)',
|
|
104
|
+
'local picked = {}',
|
|
105
|
+
'local n = math.min(maxRows, #rows)',
|
|
106
|
+
'for i = 1, n do',
|
|
107
|
+
' local jobId = rows[i][1]',
|
|
108
|
+
' redis.call("ZREM", readyKey, jobId)',
|
|
109
|
+
' redis.call("ZADD", inflightKey, leaseExpiresAt, jobId)',
|
|
110
|
+
' redis.call("HINCRBY", jobKeyPrefix..jobId, "attempts", 1)',
|
|
111
|
+
' redis.call("HSET", jobKeyPrefix..jobId,',
|
|
112
|
+
' "status", "inflight",',
|
|
113
|
+
' "leasedAt", nowMs,',
|
|
114
|
+
' "leaseExpiresAt", leaseExpiresAt)',
|
|
115
|
+
' picked[i] = jobId',
|
|
116
|
+
'end',
|
|
117
|
+
'return picked',
|
|
118
|
+
].join("\n");
|
|
119
|
+
|
|
120
|
+
// SWEEP_LUA — find jobs in inflight whose lease expired, push back to
|
|
121
|
+
// ready with score=nowMs (so they're immediately leasable again).
|
|
122
|
+
//
|
|
123
|
+
// KEYS[1] = inflight zset
|
|
124
|
+
// KEYS[2] = ready zset
|
|
125
|
+
// ARGV[1] = nowMs
|
|
126
|
+
// ARGV[2] = job-key prefix
|
|
127
|
+
var SWEEP_LUA = [
|
|
128
|
+
'local inflightKey = KEYS[1]',
|
|
129
|
+
'local readyKey = KEYS[2]',
|
|
130
|
+
'local nowMs = tonumber(ARGV[1])',
|
|
131
|
+
'local jobKeyPrefix = ARGV[2]',
|
|
132
|
+
'local expired = redis.call("ZRANGEBYSCORE", inflightKey, 0, nowMs)',
|
|
133
|
+
'local count = 0',
|
|
134
|
+
'for i = 1, #expired do',
|
|
135
|
+
' local jobId = expired[i]',
|
|
136
|
+
' redis.call("ZREM", inflightKey, jobId)',
|
|
137
|
+
' redis.call("ZADD", readyKey, nowMs, jobId)',
|
|
138
|
+
' redis.call("HSET", jobKeyPrefix..jobId, "status", "pending", "leaseExpiresAt", "")',
|
|
139
|
+
' count = count + 1',
|
|
140
|
+
'end',
|
|
141
|
+
'return count',
|
|
142
|
+
].join("\n");
|
|
143
|
+
|
|
144
|
+
// COMPLETE_LUA — atomically remove from inflight zset, flip status to
|
|
145
|
+
// done, set finishedAt. Returns 1 if the job was inflight, 0 otherwise.
|
|
146
|
+
//
|
|
147
|
+
// KEYS[1] = inflight zset
|
|
148
|
+
// KEYS[2] = job hash key
|
|
149
|
+
// ARGV[1] = jobId (member to ZREM)
|
|
150
|
+
// ARGV[2] = nowMs
|
|
151
|
+
var COMPLETE_LUA = [
|
|
152
|
+
'local inflightKey = KEYS[1]',
|
|
153
|
+
'local jobKey = KEYS[2]',
|
|
154
|
+
'local jobId = ARGV[1]',
|
|
155
|
+
'local nowMs = tonumber(ARGV[2])',
|
|
156
|
+
'local removed = redis.call("ZREM", inflightKey, jobId)',
|
|
157
|
+
'if removed == 1 then',
|
|
158
|
+
' redis.call("HSET", jobKey, "status", "done", "finishedAt", nowMs, "leaseExpiresAt", "")',
|
|
159
|
+
'end',
|
|
160
|
+
'return removed',
|
|
161
|
+
].join("\n");
|
|
162
|
+
|
|
163
|
+
// FAIL_LUA — decide retry vs DLQ based on the row's current attempts
|
|
164
|
+
// vs maxAttempts (read from HASH for race-freedom). Retry: ZADD ready
|
|
165
|
+
// at score=nextAvailableAt, status=pending. DLQ: ZADD dlq at
|
|
166
|
+
// score=nowMs, status=failed.
|
|
167
|
+
//
|
|
168
|
+
// KEYS[1] = inflight zset
|
|
169
|
+
// KEYS[2] = ready zset
|
|
170
|
+
// KEYS[3] = dlq zset
|
|
171
|
+
// KEYS[4] = job hash key
|
|
172
|
+
// ARGV[1] = jobId
|
|
173
|
+
// ARGV[2] = nowMs
|
|
174
|
+
// ARGV[3] = sealedErr (string; "" if no error)
|
|
175
|
+
// ARGV[4] = nextAvailableAt
|
|
176
|
+
var FAIL_LUA = [
|
|
177
|
+
'local inflightKey = KEYS[1]',
|
|
178
|
+
'local readyKey = KEYS[2]',
|
|
179
|
+
'local dlqKey = KEYS[3]',
|
|
180
|
+
'local jobKey = KEYS[4]',
|
|
181
|
+
'local jobId = ARGV[1]',
|
|
182
|
+
'local nowMs = tonumber(ARGV[2])',
|
|
183
|
+
'local sealedErr = ARGV[3]',
|
|
184
|
+
'local nextAvailableAt = tonumber(ARGV[4])',
|
|
185
|
+
'local attempts = tonumber(redis.call("HGET", jobKey, "attempts")) or 0',
|
|
186
|
+
'local maxAttempts = tonumber(redis.call("HGET", jobKey, "maxAttempts")) or 5',
|
|
187
|
+
'redis.call("ZREM", inflightKey, jobId)',
|
|
188
|
+
'if sealedErr ~= "" then redis.call("HSET", jobKey, "lastError", sealedErr) end',
|
|
189
|
+
'redis.call("HSET", jobKey, "leaseExpiresAt", "")',
|
|
190
|
+
'if attempts < maxAttempts then',
|
|
191
|
+
' redis.call("HSET", jobKey, "status", "pending", "availableAt", nextAvailableAt)',
|
|
192
|
+
' redis.call("ZADD", readyKey, nextAvailableAt, jobId)',
|
|
193
|
+
' return 0', // retried
|
|
194
|
+
'else',
|
|
195
|
+
' redis.call("HSET", jobKey, "status", "failed", "finishedAt", nowMs, "availableAt", "")',
|
|
196
|
+
' redis.call("ZADD", dlqKey, nowMs, jobId)',
|
|
197
|
+
' return 1', // landed in dlq
|
|
198
|
+
'end',
|
|
199
|
+
].join("\n");
|
|
200
|
+
|
|
201
|
+
// EXTEND_LUA — push leaseExpiresAt forward iff the job is still inflight.
|
|
202
|
+
//
|
|
203
|
+
// KEYS[1] = inflight zset
|
|
204
|
+
// KEYS[2] = job hash key
|
|
205
|
+
// ARGV[1] = jobId
|
|
206
|
+
// ARGV[2] = newExpiry
|
|
207
|
+
var EXTEND_LUA = [
|
|
208
|
+
'local inflightKey = KEYS[1]',
|
|
209
|
+
'local jobKey = KEYS[2]',
|
|
210
|
+
'local jobId = ARGV[1]',
|
|
211
|
+
'local newExpiry = tonumber(ARGV[2])',
|
|
212
|
+
'local score = redis.call("ZSCORE", inflightKey, jobId)',
|
|
213
|
+
'if score == false then return 0 end',
|
|
214
|
+
'redis.call("ZADD", inflightKey, newExpiry, jobId)',
|
|
215
|
+
'redis.call("HSET", jobKey, "leaseExpiresAt", newExpiry)',
|
|
216
|
+
'return 1',
|
|
217
|
+
].join("\n");
|
|
218
|
+
|
|
219
|
+
// DLQ_RETRY_LUA — pull a job out of dlq, reset attempts, ZADD ready.
|
|
220
|
+
//
|
|
221
|
+
// KEYS[1] = dlq zset
|
|
222
|
+
// KEYS[2] = ready zset
|
|
223
|
+
// KEYS[3] = job hash key
|
|
224
|
+
// ARGV[1] = jobId
|
|
225
|
+
// ARGV[2] = nowMs
|
|
226
|
+
var DLQ_RETRY_LUA = [
|
|
227
|
+
'local dlqKey = KEYS[1]',
|
|
228
|
+
'local readyKey = KEYS[2]',
|
|
229
|
+
'local jobKey = KEYS[3]',
|
|
230
|
+
'local jobId = ARGV[1]',
|
|
231
|
+
'local nowMs = tonumber(ARGV[2])',
|
|
232
|
+
'local removed = redis.call("ZREM", dlqKey, jobId)',
|
|
233
|
+
'if removed == 0 then return 0 end',
|
|
234
|
+
'redis.call("HSET", jobKey,',
|
|
235
|
+
' "status", "pending",',
|
|
236
|
+
' "attempts", 0,',
|
|
237
|
+
' "availableAt", nowMs,',
|
|
238
|
+
' "lastError", "",',
|
|
239
|
+
' "finishedAt", "",',
|
|
240
|
+
' "leasedAt", "",',
|
|
241
|
+
' "leaseExpiresAt", "")',
|
|
242
|
+
'redis.call("ZADD", readyKey, nowMs, jobId)',
|
|
243
|
+
'return 1',
|
|
244
|
+
].join("\n");
|
|
245
|
+
|
|
246
|
+
// ---- Adapter ----
|
|
247
|
+
|
|
248
|
+
function create(opts) {
|
|
249
|
+
opts = opts || {};
|
|
250
|
+
if (typeof opts.url !== "string" || opts.url.length === 0) {
|
|
251
|
+
throw _err("INVALID_CONFIG",
|
|
252
|
+
"queue-redis: opts.url is required (e.g. redis://localhost:6379/0)", true);
|
|
253
|
+
}
|
|
254
|
+
var prefix = typeof opts.keyPrefix === "string" && opts.keyPrefix.length > 0
|
|
255
|
+
? opts.keyPrefix : DEFAULT_PREFIX;
|
|
256
|
+
|
|
257
|
+
var client = redisClient.create(redisClient.pickClientOpts(opts));
|
|
258
|
+
|
|
259
|
+
// Lazy connect — defer first connect until the first operation so
|
|
260
|
+
// queue.init({ backends }) doesn't have to be async.
|
|
261
|
+
var connectPromise = null;
|
|
262
|
+
function _ensureConnected() {
|
|
263
|
+
if (client.isOpen()) return Promise.resolve();
|
|
264
|
+
if (!connectPromise) connectPromise = client.connect();
|
|
265
|
+
return connectPromise;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---- Key helpers ----
|
|
269
|
+
function _jobKey(jobId) { return prefix + ":job:" + jobId; }
|
|
270
|
+
function _readyKey(queueName) { return prefix + ":q:" + queueName + ":ready"; }
|
|
271
|
+
function _inflightKey(queueName){ return prefix + ":q:" + queueName + ":inflight"; }
|
|
272
|
+
function _dlqKey(queueName) { return prefix + ":q:" + queueName + ":dlq"; }
|
|
273
|
+
function _queuesKey() { return prefix + ":queues"; }
|
|
274
|
+
function _jobKeyPrefix() { return prefix + ":job:"; }
|
|
275
|
+
function _flowKey(flowId) { return prefix + ":flow:" + flowId; }
|
|
276
|
+
|
|
277
|
+
// ---- Row encoding ----
|
|
278
|
+
//
|
|
279
|
+
// Redis HSET fields are flat string-or-binary. Encode a JS object
|
|
280
|
+
// into HSET-friendly args while preserving null/undefined as missing
|
|
281
|
+
// (HDEL on update; never sent on insert) and boolean/number/buffer
|
|
282
|
+
// as their natural string form.
|
|
283
|
+
function _hsetArgs(jobId, fieldsObj) {
|
|
284
|
+
var args = ["HSET", _jobKey(jobId)];
|
|
285
|
+
Object.keys(fieldsObj).forEach(function (k) {
|
|
286
|
+
var v = fieldsObj[k];
|
|
287
|
+
if (v === null || v === undefined) return; // skip
|
|
288
|
+
args.push(k);
|
|
289
|
+
if (Buffer.isBuffer(v)) args.push(v);
|
|
290
|
+
else if (v === true || v === false) args.push(v ? "1" : "0");
|
|
291
|
+
else args.push(String(v));
|
|
292
|
+
});
|
|
293
|
+
return args;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Decode an HGETALL reply (alternating field/value Buffers) into a
|
|
297
|
+
// plain object with Buffer/string values as appropriate. Returns
|
|
298
|
+
// null when the hash didn't exist (HGETALL on missing key returns []).
|
|
299
|
+
function _decodeHash(hashArr) {
|
|
300
|
+
if (!hashArr || hashArr.length === 0) return null;
|
|
301
|
+
var out = {};
|
|
302
|
+
for (var i = 0; i + 1 < hashArr.length; i += 2) {
|
|
303
|
+
var k = Buffer.isBuffer(hashArr[i]) ? hashArr[i].toString("utf8") : String(hashArr[i]);
|
|
304
|
+
out[k] = Buffer.isBuffer(hashArr[i + 1]) ? hashArr[i + 1].toString("utf8") : hashArr[i + 1];
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Shape a leased row into the same { jobId, queueName, payload, ... }
|
|
310
|
+
// contract queue-local returns from _shapeLeasedRow.
|
|
311
|
+
function _shapeLeasedRow(jobId, raw) {
|
|
312
|
+
if (!raw) return null;
|
|
313
|
+
// Pretend it's a "_blamejs_jobs" row so cryptoField unseals correctly.
|
|
314
|
+
var unsealed = cryptoField.unsealRow("_blamejs_jobs", raw);
|
|
315
|
+
return {
|
|
316
|
+
jobId: jobId,
|
|
317
|
+
queueName: unsealed.queueName,
|
|
318
|
+
payload: unsealed.payload ? safeJson.parse(unsealed.payload) : null,
|
|
319
|
+
attempts: Number(unsealed.attempts),
|
|
320
|
+
maxAttempts: Number(unsealed.maxAttempts),
|
|
321
|
+
traceId: unsealed.traceId || null,
|
|
322
|
+
classification: unsealed.classification || null,
|
|
323
|
+
enqueuedAt: Number(unsealed.enqueuedAt),
|
|
324
|
+
leaseExpiresAt: Number(unsealed.leaseExpiresAt),
|
|
325
|
+
repeatCron: unsealed.repeatCron || null,
|
|
326
|
+
repeatTimezone: unsealed.repeatTimezone || null,
|
|
327
|
+
flowId: unsealed.flowId || null,
|
|
328
|
+
flowChildName: unsealed.flowChildName || null,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---- Public adapter ops ----
|
|
333
|
+
|
|
334
|
+
async function enqueue(queueName, payload, opts2) {
|
|
335
|
+
await _ensureConnected();
|
|
336
|
+
opts2 = opts2 || {};
|
|
337
|
+
var nowMs = Date.now();
|
|
338
|
+
// Same SCHEDULING PRECEDENCE rule as queue-local: opts.availableAt
|
|
339
|
+
// wins when finite; relative form is shorthand only.
|
|
340
|
+
var availableAt;
|
|
341
|
+
if (typeof opts2.availableAt === "number" && isFinite(opts2.availableAt)) {
|
|
342
|
+
availableAt = opts2.availableAt;
|
|
343
|
+
} else {
|
|
344
|
+
availableAt = nowMs + (opts2.delaySeconds ? C.TIME.seconds(opts2.delaySeconds) : 0);
|
|
345
|
+
}
|
|
346
|
+
var jobId = generateToken(C.BYTES.bytes(16));
|
|
347
|
+
var row = {
|
|
348
|
+
_id: jobId,
|
|
349
|
+
queueName: queueName,
|
|
350
|
+
payload: payload === undefined ? null : JSON.stringify(payload),
|
|
351
|
+
status: "pending",
|
|
352
|
+
enqueuedAt: nowMs,
|
|
353
|
+
availableAt: availableAt,
|
|
354
|
+
attempts: 0,
|
|
355
|
+
maxAttempts: opts2.maxAttempts != null ? opts2.maxAttempts : 5,
|
|
356
|
+
lastError: null,
|
|
357
|
+
finishedAt: null,
|
|
358
|
+
traceId: opts2.traceId || null,
|
|
359
|
+
classification: opts2.classification || null,
|
|
360
|
+
priority: (typeof opts2.priority === "number" && isFinite(opts2.priority))
|
|
361
|
+
? Math.floor(opts2.priority) : 0,
|
|
362
|
+
repeatCron: opts2.repeat && typeof opts2.repeat.cron === "string"
|
|
363
|
+
? opts2.repeat.cron : null,
|
|
364
|
+
repeatTimezone: opts2.repeat && typeof opts2.repeat.timezone === "string"
|
|
365
|
+
? opts2.repeat.timezone : null,
|
|
366
|
+
flowId: typeof opts2.flowId === "string" ? opts2.flowId : null,
|
|
367
|
+
flowChildName: typeof opts2.flowChildName === "string" ? opts2.flowChildName : null,
|
|
368
|
+
dependsOn: Array.isArray(opts2.dependsOn) && opts2.dependsOn.length > 0
|
|
369
|
+
? JSON.stringify(opts2.dependsOn) : null,
|
|
370
|
+
};
|
|
371
|
+
var sealed = cryptoField.sealRow("_blamejs_jobs", row);
|
|
372
|
+
|
|
373
|
+
// Pipeline: HSET job + ZADD ready + SADD queues + (if flowId)
|
|
374
|
+
// SADD flow set. Pipelined writes hit Redis without round-trips
|
|
375
|
+
// between them. The flow set is the per-flow registry that
|
|
376
|
+
// complete() walks to release dependents — it lets us avoid a
|
|
377
|
+
// SCAN over every job hash when releasing children, matching the
|
|
378
|
+
// queue-local pattern of "SELECT siblings WHERE flowId = ?".
|
|
379
|
+
var hsetArgs = _hsetArgs(jobId, sealed);
|
|
380
|
+
var pipeline = [
|
|
381
|
+
client.command.apply(null, hsetArgs),
|
|
382
|
+
client.command("ZADD", _readyKey(queueName), String(availableAt), jobId),
|
|
383
|
+
client.command("SADD", _queuesKey(), queueName),
|
|
384
|
+
];
|
|
385
|
+
if (row.flowId) {
|
|
386
|
+
pipeline.push(client.command("SADD", _flowKey(row.flowId), jobId));
|
|
387
|
+
}
|
|
388
|
+
await Promise.all(pipeline);
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
jobId: jobId,
|
|
392
|
+
queueName: queueName,
|
|
393
|
+
enqueuedAt: nowMs,
|
|
394
|
+
availableAt: availableAt,
|
|
395
|
+
classification: row.classification,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function lease(queueName, leaseMs, count) {
|
|
400
|
+
await _ensureConnected();
|
|
401
|
+
var nowMs = Date.now();
|
|
402
|
+
var leaseExpiresAt = nowMs + leaseMs;
|
|
403
|
+
var maxRows = count != null ? count : 1;
|
|
404
|
+
|
|
405
|
+
var jobIdsRaw = await client.runScript(
|
|
406
|
+
LEASE_LUA, 2,
|
|
407
|
+
_readyKey(queueName), _inflightKey(queueName),
|
|
408
|
+
String(nowMs), String(leaseExpiresAt), String(maxRows), _jobKeyPrefix()
|
|
409
|
+
);
|
|
410
|
+
if (!jobIdsRaw || jobIdsRaw.length === 0) return [];
|
|
411
|
+
|
|
412
|
+
var jobIds = jobIdsRaw.map(function (x) {
|
|
413
|
+
return Buffer.isBuffer(x) ? x.toString("utf8") : String(x);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// Fetch each job's full record. Pipelined HGETALLs.
|
|
417
|
+
var hashes = await Promise.all(jobIds.map(function (id) {
|
|
418
|
+
return client.command("HGETALL", _jobKey(id));
|
|
419
|
+
}));
|
|
420
|
+
var leased = [];
|
|
421
|
+
for (var i = 0; i < jobIds.length; i++) {
|
|
422
|
+
var raw = _decodeHash(hashes[i]);
|
|
423
|
+
var shaped = _shapeLeasedRow(jobIds[i], raw);
|
|
424
|
+
if (shaped) leased.push(shaped);
|
|
425
|
+
}
|
|
426
|
+
return leased;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function extendLease(jobId, additionalMs) {
|
|
430
|
+
await _ensureConnected();
|
|
431
|
+
if (typeof additionalMs !== "number" || additionalMs <= 0) {
|
|
432
|
+
throw _err("INVALID_LEASE_EXTENSION",
|
|
433
|
+
"extendLease: additionalMs must be a positive number", true);
|
|
434
|
+
}
|
|
435
|
+
var newExpiry = Date.now() + additionalMs;
|
|
436
|
+
// We don't know which queue the job belongs to without a HGET, so
|
|
437
|
+
// fetch queueName first (avoids storing inflight by queue, which
|
|
438
|
+
// would otherwise need a global secondary index).
|
|
439
|
+
var qBuf = await client.command("HGET", _jobKey(jobId), "queueName");
|
|
440
|
+
if (qBuf === null || qBuf === undefined) return false;
|
|
441
|
+
var queueName = Buffer.isBuffer(qBuf) ? qBuf.toString("utf8") : String(qBuf);
|
|
442
|
+
var rv = await client.runScript(
|
|
443
|
+
EXTEND_LUA, 2,
|
|
444
|
+
_inflightKey(queueName), _jobKey(jobId),
|
|
445
|
+
jobId, String(newExpiry)
|
|
446
|
+
);
|
|
447
|
+
return rv === 1;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function complete(jobId) {
|
|
451
|
+
await _ensureConnected();
|
|
452
|
+
var nowMs = Date.now();
|
|
453
|
+
// Read row first to act on cron-repeat metadata. Same shape as
|
|
454
|
+
// queue-local: SELECT row → flip status → if repeatCron, enqueue
|
|
455
|
+
// next firing.
|
|
456
|
+
var rawArr = await client.command("HGETALL", _jobKey(jobId));
|
|
457
|
+
var raw = _decodeHash(rawArr);
|
|
458
|
+
if (!raw) return false;
|
|
459
|
+
var queueName = raw.queueName || "unknown";
|
|
460
|
+
|
|
461
|
+
await client.runScript(
|
|
462
|
+
COMPLETE_LUA, 2,
|
|
463
|
+
_inflightKey(queueName), _jobKey(jobId),
|
|
464
|
+
jobId, String(nowMs)
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
if (raw.repeatCron) {
|
|
468
|
+
try {
|
|
469
|
+
var unsealed = cryptoField.unsealRow("_blamejs_jobs", raw);
|
|
470
|
+
var cron = scheduler.parseCron(unsealed.repeatCron);
|
|
471
|
+
var nextMs = scheduler.nextCronFire(
|
|
472
|
+
cron, new Date(nowMs), unsealed.repeatTimezone || null);
|
|
473
|
+
await enqueue(unsealed.queueName,
|
|
474
|
+
unsealed.payload ? safeJson.parse(unsealed.payload) : null,
|
|
475
|
+
{
|
|
476
|
+
availableAt: nextMs,
|
|
477
|
+
repeat: { cron: unsealed.repeatCron, timezone: unsealed.repeatTimezone },
|
|
478
|
+
priority: Number(unsealed.priority) || 0,
|
|
479
|
+
classification: unsealed.classification || null,
|
|
480
|
+
traceId: unsealed.traceId || null,
|
|
481
|
+
});
|
|
482
|
+
} catch (_e) { /* best-effort — cron resumes next tick if op fixes the issue */ }
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Flow propagation: walk siblings whose dependsOn includes this
|
|
486
|
+
// jobId (or this job's flowChildName) and bump availableAt to now
|
|
487
|
+
// if ALL their deps are now complete. Mirrors queue-local's
|
|
488
|
+
// _maybeReleaseFlowChildren, but uses a per-flow Redis SET as
|
|
489
|
+
// the sibling registry instead of a SQL SELECT.
|
|
490
|
+
if (raw.flowId) {
|
|
491
|
+
try {
|
|
492
|
+
await _maybeReleaseFlowChildren(raw.flowId, jobId, raw.flowChildName || null, nowMs);
|
|
493
|
+
} catch (_e) { /* best-effort — sweepExpired retries if a deps check fails */ }
|
|
494
|
+
// The completed job stays in the flow set so a LATER sibling
|
|
495
|
+
// whose dependsOn includes this job's flowChildName can still
|
|
496
|
+
// find a "done" sibling when its own complete() walks the flow.
|
|
497
|
+
// The set is purge()'d when the queue is purged or — for the
|
|
498
|
+
// typical case where the operator wants to reclaim flow memory
|
|
499
|
+
// after the whole flow finishes — by an explicit operator call
|
|
500
|
+
// to purge() OR by letting `_maybeReleaseFlowChildren` reach a
|
|
501
|
+
// state where every sibling has status='done' (which the
|
|
502
|
+
// operator can detect via b.queue.dlqList / app-level queries).
|
|
503
|
+
}
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function _maybeReleaseFlowChildren(flowId, completedJobId, completedChildName, nowMs) {
|
|
508
|
+
var flowKey = _flowKey(flowId);
|
|
509
|
+
var members = await client.command("SMEMBERS", flowKey);
|
|
510
|
+
if (!members || members.length === 0) return;
|
|
511
|
+
|
|
512
|
+
// Pull dependsOn + status + flowChildName for every sibling in one
|
|
513
|
+
// pipelined batch — far cheaper than per-sibling HMGET round trips.
|
|
514
|
+
var siblingIds = members.map(function (m) {
|
|
515
|
+
return Buffer.isBuffer(m) ? m.toString("utf8") : String(m);
|
|
516
|
+
}).filter(function (id) { return id !== completedJobId; });
|
|
517
|
+
if (siblingIds.length === 0) return;
|
|
518
|
+
|
|
519
|
+
var hmgetCalls = siblingIds.map(function (sibId) {
|
|
520
|
+
return client.command("HMGET", _jobKey(sibId),
|
|
521
|
+
"dependsOn", "status", "flowChildName");
|
|
522
|
+
});
|
|
523
|
+
var results = await Promise.all(hmgetCalls);
|
|
524
|
+
|
|
525
|
+
// For each pending sibling with a dependsOn array, check if all
|
|
526
|
+
// deps are satisfied (the just-completed job AND any prior
|
|
527
|
+
// already-done sibling in the flow).
|
|
528
|
+
for (var i = 0; i < siblingIds.length; i++) {
|
|
529
|
+
var sibId = siblingIds[i];
|
|
530
|
+
var rv = results[i];
|
|
531
|
+
if (!rv || rv.length < 3) continue;
|
|
532
|
+
var rawDeps = rv[0] && (Buffer.isBuffer(rv[0]) ? rv[0].toString("utf8") : String(rv[0]));
|
|
533
|
+
var status = rv[1] && (Buffer.isBuffer(rv[1]) ? rv[1].toString("utf8") : String(rv[1]));
|
|
534
|
+
if (!rawDeps || status !== "pending") continue;
|
|
535
|
+
var deps;
|
|
536
|
+
try { deps = safeJson.parse(rawDeps, { maxBytes: C.BYTES.mib(1) }); }
|
|
537
|
+
catch (_e) { continue; }
|
|
538
|
+
if (!Array.isArray(deps) || deps.length === 0) continue;
|
|
539
|
+
|
|
540
|
+
// Resolve each dep against the just-completed job (id or child
|
|
541
|
+
// name) OR a prior-completed sibling in the flow.
|
|
542
|
+
var allDone = true;
|
|
543
|
+
for (var d = 0; d < deps.length; d++) {
|
|
544
|
+
var dep = deps[d];
|
|
545
|
+
if (dep === completedJobId) continue;
|
|
546
|
+
if (completedChildName && dep === completedChildName) continue;
|
|
547
|
+
// Look up by id first; if no hit, fall back to scanning the
|
|
548
|
+
// flow set for a sibling whose flowChildName matches.
|
|
549
|
+
var depHash = await client.command("HMGET", _jobKey(dep), "status", "flowId");
|
|
550
|
+
if (depHash && depHash[0]) {
|
|
551
|
+
var depStatus = Buffer.isBuffer(depHash[0]) ? depHash[0].toString("utf8") : String(depHash[0]);
|
|
552
|
+
var depFlow = depHash[1] ? (Buffer.isBuffer(depHash[1]) ? depHash[1].toString("utf8") : String(depHash[1])) : "";
|
|
553
|
+
if (depStatus === "done" && depFlow === flowId) continue;
|
|
554
|
+
}
|
|
555
|
+
// Scan the flow's set for a child-name match — cheap because
|
|
556
|
+
// a flow typically has 5-50 children, not thousands.
|
|
557
|
+
var matched = false;
|
|
558
|
+
for (var s = 0; s < siblingIds.length && !matched; s++) {
|
|
559
|
+
if (siblingIds[s] === sibId) continue;
|
|
560
|
+
var sRv = results[s];
|
|
561
|
+
if (!sRv || !sRv[2]) continue;
|
|
562
|
+
var sName = Buffer.isBuffer(sRv[2]) ? sRv[2].toString("utf8") : String(sRv[2]);
|
|
563
|
+
var sStatus = sRv[1] ? (Buffer.isBuffer(sRv[1]) ? sRv[1].toString("utf8") : String(sRv[1])) : "";
|
|
564
|
+
if (sName === dep && sStatus === "done") matched = true;
|
|
565
|
+
}
|
|
566
|
+
if (!matched) { allDone = false; break; }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (allDone) {
|
|
570
|
+
// Read queueName so the ZADD targets the right ready zset.
|
|
571
|
+
var qBuf = await client.command("HGET", _jobKey(sibId), "queueName");
|
|
572
|
+
if (!qBuf) continue;
|
|
573
|
+
var queueName = Buffer.isBuffer(qBuf) ? qBuf.toString("utf8") : String(qBuf);
|
|
574
|
+
await Promise.all([
|
|
575
|
+
client.command("HSET", _jobKey(sibId), "availableAt", String(nowMs)),
|
|
576
|
+
client.command("ZADD", _readyKey(queueName), String(nowMs), sibId),
|
|
577
|
+
]);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function fail(jobId, errorMessage, retryDelayMs) {
|
|
583
|
+
await _ensureConnected();
|
|
584
|
+
var nowMs = Date.now();
|
|
585
|
+
if (typeof retryDelayMs !== "number" || !isFinite(retryDelayMs) || retryDelayMs < 0) {
|
|
586
|
+
retryDelayMs = 0;
|
|
587
|
+
}
|
|
588
|
+
var nextAvailableAt = nowMs + retryDelayMs;
|
|
589
|
+
|
|
590
|
+
var queueBuf = await client.command("HGET", _jobKey(jobId), "queueName");
|
|
591
|
+
if (queueBuf === null || queueBuf === undefined) return false;
|
|
592
|
+
var queueName = Buffer.isBuffer(queueBuf) ? queueBuf.toString("utf8") : String(queueBuf);
|
|
593
|
+
|
|
594
|
+
var sealedErr = errorMessage ? vault().seal(String(errorMessage)) : "";
|
|
595
|
+
|
|
596
|
+
await client.runScript(
|
|
597
|
+
FAIL_LUA, 4,
|
|
598
|
+
_inflightKey(queueName), _readyKey(queueName), _dlqKey(queueName), _jobKey(jobId),
|
|
599
|
+
jobId, String(nowMs), sealedErr, String(nextAvailableAt)
|
|
600
|
+
);
|
|
601
|
+
return true;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function sweepExpired() {
|
|
605
|
+
await _ensureConnected();
|
|
606
|
+
// Walk every known queue; the queues SET keeps the list current
|
|
607
|
+
// (enqueue SADDs the name).
|
|
608
|
+
var qs = await client.command("SMEMBERS", _queuesKey());
|
|
609
|
+
if (!qs || qs.length === 0) return 0;
|
|
610
|
+
var nowMs = Date.now();
|
|
611
|
+
var totals = await Promise.all(qs.map(function (qBuf) {
|
|
612
|
+
var queueName = Buffer.isBuffer(qBuf) ? qBuf.toString("utf8") : String(qBuf);
|
|
613
|
+
return client.runScript(
|
|
614
|
+
SWEEP_LUA, 2,
|
|
615
|
+
_inflightKey(queueName), _readyKey(queueName),
|
|
616
|
+
String(nowMs), _jobKeyPrefix());
|
|
617
|
+
}));
|
|
618
|
+
return totals.reduce(function (acc, n) { return acc + Number(n || 0); }, 0);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async function size(queueName) {
|
|
622
|
+
await _ensureConnected();
|
|
623
|
+
var [r, i] = await Promise.all([
|
|
624
|
+
client.command("ZCARD", _readyKey(queueName)),
|
|
625
|
+
client.command("ZCARD", _inflightKey(queueName)),
|
|
626
|
+
]);
|
|
627
|
+
return Number(r || 0) + Number(i || 0);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function purge(queueName) {
|
|
631
|
+
await _ensureConnected();
|
|
632
|
+
// Walk the ready + inflight + dlq zsets, collect every job id,
|
|
633
|
+
// also clear each job from its flow set (if any), then DEL the
|
|
634
|
+
// job hashes + zsets + queues-membership. Flow sets are cleared
|
|
635
|
+
// job-by-job because a single flow can span multiple queues, and
|
|
636
|
+
// we only want to evict THIS queue's contributors.
|
|
637
|
+
var [readyMembers, inflightMembers, dlqMembers] = await Promise.all([
|
|
638
|
+
client.command("ZRANGE", _readyKey(queueName), "0", "-1"),
|
|
639
|
+
client.command("ZRANGE", _inflightKey(queueName), "0", "-1"),
|
|
640
|
+
client.command("ZRANGE", _dlqKey(queueName), "0", "-1"),
|
|
641
|
+
]);
|
|
642
|
+
var allIds = [].concat(readyMembers || [], inflightMembers || [], dlqMembers || [])
|
|
643
|
+
.map(function (b) { return Buffer.isBuffer(b) ? b.toString("utf8") : String(b); });
|
|
644
|
+
// Pull the flowId for each job (if set) so we can SREM from the
|
|
645
|
+
// matching flow set BEFORE we DEL the job hash.
|
|
646
|
+
var flowIdLookups = await Promise.all(allIds.map(function (id) {
|
|
647
|
+
return client.command("HGET", _jobKey(id), "flowId");
|
|
648
|
+
}));
|
|
649
|
+
var flowSrems = [];
|
|
650
|
+
for (var fi = 0; fi < allIds.length; fi++) {
|
|
651
|
+
var fIdBuf = flowIdLookups[fi];
|
|
652
|
+
if (!fIdBuf) continue;
|
|
653
|
+
var fId = Buffer.isBuffer(fIdBuf) ? fIdBuf.toString("utf8") : String(fIdBuf);
|
|
654
|
+
if (fId) flowSrems.push(client.command("SREM", _flowKey(fId), allIds[fi]));
|
|
655
|
+
}
|
|
656
|
+
var dels = allIds.map(function (id) { return client.command("DEL", _jobKey(id)); });
|
|
657
|
+
var zdrops = [
|
|
658
|
+
client.command("DEL", _readyKey(queueName)),
|
|
659
|
+
client.command("DEL", _inflightKey(queueName)),
|
|
660
|
+
client.command("DEL", _dlqKey(queueName)),
|
|
661
|
+
client.command("SREM", _queuesKey(), queueName),
|
|
662
|
+
];
|
|
663
|
+
await Promise.all(flowSrems.concat(dels, zdrops));
|
|
664
|
+
return allIds.length;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function dlqList(queueName, opts2) {
|
|
668
|
+
await _ensureConnected();
|
|
669
|
+
opts2 = opts2 || {};
|
|
670
|
+
var limit = (typeof opts2.limit === "number" && opts2.limit > 0) ? opts2.limit : 100;
|
|
671
|
+
// Newest failures first — score is finishedAtMs, so ZREVRANGE.
|
|
672
|
+
var ids = await client.command(
|
|
673
|
+
"ZREVRANGE", _dlqKey(queueName), "0", String(limit - 1));
|
|
674
|
+
if (!ids || ids.length === 0) return [];
|
|
675
|
+
var idStrs = ids.map(function (b) { return Buffer.isBuffer(b) ? b.toString("utf8") : String(b); });
|
|
676
|
+
var hashes = await Promise.all(idStrs.map(function (id) {
|
|
677
|
+
return client.command("HGETALL", _jobKey(id));
|
|
678
|
+
}));
|
|
679
|
+
var out = [];
|
|
680
|
+
for (var i = 0; i < idStrs.length; i++) {
|
|
681
|
+
var raw = _decodeHash(hashes[i]);
|
|
682
|
+
if (!raw) continue;
|
|
683
|
+
var unsealed = cryptoField.unsealRow("_blamejs_jobs", raw);
|
|
684
|
+
out.push({
|
|
685
|
+
jobId: idStrs[i],
|
|
686
|
+
queueName: unsealed.queueName,
|
|
687
|
+
payload: unsealed.payload ? safeJson.parse(unsealed.payload) : null,
|
|
688
|
+
status: unsealed.status,
|
|
689
|
+
enqueuedAt: Number(unsealed.enqueuedAt),
|
|
690
|
+
finishedAt: unsealed.finishedAt ? Number(unsealed.finishedAt) : null,
|
|
691
|
+
attempts: Number(unsealed.attempts),
|
|
692
|
+
maxAttempts: Number(unsealed.maxAttempts),
|
|
693
|
+
lastError: unsealed.lastError || null,
|
|
694
|
+
traceId: unsealed.traceId || null,
|
|
695
|
+
classification: unsealed.classification || null,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
return out;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async function dlqRetry(jobId) {
|
|
702
|
+
await _ensureConnected();
|
|
703
|
+
var nowMs = Date.now();
|
|
704
|
+
var queueBuf = await client.command("HGET", _jobKey(jobId), "queueName");
|
|
705
|
+
if (queueBuf === null || queueBuf === undefined) return false;
|
|
706
|
+
var queueName = Buffer.isBuffer(queueBuf) ? queueBuf.toString("utf8") : String(queueBuf);
|
|
707
|
+
var rv = await client.runScript(
|
|
708
|
+
DLQ_RETRY_LUA, 3,
|
|
709
|
+
_dlqKey(queueName), _readyKey(queueName), _jobKey(jobId),
|
|
710
|
+
jobId, String(nowMs)
|
|
711
|
+
);
|
|
712
|
+
return rv === 1;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async function dlqSize(queueName) {
|
|
716
|
+
await _ensureConnected();
|
|
717
|
+
var n = await client.command("ZCARD", _dlqKey(queueName));
|
|
718
|
+
return Number(n || 0);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async function shutdown() {
|
|
722
|
+
try { await client.close(); } catch (_e) { /* best effort */ }
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
return {
|
|
726
|
+
protocol: "redis",
|
|
727
|
+
enqueue: enqueue,
|
|
728
|
+
lease: lease,
|
|
729
|
+
extendLease: extendLease,
|
|
730
|
+
complete: complete,
|
|
731
|
+
fail: fail,
|
|
732
|
+
sweepExpired: sweepExpired,
|
|
733
|
+
size: size,
|
|
734
|
+
purge: purge,
|
|
735
|
+
dlqList: dlqList,
|
|
736
|
+
dlqRetry: dlqRetry,
|
|
737
|
+
dlqSize: dlqSize,
|
|
738
|
+
shutdown: shutdown,
|
|
739
|
+
// Diagnostic — exposed for tests + ops dashboards
|
|
740
|
+
_client: client,
|
|
741
|
+
_prefix: function () { return prefix; },
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
module.exports = { create: create };
|