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