@getstrata/core 2.2.0 → 2.2.2
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 +11 -0
- package/dist/core/contracts/serviceTokens.d.ts +3 -2
- package/dist/core/database/baseRepository.d.ts +5 -0
- package/dist/core/database/model.d.ts +3 -1
- package/dist/core/database/query.d.ts +6 -1
- package/dist/core/database/repositoryQuery.d.ts +7 -1
- package/dist/core/database/schema/blueprint.d.ts +2 -0
- package/dist/core/database/schema/columnDefinition.d.ts +1 -1
- package/dist/core/database/types.d.ts +10 -1
- package/dist/core/http/requestMetaContext.d.ts +2 -0
- package/dist/core/queue/createAppQueue.d.ts +2 -1
- package/dist/core/queue/failedJobService.d.ts +3 -1
- package/dist/core/queue/index.d.ts +10 -2
- package/dist/core/queue/jobRunner.d.ts +7 -1
- package/dist/core/queue/publicQueue.d.ts +2 -1
- package/dist/core/queue/queueConfig.d.ts +3 -0
- package/dist/core/queue/redisQueue.d.ts +5 -7
- package/dist/core/queue/redisQueueKeys.d.ts +8 -0
- package/dist/core/queue/redisStreams.d.ts +73 -0
- package/dist/core/queue/types.d.ts +1 -0
- package/dist/core/tenant/publicTenantHost.d.ts +11 -0
- package/dist/core/tenant/tenantMiddleware.d.ts +4 -0
- package/dist/entries/auth/basicAuthGuard.js +1 -0
- package/dist/entries/auth/jwtGuard.js +1 -0
- package/dist/entries/auth/sessionGuard.js +1 -0
- package/dist/entries/contracts/serviceTokens.js +2 -0
- package/dist/entries/database/query.js +70 -1
- package/dist/entries/database/repositoryQuery.js +99 -1
- package/dist/entries/database/schema.js +9 -0
- package/dist/entries/database/sqliteConnection.js +1 -1
- package/dist/entries/queue/createAppQueue.js +681 -66
- package/dist/entries/queue/failedJobRepository.js +1 -1
- package/dist/entries/queue/failedJobService.js +5 -1
- package/dist/entries/queue/jobRunner.js +67 -2
- package/dist/entries/queue/publicQueue.js +679 -65
- package/dist/entries/queue/queueMetrics.js +694 -72
- package/dist/entries/queue/redisQueue.js +765 -148
- package/dist/framework/public-api.d.ts +2 -2
- package/dist/index.js +855 -69
- package/package.json +1 -1
|
@@ -7,7 +7,7 @@ import { defineTable } from "@getstrata/core/database/table";
|
|
|
7
7
|
var failedJobTable = defineTable({
|
|
8
8
|
name: "failed_job",
|
|
9
9
|
primaryKey: "id",
|
|
10
|
-
columns: ["id", "job_name", "payload", "exception", "failed_at"],
|
|
10
|
+
columns: ["id", "job_name", "job_id", "payload", "exception", "failed_at"],
|
|
11
11
|
defaultOrderBy: { column: "failed_at", direction: "DESC" }
|
|
12
12
|
});
|
|
13
13
|
|
|
@@ -28,6 +28,7 @@ class FailedJobService {
|
|
|
28
28
|
async recordFailure(input) {
|
|
29
29
|
return await this.repository.create({
|
|
30
30
|
job_name: input.jobName,
|
|
31
|
+
job_id: input.jobId ?? null,
|
|
31
32
|
payload: input.payload,
|
|
32
33
|
exception: input.exception,
|
|
33
34
|
failed_at: new Date
|
|
@@ -39,8 +40,11 @@ class FailedJobService {
|
|
|
39
40
|
orderBy: { column: "failed_at", direction: "DESC" }
|
|
40
41
|
});
|
|
41
42
|
}
|
|
42
|
-
async retry(id) {
|
|
43
|
+
async retry(id, enqueue) {
|
|
44
|
+
if (typeof enqueue !== "function")
|
|
45
|
+
throw new Error("Failed-job retry requires an admission callback.");
|
|
43
46
|
const failedJob = await this.repository.findByIdOrThrow(id, (jobId) => new Error(`Failed job ${jobId} not found.`));
|
|
47
|
+
await enqueue(failedJob);
|
|
44
48
|
await this.repository.deleteById(id);
|
|
45
49
|
return failedJob;
|
|
46
50
|
}
|
|
@@ -100,6 +104,9 @@ function readSharedJobRegistry() {
|
|
|
100
104
|
}
|
|
101
105
|
var jobRegistry = readSharedJobRegistry();
|
|
102
106
|
|
|
107
|
+
// ../../src/core/queue/jobRunner.ts
|
|
108
|
+
import { setTimeout as delay } from "timers/promises";
|
|
109
|
+
|
|
103
110
|
// ../../src/core/queue/queueConfig.ts
|
|
104
111
|
function resolveQueueConfig() {
|
|
105
112
|
const driver = process.env.QUEUE_DRIVER;
|
|
@@ -122,22 +129,82 @@ var queueConfig = {
|
|
|
122
129
|
return resolveQueueConfig().backoffMs;
|
|
123
130
|
}
|
|
124
131
|
};
|
|
132
|
+
function resolveRedisQueueTransport() {
|
|
133
|
+
const transport = process.env.QUEUE_REDIS_TRANSPORT ?? "lists";
|
|
134
|
+
if (transport !== "lists" && transport !== "streams")
|
|
135
|
+
throw new Error('QUEUE_REDIS_TRANSPORT must be "lists" or "streams".');
|
|
136
|
+
return transport;
|
|
137
|
+
}
|
|
138
|
+
function readQueueVisibilityMs() {
|
|
139
|
+
const value = Number(process.env.QUEUE_VISIBILITY_MS ?? "60000");
|
|
140
|
+
if (!Number.isSafeInteger(value) || value < 30)
|
|
141
|
+
throw new Error("QUEUE_VISIBILITY_MS must be an integer of at least 30 milliseconds.");
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
125
144
|
|
|
126
145
|
// ../../src/core/queue/jobRunner.ts
|
|
146
|
+
function parseQueueJobEnvelope(rawPayload) {
|
|
147
|
+
let parsed;
|
|
148
|
+
try {
|
|
149
|
+
parsed = JSON.parse(rawPayload);
|
|
150
|
+
} catch {
|
|
151
|
+
console.error("[QueueWorker] Ignoring malformed queue payload");
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
if (!parsed || typeof parsed !== "object") {
|
|
155
|
+
console.error("[QueueWorker] Ignoring non-object queue payload");
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
const envelope = parsed;
|
|
159
|
+
if (typeof envelope.name !== "string" || envelope.name.length === 0) {
|
|
160
|
+
console.error("[QueueWorker] Ignoring queue payload without job name");
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
if (!jobRegistry.create(envelope.name)) {
|
|
164
|
+
console.error(`[QueueWorker] Ignoring unknown job name: ${envelope.name}`);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
if (envelope.payload !== undefined && (typeof envelope.payload !== "object" || envelope.payload === null)) {
|
|
168
|
+
console.error("[QueueWorker] Ignoring queue payload with invalid payload object");
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
if (envelope.jobId !== undefined && (typeof envelope.jobId !== "string" || !envelope.jobId.length))
|
|
172
|
+
return null;
|
|
173
|
+
if (envelope.attempts !== undefined && (!Number.isSafeInteger(envelope.attempts) || envelope.attempts < 0))
|
|
174
|
+
return null;
|
|
175
|
+
if (envelope.deadlineAtMs !== undefined && (!Number.isSafeInteger(envelope.deadlineAtMs) || envelope.deadlineAtMs <= 0))
|
|
176
|
+
return null;
|
|
177
|
+
if (envelope.cancellable !== undefined && (typeof envelope.cancellable !== "boolean" || envelope.cancellable && !envelope.jobId))
|
|
178
|
+
return null;
|
|
179
|
+
return {
|
|
180
|
+
...envelope.cancellable ? { cancellable: true } : {},
|
|
181
|
+
...envelope.deadlineAtMs === undefined ? {} : { deadlineAtMs: envelope.deadlineAtMs },
|
|
182
|
+
jobId: envelope.jobId,
|
|
183
|
+
name: envelope.name,
|
|
184
|
+
payload: envelope.payload ?? {},
|
|
185
|
+
attempts: typeof envelope.attempts === "number" ? envelope.attempts : 0
|
|
186
|
+
};
|
|
187
|
+
}
|
|
127
188
|
async function runQueueJob(envelope, failedJobs, options = {}) {
|
|
189
|
+
options.context?.signal.throwIfAborted();
|
|
128
190
|
const job = jobRegistry.create(envelope.name);
|
|
129
191
|
if (!job) {
|
|
130
192
|
throw new Error(`Unknown job "${envelope.name}".`);
|
|
131
193
|
}
|
|
132
194
|
const attempts = envelope.attempts ?? 0;
|
|
133
195
|
try {
|
|
134
|
-
await job.handle(envelope.payload);
|
|
196
|
+
await job.handle(envelope.payload, options.context);
|
|
135
197
|
} catch (error) {
|
|
198
|
+
options.context?.signal.throwIfAborted();
|
|
136
199
|
const nextAttempt = attempts + 1;
|
|
137
200
|
const maxAttempts = job.maxAttempts ?? queueConfig.maxAttempts;
|
|
138
201
|
if (nextAttempt < maxAttempts) {
|
|
139
202
|
const backoffMs = job.backoffMs ?? queueConfig.backoffMs;
|
|
140
|
-
|
|
203
|
+
if (options.deferRetry) {
|
|
204
|
+
await options.deferRetry({ ...envelope, attempts: nextAttempt }, backoffMs * nextAttempt);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
await delay(backoffMs * nextAttempt, undefined, { signal: options.context?.signal });
|
|
141
208
|
await runQueueJob({
|
|
142
209
|
...envelope,
|
|
143
210
|
attempts: nextAttempt
|
|
@@ -146,6 +213,7 @@ async function runQueueJob(envelope, failedJobs, options = {}) {
|
|
|
146
213
|
}
|
|
147
214
|
await failedJobs.recordFailure({
|
|
148
215
|
jobName: envelope.name,
|
|
216
|
+
jobId: options.context?.jobId ?? envelope.jobId,
|
|
149
217
|
payload: envelope.payload,
|
|
150
218
|
exception: error instanceof Error ? error.stack ?? error.message : String(error)
|
|
151
219
|
});
|
|
@@ -155,7 +223,7 @@ async function runQueueJob(envelope, failedJobs, options = {}) {
|
|
|
155
223
|
}
|
|
156
224
|
|
|
157
225
|
// ../../src/core/queue/redisQueue.ts
|
|
158
|
-
var {RedisClient } = globalThis.Bun;
|
|
226
|
+
var {RedisClient: RedisClient2 } = globalThis.Bun;
|
|
159
227
|
|
|
160
228
|
// ../../src/core/runtime/appEnv.ts
|
|
161
229
|
var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
|
|
@@ -243,7 +311,7 @@ function sdkClientClassName() {
|
|
|
243
311
|
return fromName ? `${fromName}Client` : "AppClient";
|
|
244
312
|
}
|
|
245
313
|
|
|
246
|
-
// ../../src/core/queue/
|
|
314
|
+
// ../../src/core/queue/redisQueueKeys.ts
|
|
247
315
|
function queueListKey() {
|
|
248
316
|
return namespacedRedisKey("queue:default");
|
|
249
317
|
}
|
|
@@ -256,31 +324,10 @@ function queueLowKey() {
|
|
|
256
324
|
var QUEUE_LIST_KEY = queueListKey();
|
|
257
325
|
var QUEUE_HIGH_KEY = queueHighKey();
|
|
258
326
|
var QUEUE_LOW_KEY = queueLowKey();
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
function queueProcessingKey(queueKey) {
|
|
262
|
-
return `${queueKey}:processing`;
|
|
263
|
-
}
|
|
264
|
-
function queueProcessingLeaseKey(queueKey) {
|
|
265
|
-
return `${queueKey}:processing:leases`;
|
|
266
|
-
}
|
|
267
|
-
function readVisibilityMs() {
|
|
268
|
-
const parsed = Number(process.env.QUEUE_VISIBILITY_MS ?? String(DEFAULT_VISIBILITY_MS));
|
|
269
|
-
if (!Number.isSafeInteger(parsed) || parsed < 30) {
|
|
270
|
-
throw new Error("QUEUE_VISIBILITY_MS must be an integer of at least 30 milliseconds.");
|
|
271
|
-
}
|
|
272
|
-
return parsed;
|
|
273
|
-
}
|
|
274
|
-
function reservationExpired(lease, now, visibilityMs) {
|
|
275
|
-
if (lease === null || lease.length === 0) {
|
|
276
|
-
return true;
|
|
277
|
-
}
|
|
278
|
-
const reservedAt = Number(lease);
|
|
279
|
-
if (!Number.isFinite(reservedAt)) {
|
|
280
|
-
return true;
|
|
281
|
-
}
|
|
282
|
-
return now - reservedAt >= visibilityMs;
|
|
327
|
+
function defaultQueueKeys() {
|
|
328
|
+
return [queueHighKey(), queueListKey(), queueLowKey()];
|
|
283
329
|
}
|
|
330
|
+
var QUEUE_KEYS = defaultQueueKeys();
|
|
284
331
|
function queueKeyForPriority(priority = "default") {
|
|
285
332
|
switch (priority) {
|
|
286
333
|
case "high":
|
|
@@ -291,36 +338,603 @@ function queueKeyForPriority(priority = "default") {
|
|
|
291
338
|
return queueListKey();
|
|
292
339
|
}
|
|
293
340
|
}
|
|
294
|
-
|
|
295
|
-
|
|
341
|
+
|
|
342
|
+
// ../../src/core/queue/redisStreams.ts
|
|
343
|
+
var {RedisClient } = globalThis.Bun;
|
|
344
|
+
var STREAM_GROUP = "strata-workers-v1";
|
|
345
|
+
function streamQueueKey(queueKey) {
|
|
346
|
+
return `${queueKey}:stream`;
|
|
347
|
+
}
|
|
348
|
+
function streamDeadLetterKey(queueKey) {
|
|
349
|
+
return `${streamQueueKey(queueKey)}:invalid`;
|
|
350
|
+
}
|
|
351
|
+
function streamRetryKey(queueKey) {
|
|
352
|
+
return `${streamQueueKey(queueKey)}:retries`;
|
|
353
|
+
}
|
|
354
|
+
function streamControlKey(key, jobId) {
|
|
355
|
+
const prefix = `${streamQueueKey(key)}/`;
|
|
356
|
+
if (!jobId.startsWith(prefix) || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(jobId.slice(prefix.length)))
|
|
357
|
+
throw new Error("Invalid cancellable Streams job ID");
|
|
358
|
+
return `${streamQueueKey(key)}:control:${jobId.slice(prefix.length)}`;
|
|
359
|
+
}
|
|
360
|
+
var ENQUEUE_CONTROLLED = `
|
|
361
|
+
local kind = redis.call('TYPE', KEYS[1]).ok
|
|
362
|
+
if kind ~= 'none' and kind ~= 'stream' then return redis.error_reply('Invalid stream admission key type') end
|
|
363
|
+
if redis.call('EXISTS', KEYS[2]) ~= 0 then return redis.error_reply('Streams control identity already exists') end
|
|
364
|
+
redis.call('XADD', KEYS[1], '*', 'payload', ARGV[1])
|
|
365
|
+
redis.call('HSET', KEYS[2], 'state', 'active')
|
|
366
|
+
return 1
|
|
367
|
+
`;
|
|
368
|
+
var CANCEL_STREAM = `
|
|
369
|
+
local state = redis.call('HGET', KEYS[1], 'state')
|
|
370
|
+
if not state then return 0 end
|
|
371
|
+
local kind = redis.call('TYPE', KEYS[2]).ok
|
|
372
|
+
if kind ~= 'none' and kind ~= 'zset' then return redis.error_reply('Invalid stream retry key type') end
|
|
373
|
+
local payload = redis.call('HGET', KEYS[1], 'retryPayload')
|
|
374
|
+
if payload and redis.call('ZSCORE', KEYS[2], payload) then redis.call('ZADD', KEYS[2], 0, payload) end
|
|
375
|
+
redis.call('HSET', KEYS[1], 'state', 'cancelled')
|
|
376
|
+
return 1
|
|
377
|
+
`;
|
|
378
|
+
async function cancelStreamJob(client, key, jobId) {
|
|
379
|
+
return Number(await client.send("EVAL", [
|
|
380
|
+
CANCEL_STREAM,
|
|
381
|
+
"2",
|
|
382
|
+
streamControlKey(key, jobId),
|
|
383
|
+
streamRetryKey(key)
|
|
384
|
+
])) === 1;
|
|
385
|
+
}
|
|
386
|
+
async function readStreamCancellation(client, controlKey) {
|
|
387
|
+
const state = await client.send("HGET", [controlKey, "state"]);
|
|
388
|
+
if (state !== "active" && state !== "cancelled")
|
|
389
|
+
throw new Error("Missing or invalid Streams control state");
|
|
390
|
+
return state === "cancelled";
|
|
391
|
+
}
|
|
392
|
+
var MAX_TIMER_MS = 2147483647;
|
|
393
|
+
async function readStreamTime(client) {
|
|
394
|
+
const value = await client.send("TIME", []);
|
|
395
|
+
if (!Array.isArray(value) || value.length !== 2 || typeof value[0] !== "string" || typeof value[1] !== "string" || !/^\d+$/.test(value[0]) || !/^\d+$/.test(value[1]))
|
|
396
|
+
throw new Error("Invalid Redis TIME response");
|
|
397
|
+
const seconds = Number(value[0]);
|
|
398
|
+
const micros = Number(value[1]);
|
|
399
|
+
const now = seconds * 1000 + Math.floor(micros / 1000);
|
|
400
|
+
if (!Number.isSafeInteger(seconds) || seconds < 0 || !Number.isSafeInteger(micros) || micros < 0 || micros >= 1e6 || !Number.isSafeInteger(now))
|
|
401
|
+
throw new Error("Invalid Redis TIME response");
|
|
402
|
+
return now;
|
|
403
|
+
}
|
|
404
|
+
function watchStreamDeadline(client, deadlineAtMs, remainingMs, expire, fail) {
|
|
405
|
+
let stopped = false;
|
|
406
|
+
let timer;
|
|
407
|
+
const schedule = (remaining) => {
|
|
408
|
+
timer = setTimeout(() => {
|
|
409
|
+
(async () => {
|
|
410
|
+
try {
|
|
411
|
+
const remaining = deadlineAtMs - await readStreamTime(client);
|
|
412
|
+
if (stopped)
|
|
413
|
+
return;
|
|
414
|
+
if (remaining <= 0)
|
|
415
|
+
expire();
|
|
416
|
+
else
|
|
417
|
+
schedule(remaining);
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (!stopped)
|
|
420
|
+
fail(error);
|
|
421
|
+
}
|
|
422
|
+
})();
|
|
423
|
+
}, Math.max(1, Math.min(MAX_TIMER_MS, remaining)));
|
|
424
|
+
};
|
|
425
|
+
schedule(remainingMs);
|
|
426
|
+
return () => {
|
|
427
|
+
stopped = true;
|
|
428
|
+
clearTimeout(timer);
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
var SCHEDULE_RETRY = `
|
|
432
|
+
local pending = redis.call('XPENDING', KEYS[1], ARGV[1], ARGV[2], ARGV[2], 1)
|
|
433
|
+
if #pending == 0 or pending[1][2] ~= ARGV[3] or tonumber(pending[1][3]) >= tonumber(ARGV[4]) then return 0 end
|
|
434
|
+
local kind = redis.call('TYPE', KEYS[2]).ok
|
|
435
|
+
if kind ~= 'none' and kind ~= 'zset' then return redis.error_reply('Invalid stream retry key type') end
|
|
436
|
+
if ARGV[8] == '1' then
|
|
437
|
+
local state = redis.call('HGET', KEYS[3], 'state')
|
|
438
|
+
if state == 'cancelled' then return redis.error_reply('Streams job cancelled') end
|
|
439
|
+
if state ~= 'active' then return redis.error_reply('Missing or invalid Streams control state') end
|
|
440
|
+
end
|
|
441
|
+
local now = redis.call('TIME')
|
|
442
|
+
local due = tonumber(now[1])*1000 + math.floor(tonumber(now[2])/1000) + tonumber(ARGV[5])
|
|
443
|
+
if ARGV[7] ~= '' then due = math.min(due, tonumber(ARGV[7])) end
|
|
444
|
+
redis.call('ZADD', KEYS[2], due, ARGV[6])
|
|
445
|
+
if ARGV[8] == '1' then redis.call('HSET', KEYS[3], 'retryPayload', ARGV[6]) end
|
|
446
|
+
redis.call('XACK', KEYS[1], ARGV[1], ARGV[2])
|
|
447
|
+
redis.call('XDEL', KEYS[1], ARGV[2])
|
|
448
|
+
if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[3]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[3]) end
|
|
449
|
+
return 1
|
|
450
|
+
`;
|
|
451
|
+
var PROMOTE_RETRIES = `
|
|
452
|
+
local kinds = {'stream','zset'}
|
|
453
|
+
for i=1,2 do
|
|
454
|
+
local kind = redis.call('TYPE', KEYS[i]).ok
|
|
455
|
+
if kind ~= 'none' and kind ~= kinds[i] then return redis.error_reply('Invalid stream retry promotion key type') end
|
|
456
|
+
end
|
|
457
|
+
local now = redis.call('TIME')
|
|
458
|
+
local due = tonumber(now[1])*1000 + math.floor(tonumber(now[2])/1000)
|
|
459
|
+
local entries = redis.call('ZRANGE', KEYS[2], '-inf', due, 'BYSCORE', 'LIMIT', 0, 100)
|
|
460
|
+
for _, payload in ipairs(entries) do
|
|
461
|
+
redis.call('XADD', KEYS[1], '*', 'payload', payload)
|
|
462
|
+
redis.call('ZREM', KEYS[2], payload)
|
|
463
|
+
end
|
|
464
|
+
return #entries
|
|
465
|
+
`;
|
|
466
|
+
async function scheduleStreamRetry(client, key, reservation, envelope, delayMs) {
|
|
467
|
+
if (!Number.isSafeInteger(delayMs) || delayMs < 0 || delayMs > Number.MAX_SAFE_INTEGER / 2)
|
|
468
|
+
throw new Error("Invalid Streams retry delay");
|
|
469
|
+
const scheduled = await client.send("EVAL", [
|
|
470
|
+
SCHEDULE_RETRY,
|
|
471
|
+
"3",
|
|
472
|
+
streamQueueKey(key),
|
|
473
|
+
streamRetryKey(key),
|
|
474
|
+
envelope.cancellable ? streamControlKey(key, envelope.jobId ?? "") : streamQueueKey(key),
|
|
475
|
+
STREAM_GROUP,
|
|
476
|
+
reservation.id,
|
|
477
|
+
reservation.owner,
|
|
478
|
+
String(readQueueVisibilityMs()),
|
|
479
|
+
String(delayMs),
|
|
480
|
+
JSON.stringify(envelope),
|
|
481
|
+
envelope.deadlineAtMs === undefined ? "" : String(envelope.deadlineAtMs),
|
|
482
|
+
envelope.cancellable ? "1" : "0"
|
|
483
|
+
]);
|
|
484
|
+
if (Number(scheduled) !== 1)
|
|
485
|
+
throw new Error("Queue lease lost while scheduling retry");
|
|
486
|
+
}
|
|
487
|
+
async function promoteStreamRetries(client, key) {
|
|
488
|
+
return Number(await client.send("EVAL", [PROMOTE_RETRIES, "2", streamQueueKey(key), streamRetryKey(key)]));
|
|
489
|
+
}
|
|
490
|
+
async function readStreamQueueDepth(client, key) {
|
|
491
|
+
return Number(await client.send("EVAL", [
|
|
492
|
+
"return redis.call('XLEN', KEYS[1]) + redis.call('ZCARD', KEYS[2])",
|
|
493
|
+
"2",
|
|
494
|
+
streamQueueKey(key),
|
|
495
|
+
streamRetryKey(key)
|
|
496
|
+
]));
|
|
497
|
+
}
|
|
498
|
+
var ASSERT_DRAINED = `
|
|
499
|
+
if redis.call('LLEN', KEYS[1]) > 0 or redis.call('LLEN', KEYS[2]) > 0 or redis.call('HLEN', KEYS[3]) > 0 then
|
|
500
|
+
return redis.error_reply('Legacy queue state requires maintenance conversion before Streams activation')
|
|
501
|
+
end
|
|
502
|
+
return 1
|
|
503
|
+
`;
|
|
504
|
+
async function assertLegacyDrained(client, key) {
|
|
505
|
+
await client.send("EVAL", [
|
|
506
|
+
ASSERT_DRAINED,
|
|
507
|
+
"3",
|
|
508
|
+
key,
|
|
509
|
+
`${key}:processing`,
|
|
510
|
+
`${key}:processing:leases`
|
|
511
|
+
]);
|
|
512
|
+
}
|
|
513
|
+
var UPDATE_STREAM = `
|
|
514
|
+
local pending = redis.call('XPENDING', KEYS[1], ARGV[1], ARGV[2], ARGV[2], 1)
|
|
515
|
+
if #pending == 0 or pending[1][2] ~= ARGV[3] or tonumber(pending[1][3]) >= tonumber(ARGV[4]) then return 0 end
|
|
516
|
+
if ARGV[7] == '1' then
|
|
517
|
+
local kind = redis.call('TYPE', KEYS[3]).ok
|
|
518
|
+
if kind ~= 'none' and kind ~= 'hash' then return redis.error_reply('Invalid stream control key type') end
|
|
519
|
+
end
|
|
520
|
+
if ARGV[5] == 'renew' then
|
|
521
|
+
local claimed = redis.call('XCLAIM', KEYS[1], ARGV[1], ARGV[3], 0, ARGV[2], 'JUSTID')
|
|
522
|
+
return #claimed
|
|
523
|
+
end
|
|
524
|
+
if ARGV[5] == 'quarantine' then
|
|
525
|
+
local kind = redis.call('TYPE', KEYS[2]).ok
|
|
526
|
+
if kind ~= 'none' and kind ~= 'stream' then return redis.error_reply('Invalid stream quarantine key type') end
|
|
527
|
+
redis.call('XADD', KEYS[2], '*', 'sourceId', ARGV[2], 'payload', ARGV[6])
|
|
528
|
+
end
|
|
529
|
+
local acknowledged = redis.call('XACK', KEYS[1], ARGV[1], ARGV[2])
|
|
530
|
+
if acknowledged == 1 then
|
|
531
|
+
redis.call('XDEL', KEYS[1], ARGV[2])
|
|
532
|
+
if ARGV[7] == '1' then redis.call('DEL', KEYS[3]) end
|
|
533
|
+
end
|
|
534
|
+
if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[3]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[3]) end
|
|
535
|
+
return acknowledged
|
|
536
|
+
`;
|
|
537
|
+
var CLAIM_STREAM = `
|
|
538
|
+
local window = redis.call('XPENDING', KEYS[1], ARGV[1], ARGV[4], '+', 10)
|
|
539
|
+
local claimed = redis.call('XAUTOCLAIM', KEYS[1], ARGV[1], ARGV[2], ARGV[3], ARGV[4], 'COUNT', 1)
|
|
540
|
+
for _, entry in ipairs(window) do
|
|
541
|
+
if entry[2] ~= ARGV[2] and #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, entry[2]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], entry[2]) end
|
|
542
|
+
end
|
|
543
|
+
if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[2]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[2]) end
|
|
544
|
+
return claimed
|
|
545
|
+
`;
|
|
546
|
+
var READ_STREAM = `
|
|
547
|
+
local entries = redis.call('XREADGROUP', 'GROUP', ARGV[1], ARGV[2], 'COUNT', 1, 'STREAMS', KEYS[1], '>')
|
|
548
|
+
if not entries and #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[2]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[2]) end
|
|
549
|
+
return entries
|
|
550
|
+
`;
|
|
551
|
+
async function ensureStreamGroup(client, key) {
|
|
552
|
+
await assertLegacyDrained(client, key);
|
|
296
553
|
try {
|
|
297
|
-
|
|
298
|
-
} catch {
|
|
299
|
-
|
|
300
|
-
|
|
554
|
+
await client.send("XGROUP", ["CREATE", streamQueueKey(key), STREAM_GROUP, "0", "MKSTREAM"]);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
if (!(error instanceof Error) || !error.message.includes("BUSYGROUP"))
|
|
557
|
+
throw error;
|
|
301
558
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
559
|
+
}
|
|
560
|
+
function readStreamEntry(value, owner) {
|
|
561
|
+
if (!Array.isArray(value) || typeof value[0] !== "string" || !Array.isArray(value[1]))
|
|
562
|
+
throw new Error("Invalid Redis stream entry response");
|
|
563
|
+
const fields = value[1];
|
|
564
|
+
let payload;
|
|
565
|
+
for (let index = 0;index < fields.length; index += 2) {
|
|
566
|
+
if (fields[index] === "payload" && typeof fields[index + 1] === "string")
|
|
567
|
+
payload = fields[index + 1];
|
|
305
568
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
569
|
+
return { id: value[0], owner, payload: payload ?? "" };
|
|
570
|
+
}
|
|
571
|
+
async function updateStreamReservation(client, key, reservation, action, controlKey) {
|
|
572
|
+
return Number(await client.send("EVAL", [
|
|
573
|
+
UPDATE_STREAM,
|
|
574
|
+
"3",
|
|
575
|
+
streamQueueKey(key),
|
|
576
|
+
streamDeadLetterKey(key),
|
|
577
|
+
controlKey ?? streamQueueKey(key),
|
|
578
|
+
STREAM_GROUP,
|
|
579
|
+
reservation.id,
|
|
580
|
+
reservation.owner,
|
|
581
|
+
String(readQueueVisibilityMs()),
|
|
582
|
+
action,
|
|
583
|
+
reservation.payload,
|
|
584
|
+
controlKey ? "1" : "0"
|
|
585
|
+
])) === 1;
|
|
586
|
+
}
|
|
587
|
+
async function reserveStreamJob(client, key, owner, cursor = "0-0") {
|
|
588
|
+
const claimed = await client.send("EVAL", [
|
|
589
|
+
CLAIM_STREAM,
|
|
590
|
+
"1",
|
|
591
|
+
streamQueueKey(key),
|
|
592
|
+
STREAM_GROUP,
|
|
593
|
+
owner,
|
|
594
|
+
String(readQueueVisibilityMs()),
|
|
595
|
+
cursor
|
|
596
|
+
]);
|
|
597
|
+
if (!Array.isArray(claimed) || typeof claimed[0] !== "string" || !Array.isArray(claimed[1]))
|
|
598
|
+
throw new Error("Invalid Redis XAUTOCLAIM response");
|
|
599
|
+
const nextCursor = claimed[0];
|
|
600
|
+
if (claimed[1].length > 0)
|
|
601
|
+
return { reservation: readStreamEntry(claimed[1][0], owner), cursor: nextCursor };
|
|
602
|
+
const fresh = await client.send("EVAL", [
|
|
603
|
+
READ_STREAM,
|
|
604
|
+
"1",
|
|
605
|
+
streamQueueKey(key),
|
|
606
|
+
STREAM_GROUP,
|
|
607
|
+
owner
|
|
608
|
+
]);
|
|
609
|
+
if (fresh === null)
|
|
610
|
+
return { reservation: null, cursor: nextCursor };
|
|
611
|
+
if (!Array.isArray(fresh) || !Array.isArray(fresh[0]) || !Array.isArray(fresh[0][1]))
|
|
612
|
+
throw new Error("Invalid Redis XREADGROUP response");
|
|
613
|
+
return { reservation: readStreamEntry(fresh[0][1][0], owner), cursor: nextCursor };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
class RedisStreamsQueue {
|
|
617
|
+
client;
|
|
618
|
+
constructor(redisUrl) {
|
|
619
|
+
this.client = new RedisClient(redisUrl);
|
|
310
620
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
return null;
|
|
621
|
+
close() {
|
|
622
|
+
this.client.close();
|
|
314
623
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
624
|
+
async dispatch(job, payload) {
|
|
625
|
+
await this.enqueue(job, payload);
|
|
626
|
+
}
|
|
627
|
+
async enqueue(job, payload, options = {}) {
|
|
628
|
+
return this.admit(job, payload, options);
|
|
629
|
+
}
|
|
630
|
+
async replay(job, payload, jobId) {
|
|
631
|
+
const prefix = defaultQueueKeys().find((key) => jobId.startsWith(`${streamQueueKey(key)}/`));
|
|
632
|
+
if (!prefix || !/^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9]+-[0-9]+)$/.test(jobId.slice(streamQueueKey(prefix).length + 1)))
|
|
633
|
+
throw new Error("Replay identity does not belong to this Streams namespace");
|
|
634
|
+
await this.admit(job, payload, {}, jobId);
|
|
635
|
+
}
|
|
636
|
+
async admit(job, payload, options, replayId) {
|
|
637
|
+
if (options.cancellable !== undefined && typeof options.cancellable !== "boolean")
|
|
638
|
+
throw new Error("cancellable must be a boolean");
|
|
639
|
+
if (options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > MAX_TIMER_MS))
|
|
640
|
+
throw new Error("Streams timeoutMs must be an integer between 1 and 2147483647");
|
|
641
|
+
const name = jobRegistry.resolveName(job);
|
|
642
|
+
if (!name)
|
|
643
|
+
throw new Error("Job is not registered with the queue worker registry.");
|
|
644
|
+
const key = queueKeyForPriority(job.priority);
|
|
645
|
+
await assertLegacyDrained(this.client, key);
|
|
646
|
+
const jobId = replayId ?? `${streamQueueKey(key)}/${crypto.randomUUID()}`;
|
|
647
|
+
const deadlineAtMs = options.timeoutMs === undefined ? undefined : await readStreamTime(this.client) + options.timeoutMs;
|
|
648
|
+
const envelope = JSON.stringify({
|
|
649
|
+
name,
|
|
650
|
+
payload,
|
|
651
|
+
attempts: 0,
|
|
652
|
+
jobId,
|
|
653
|
+
deadlineAtMs,
|
|
654
|
+
...options.cancellable ? { cancellable: true } : {}
|
|
655
|
+
});
|
|
656
|
+
if (options.cancellable)
|
|
657
|
+
await this.client.send("EVAL", [
|
|
658
|
+
ENQUEUE_CONTROLLED,
|
|
659
|
+
"2",
|
|
660
|
+
streamQueueKey(key),
|
|
661
|
+
streamControlKey(key, jobId),
|
|
662
|
+
envelope
|
|
663
|
+
]);
|
|
664
|
+
else
|
|
665
|
+
await this.client.send("XADD", [streamQueueKey(key), "*", "payload", envelope]);
|
|
666
|
+
return jobId;
|
|
667
|
+
}
|
|
668
|
+
async cancel(jobId) {
|
|
669
|
+
const key = defaultQueueKeys().find((key) => jobId.startsWith(`${streamQueueKey(key)}/`));
|
|
670
|
+
if (!key)
|
|
671
|
+
throw new Error("Invalid cancellable Streams job ID");
|
|
672
|
+
return cancelStreamJob(this.client, key, jobId);
|
|
318
673
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
class RedisStreamsWorker {
|
|
677
|
+
failedJobs;
|
|
678
|
+
timeoutSeconds;
|
|
679
|
+
queueKeys;
|
|
680
|
+
client;
|
|
681
|
+
owner = crypto.randomUUID();
|
|
682
|
+
cursors = new Map;
|
|
683
|
+
processing = false;
|
|
684
|
+
initialized = false;
|
|
685
|
+
running = false;
|
|
686
|
+
stopping = false;
|
|
687
|
+
active;
|
|
688
|
+
constructor(redisUrl, failedJobs, timeoutSeconds = 5, queueKeys = defaultQueueKeys()) {
|
|
689
|
+
this.failedJobs = failedJobs;
|
|
690
|
+
this.timeoutSeconds = timeoutSeconds;
|
|
691
|
+
this.queueKeys = queueKeys;
|
|
692
|
+
readQueueVisibilityMs();
|
|
693
|
+
this.client = new RedisClient(redisUrl);
|
|
694
|
+
}
|
|
695
|
+
requestStop() {
|
|
696
|
+
this.stopping = true;
|
|
697
|
+
}
|
|
698
|
+
isRunning() {
|
|
699
|
+
return this.running;
|
|
700
|
+
}
|
|
701
|
+
close() {
|
|
702
|
+
this.stopping = true;
|
|
703
|
+
this.active?.abort(new Error("Queue worker closed"));
|
|
704
|
+
this.client.close();
|
|
705
|
+
}
|
|
706
|
+
async initialize() {
|
|
707
|
+
if (this.initialized)
|
|
708
|
+
return;
|
|
709
|
+
for (const key of this.queueKeys)
|
|
710
|
+
await ensureStreamGroup(this.client, key);
|
|
711
|
+
this.initialized = true;
|
|
712
|
+
}
|
|
713
|
+
async reserve(key) {
|
|
714
|
+
await promoteStreamRetries(this.client, key);
|
|
715
|
+
const result = await reserveStreamJob(this.client, key, this.owner, this.cursors.get(key));
|
|
716
|
+
this.cursors.set(key, result.cursor);
|
|
717
|
+
return result.reservation;
|
|
718
|
+
}
|
|
719
|
+
async processNext() {
|
|
720
|
+
if (this.processing)
|
|
721
|
+
throw new Error("Queue worker already processing a job.");
|
|
722
|
+
this.processing = true;
|
|
723
|
+
try {
|
|
724
|
+
return await this.processOne();
|
|
725
|
+
} finally {
|
|
726
|
+
this.processing = false;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
async processOne() {
|
|
730
|
+
if (this.stopping)
|
|
731
|
+
return false;
|
|
732
|
+
await this.initialize();
|
|
733
|
+
const deadline = performance.now() + this.timeoutSeconds * 1000;
|
|
734
|
+
do {
|
|
735
|
+
for (const key of this.queueKeys) {
|
|
736
|
+
if (this.stopping)
|
|
737
|
+
return false;
|
|
738
|
+
const reservation = await this.reserve(key);
|
|
739
|
+
if (!reservation)
|
|
740
|
+
continue;
|
|
741
|
+
await this.handle(key, reservation);
|
|
742
|
+
return true;
|
|
743
|
+
}
|
|
744
|
+
if (performance.now() >= deadline || this.stopping)
|
|
745
|
+
return false;
|
|
746
|
+
await Bun.sleep(Math.min(100, Math.max(1, deadline - performance.now())));
|
|
747
|
+
} while (!this.stopping);
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
async handle(key, reservation) {
|
|
751
|
+
const envelope = parseQueueJobEnvelope(reservation.payload);
|
|
752
|
+
if (!envelope) {
|
|
753
|
+
await updateStreamReservation(this.client, key, reservation, "quarantine");
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
envelope.jobId ??= `${streamQueueKey(key)}/${reservation.id}`;
|
|
757
|
+
let controlKey;
|
|
758
|
+
if (envelope.cancellable) {
|
|
759
|
+
try {
|
|
760
|
+
controlKey = streamControlKey(key, envelope.jobId);
|
|
761
|
+
} catch {
|
|
762
|
+
await updateStreamReservation(this.client, key, reservation, "quarantine");
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const cancellationError = new Error("Streams job cancelled");
|
|
767
|
+
if (controlKey && await readStreamCancellation(this.client, controlKey)) {
|
|
768
|
+
await this.failedJobs.recordFailure({
|
|
769
|
+
jobName: envelope.name,
|
|
770
|
+
jobId: envelope.jobId,
|
|
771
|
+
payload: envelope.payload,
|
|
772
|
+
exception: cancellationError.message
|
|
773
|
+
});
|
|
774
|
+
await updateStreamReservation(this.client, key, reservation, "ack", controlKey);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
const remainingMs = envelope.deadlineAtMs === undefined ? undefined : envelope.deadlineAtMs - await readStreamTime(this.client);
|
|
778
|
+
const deadlineError = new Error("Streams job deadline exceeded");
|
|
779
|
+
if (remainingMs !== undefined && remainingMs <= 0) {
|
|
780
|
+
await this.failedJobs.recordFailure({
|
|
781
|
+
jobName: envelope.name,
|
|
782
|
+
jobId: envelope.jobId,
|
|
783
|
+
payload: envelope.payload,
|
|
784
|
+
exception: deadlineError.message
|
|
785
|
+
});
|
|
786
|
+
await updateStreamReservation(this.client, key, reservation, "ack", controlKey);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const controller = new AbortController;
|
|
790
|
+
this.active = controller;
|
|
791
|
+
let acknowledge = false;
|
|
792
|
+
let released = false;
|
|
793
|
+
let ownershipLost = false;
|
|
794
|
+
let renewal;
|
|
795
|
+
let deadlineExpired = false;
|
|
796
|
+
let cancellationRequested = false;
|
|
797
|
+
let failureRecorded = false;
|
|
798
|
+
const loseOwnership = (error) => {
|
|
799
|
+
ownershipLost = true;
|
|
800
|
+
controller.abort(error);
|
|
801
|
+
};
|
|
802
|
+
const stopDeadlineWatch = remainingMs === undefined || envelope.deadlineAtMs === undefined ? undefined : watchStreamDeadline(this.client, envelope.deadlineAtMs, remainingMs, () => {
|
|
803
|
+
deadlineExpired = true;
|
|
804
|
+
controller.abort(deadlineError);
|
|
805
|
+
}, loseOwnership);
|
|
806
|
+
const heartbeat = setInterval(() => {
|
|
807
|
+
if (renewal || ownershipLost || released)
|
|
808
|
+
return;
|
|
809
|
+
renewal = (async () => {
|
|
810
|
+
try {
|
|
811
|
+
if (!await updateStreamReservation(this.client, key, reservation, "renew", controlKey))
|
|
812
|
+
loseOwnership(new Error("Queue lease lost"));
|
|
813
|
+
else if (controlKey && await readStreamCancellation(this.client, controlKey)) {
|
|
814
|
+
cancellationRequested = true;
|
|
815
|
+
controller.abort(cancellationError);
|
|
816
|
+
}
|
|
817
|
+
} catch (error) {
|
|
818
|
+
loseOwnership(error);
|
|
819
|
+
console.error("[StreamsWorker] Lease renewal failed:", error);
|
|
820
|
+
}
|
|
821
|
+
})().finally(() => {
|
|
822
|
+
renewal = undefined;
|
|
823
|
+
});
|
|
824
|
+
}, Math.max(1, Math.floor(readQueueVisibilityMs() / 3)));
|
|
825
|
+
try {
|
|
826
|
+
await runQueueJob(envelope, this.failedJobs, {
|
|
827
|
+
context: {
|
|
828
|
+
jobId: envelope.jobId,
|
|
829
|
+
signal: controller.signal,
|
|
830
|
+
deadlineAtMs: envelope.deadlineAtMs
|
|
831
|
+
},
|
|
832
|
+
deferRetry: async (next, delayMs) => {
|
|
833
|
+
controller.signal.throwIfAborted();
|
|
834
|
+
await scheduleStreamRetry(this.client, key, reservation, next, delayMs);
|
|
835
|
+
released = true;
|
|
836
|
+
},
|
|
837
|
+
onFailureRecorded: () => {
|
|
838
|
+
acknowledge = true;
|
|
839
|
+
failureRecorded = true;
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
acknowledge = true;
|
|
843
|
+
} catch (error) {
|
|
844
|
+
console.error("[StreamsWorker] Job failed:", error);
|
|
845
|
+
} finally {
|
|
846
|
+
stopDeadlineWatch?.();
|
|
847
|
+
clearInterval(heartbeat);
|
|
848
|
+
await renewal;
|
|
849
|
+
this.active = undefined;
|
|
850
|
+
}
|
|
851
|
+
if (!ownershipLost && !released && controlKey && (cancellationRequested || await readStreamCancellation(this.client, controlKey))) {
|
|
852
|
+
if (!failureRecorded)
|
|
853
|
+
await this.failedJobs.recordFailure({
|
|
854
|
+
jobName: envelope.name,
|
|
855
|
+
jobId: envelope.jobId,
|
|
856
|
+
payload: envelope.payload,
|
|
857
|
+
exception: cancellationError.message
|
|
858
|
+
});
|
|
859
|
+
failureRecorded = true;
|
|
860
|
+
acknowledge = true;
|
|
861
|
+
}
|
|
862
|
+
if (!ownershipLost && !released && envelope.deadlineAtMs !== undefined && (deadlineExpired || envelope.deadlineAtMs <= await readStreamTime(this.client))) {
|
|
863
|
+
if (!failureRecorded)
|
|
864
|
+
await this.failedJobs.recordFailure({
|
|
865
|
+
jobName: envelope.name,
|
|
866
|
+
jobId: envelope.jobId,
|
|
867
|
+
payload: envelope.payload,
|
|
868
|
+
exception: deadlineError.message
|
|
869
|
+
});
|
|
870
|
+
acknowledge = true;
|
|
871
|
+
}
|
|
872
|
+
if (acknowledge && !ownershipLost && !released)
|
|
873
|
+
await updateStreamReservation(this.client, key, reservation, "ack", controlKey);
|
|
874
|
+
}
|
|
875
|
+
async run() {
|
|
876
|
+
this.running = true;
|
|
877
|
+
try {
|
|
878
|
+
while (!this.stopping)
|
|
879
|
+
await this.processNext();
|
|
880
|
+
} finally {
|
|
881
|
+
this.running = false;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
var CONVERT_LIST = `
|
|
886
|
+
local kinds = {'list','list','hash','stream'}
|
|
887
|
+
for i=1,4 do
|
|
888
|
+
local kind = redis.call('TYPE', KEYS[i]).ok
|
|
889
|
+
if kind ~= 'none' and kind ~= kinds[i] then return redis.error_reply('Invalid queue conversion key type') end
|
|
890
|
+
end
|
|
891
|
+
if redis.call('LLEN', KEYS[2]) > 0 or redis.call('HLEN', KEYS[3]) > 0 then return redis.error_reply('Drain or recover processing reservations before queue conversion') end
|
|
892
|
+
local converted = 0
|
|
893
|
+
for i=1,tonumber(ARGV[1]) do
|
|
894
|
+
local payload = redis.call('LINDEX', KEYS[1], -1)
|
|
895
|
+
if not payload then break end
|
|
896
|
+
redis.call('XADD', KEYS[4], '*', 'payload', payload)
|
|
897
|
+
redis.call('RPOP', KEYS[1])
|
|
898
|
+
converted = converted + 1
|
|
899
|
+
end
|
|
900
|
+
return converted
|
|
901
|
+
`;
|
|
902
|
+
async function migrateLegacyQueueToStreams(client, options) {
|
|
903
|
+
if (options.maintenance !== true)
|
|
904
|
+
throw new Error("Queue conversion requires maintenance mode: stop all producers and workers first.");
|
|
905
|
+
const batchSize = options.batchSize ?? 100;
|
|
906
|
+
if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 1000)
|
|
907
|
+
throw new Error("Queue conversion batchSize must be between 1 and 1000.");
|
|
908
|
+
let converted = 0;
|
|
909
|
+
for (const key of options.queueKeys ?? defaultQueueKeys())
|
|
910
|
+
converted += Number(await client.send("EVAL", [
|
|
911
|
+
CONVERT_LIST,
|
|
912
|
+
"4",
|
|
913
|
+
key,
|
|
914
|
+
`${key}:processing`,
|
|
915
|
+
`${key}:processing:leases`,
|
|
916
|
+
streamQueueKey(key),
|
|
917
|
+
String(batchSize)
|
|
918
|
+
]));
|
|
919
|
+
return converted;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// ../../src/core/queue/redisQueue.ts
|
|
923
|
+
function queueProcessingKey(queueKey) {
|
|
924
|
+
return `${queueKey}:processing`;
|
|
925
|
+
}
|
|
926
|
+
function queueProcessingLeaseKey(queueKey) {
|
|
927
|
+
return `${queueKey}:processing:leases`;
|
|
928
|
+
}
|
|
929
|
+
function reservationExpired(lease, now, visibilityMs) {
|
|
930
|
+
if (lease === null || lease.length === 0) {
|
|
931
|
+
return true;
|
|
932
|
+
}
|
|
933
|
+
const reservedAt = Number(lease);
|
|
934
|
+
if (!Number.isFinite(reservedAt)) {
|
|
935
|
+
return true;
|
|
936
|
+
}
|
|
937
|
+
return now - reservedAt >= visibilityMs;
|
|
324
938
|
}
|
|
325
939
|
var SCRIPT_HEADER = `
|
|
326
940
|
local function checkType(key, expected)
|
|
@@ -409,7 +1023,7 @@ async function reserveQueueJob(client, queueKey, owner) {
|
|
|
409
1023
|
queueProcessingLeaseKey(queueKey),
|
|
410
1024
|
crypto.randomUUID(),
|
|
411
1025
|
owner,
|
|
412
|
-
String(
|
|
1026
|
+
String(readQueueVisibilityMs())
|
|
413
1027
|
]);
|
|
414
1028
|
if (typeof raw !== "string")
|
|
415
1029
|
return null;
|
|
@@ -427,7 +1041,7 @@ async function updateQueueReservation(client, queueKey, reservation, action) {
|
|
|
427
1041
|
reservation.owner,
|
|
428
1042
|
reservation.record,
|
|
429
1043
|
action,
|
|
430
|
-
String(
|
|
1044
|
+
String(readQueueVisibilityMs()),
|
|
431
1045
|
reservation.payload
|
|
432
1046
|
])) === 1;
|
|
433
1047
|
}
|
|
@@ -440,7 +1054,7 @@ async function reclaimExpiredQueueReservations(client, queueKeys = QUEUE_KEYS) {
|
|
|
440
1054
|
key,
|
|
441
1055
|
queueProcessingKey(key),
|
|
442
1056
|
queueProcessingLeaseKey(key),
|
|
443
|
-
String(
|
|
1057
|
+
String(readQueueVisibilityMs())
|
|
444
1058
|
]));
|
|
445
1059
|
}
|
|
446
1060
|
return reclaimed;
|
|
@@ -449,7 +1063,7 @@ async function reclaimExpiredQueueReservations(client, queueKeys = QUEUE_KEYS) {
|
|
|
449
1063
|
class RedisQueue {
|
|
450
1064
|
client;
|
|
451
1065
|
constructor(redisUrl) {
|
|
452
|
-
this.client = new
|
|
1066
|
+
this.client = new RedisClient2(redisUrl);
|
|
453
1067
|
}
|
|
454
1068
|
close() {
|
|
455
1069
|
this.client.close();
|
|
@@ -481,7 +1095,7 @@ class QueueWorker {
|
|
|
481
1095
|
this.failedJobs = failedJobs;
|
|
482
1096
|
this.timeoutSeconds = timeoutSeconds;
|
|
483
1097
|
this.queueKeys = queueKeys;
|
|
484
|
-
this.client = new
|
|
1098
|
+
this.client = new RedisClient2(redisUrl);
|
|
485
1099
|
}
|
|
486
1100
|
requestStop() {
|
|
487
1101
|
this.stopping = true;
|
|
@@ -536,7 +1150,7 @@ class QueueWorker {
|
|
|
536
1150
|
})().finally(() => {
|
|
537
1151
|
renewal = undefined;
|
|
538
1152
|
});
|
|
539
|
-
}, Math.max(1, Math.floor(
|
|
1153
|
+
}, Math.max(1, Math.floor(readQueueVisibilityMs() / 3)));
|
|
540
1154
|
try {
|
|
541
1155
|
await runQueueJob(envelope, this.failedJobs, {
|
|
542
1156
|
onFailureRecorded: () => {
|
|
@@ -570,11 +1184,11 @@ class QueueWorker {
|
|
|
570
1184
|
}
|
|
571
1185
|
}
|
|
572
1186
|
async function countPendingQueueJobs(redisUrl) {
|
|
573
|
-
const client = new
|
|
1187
|
+
const client = new RedisClient2(redisUrl);
|
|
574
1188
|
try {
|
|
575
1189
|
let total = 0;
|
|
576
|
-
for (const queueKey of QUEUE_KEYS) {
|
|
577
|
-
total += await client.llen(queueKey);
|
|
1190
|
+
for (const queueKey of resolveRedisQueueTransport() === "streams" ? defaultQueueKeys() : QUEUE_KEYS) {
|
|
1191
|
+
total += resolveRedisQueueTransport() === "streams" ? await readStreamQueueDepth(client, queueKey) : await client.llen(queueKey);
|
|
578
1192
|
}
|
|
579
1193
|
return total;
|
|
580
1194
|
} finally {
|
|
@@ -684,12 +1298,12 @@ function createProductionQueue(driver, options = {}) {
|
|
|
684
1298
|
if (!options.redisUrl) {
|
|
685
1299
|
throw new Error('QUEUE_DRIVER="redis" requires REDIS_URL to be set.');
|
|
686
1300
|
}
|
|
687
|
-
return new RedisQueue(options.redisUrl);
|
|
1301
|
+
return resolveRedisQueueTransport() === "streams" ? new RedisStreamsQueue(options.redisUrl) : new RedisQueue(options.redisUrl);
|
|
688
1302
|
}
|
|
689
1303
|
return new ResilientQueue(failedJobs, driver === "async");
|
|
690
1304
|
}
|
|
691
1305
|
function createQueueWorker(redisUrl, failedJobs = createFailedJobService()) {
|
|
692
|
-
return new QueueWorker(redisUrl, failedJobs);
|
|
1306
|
+
return resolveRedisQueueTransport() === "streams" ? new RedisStreamsWorker(redisUrl, failedJobs) : new QueueWorker(redisUrl, failedJobs);
|
|
693
1307
|
}
|
|
694
1308
|
|
|
695
1309
|
// ../../src/core/queue/createAppQueue.ts
|
|
@@ -706,5 +1320,6 @@ export {
|
|
|
706
1320
|
createAppQueue,
|
|
707
1321
|
createFailedJobService,
|
|
708
1322
|
createQueueWorker,
|
|
709
|
-
createTrackedJob
|
|
1323
|
+
createTrackedJob,
|
|
1324
|
+
resolveQueueConfig
|
|
710
1325
|
};
|