@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/core/contracts/serviceTokens.d.ts +3 -2
  3. package/dist/core/database/baseRepository.d.ts +5 -0
  4. package/dist/core/database/model.d.ts +3 -1
  5. package/dist/core/database/query.d.ts +6 -1
  6. package/dist/core/database/repositoryQuery.d.ts +7 -1
  7. package/dist/core/database/schema/blueprint.d.ts +2 -0
  8. package/dist/core/database/schema/columnDefinition.d.ts +1 -1
  9. package/dist/core/database/types.d.ts +10 -1
  10. package/dist/core/http/requestMetaContext.d.ts +2 -0
  11. package/dist/core/queue/createAppQueue.d.ts +2 -1
  12. package/dist/core/queue/failedJobService.d.ts +3 -1
  13. package/dist/core/queue/index.d.ts +10 -2
  14. package/dist/core/queue/jobRunner.d.ts +7 -1
  15. package/dist/core/queue/publicQueue.d.ts +2 -1
  16. package/dist/core/queue/queueConfig.d.ts +3 -0
  17. package/dist/core/queue/redisQueue.d.ts +5 -7
  18. package/dist/core/queue/redisQueueKeys.d.ts +8 -0
  19. package/dist/core/queue/redisStreams.d.ts +73 -0
  20. package/dist/core/queue/types.d.ts +1 -0
  21. package/dist/core/tenant/publicTenantHost.d.ts +11 -0
  22. package/dist/core/tenant/tenantMiddleware.d.ts +4 -0
  23. package/dist/entries/auth/basicAuthGuard.js +1 -0
  24. package/dist/entries/auth/jwtGuard.js +1 -0
  25. package/dist/entries/auth/sessionGuard.js +1 -0
  26. package/dist/entries/contracts/serviceTokens.js +2 -0
  27. package/dist/entries/database/query.js +70 -1
  28. package/dist/entries/database/repositoryQuery.js +99 -1
  29. package/dist/entries/database/schema.js +9 -0
  30. package/dist/entries/database/sqliteConnection.js +1 -1
  31. package/dist/entries/queue/createAppQueue.js +681 -66
  32. package/dist/entries/queue/failedJobRepository.js +1 -1
  33. package/dist/entries/queue/failedJobService.js +5 -1
  34. package/dist/entries/queue/jobRunner.js +67 -2
  35. package/dist/entries/queue/publicQueue.js +679 -65
  36. package/dist/entries/queue/queueMetrics.js +694 -72
  37. package/dist/entries/queue/redisQueue.js +765 -148
  38. package/dist/framework/public-api.d.ts +2 -2
  39. package/dist/index.js +855 -69
  40. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  // @bun
2
2
  // ../../src/core/queue/queueMetrics.ts
3
- var {RedisClient: RedisClient2 } = globalThis.Bun;
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
- await new Promise((resolve) => setTimeout(resolve, backoffMs * nextAttempt));
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/redisQueue.ts
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
- var QUEUE_KEYS = [QUEUE_HIGH_KEY, QUEUE_LIST_KEY, QUEUE_LOW_KEY];
263
- var DEFAULT_VISIBILITY_MS = 60000;
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
- function parseQueueJobEnvelope(rawPayload) {
298
- let parsed;
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
- parsed = JSON.parse(rawPayload);
301
- } catch {
302
- console.error("[QueueWorker] Ignoring malformed queue payload");
303
- return null;
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
- if (!parsed || typeof parsed !== "object") {
306
- console.error("[QueueWorker] Ignoring non-object queue payload");
307
- return null;
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
- const envelope = parsed;
310
- if (typeof envelope.name !== "string" || envelope.name.length === 0) {
311
- console.error("[QueueWorker] Ignoring queue payload without job name");
312
- return null;
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
- if (!jobRegistry.create(envelope.name)) {
315
- console.error(`[QueueWorker] Ignoring unknown job name: ${envelope.name}`);
316
- return null;
624
+ close() {
625
+ this.client.close();
317
626
  }
318
- if (envelope.payload !== undefined && (typeof envelope.payload !== "object" || envelope.payload === null)) {
319
- console.error("[QueueWorker] Ignoring queue payload with invalid payload object");
320
- return null;
627
+ async dispatch(job, payload) {
628
+ await this.enqueue(job, payload);
321
629
  }
322
- return {
323
- name: envelope.name,
324
- payload: envelope.payload ?? {},
325
- attempts: typeof envelope.attempts === "number" ? envelope.attempts : 0
326
- };
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
+ }
886
+ }
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(readVisibilityMs())
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(readVisibilityMs()),
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(readVisibilityMs())
1060
+ String(readQueueVisibilityMs())
447
1061
  ]));
