@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
|
@@ -1,92 +1,6 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// ../../src/core/queue/redisQueue.ts
|
|
3
|
-
var {RedisClient } = globalThis.Bun;
|
|
4
|
-
|
|
5
|
-
// ../../src/core/runtime/appEnv.ts
|
|
6
|
-
var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
|
|
7
|
-
function normalizeEnvValue(value) {
|
|
8
|
-
return (value ?? "").trim().toLowerCase();
|
|
9
|
-
}
|
|
10
|
-
function isProductionEnv(env = process.env) {
|
|
11
|
-
const appEnv = normalizeEnvValue(env.APP_ENV);
|
|
12
|
-
const nodeEnv = normalizeEnvValue(env.NODE_ENV);
|
|
13
|
-
if (appEnv === "production" || nodeEnv === "production") {
|
|
14
|
-
return true;
|
|
15
|
-
}
|
|
16
|
-
if (appEnv === "") {
|
|
17
|
-
return false;
|
|
18
|
-
}
|
|
19
|
-
return !NON_PRODUCTION_APP_ENVS.has(appEnv);
|
|
20
|
-
}
|
|
21
|
-
function envFlagEnabled(value) {
|
|
22
|
-
return value === "true";
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// ../../src/core/runtime/appKeyPrefix.ts
|
|
26
|
-
function appKeyPrefix() {
|
|
27
|
-
return process.env.APP_KEY_PREFIX?.trim() || "strata";
|
|
28
|
-
}
|
|
29
|
-
function appCookieName(kind) {
|
|
30
|
-
return `${appKeyPrefix()}_${kind}`;
|
|
31
|
-
}
|
|
32
|
-
function appDevSecret(kind) {
|
|
33
|
-
return `${appKeyPrefix()}-dev-${kind}`;
|
|
34
|
-
}
|
|
35
|
-
function requireConfiguredSecret(names, devKind, env = process.env) {
|
|
36
|
-
for (const name of names) {
|
|
37
|
-
const value = env[name]?.trim();
|
|
38
|
-
if (value) {
|
|
39
|
-
return value;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
if (isProductionEnv(env)) {
|
|
43
|
-
throw new Error(`${names[0]} must be set outside development. Do not derive secrets from the app name.`);
|
|
44
|
-
}
|
|
45
|
-
return appDevSecret(devKind);
|
|
46
|
-
}
|
|
47
|
-
function namespacedRedisKey(kind) {
|
|
48
|
-
return `${appKeyPrefix()}:${kind}`;
|
|
49
|
-
}
|
|
50
|
-
function smtpEhloHost() {
|
|
51
|
-
const raw = process.env.MAIL_EHLO?.trim() || `${appKeyPrefix()}.local`;
|
|
52
|
-
const safe = raw.replace(/[^a-zA-Z0-9.-]/g, "");
|
|
53
|
-
return safe || "strata.local";
|
|
54
|
-
}
|
|
55
|
-
function siemEventType() {
|
|
56
|
-
return process.env.SIEM_EVENT_TYPE?.trim() || `${appKeyPrefix()}.audit`;
|
|
57
|
-
}
|
|
58
|
-
function appUserAgent() {
|
|
59
|
-
return process.env.APP_USER_AGENT?.trim() || appKeyPrefix();
|
|
60
|
-
}
|
|
61
|
-
function otelServiceName() {
|
|
62
|
-
return process.env.OTEL_SERVICE_NAME?.trim() || `${appKeyPrefix()}-api`;
|
|
63
|
-
}
|
|
64
|
-
function webhookSignatureHeader() {
|
|
65
|
-
return process.env.WEBHOOK_SIGNATURE_HEADER?.trim() || `x-${appKeyPrefix()}-signature`;
|
|
66
|
-
}
|
|
67
|
-
function appDisplayName() {
|
|
68
|
-
return process.env.APP_NAME?.trim() || "Strata";
|
|
69
|
-
}
|
|
70
|
-
function appEnv() {
|
|
71
|
-
return process.env.APP_ENV?.trim() || "local";
|
|
72
|
-
}
|
|
73
|
-
function appUrl() {
|
|
74
|
-
return (process.env.APP_URL?.trim() || "http://localhost:3000").replace(/\/$/, "");
|
|
75
|
-
}
|
|
76
|
-
function apiPrefix() {
|
|
77
|
-
const raw = process.env.API_PREFIX?.trim() || "/api/v1";
|
|
78
|
-
const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
|
|
79
|
-
const trimmed = withSlash.replace(/\/+$/, "");
|
|
80
|
-
return trimmed || "/api/v1";
|
|
81
|
-
}
|
|
82
|
-
function sdkClientClassName() {
|
|
83
|
-
const override = process.env.APP_SDK_CLASS?.trim();
|
|
84
|
-
if (override && /^[A-Za-z_][A-Za-z0-9_]*$/.test(override)) {
|
|
85
|
-
return override;
|
|
86
|
-
}
|
|
87
|
-
const fromName = appDisplayName().replace(/[^A-Za-z0-9]/g, "");
|
|
88
|
-
return fromName ? `${fromName}Client` : "AppClient";
|
|
89
|
-
}
|
|
3
|
+
var {RedisClient: RedisClient2 } = globalThis.Bun;
|
|
90
4
|
|
|
91
5
|
// ../../src/core/queue/jobRegistry.ts
|
|
92
6
|
class JobRegistry {
|
|
@@ -125,6 +39,9 @@ function readSharedJobRegistry() {
|
|
|
125
39
|
}
|
|
126
40
|
var jobRegistry = readSharedJobRegistry();
|
|
127
41
|
|
|
42
|
+
// ../../src/core/queue/jobRunner.ts
|
|
43
|
+
import { setTimeout as delay } from "timers/promises";
|
|
44
|
+
|
|
128
45
|
// ../../src/core/queue/queueConfig.ts
|
|
129
46
|
function resolveQueueConfig() {
|
|
130
47
|
const driver = process.env.QUEUE_DRIVER;
|
|
@@ -147,22 +64,82 @@ var queueConfig = {
|
|
|
147
64
|
return resolveQueueConfig().backoffMs;
|
|
148
65
|
}
|
|
149
66
|
};
|
|
67
|
+
function resolveRedisQueueTransport() {
|
|
68
|
+
const transport = process.env.QUEUE_REDIS_TRANSPORT ?? "lists";
|
|
69
|
+
if (transport !== "lists" && transport !== "streams")
|
|
70
|
+
throw new Error('QUEUE_REDIS_TRANSPORT must be "lists" or "streams".');
|
|
71
|
+
return transport;
|
|
72
|
+
}
|
|
73
|
+
function readQueueVisibilityMs() {
|
|
74
|
+
const value = Number(process.env.QUEUE_VISIBILITY_MS ?? "60000");
|
|
75
|
+
if (!Number.isSafeInteger(value) || value < 30)
|
|
76
|
+
throw new Error("QUEUE_VISIBILITY_MS must be an integer of at least 30 milliseconds.");
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
150
79
|
|
|
151
80
|
// ../../src/core/queue/jobRunner.ts
|
|
81
|
+
function parseQueueJobEnvelope(rawPayload) {
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(rawPayload);
|
|
85
|
+
} catch {
|
|
86
|
+
console.error("[QueueWorker] Ignoring malformed queue payload");
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
if (!parsed || typeof parsed !== "object") {
|
|
90
|
+
console.error("[QueueWorker] Ignoring non-object queue payload");
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const envelope = parsed;
|
|
94
|
+
if (typeof envelope.name !== "string" || envelope.name.length === 0) {
|
|
95
|
+
console.error("[QueueWorker] Ignoring queue payload without job name");
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
if (!jobRegistry.create(envelope.name)) {
|
|
99
|
+
console.error(`[QueueWorker] Ignoring unknown job name: ${envelope.name}`);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
if (envelope.payload !== undefined && (typeof envelope.payload !== "object" || envelope.payload === null)) {
|
|
103
|
+
console.error("[QueueWorker] Ignoring queue payload with invalid payload object");
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
if (envelope.jobId !== undefined && (typeof envelope.jobId !== "string" || !envelope.jobId.length))
|
|
107
|
+
return null;
|
|
108
|
+
if (envelope.attempts !== undefined && (!Number.isSafeInteger(envelope.attempts) || envelope.attempts < 0))
|
|
109
|
+
return null;
|
|
110
|
+
if (envelope.deadlineAtMs !== undefined && (!Number.isSafeInteger(envelope.deadlineAtMs) || envelope.deadlineAtMs <= 0))
|
|
111
|
+
return null;
|
|
112
|
+
if (envelope.cancellable !== undefined && (typeof envelope.cancellable !== "boolean" || envelope.cancellable && !envelope.jobId))
|
|
113
|
+
return null;
|
|
114
|
+
return {
|
|
115
|
+
...envelope.cancellable ? { cancellable: true } : {},
|
|
116
|
+
...envelope.deadlineAtMs === undefined ? {} : { deadlineAtMs: envelope.deadlineAtMs },
|
|
117
|
+
jobId: envelope.jobId,
|
|
118
|
+
name: envelope.name,
|
|
119
|
+
payload: envelope.payload ?? {},
|
|
120
|
+
attempts: typeof envelope.attempts === "number" ? envelope.attempts : 0
|
|
121
|
+
};
|
|
122
|
+
}
|
|
152
123
|
async function runQueueJob(envelope, failedJobs, options = {}) {
|
|
124
|
+
options.context?.signal.throwIfAborted();
|
|
153
125
|
const job = jobRegistry.create(envelope.name);
|
|
154
126
|
if (!job) {
|
|
155
127
|
throw new Error(`Unknown job "${envelope.name}".`);
|
|
156
128
|
}
|
|
157
129
|
const attempts = envelope.attempts ?? 0;
|
|
158
130
|
try {
|
|
159
|
-
await job.handle(envelope.payload);
|
|
131
|
+
await job.handle(envelope.payload, options.context);
|
|
160
132
|
} catch (error) {
|
|
133
|
+
options.context?.signal.throwIfAborted();
|
|
161
134
|
const nextAttempt = attempts + 1;
|
|
162
135
|
const maxAttempts = job.maxAttempts ?? queueConfig.maxAttempts;
|
|
163
136
|
if (nextAttempt < maxAttempts) {
|
|
164
137
|
const backoffMs = job.backoffMs ?? queueConfig.backoffMs;
|
|
165
|
-
|
|
138
|
+
if (options.deferRetry) {
|
|
139
|
+
await options.deferRetry({ ...envelope, attempts: nextAttempt }, backoffMs * nextAttempt);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
await delay(backoffMs * nextAttempt, undefined, { signal: options.context?.signal });
|
|
166
143
|
await runQueueJob({
|
|
167
144
|
...envelope,
|
|
168
145
|
attempts: nextAttempt
|
|
@@ -171,6 +148,7 @@ async function runQueueJob(envelope, failedJobs, options = {}) {
|
|
|
171
148
|
}
|
|
172
149
|
await failedJobs.recordFailure({
|
|
173
150
|
jobName: envelope.name,
|
|
151
|
+
jobId: options.context?.jobId ?? envelope.jobId,
|
|
174
152
|
payload: envelope.payload,
|
|
175
153
|
exception: error instanceof Error ? error.stack ?? error.message : String(error)
|
|
176
154
|
});
|
|
@@ -179,7 +157,93 @@ async function runQueueJob(envelope, failedJobs, options = {}) {
|
|
|
179
157
|
}
|
|
180
158
|
}
|
|
181
159
|
|
|
182
|
-
// ../../src/core/
|
|
160
|
+
// ../../src/core/runtime/appEnv.ts
|
|
161
|
+
var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
|
|
162
|
+
function normalizeEnvValue(value) {
|
|
163
|
+
return (value ?? "").trim().toLowerCase();
|
|
164
|
+
}
|
|
165
|
+
function isProductionEnv(env = process.env) {
|
|
166
|
+
const appEnv = normalizeEnvValue(env.APP_ENV);
|
|
167
|
+
const nodeEnv = normalizeEnvValue(env.NODE_ENV);
|
|
168
|
+
if (appEnv === "production" || nodeEnv === "production") {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
if (appEnv === "") {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return !NON_PRODUCTION_APP_ENVS.has(appEnv);
|
|
175
|
+
}
|
|
176
|
+
function envFlagEnabled(value) {
|
|
177
|
+
return value === "true";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ../../src/core/runtime/appKeyPrefix.ts
|
|
181
|
+
function appKeyPrefix() {
|
|
182
|
+
return process.env.APP_KEY_PREFIX?.trim() || "strata";
|
|
183
|
+
}
|
|
184
|
+
function appCookieName(kind) {
|
|
185
|
+
return `${appKeyPrefix()}_${kind}`;
|
|
186
|
+
}
|
|
187
|
+
function appDevSecret(kind) {
|
|
188
|
+
return `${appKeyPrefix()}-dev-${kind}`;
|
|
189
|
+
}
|
|
190
|
+
function requireConfiguredSecret(names, devKind, env = process.env) {
|
|
191
|
+
for (const name of names) {
|
|
192
|
+
const value = env[name]?.trim();
|
|
193
|
+
if (value) {
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (isProductionEnv(env)) {
|
|
198
|
+
throw new Error(`${names[0]} must be set outside development. Do not derive secrets from the app name.`);
|
|
199
|
+
}
|
|
200
|
+
return appDevSecret(devKind);
|
|
201
|
+
}
|
|
202
|
+
function namespacedRedisKey(kind) {
|
|
203
|
+
return `${appKeyPrefix()}:${kind}`;
|
|
204
|
+
}
|
|
205
|
+
function smtpEhloHost() {
|
|
206
|
+
const raw = process.env.MAIL_EHLO?.trim() || `${appKeyPrefix()}.local`;
|
|
207
|
+
const safe = raw.replace(/[^a-zA-Z0-9.-]/g, "");
|
|
208
|
+
return safe || "strata.local";
|
|
209
|
+
}
|
|
210
|
+
function siemEventType() {
|
|
211
|
+
return process.env.SIEM_EVENT_TYPE?.trim() || `${appKeyPrefix()}.audit`;
|
|
212
|
+
}
|
|
213
|
+
function appUserAgent() {
|
|
214
|
+
return process.env.APP_USER_AGENT?.trim() || appKeyPrefix();
|
|
215
|
+
}
|
|
216
|
+
function otelServiceName() {
|
|
217
|
+
return process.env.OTEL_SERVICE_NAME?.trim() || `${appKeyPrefix()}-api`;
|
|
218
|
+
}
|
|
219
|
+
function webhookSignatureHeader() {
|
|
220
|
+
return process.env.WEBHOOK_SIGNATURE_HEADER?.trim() || `x-${appKeyPrefix()}-signature`;
|
|
221
|
+
}
|
|
222
|
+
function appDisplayName() {
|
|
223
|
+
return process.env.APP_NAME?.trim() || "Strata";
|
|
224
|
+
}
|
|
225
|
+
function appEnv() {
|
|
226
|
+
return process.env.APP_ENV?.trim() || "local";
|
|
227
|
+
}
|
|
228
|
+
function appUrl() {
|
|
229
|
+
return (process.env.APP_URL?.trim() || "http://localhost:3000").replace(/\/$/, "");
|
|
230
|
+
}
|
|
231
|
+
function apiPrefix() {
|
|
232
|
+
const raw = process.env.API_PREFIX?.trim() || "/api/v1";
|
|
233
|
+
const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
|
|
234
|
+
const trimmed = withSlash.replace(/\/+$/, "");
|
|
235
|
+
return trimmed || "/api/v1";
|
|
236
|
+
}
|
|
237
|
+
function sdkClientClassName() {
|
|
238
|
+
const override = process.env.APP_SDK_CLASS?.trim();
|
|
239
|
+
if (override && /^[A-Za-z_][A-Za-z0-9_]*$/.test(override)) {
|
|
240
|
+
return override;
|
|
241
|
+
}
|
|
242
|
+
const fromName = appDisplayName().replace(/[^A-Za-z0-9]/g, "");
|
|
243
|
+
return fromName ? `${fromName}Client` : "AppClient";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ../../src/core/queue/redisQueueKeys.ts
|
|
183
247
|
function queueListKey() {
|
|
184
248
|
return namespacedRedisKey("queue:default");
|
|
185
249
|
}
|
|
@@ -192,31 +256,10 @@ function queueLowKey() {
|
|
|
192
256
|
var QUEUE_LIST_KEY = queueListKey();
|
|
193
257
|
var QUEUE_HIGH_KEY = queueHighKey();
|
|
194
258
|
var QUEUE_LOW_KEY = queueLowKey();
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
function queueProcessingKey(queueKey) {
|
|
198
|
-
return `${queueKey}:processing`;
|
|
199
|
-
}
|
|
200
|
-
function queueProcessingLeaseKey(queueKey) {
|
|
201
|
-
return `${queueKey}:processing:leases`;
|
|
202
|
-
}
|
|
203
|
-
function readVisibilityMs() {
|
|
204
|
-
const parsed = Number(process.env.QUEUE_VISIBILITY_MS ?? String(DEFAULT_VISIBILITY_MS));
|
|
205
|
-
if (!Number.isSafeInteger(parsed) || parsed < 30) {
|
|
206
|
-
throw new Error("QUEUE_VISIBILITY_MS must be an integer of at least 30 milliseconds.");
|
|
207
|
-
}
|
|
208
|
-
return parsed;
|
|
209
|
-
}
|
|
210
|
-
function reservationExpired(lease, now, visibilityMs) {
|
|
211
|
-
if (lease === null || lease.length === 0) {
|
|
212
|
-
return true;
|
|
213
|
-
}
|
|
214
|
-
const reservedAt = Number(lease);
|
|
215
|
-
if (!Number.isFinite(reservedAt)) {
|
|
216
|
-
return true;
|
|
217
|
-
}
|
|
218
|
-
return now - reservedAt >= visibilityMs;
|
|
259
|
+
function defaultQueueKeys() {
|
|
260
|
+
return [queueHighKey(), queueListKey(), queueLowKey()];
|
|
219
261
|
}
|
|
262
|
+
var QUEUE_KEYS = defaultQueueKeys();
|
|
220
263
|
function queueKeyForPriority(priority = "default") {
|
|
221
264
|
switch (priority) {
|
|
222
265
|
case "high":
|
|
@@ -227,36 +270,603 @@ function queueKeyForPriority(priority = "default") {
|
|
|
227
270
|
return queueListKey();
|
|
228
271
|
}
|
|
229
272
|
}
|
|
230
|
-
|
|
231
|
-
|
|
273
|
+
|
|
274
|
+
// ../../src/core/queue/redisStreams.ts
|
|
275
|
+
var {RedisClient } = globalThis.Bun;
|
|
276
|
+
var STREAM_GROUP = "strata-workers-v1";
|
|
277
|
+
function streamQueueKey(queueKey) {
|
|
278
|
+
return `${queueKey}:stream`;
|
|
279
|
+
}
|
|
280
|
+
function streamDeadLetterKey(queueKey) {
|
|
281
|
+
return `${streamQueueKey(queueKey)}:invalid`;
|
|
282
|
+
}
|
|
283
|
+
function streamRetryKey(queueKey) {
|
|
284
|
+
return `${streamQueueKey(queueKey)}:retries`;
|
|
285
|
+
}
|
|
286
|
+
function streamControlKey(key, jobId) {
|
|
287
|
+
const prefix = `${streamQueueKey(key)}/`;
|
|
288
|
+
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)))
|
|
289
|
+
throw new Error("Invalid cancellable Streams job ID");
|
|
290
|
+
return `${streamQueueKey(key)}:control:${jobId.slice(prefix.length)}`;
|
|
291
|
+
}
|
|
292
|
+
var ENQUEUE_CONTROLLED = `
|
|
293
|
+
local kind = redis.call('TYPE', KEYS[1]).ok
|
|
294
|
+
if kind ~= 'none' and kind ~= 'stream' then return redis.error_reply('Invalid stream admission key type') end
|
|
295
|
+
if redis.call('EXISTS', KEYS[2]) ~= 0 then return redis.error_reply('Streams control identity already exists') end
|
|
296
|
+
redis.call('XADD', KEYS[1], '*', 'payload', ARGV[1])
|
|
297
|
+
redis.call('HSET', KEYS[2], 'state', 'active')
|
|
298
|
+
return 1
|
|
299
|
+
`;
|
|
300
|
+
var CANCEL_STREAM = `
|
|
301
|
+
local state = redis.call('HGET', KEYS[1], 'state')
|
|
302
|
+
if not state then return 0 end
|
|
303
|
+
local kind = redis.call('TYPE', KEYS[2]).ok
|
|
304
|
+
if kind ~= 'none' and kind ~= 'zset' then return redis.error_reply('Invalid stream retry key type') end
|
|
305
|
+
local payload = redis.call('HGET', KEYS[1], 'retryPayload')
|
|
306
|
+
if payload and redis.call('ZSCORE', KEYS[2], payload) then redis.call('ZADD', KEYS[2], 0, payload) end
|
|
307
|
+
redis.call('HSET', KEYS[1], 'state', 'cancelled')
|
|
308
|
+
return 1
|
|
309
|
+
`;
|
|
310
|
+
async function cancelStreamJob(client, key, jobId) {
|
|
311
|
+
return Number(await client.send("EVAL", [
|
|
312
|
+
CANCEL_STREAM,
|
|
313
|
+
"2",
|
|
314
|
+
streamControlKey(key, jobId),
|
|
315
|
+
streamRetryKey(key)
|
|
316
|
+
])) === 1;
|
|
317
|
+
}
|
|
318
|
+
async function readStreamCancellation(client, controlKey) {
|
|
319
|
+
const state = await client.send("HGET", [controlKey, "state"]);
|
|
320
|
+
if (state !== "active" && state !== "cancelled")
|
|
321
|
+
throw new Error("Missing or invalid Streams control state");
|
|
322
|
+
return state === "cancelled";
|
|
323
|
+
}
|
|
324
|
+
var MAX_TIMER_MS = 2147483647;
|
|
325
|
+
async function readStreamTime(client) {
|
|
326
|
+
const value = await client.send("TIME", []);
|
|
327
|
+
if (!Array.isArray(value) || value.length !== 2 || typeof value[0] !== "string" || typeof value[1] !== "string" || !/^\d+$/.test(value[0]) || !/^\d+$/.test(value[1]))
|
|
328
|
+
throw new Error("Invalid Redis TIME response");
|
|
329
|
+
const seconds = Number(value[0]);
|
|
330
|
+
const micros = Number(value[1]);
|
|
331
|
+
const now = seconds * 1000 + Math.floor(micros / 1000);
|
|
332
|
+
if (!Number.isSafeInteger(seconds) || seconds < 0 || !Number.isSafeInteger(micros) || micros < 0 || micros >= 1e6 || !Number.isSafeInteger(now))
|
|
333
|
+
throw new Error("Invalid Redis TIME response");
|
|
334
|
+
return now;
|
|
335
|
+
}
|
|
336
|
+
function watchStreamDeadline(client, deadlineAtMs, remainingMs, expire, fail) {
|
|
337
|
+
let stopped = false;
|
|
338
|
+
let timer;
|
|
339
|
+
const schedule = (remaining) => {
|
|
340
|
+
timer = setTimeout(() => {
|
|
341
|
+
(async () => {
|
|
342
|
+
try {
|
|
343
|
+
const remaining = deadlineAtMs - await readStreamTime(client);
|
|
344
|
+
if (stopped)
|
|
345
|
+
return;
|
|
346
|
+
if (remaining <= 0)
|
|
347
|
+
expire();
|
|
348
|
+
else
|
|
349
|
+
schedule(remaining);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
if (!stopped)
|
|
352
|
+
fail(error);
|
|
353
|
+
}
|
|
354
|
+
})();
|
|
355
|
+
}, Math.max(1, Math.min(MAX_TIMER_MS, remaining)));
|
|
356
|
+
};
|
|
357
|
+
schedule(remainingMs);
|
|
358
|
+
return () => {
|
|
359
|
+
stopped = true;
|
|
360
|
+
clearTimeout(timer);
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
var SCHEDULE_RETRY = `
|
|
364
|
+
local pending = redis.call('XPENDING', KEYS[1], ARGV[1], ARGV[2], ARGV[2], 1)
|
|
365
|
+
if #pending == 0 or pending[1][2] ~= ARGV[3] or tonumber(pending[1][3]) >= tonumber(ARGV[4]) then return 0 end
|
|
366
|
+
local kind = redis.call('TYPE', KEYS[2]).ok
|
|
367
|
+
if kind ~= 'none' and kind ~= 'zset' then return redis.error_reply('Invalid stream retry key type') end
|
|
368
|
+
if ARGV[8] == '1' then
|
|
369
|
+
local state = redis.call('HGET', KEYS[3], 'state')
|
|
370
|
+
if state == 'cancelled' then return redis.error_reply('Streams job cancelled') end
|
|
371
|
+
if state ~= 'active' then return redis.error_reply('Missing or invalid Streams control state') end
|
|
372
|
+
end
|
|
373
|
+
local now = redis.call('TIME')
|
|
374
|
+
local due = tonumber(now[1])*1000 + math.floor(tonumber(now[2])/1000) + tonumber(ARGV[5])
|
|
375
|
+
if ARGV[7] ~= '' then due = math.min(due, tonumber(ARGV[7])) end
|
|
376
|
+
redis.call('ZADD', KEYS[2], due, ARGV[6])
|
|
377
|
+
if ARGV[8] == '1' then redis.call('HSET', KEYS[3], 'retryPayload', ARGV[6]) end
|
|
378
|
+
redis.call('XACK', KEYS[1], ARGV[1], ARGV[2])
|
|
379
|
+
redis.call('XDEL', KEYS[1], ARGV[2])
|
|
380
|
+
if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[3]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[3]) end
|
|
381
|
+
return 1
|
|
382
|
+
`;
|
|
383
|
+
var PROMOTE_RETRIES = `
|
|
384
|
+
local kinds = {'stream','zset'}
|
|
385
|
+
for i=1,2 do
|
|
386
|
+
local kind = redis.call('TYPE', KEYS[i]).ok
|
|
387
|
+
if kind ~= 'none' and kind ~= kinds[i] then return redis.error_reply('Invalid stream retry promotion key type') end
|
|
388
|
+
end
|
|
389
|
+
local now = redis.call('TIME')
|
|
390
|
+
local due = tonumber(now[1])*1000 + math.floor(tonumber(now[2])/1000)
|
|
391
|
+
local entries = redis.call('ZRANGE', KEYS[2], '-inf', due, 'BYSCORE', 'LIMIT', 0, 100)
|
|
392
|
+
for _, payload in ipairs(entries) do
|
|
393
|
+
redis.call('XADD', KEYS[1], '*', 'payload', payload)
|
|
394
|
+
redis.call('ZREM', KEYS[2], payload)
|
|
395
|
+
end
|
|
396
|
+
return #entries
|
|
397
|
+
`;
|
|
398
|
+
async function scheduleStreamRetry(client, key, reservation, envelope, delayMs) {
|
|
399
|
+
if (!Number.isSafeInteger(delayMs) || delayMs < 0 || delayMs > Number.MAX_SAFE_INTEGER / 2)
|
|
400
|
+
throw new Error("Invalid Streams retry delay");
|
|
401
|
+
const scheduled = await client.send("EVAL", [
|
|
402
|
+
SCHEDULE_RETRY,
|
|
403
|
+
"3",
|
|
404
|
+
streamQueueKey(key),
|
|
405
|
+
streamRetryKey(key),
|
|
406
|
+
envelope.cancellable ? streamControlKey(key, envelope.jobId ?? "") : streamQueueKey(key),
|
|
407
|
+
STREAM_GROUP,
|
|
408
|
+
reservation.id,
|
|
409
|
+
reservation.owner,
|
|
410
|
+
String(readQueueVisibilityMs()),
|
|
411
|
+
String(delayMs),
|
|
412
|
+
JSON.stringify(envelope),
|
|
413
|
+
envelope.deadlineAtMs === undefined ? "" : String(envelope.deadlineAtMs),
|
|
414
|
+
envelope.cancellable ? "1" : "0"
|
|
415
|
+
]);
|
|
416
|
+
if (Number(scheduled) !== 1)
|
|
417
|
+
throw new Error("Queue lease lost while scheduling retry");
|
|
418
|
+
}
|
|
419
|
+
async function promoteStreamRetries(client, key) {
|
|
420
|
+
return Number(await client.send("EVAL", [PROMOTE_RETRIES, "2", streamQueueKey(key), streamRetryKey(key)]));
|
|
421
|
+
}
|
|
422
|
+
async function readStreamQueueDepth(client, key) {
|
|
423
|
+
return Number(await client.send("EVAL", [
|
|
424
|
+
"return redis.call('XLEN', KEYS[1]) + redis.call('ZCARD', KEYS[2])",
|
|
425
|
+
"2",
|
|
426
|
+
streamQueueKey(key),
|
|
427
|
+
streamRetryKey(key)
|
|
428
|
+
]));
|
|
429
|
+
}
|
|
430
|
+
var ASSERT_DRAINED = `
|
|
431
|
+
if redis.call('LLEN', KEYS[1]) > 0 or redis.call('LLEN', KEYS[2]) > 0 or redis.call('HLEN', KEYS[3]) > 0 then
|
|
432
|
+
return redis.error_reply('Legacy queue state requires maintenance conversion before Streams activation')
|
|
433
|
+
end
|
|
434
|
+
return 1
|
|
435
|
+
`;
|
|
436
|
+
async function assertLegacyDrained(client, key) {
|
|
437
|
+
await client.send("EVAL", [
|
|
438
|
+
ASSERT_DRAINED,
|
|
439
|
+
"3",
|
|
440
|
+
key,
|
|
441
|
+
`${key}:processing`,
|
|
442
|
+
`${key}:processing:leases`
|
|
443
|
+
]);
|
|
444
|
+
}
|
|
445
|
+
var UPDATE_STREAM = `
|
|
446
|
+
local pending = redis.call('XPENDING', KEYS[1], ARGV[1], ARGV[2], ARGV[2], 1)
|
|
447
|
+
if #pending == 0 or pending[1][2] ~= ARGV[3] or tonumber(pending[1][3]) >= tonumber(ARGV[4]) then return 0 end
|
|
448
|
+
if ARGV[7] == '1' then
|
|
449
|
+
local kind = redis.call('TYPE', KEYS[3]).ok
|
|
450
|
+
if kind ~= 'none' and kind ~= 'hash' then return redis.error_reply('Invalid stream control key type') end
|
|
451
|
+
end
|
|
452
|
+
if ARGV[5] == 'renew' then
|
|
453
|
+
local claimed = redis.call('XCLAIM', KEYS[1], ARGV[1], ARGV[3], 0, ARGV[2], 'JUSTID')
|
|
454
|
+
return #claimed
|
|
455
|
+
end
|
|
456
|
+
if ARGV[5] == 'quarantine' then
|
|
457
|
+
local kind = redis.call('TYPE', KEYS[2]).ok
|
|
458
|
+
if kind ~= 'none' and kind ~= 'stream' then return redis.error_reply('Invalid stream quarantine key type') end
|
|
459
|
+
redis.call('XADD', KEYS[2], '*', 'sourceId', ARGV[2], 'payload', ARGV[6])
|
|
460
|
+
end
|
|
461
|
+
local acknowledged = redis.call('XACK', KEYS[1], ARGV[1], ARGV[2])
|
|
462
|
+
if acknowledged == 1 then
|
|
463
|
+
redis.call('XDEL', KEYS[1], ARGV[2])
|
|
464
|
+
if ARGV[7] == '1' then redis.call('DEL', KEYS[3]) end
|
|
465
|
+
end
|
|
466
|
+
if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[3]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[3]) end
|
|
467
|
+
return acknowledged
|
|
468
|
+
`;
|
|
469
|
+
var CLAIM_STREAM = `
|
|
470
|
+
local window = redis.call('XPENDING', KEYS[1], ARGV[1], ARGV[4], '+', 10)
|
|
471
|
+
local claimed = redis.call('XAUTOCLAIM', KEYS[1], ARGV[1], ARGV[2], ARGV[3], ARGV[4], 'COUNT', 1)
|
|
472
|
+
for _, entry in ipairs(window) do
|
|
473
|
+
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
|
|
474
|
+
end
|
|
475
|
+
if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[2]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[2]) end
|
|
476
|
+
return claimed
|
|
477
|
+
`;
|
|
478
|
+
var READ_STREAM = `
|
|
479
|
+
local entries = redis.call('XREADGROUP', 'GROUP', ARGV[1], ARGV[2], 'COUNT', 1, 'STREAMS', KEYS[1], '>')
|
|
480
|
+
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
|
|
481
|
+
return entries
|
|
482
|
+
`;
|
|
483
|
+
async function ensureStreamGroup(client, key) {
|
|
484
|
+
await assertLegacyDrained(client, key);
|
|
232
485
|
try {
|
|
233
|
-
|
|
234
|
-
} catch {
|
|
235
|
-
|
|
236
|
-
|
|
486
|
+
await client.send("XGROUP", ["CREATE", streamQueueKey(key), STREAM_GROUP, "0", "MKSTREAM"]);
|
|
487
|
+
} catch (error) {
|
|
488
|
+
if (!(error instanceof Error) || !error.message.includes("BUSYGROUP"))
|
|
489
|
+
throw error;
|
|
237
490
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
491
|
+
}
|
|
492
|
+
function readStreamEntry(value, owner) {
|
|
493
|
+
if (!Array.isArray(value) || typeof value[0] !== "string" || !Array.isArray(value[1]))
|
|
494
|
+
throw new Error("Invalid Redis stream entry response");
|
|
495
|
+
const fields = value[1];
|
|
496
|
+
let payload;
|
|
497
|
+
for (let index = 0;index < fields.length; index += 2) {
|
|
498
|
+
if (fields[index] === "payload" && typeof fields[index + 1] === "string")
|
|
499
|
+
payload = fields[index + 1];
|
|
241
500
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
501
|
+
return { id: value[0], owner, payload: payload ?? "" };
|
|
502
|
+
}
|
|
503
|
+
async function updateStreamReservation(client, key, reservation, action, controlKey) {
|
|
504
|
+
return Number(await client.send("EVAL", [
|
|
505
|
+
UPDATE_STREAM,
|
|
506
|
+
"3",
|
|
507
|
+
streamQueueKey(key),
|
|
508
|
+
streamDeadLetterKey(key),
|
|
509
|
+
controlKey ?? streamQueueKey(key),
|
|
510
|
+
STREAM_GROUP,
|
|
511
|
+
reservation.id,
|
|
512
|
+
reservation.owner,
|
|
513
|
+
String(readQueueVisibilityMs()),
|
|
514
|
+
action,
|
|
515
|
+
reservation.payload,
|
|
516
|
+
controlKey ? "1" : "0"
|
|
517
|
+
])) === 1;
|
|
518
|
+
}
|
|
519
|
+
async function reserveStreamJob(client, key, owner, cursor = "0-0") {
|
|
520
|
+
const claimed = await client.send("EVAL", [
|
|
521
|
+
CLAIM_STREAM,
|
|
522
|
+
"1",
|
|
523
|
+
streamQueueKey(key),
|
|
524
|
+
STREAM_GROUP,
|
|
525
|
+
owner,
|
|
526
|
+
String(readQueueVisibilityMs()),
|
|
527
|
+
cursor
|
|
528
|
+
]);
|
|
529
|
+
if (!Array.isArray(claimed) || typeof claimed[0] !== "string" || !Array.isArray(claimed[1]))
|
|
530
|
+
throw new Error("Invalid Redis XAUTOCLAIM response");
|
|
531
|
+
const nextCursor = claimed[0];
|
|
532
|
+
if (claimed[1].length > 0)
|
|
533
|
+
return { reservation: readStreamEntry(claimed[1][0], owner), cursor: nextCursor };
|
|
534
|
+
const fresh = await client.send("EVAL", [
|
|
535
|
+
READ_STREAM,
|
|
536
|
+
"1",
|
|
537
|
+
streamQueueKey(key),
|
|
538
|
+
STREAM_GROUP,
|
|
539
|
+
owner
|
|
540
|
+
]);
|
|
541
|
+
if (fresh === null)
|
|
542
|
+
return { reservation: null, cursor: nextCursor };
|
|
543
|
+
if (!Array.isArray(fresh) || !Array.isArray(fresh[0]) || !Array.isArray(fresh[0][1]))
|
|
544
|
+
throw new Error("Invalid Redis XREADGROUP response");
|
|
545
|
+
return { reservation: readStreamEntry(fresh[0][1][0], owner), cursor: nextCursor };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
class RedisStreamsQueue {
|
|
549
|
+
client;
|
|
550
|
+
constructor(redisUrl) {
|
|
551
|
+
this.client = new RedisClient(redisUrl);
|
|
246
552
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
return null;
|
|
553
|
+
close() {
|
|
554
|
+
this.client.close();
|
|
250
555
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
556
|
+
async dispatch(job, payload) {
|
|
557
|
+
await this.enqueue(job, payload);
|
|
558
|
+
}
|
|
559
|
+
async enqueue(job, payload, options = {}) {
|
|
560
|
+
return this.admit(job, payload, options);
|
|
561
|
+
}
|
|
562
|
+
async replay(job, payload, jobId) {
|
|
563
|
+
const prefix = defaultQueueKeys().find((key) => jobId.startsWith(`${streamQueueKey(key)}/`));
|
|
564
|
+
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)))
|
|
565
|
+
throw new Error("Replay identity does not belong to this Streams namespace");
|
|
566
|
+
await this.admit(job, payload, {}, jobId);
|
|
567
|
+
}
|
|
568
|
+
async admit(job, payload, options, replayId) {
|
|
569
|
+
if (options.cancellable !== undefined && typeof options.cancellable !== "boolean")
|
|
570
|
+
throw new Error("cancellable must be a boolean");
|
|
571
|
+
if (options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > MAX_TIMER_MS))
|
|
572
|
+
throw new Error("Streams timeoutMs must be an integer between 1 and 2147483647");
|
|
573
|
+
const name = jobRegistry.resolveName(job);
|
|
574
|
+
if (!name)
|
|
575
|
+
throw new Error("Job is not registered with the queue worker registry.");
|
|
576
|
+
const key = queueKeyForPriority(job.priority);
|
|
577
|
+
await assertLegacyDrained(this.client, key);
|
|
578
|
+
const jobId = replayId ?? `${streamQueueKey(key)}/${crypto.randomUUID()}`;
|
|
579
|
+
const deadlineAtMs = options.timeoutMs === undefined ? undefined : await readStreamTime(this.client) + options.timeoutMs;
|
|
580
|
+
const envelope = JSON.stringify({
|
|
581
|
+
name,
|
|
582
|
+
payload,
|
|
583
|
+
attempts: 0,
|
|
584
|
+
jobId,
|
|
585
|
+
deadlineAtMs,
|
|
586
|
+
...options.cancellable ? { cancellable: true } : {}
|
|
587
|
+
});
|
|
588
|
+
if (options.cancellable)
|
|
589
|
+
await this.client.send("EVAL", [
|
|
590
|
+
ENQUEUE_CONTROLLED,
|
|
591
|
+
"2",
|
|
592
|
+
streamQueueKey(key),
|
|
593
|
+
streamControlKey(key, jobId),
|
|
594
|
+
envelope
|
|
595
|
+
]);
|
|
596
|
+
else
|
|
597
|
+
await this.client.send("XADD", [streamQueueKey(key), "*", "payload", envelope]);
|
|
598
|
+
return jobId;
|
|
599
|
+
}
|
|
600
|
+
async cancel(jobId) {
|
|
601
|
+
const key = defaultQueueKeys().find((key) => jobId.startsWith(`${streamQueueKey(key)}/`));
|
|
602
|
+
if (!key)
|
|
603
|
+
throw new Error("Invalid cancellable Streams job ID");
|
|
604
|
+
return cancelStreamJob(this.client, key, jobId);
|
|
254
605
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
class RedisStreamsWorker {
|
|
609
|
+
failedJobs;
|
|
610
|
+
timeoutSeconds;
|
|
611
|
+
queueKeys;
|
|
612
|
+
client;
|
|
613
|
+
owner = crypto.randomUUID();
|
|
614
|
+
cursors = new Map;
|
|
615
|
+
processing = false;
|
|
616
|
+
initialized = false;
|
|
617
|
+
running = false;
|
|
618
|
+
stopping = false;
|
|
619
|
+
active;
|
|
620
|
+
constructor(redisUrl, failedJobs, timeoutSeconds = 5, queueKeys = defaultQueueKeys()) {
|
|
621
|
+
this.failedJobs = failedJobs;
|
|
622
|
+
this.timeoutSeconds = timeoutSeconds;
|
|
623
|
+
this.queueKeys = queueKeys;
|
|
624
|
+
readQueueVisibilityMs();
|
|
625
|
+
this.client = new RedisClient(redisUrl);
|
|
626
|
+
}
|
|
627
|
+
requestStop() {
|
|
628
|
+
this.stopping = true;
|
|
629
|
+
}
|
|
630
|
+
isRunning() {
|
|
631
|
+
return this.running;
|
|
632
|
+
}
|
|
633
|
+
close() {
|
|
634
|
+
this.stopping = true;
|
|
635
|
+
this.active?.abort(new Error("Queue worker closed"));
|
|
636
|
+
this.client.close();
|
|
637
|
+
}
|
|
638
|
+
async initialize() {
|
|
639
|
+
if (this.initialized)
|
|
640
|
+
return;
|
|
641
|
+
for (const key of this.queueKeys)
|
|
642
|
+
await ensureStreamGroup(this.client, key);
|
|
643
|
+
this.initialized = true;
|
|
644
|
+
}
|
|
645
|
+
async reserve(key) {
|
|
646
|
+
await promoteStreamRetries(this.client, key);
|
|
647
|
+
const result = await reserveStreamJob(this.client, key, this.owner, this.cursors.get(key));
|
|
648
|
+
this.cursors.set(key, result.cursor);
|
|
649
|
+
return result.reservation;
|
|
650
|
+
}
|
|
651
|
+
async processNext() {
|
|
652
|
+
if (this.processing)
|
|
653
|
+
throw new Error("Queue worker already processing a job.");
|
|
654
|
+
this.processing = true;
|
|
655
|
+
try {
|
|
656
|
+
return await this.processOne();
|
|
657
|
+
} finally {
|
|
658
|
+
this.processing = false;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
async processOne() {
|
|
662
|
+
if (this.stopping)
|
|
663
|
+
return false;
|
|
664
|
+
await this.initialize();
|
|
665
|
+
const deadline = performance.now() + this.timeoutSeconds * 1000;
|
|
666
|
+
do {
|
|
667
|
+
for (const key of this.queueKeys) {
|
|
668
|
+
if (this.stopping)
|
|
669
|
+
return false;
|
|
670
|
+
const reservation = await this.reserve(key);
|
|
671
|
+
if (!reservation)
|
|
672
|
+
continue;
|
|
673
|
+
await this.handle(key, reservation);
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
if (performance.now() >= deadline || this.stopping)
|
|
677
|
+
return false;
|
|
678
|
+
await Bun.sleep(Math.min(100, Math.max(1, deadline - performance.now())));
|
|
679
|
+
} while (!this.stopping);
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
async handle(key, reservation) {
|
|
683
|
+
const envelope = parseQueueJobEnvelope(reservation.payload);
|
|
684
|
+
if (!envelope) {
|
|
685
|
+
await updateStreamReservation(this.client, key, reservation, "quarantine");
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
envelope.jobId ??= `${streamQueueKey(key)}/${reservation.id}`;
|
|
689
|
+
let controlKey;
|
|
690
|
+
if (envelope.cancellable) {
|
|
691
|
+
try {
|
|
692
|
+
controlKey = streamControlKey(key, envelope.jobId);
|
|
693
|
+
} catch {
|
|
694
|
+
await updateStreamReservation(this.client, key, reservation, "quarantine");
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const cancellationError = new Error("Streams job cancelled");
|
|
699
|
+
if (controlKey && await readStreamCancellation(this.client, controlKey)) {
|
|
700
|
+
await this.failedJobs.recordFailure({
|
|
701
|
+
jobName: envelope.name,
|
|
702
|
+
jobId: envelope.jobId,
|
|
703
|
+
payload: envelope.payload,
|
|
704
|
+
exception: cancellationError.message
|
|
705
|
+
});
|
|
706
|
+
await updateStreamReservation(this.client, key, reservation, "ack", controlKey);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const remainingMs = envelope.deadlineAtMs === undefined ? undefined : envelope.deadlineAtMs - await readStreamTime(this.client);
|
|
710
|
+
const deadlineError = new Error("Streams job deadline exceeded");
|
|
711
|
+
if (remainingMs !== undefined && remainingMs <= 0) {
|
|
712
|
+
await this.failedJobs.recordFailure({
|
|
713
|
+
jobName: envelope.name,
|
|
714
|
+
jobId: envelope.jobId,
|
|
715
|
+
payload: envelope.payload,
|
|
716
|
+
exception: deadlineError.message
|
|
717
|
+
});
|
|
718
|
+
await updateStreamReservation(this.client, key, reservation, "ack", controlKey);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const controller = new AbortController;
|
|
722
|
+
this.active = controller;
|
|
723
|
+
let acknowledge = false;
|
|
724
|
+
let released = false;
|
|
725
|
+
let ownershipLost = false;
|
|
726
|
+
let renewal;
|
|
727
|
+
let deadlineExpired = false;
|
|
728
|
+
let cancellationRequested = false;
|
|
729
|
+
let failureRecorded = false;
|
|
730
|
+
const loseOwnership = (error) => {
|
|
731
|
+
ownershipLost = true;
|
|
732
|
+
controller.abort(error);
|
|
733
|
+
};
|
|
734
|
+
const stopDeadlineWatch = remainingMs === undefined || envelope.deadlineAtMs === undefined ? undefined : watchStreamDeadline(this.client, envelope.deadlineAtMs, remainingMs, () => {
|
|
735
|
+
deadlineExpired = true;
|
|
736
|
+
controller.abort(deadlineError);
|
|
737
|
+
}, loseOwnership);
|
|
738
|
+
const heartbeat = setInterval(() => {
|
|
739
|
+
if (renewal || ownershipLost || released)
|
|
740
|
+
return;
|
|
741
|
+
renewal = (async () => {
|
|
742
|
+
try {
|
|
743
|
+
if (!await updateStreamReservation(this.client, key, reservation, "renew", controlKey))
|
|
744
|
+
loseOwnership(new Error("Queue lease lost"));
|
|
745
|
+
else if (controlKey && await readStreamCancellation(this.client, controlKey)) {
|
|
746
|
+
cancellationRequested = true;
|
|
747
|
+
controller.abort(cancellationError);
|
|
748
|
+
}
|
|
749
|
+
} catch (error) {
|
|
750
|
+
loseOwnership(error);
|
|
751
|
+
console.error("[StreamsWorker] Lease renewal failed:", error);
|
|
752
|
+
}
|
|
753
|
+
})().finally(() => {
|
|
754
|
+
renewal = undefined;
|
|
755
|
+
});
|
|
756
|
+
}, Math.max(1, Math.floor(readQueueVisibilityMs() / 3)));
|
|
757
|
+
try {
|
|
758
|
+
await runQueueJob(envelope, this.failedJobs, {
|
|
759
|
+
context: {
|
|
760
|
+
jobId: envelope.jobId,
|
|
761
|
+
signal: controller.signal,
|
|
762
|
+
deadlineAtMs: envelope.deadlineAtMs
|
|
763
|
+
},
|
|
764
|
+
deferRetry: async (next, delayMs) => {
|
|
765
|
+
controller.signal.throwIfAborted();
|
|
766
|
+
await scheduleStreamRetry(this.client, key, reservation, next, delayMs);
|
|
767
|
+
released = true;
|
|
768
|
+
},
|
|
769
|
+
onFailureRecorded: () => {
|
|
770
|
+
acknowledge = true;
|
|
771
|
+
failureRecorded = true;
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
acknowledge = true;
|
|
775
|
+
} catch (error) {
|
|
776
|
+
console.error("[StreamsWorker] Job failed:", error);
|
|
777
|
+
} finally {
|
|
778
|
+
stopDeadlineWatch?.();
|
|
779
|
+
clearInterval(heartbeat);
|
|
780
|
+
await renewal;
|
|
781
|
+
this.active = undefined;
|
|
782
|
+
}
|
|
783
|
+
if (!ownershipLost && !released && controlKey && (cancellationRequested || await readStreamCancellation(this.client, controlKey))) {
|
|
784
|
+
if (!failureRecorded)
|
|
785
|
+
await this.failedJobs.recordFailure({
|
|
786
|
+
jobName: envelope.name,
|
|
787
|
+
jobId: envelope.jobId,
|
|
788
|
+
payload: envelope.payload,
|
|
789
|
+
exception: cancellationError.message
|
|
790
|
+
});
|
|
791
|
+
failureRecorded = true;
|
|
792
|
+
acknowledge = true;
|
|
793
|
+
}
|
|
794
|
+
if (!ownershipLost && !released && envelope.deadlineAtMs !== undefined && (deadlineExpired || envelope.deadlineAtMs <= await readStreamTime(this.client))) {
|
|
795
|
+
if (!failureRecorded)
|
|
796
|
+
await this.failedJobs.recordFailure({
|
|
797
|
+
jobName: envelope.name,
|
|
798
|
+
jobId: envelope.jobId,
|
|
799
|
+
payload: envelope.payload,
|
|
800
|
+
exception: deadlineError.message
|
|
801
|
+
});
|
|
802
|
+
acknowledge = true;
|
|
803
|
+
}
|
|
804
|
+
if (acknowledge && !ownershipLost && !released)
|
|
805
|
+
await updateStreamReservation(this.client, key, reservation, "ack", controlKey);
|
|
806
|
+
}
|
|
807
|
+
async run() {
|
|
808
|
+
this.running = true;
|
|
809
|
+
try {
|
|
810
|
+
while (!this.stopping)
|
|
811
|
+
await this.processNext();
|
|
812
|
+
} finally {
|
|
813
|
+
this.running = false;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
var CONVERT_LIST = `
|
|
818
|
+
local kinds = {'list','list','hash','stream'}
|
|
819
|
+
for i=1,4 do
|
|
820
|
+
local kind = redis.call('TYPE', KEYS[i]).ok
|
|
821
|
+
if kind ~= 'none' and kind ~= kinds[i] then return redis.error_reply('Invalid queue conversion key type') end
|
|
822
|
+
end
|
|
823
|
+
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
|
|
824
|
+
local converted = 0
|
|
825
|
+
for i=1,tonumber(ARGV[1]) do
|
|
826
|
+
local payload = redis.call('LINDEX', KEYS[1], -1)
|
|
827
|
+
if not payload then break end
|
|
828
|
+
redis.call('XADD', KEYS[4], '*', 'payload', payload)
|
|
829
|
+
redis.call('RPOP', KEYS[1])
|
|
830
|
+
converted = converted + 1
|
|
831
|
+
end
|
|
832
|
+
return converted
|
|
833
|
+
`;
|
|
834
|
+
async function migrateLegacyQueueToStreams(client, options) {
|
|
835
|
+
if (options.maintenance !== true)
|
|
836
|
+
throw new Error("Queue conversion requires maintenance mode: stop all producers and workers first.");
|
|
837
|
+
const batchSize = options.batchSize ?? 100;
|
|
838
|
+
if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 1000)
|
|
839
|
+
throw new Error("Queue conversion batchSize must be between 1 and 1000.");
|
|
840
|
+
let converted = 0;
|
|
841
|
+
for (const key of options.queueKeys ?? defaultQueueKeys())
|
|
842
|
+
converted += Number(await client.send("EVAL", [
|
|
843
|
+
CONVERT_LIST,
|
|
844
|
+
"4",
|
|
845
|
+
key,
|
|
846
|
+
`${key}:processing`,
|
|
847
|
+
`${key}:processing:leases`,
|
|
848
|
+
streamQueueKey(key),
|
|
849
|
+
String(batchSize)
|
|
850
|
+
]));
|
|
851
|
+
return converted;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// ../../src/core/queue/redisQueue.ts
|
|
855
|
+
function queueProcessingKey(queueKey) {
|
|
856
|
+
return `${queueKey}:processing`;
|
|
857
|
+
}
|
|
858
|
+
function queueProcessingLeaseKey(queueKey) {
|
|
859
|
+
return `${queueKey}:processing:leases`;
|
|
860
|
+
}
|
|
861
|
+
function reservationExpired(lease, now, visibilityMs) {
|
|
862
|
+
if (lease === null || lease.length === 0) {
|
|
863
|
+
return true;
|
|
864
|
+
}
|
|
865
|
+
const reservedAt = Number(lease);
|
|
866
|
+
if (!Number.isFinite(reservedAt)) {
|
|
867
|
+
return true;
|
|
868
|
+
}
|
|
869
|
+
return now - reservedAt >= visibilityMs;
|
|
260
870
|
}
|
|
261
871
|
var SCRIPT_HEADER = `
|
|
262
872
|
local function checkType(key, expected)
|
|
@@ -345,7 +955,7 @@ async function reserveQueueJob(client, queueKey, owner) {
|
|
|
345
955
|
queueProcessingLeaseKey(queueKey),
|
|
346
956
|
crypto.randomUUID(),
|
|
347
957
|
owner,
|
|
348
|
-
String(
|
|
958
|
+
String(readQueueVisibilityMs())
|
|
349
959
|
]);
|
|
350
960
|
if (typeof raw !== "string")
|
|
351
961
|
return null;
|
|
@@ -363,7 +973,7 @@ async function updateQueueReservation(client, queueKey, reservation, action) {
|
|
|
363
973
|
reservation.owner,
|
|
364
974
|
reservation.record,
|
|
365
975
|
action,
|
|
366
|
-
String(
|
|
976
|
+
String(readQueueVisibilityMs()),
|
|
367
977
|
reservation.payload
|
|
368
978
|
])) === 1;
|
|
369
979
|
}
|
|
@@ -376,7 +986,7 @@ async function reclaimExpiredQueueReservations(client, queueKeys = QUEUE_KEYS) {
|
|
|
376
986
|
key,
|
|
377
987
|
queueProcessingKey(key),
|
|
378
988
|
queueProcessingLeaseKey(key),
|
|
379
|
-
String(
|
|
989
|
+
String(readQueueVisibilityMs())
|
|
380
990
|
]));
|
|
381
991
|
}
|
|
382
992
|
return reclaimed;
|
|
@@ -385,7 +995,7 @@ async function reclaimExpiredQueueReservations(client, queueKeys = QUEUE_KEYS) {
|
|
|
385
995
|
class RedisQueue {
|
|
386
996
|
client;
|
|
387
997
|
constructor(redisUrl) {
|
|
388
|
-
this.client = new
|
|
998
|
+
this.client = new RedisClient2(redisUrl);
|
|
389
999
|
}
|
|
390
1000
|
close() {
|
|
391
1001
|
this.client.close();
|
|
@@ -417,7 +1027,7 @@ class QueueWorker {
|
|
|
417
1027
|
this.failedJobs = failedJobs;
|
|
418
1028
|
this.timeoutSeconds = timeoutSeconds;
|
|
419
1029
|
this.queueKeys = queueKeys;
|
|
420
|
-
this.client = new
|
|
1030
|
+
this.client = new RedisClient2(redisUrl);
|
|
421
1031
|
}
|
|
422
1032
|
requestStop() {
|
|
423
1033
|
this.stopping = true;
|
|
@@ -472,7 +1082,7 @@ class QueueWorker {
|
|
|
472
1082
|
})().finally(() => {
|
|
473
1083
|
renewal = undefined;
|
|
474
1084
|
});
|
|
475
|
-
}, Math.max(1, Math.floor(
|
|
1085
|
+
}, Math.max(1, Math.floor(readQueueVisibilityMs() / 3)));
|
|
476
1086
|
try {
|
|
477
1087
|
await runQueueJob(envelope, this.failedJobs, {
|
|
478
1088
|
onFailureRecorded: () => {
|
|
@@ -506,11 +1116,11 @@ class QueueWorker {
|
|
|
506
1116
|
}
|
|
507
1117
|
}
|
|
508
1118
|
async function countPendingQueueJobs(redisUrl) {
|
|
509
|
-
const client = new
|
|
1119
|
+
const client = new RedisClient2(redisUrl);
|
|
510
1120
|
try {
|
|
511
1121
|
let total = 0;
|
|
512
|
-
for (const queueKey of QUEUE_KEYS) {
|
|
513
|
-
total += await client.llen(queueKey);
|
|
1122
|
+
for (const queueKey of resolveRedisQueueTransport() === "streams" ? defaultQueueKeys() : QUEUE_KEYS) {
|
|
1123
|
+
total += resolveRedisQueueTransport() === "streams" ? await readStreamQueueDepth(client, queueKey) : await client.llen(queueKey);
|
|
514
1124
|
}
|
|
515
1125
|
return total;
|
|
516
1126
|
} finally {
|
|
@@ -523,7 +1133,11 @@ export {
|
|
|
523
1133
|
QUEUE_LOW_KEY,
|
|
524
1134
|
QueueWorker,
|
|
525
1135
|
RedisQueue,
|
|
1136
|
+
RedisStreamsQueue,
|
|
1137
|
+
RedisStreamsWorker,
|
|
1138
|
+
STREAM_GROUP,
|
|
526
1139
|
countPendingQueueJobs,
|
|
1140
|
+
migrateLegacyQueueToStreams,
|
|
527
1141
|
parseQueueJobEnvelope,
|
|
528
1142
|
queueInvalidKey,
|
|
529
1143
|
queueKeyForPriority,
|
|
@@ -532,5 +1146,8 @@ export {
|
|
|
532
1146
|
reclaimExpiredQueueReservations,
|
|
533
1147
|
reservationExpired,
|
|
534
1148
|
reserveQueueJob,
|
|
535
|
-
|
|
1149
|
+
streamDeadLetterKey,
|
|
1150
|
+
streamQueueKey,
|
|
1151
|
+
updateQueueReservation,
|
|
1152
|
+
updateStreamReservation
|
|
536
1153
|
};
|