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