448
1062
  }
449
1063
  return reclaimed;
@@ -452,7 +1066,7 @@ async function reclaimExpiredQueueReservations(client, queueKeys = QUEUE_KEYS) {
452
1066
  class RedisQueue {
453
1067
  client;
454
1068
  constructor(redisUrl) {
455
- this.client = new RedisClient(redisUrl);
1069
+ this.client = new RedisClient2(redisUrl);
456
1070
  }
457
1071
  close() {
458
1072
  this.client.close();
@@ -484,7 +1098,7 @@ class QueueWorker {
484
1098
  this.failedJobs = failedJobs;
485
1099
  this.timeoutSeconds = timeoutSeconds;
486
1100
  this.queueKeys = queueKeys;
487
- this.client = new RedisClient(redisUrl);
1101
+ this.client = new RedisClient2(redisUrl);
488
1102
  }
489
1103
  requestStop() {
490
1104
  this.stopping = true;
@@ -539,7 +1153,7 @@ class QueueWorker {
539
1153
  })().finally(() => {
540
1154
  renewal = undefined;
541
1155
  });
542
- }, Math.max(1, Math.floor(readVisibilityMs() / 3)));
1156
+ }, Math.max(1, Math.floor(readQueueVisibilityMs() / 3)));
543
1157
  try {
544
1158
  await runQueueJob(envelope, this.failedJobs, {
545
1159
  onFailureRecorded: () => {
@@ -573,11 +1187,11 @@ class QueueWorker {
573
1187
  }
574
1188
  }
575
1189
  async function countPendingQueueJobs(redisUrl) {
576
- const client = new RedisClient(redisUrl);
1190
+ const client = new RedisClient2(redisUrl);
577
1191
  try {
578
1192
  let total = 0;
579
- for (const queueKey of QUEUE_KEYS) {
580
- 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);
581
1195
  }
582
1196
  return total;
583
1197
  } finally {
@@ -687,12 +1301,12 @@ function createProductionQueue(driver, options = {}) {
687
1301
  if (!options.redisUrl) {
688
1302
  throw new Error('QUEUE_DRIVER="redis" requires REDIS_URL to be set.');
689
1303
  }
690
- return new RedisQueue(options.redisUrl);
1304
+ return resolveRedisQueueTransport() === "streams" ? new RedisStreamsQueue(options.redisUrl) : new RedisQueue(options.redisUrl);
691
1305
  }
692
1306
  return new ResilientQueue(failedJobs, driver === "async");
693
1307
  }
694
1308
  function createQueueWorker(redisUrl, failedJobs = createFailedJobService()) {
695
- return new QueueWorker(redisUrl, failedJobs);
1309
+ return resolveRedisQueueTransport() === "streams" ? new RedisStreamsWorker(redisUrl, failedJobs) : new QueueWorker(redisUrl, failedJobs);
696
1310
  }
697
1311
 
698
1312
  // ../../src/core/queue/createAppQueue.ts
@@ -707,12 +1321,20 @@ function createAppQueue(driver, redisUrl, failedJobs = createFailedJobService(),
707
1321
 
708
1322
  // ../../src/core/queue/queueMetrics.ts
709
1323
  async function readRedisQueueDepth(redisUrl) {
710
- const client = new RedisClient2(redisUrl);
711
- const [high, defaultQueue, low] = await Promise.all([
712
- client.llen(QUEUE_HIGH_KEY),
713
- client.llen(QUEUE_LIST_KEY),
714
- client.llen(QUEUE_LOW_KEY)
715
- ]);
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
+ }
716
1338
  return {
717
1339
  high: Number(high ?? 0),
718
1340
  default: Number(defaultQueue ?? 0),