@getstrata/core 1.1.8 → 2.0.0

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 (49) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/core/database/transaction.d.ts +2 -1
  3. package/dist/core/database/transactionControl.d.ts +5 -0
  4. package/dist/core/events/deferredModelEvents.d.ts +3 -0
  5. package/dist/core/events/index.d.ts +1 -0
  6. package/dist/core/http/loginThrottleMiddleware.d.ts +3 -0
  7. package/dist/core/http/metricsMiddleware.d.ts +1 -2
  8. package/dist/core/http/requestMetaContext.d.ts +2 -0
  9. package/dist/core/http/throttleMiddleware.d.ts +11 -2
  10. package/dist/core/metrics/prometheus.d.ts +11 -1
  11. package/dist/core/queue/jobRunner.d.ts +3 -1
  12. package/dist/core/queue/redisQueue.d.ts +20 -3
  13. package/dist/core/tenant/databaseTenantContext.d.ts +5 -1
  14. package/dist/entries/audit/exportAuditLogs.js +751 -97
  15. package/dist/entries/auth/accessControl.js +16 -2
  16. package/dist/entries/auth/basicAuthGuard.js +271 -224
  17. package/dist/entries/auth/membershipMiddleware.js +17 -0
  18. package/dist/entries/auth/membershipScope.js +21 -6
  19. package/dist/entries/auth/membershipService.js +20 -5
  20. package/dist/entries/auth/oauth/samlProvider.js +187 -142
  21. package/dist/entries/auth/oneTimeToken.js +142 -123
  22. package/dist/entries/auth/passwordLogin.js +212 -191
  23. package/dist/entries/auth/policy.js +16 -2
  24. package/dist/entries/auth/saml/samlServiceProvider.js +187 -142
  25. package/dist/entries/auth/scimAuthMiddleware.js +732 -64
  26. package/dist/entries/database/migrations.js +144 -125
  27. package/dist/entries/database/schema.js +58 -39
  28. package/dist/entries/http/csrfMiddleware.js +28 -14
  29. package/dist/entries/http/csrfToken.js +28 -14
  30. package/dist/entries/http/flashMiddleware.js +28 -14
  31. package/dist/entries/http/metricsMiddleware.js +124 -27
  32. package/dist/entries/http/response.js +107 -0
  33. package/dist/entries/http/routeMiddleware.js +40 -0
  34. package/dist/entries/http/throttleMiddleware.js +124 -11
  35. package/dist/entries/jobs/exportAuditLogsJob.js +751 -97
  36. package/dist/entries/metrics/prometheus.js +71 -11
  37. package/dist/entries/queue/createAppQueue.js +215 -21
  38. package/dist/entries/queue/jobRunner.js +3 -2
  39. package/dist/entries/queue/publicQueue.js +215 -21
  40. package/dist/entries/queue/queueMetrics.js +215 -21
  41. package/dist/entries/queue/redisQueue.js +223 -22
  42. package/dist/entries/runtime/asyncContextStore.js +16 -0
  43. package/dist/entries/tenant/databaseTenantContext.js +666 -11
  44. package/dist/entries/tenant/tenancyConfig.js +19 -1
  45. package/dist/entries/tenant/tenantDatabaseScope.js +688 -18
  46. package/dist/entries/tracing/tracingMiddleware.js +28 -14
  47. package/dist/framework/public-api.d.ts +3 -3
  48. package/dist/index.js +1344 -651
  49. package/package.json +1 -1
@@ -124,7 +124,7 @@ var queueConfig = {
124
124
  };
125
125
 
126
126
  // ../../src/core/queue/jobRunner.ts
127
- async function runQueueJob(envelope, failedJobs) {
127
+ async function runQueueJob(envelope, failedJobs, options = {}) {
128
128
  const job = jobRegistry.create(envelope.name);
129
129
  if (!job) {
130
130
  throw new Error(`Unknown job "${envelope.name}".`);
@@ -141,7 +141,7 @@ async function runQueueJob(envelope, failedJobs) {
141
141
  await runQueueJob({
142
142
  ...envelope,
143
143
  attempts: nextAttempt
144
- }, failedJobs);
144
+ }, failedJobs, options);
145
145
  return;
146
146
  }
147
147
  await failedJobs.recordFailure({
@@ -149,6 +149,7 @@ async function runQueueJob(envelope, failedJobs) {
149
149
  payload: envelope.payload,
150
150
  exception: error instanceof Error ? error.stack ?? error.message : String(error)
151
151
  });
152
+ options.onFailureRecorded?.();
152
153
  throw error;
153
154
  }
154
155
  }
@@ -256,6 +257,30 @@ var QUEUE_LIST_KEY = queueListKey();
256
257
  var QUEUE_HIGH_KEY = queueHighKey();
257
258
  var QUEUE_LOW_KEY = queueLowKey();
258
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;
283
+ }
259
284
  function queueKeyForPriority(priority = "default") {
260
285
  switch (priority) {
261
286
  case "high":
@@ -297,6 +322,129 @@ function parseQueueJobEnvelope(rawPayload) {
297
322
  attempts: typeof envelope.attempts === "number" ? envelope.attempts : 0
298
323
  };
299
324
  }
325
+ var SCRIPT_HEADER = `
326
+ local function checkType(key, expected)
327
+ local actual = redis.call('TYPE', key).ok
328
+ if actual ~= 'none' and actual ~= expected then
329
+ return redis.error_reply('Unexpected queue key type: ' .. key)
330
+ end
331
+ end
332
+ local function nowMs()
333
+ local time = redis.call('TIME')
334
+ return tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
335
+ end
336
+ `;
337
+ var RESERVE_JOB = `${SCRIPT_HEADER}
338
+ local err = checkType(KEYS[1], 'list') or checkType(KEYS[2], 'list') or checkType(KEYS[3], 'hash')
339
+ if err then return err end
340
+ local payload = redis.call('LINDEX', KEYS[1], -1)
341
+ if not payload then return nil end
342
+ local record = cjson.encode({strataReservation=1, id=ARGV[1], owner=ARGV[2], payload=payload})
343
+ local lease = cjson.encode({owner=ARGV[2], expiresAt=nowMs()+tonumber(ARGV[3])})
344
+ redis.call('LPUSH', KEYS[2], record)
345
+ redis.call('HSET', KEYS[3], ARGV[1], lease)
346
+ redis.call('RPOP', KEYS[1])
347
+ return record
348
+ `;
349
+ var RENEW_OR_ACK = `${SCRIPT_HEADER}
350
+ local err = checkType(KEYS[1], 'list') or checkType(KEYS[2], 'hash') or checkType(KEYS[3], 'list')
351
+ if err then return err end
352
+ local raw = redis.call('HGET', KEYS[2], ARGV[1])
353
+ if not raw then return 0 end
354
+ local ok, lease = pcall(cjson.decode, raw)
355
+ local now = nowMs()
356
+ if not ok or type(lease) ~= 'table' or lease.owner ~= ARGV[2] or not tonumber(lease.expiresAt) or tonumber(lease.expiresAt) <= now then
357
+ return 0
358
+ end
359
+ if ARGV[4] == 'renew' then
360
+ redis.call('HSET', KEYS[2], ARGV[1], cjson.encode({owner=ARGV[2], expiresAt=now+tonumber(ARGV[5])}))
361
+ return 1
362
+ end
363
+ if ARGV[4] == 'quarantine' then redis.call('LPUSH', KEYS[3], ARGV[6]) end
364
+ local removed = redis.call('LREM', KEYS[1], 1, ARGV[3])
365
+ redis.call('HDEL', KEYS[2], ARGV[1])
366
+ return removed
367
+ `;
368
+ var RECLAIM_JOBS = `${SCRIPT_HEADER}
369
+ local err = checkType(KEYS[1], 'list') or checkType(KEYS[2], 'list') or checkType(KEYS[3], 'hash')
370
+ if err then return err end
371
+ local now = nowMs()
372
+ local reclaimed = 0
373
+ for _, record in ipairs(redis.call('LRANGE', KEYS[2], 0, -1)) do
374
+ local payload, field = record, record
375
+ local modern = false
376
+ local ok, reservation = pcall(cjson.decode, record)
377
+ if ok and type(reservation) == 'table' and reservation.strataReservation == 1 then
378
+ payload, field, modern = reservation.payload, reservation.id, true
379
+ end
380
+ local raw = redis.call('HGET', KEYS[3], field)
381
+ local expired = true
382
+ if raw then
383
+ if modern then
384
+ local leaseOk, lease = pcall(cjson.decode, raw)
385
+ expired = not leaseOk or type(lease) ~= 'table' or not tonumber(lease.expiresAt) or tonumber(lease.expiresAt) <= now
386
+ else
387
+ local reservedAt = tonumber(raw)
388
+ expired = not reservedAt or now - reservedAt >= tonumber(ARGV[1])
389
+ end
390
+ end
391
+ if expired then
392
+ redis.call('LPUSH', KEYS[1], payload)
393
+ redis.call('LREM', KEYS[2], 1, record)
394
+ redis.call('HDEL', KEYS[3], field)
395
+ reclaimed = reclaimed + 1
396
+ end
397
+ end
398
+ return reclaimed
399
+ `;
400
+ function queueInvalidKey(queueKey) {
401
+ return `${queueKey}:invalid`;
402
+ }
403
+ async function reserveQueueJob(client, queueKey, owner) {
404
+ const raw = await client.send("EVAL", [
405
+ RESERVE_JOB,
406
+ "3",
407
+ queueKey,
408
+ queueProcessingKey(queueKey),
409
+ queueProcessingLeaseKey(queueKey),
410
+ crypto.randomUUID(),
411
+ owner,
412
+ String(readVisibilityMs())
413
+ ]);
414
+ if (typeof raw !== "string")
415
+ return null;
416
+ const reservation = JSON.parse(raw);
417
+ return { ...reservation, record: raw };
418
+ }
419
+ async function updateQueueReservation(client, queueKey, reservation, action) {
420
+ return Number(await client.send("EVAL", [
421
+ RENEW_OR_ACK,
422
+ "3",
423
+ queueProcessingKey(queueKey),
424
+ queueProcessingLeaseKey(queueKey),
425
+ queueInvalidKey(queueKey),
426
+ reservation.id,
427
+ reservation.owner,
428
+ reservation.record,
429
+ action,
430
+ String(readVisibilityMs()),
431
+ reservation.payload
432
+ ])) === 1;
433
+ }
434
+ async function reclaimExpiredQueueReservations(client, queueKeys = QUEUE_KEYS) {
435
+ let reclaimed = 0;
436
+ for (const key of queueKeys) {
437
+ reclaimed += Number(await client.send("EVAL", [
438
+ RECLAIM_JOBS,
439
+ "3",
440
+ key,
441
+ queueProcessingKey(key),
442
+ queueProcessingLeaseKey(key),
443
+ String(readVisibilityMs())
444
+ ]));
445
+ }
446
+ return reclaimed;
447
+ }
300
448
 
301
449
  class RedisQueue {
302
450
  client;
@@ -321,12 +469,15 @@ class RedisQueue {
321
469
  class QueueWorker {
322
470
  failedJobs;
323
471
  timeoutSeconds;
472
+ queueKeys;
473
+ owner = crypto.randomUUID();
324
474
  running = false;
325
475
  stopping = false;
326
476
  client;
327
- constructor(redisUrl, failedJobs, timeoutSeconds = 5) {
477
+ constructor(redisUrl, failedJobs, timeoutSeconds = 5, queueKeys = QUEUE_KEYS) {
328
478
  this.failedJobs = failedJobs;
329
479
  this.timeoutSeconds = timeoutSeconds;
480
+ this.queueKeys = queueKeys;
330
481
  this.client = new RedisClient(redisUrl);
331
482
  }
332
483
  requestStop() {
@@ -335,38 +486,81 @@ class QueueWorker {
335
486
  isRunning() {
336
487
  return this.running;
337
488
  }
338
- async processNext() {
339
- let result = null;
340
- for (const queueKey of QUEUE_KEYS) {
341
- result = await this.client.brpop(queueKey, 1);
342
- if (result) {
343
- break;
489
+ async reserve() {
490
+ const deadline = performance.now() + this.timeoutSeconds * 1000;
491
+ do {
492
+ for (const key of this.queueKeys) {
493
+ if (this.stopping)
494
+ return null;
495
+ const reservation = await reserveQueueJob(this.client, key, this.owner);
496
+ if (reservation)
497
+ return { queueKey: key, reservation };
344
498
  }
345
- }
346
- if (!result) {
347
- result = await this.client.brpop(QUEUE_LIST_KEY, this.timeoutSeconds);
348
- }
349
- if (!result) {
499
+ if (this.stopping || performance.now() >= deadline)
500
+ return null;
501
+ await Bun.sleep(Math.min(100, Math.max(1, deadline - performance.now())));
502
+ } while (!this.stopping);
503
+ return null;
504
+ }
505
+ async processNext() {
506
+ if (this.stopping)
350
507
  return false;
351
- }
352
- const [, rawPayload] = result;
353
- const envelope = parseQueueJobEnvelope(rawPayload);
508
+ await reclaimExpiredQueueReservations(this.client, this.queueKeys);
509
+ const reserved = await this.reserve();
510
+ if (!reserved)
511
+ return false;
512
+ const { queueKey, reservation } = reserved;
513
+ const envelope = parseQueueJobEnvelope(reservation.payload);
354
514
  if (!envelope) {
515
+ await updateQueueReservation(this.client, queueKey, reservation, "quarantine");
355
516
  return true;
356
517
  }
518
+ let acknowledge = false;
519
+ let ownershipLost = false;
520
+ let renewal;
521
+ const heartbeat = setInterval(() => {
522
+ if (renewal || ownershipLost)
523
+ return;
524
+ renewal = (async () => {
525
+ try {
526
+ if (!await updateQueueReservation(this.client, queueKey, reservation, "renew")) {
527
+ ownershipLost = true;
528
+ }
529
+ } catch (error) {
530
+ ownershipLost = true;
531
+ console.error("[QueueWorker] Lease renewal failed:", error);
532
+ }
533
+ })().finally(() => {
534
+ renewal = undefined;
535
+ });
536
+ }, Math.max(1, Math.floor(readVisibilityMs() / 3)));
357
537
  try {
358
- await runQueueJob(envelope, this.failedJobs);
538
+ await runQueueJob(envelope, this.failedJobs, {
539
+ onFailureRecorded: () => {
540
+ acknowledge = true;
541
+ }
542
+ });
543
+ acknowledge = true;
359
544
  } catch (error) {
360
545
  console.error("[QueueWorker] Job failed:", error);
546
+ } finally {
547
+ clearInterval(heartbeat);
548
+ await renewal;
549
+ }
550
+ if (acknowledge && !ownershipLost) {
551
+ await updateQueueReservation(this.client, queueKey, reservation, "ack");
361
552
  }
362
553
  return true;
363
554
  }
364
555
  async run() {
365
556
  this.running = true;
366
- while (!this.stopping) {
367
- await this.processNext();
557
+ try {
558
+ while (!this.stopping) {
559
+ await this.processNext();
560
+ }
561
+ } finally {
562
+ this.running = false;
368
563
  }
369
- this.running = false;
370
564
  }
371
565
  close() {
372
566
  this.client.close();
@@ -127,7 +127,7 @@ var queueConfig = {
127
127
  };
128
128
 
129
129
  // ../../src/core/queue/jobRunner.ts
130
- async function runQueueJob(envelope, failedJobs) {
130
+ async function runQueueJob(envelope, failedJobs, options = {}) {
131
131
  const job = jobRegistry.create(envelope.name);
132
132
  if (!job) {
133
133
  throw new Error(`Unknown job "${envelope.name}".`);
@@ -144,7 +144,7 @@ async function runQueueJob(envelope, failedJobs) {
144
144
  await runQueueJob({
145
145
  ...envelope,
146
146
  attempts: nextAttempt
147
- }, failedJobs);
147
+ }, failedJobs, options);
148
148
  return;
149
149
  }
150
150
  await failedJobs.recordFailure({
@@ -152,6 +152,7 @@ async function runQueueJob(envelope, failedJobs) {
152
152
  payload: envelope.payload,
153
153
  exception: error instanceof Error ? error.stack ?? error.message : String(error)
154
154
  });
155
+ options.onFailureRecorded?.();
155
156
  throw error;
156
157
  }
157
158
  }
@@ -259,6 +260,30 @@ var QUEUE_LIST_KEY = queueListKey();
259
260
  var QUEUE_HIGH_KEY = queueHighKey();
260
261
  var QUEUE_LOW_KEY = queueLowKey();
261
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;
286
+ }
262
287
  function queueKeyForPriority(priority = "default") {
263
288
  switch (priority) {
264
289
  case "high":
@@ -300,6 +325,129 @@ function parseQueueJobEnvelope(rawPayload) {
300
325
  attempts: typeof envelope.attempts === "number" ? envelope.attempts : 0
301
326
  };
302
327
  }
328
+ var SCRIPT_HEADER = `
329
+ local function checkType(key, expected)
330
+ local actual = redis.call('TYPE', key).ok
331
+ if actual ~= 'none' and actual ~= expected then
332
+ return redis.error_reply('Unexpected queue key type: ' .. key)
333
+ end
334
+ end
335
+ local function nowMs()
336
+ local time = redis.call('TIME')
337
+ return tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
338
+ end
339
+ `;
340
+ var RESERVE_JOB = `${SCRIPT_HEADER}
341
+ local err = checkType(KEYS[1], 'list') or checkType(KEYS[2], 'list') or checkType(KEYS[3], 'hash')
342
+ if err then return err end
343
+ local payload = redis.call('LINDEX', KEYS[1], -1)
344
+ if not payload then return nil end
345
+ local record = cjson.encode({strataReservation=1, id=ARGV[1], owner=ARGV[2], payload=payload})
346
+ local lease = cjson.encode({owner=ARGV[2], expiresAt=nowMs()+tonumber(ARGV[3])})
347
+ redis.call('LPUSH', KEYS[2], record)
348
+ redis.call('HSET', KEYS[3], ARGV[1], lease)
349
+ redis.call('RPOP', KEYS[1])
350
+ return record
351
+ `;
352
+ var RENEW_OR_ACK = `${SCRIPT_HEADER}
353
+ local err = checkType(KEYS[1], 'list') or checkType(KEYS[2], 'hash') or checkType(KEYS[3], 'list')
354
+ if err then return err end
355
+ local raw = redis.call('HGET', KEYS[2], ARGV[1])
356
+ if not raw then return 0 end
357
+ local ok, lease = pcall(cjson.decode, raw)
358
+ local now = nowMs()
359
+ if not ok or type(lease) ~= 'table' or lease.owner ~= ARGV[2] or not tonumber(lease.expiresAt) or tonumber(lease.expiresAt) <= now then
360
+ return 0
361
+ end
362
+ if ARGV[4] == 'renew' then
363
+ redis.call('HSET', KEYS[2], ARGV[1], cjson.encode({owner=ARGV[2], expiresAt=now+tonumber(ARGV[5])}))
364
+ return 1
365
+ end
366
+ if ARGV[4] == 'quarantine' then redis.call('LPUSH', KEYS[3], ARGV[6]) end
367
+ local removed = redis.call('LREM', KEYS[1], 1, ARGV[3])
368
+ redis.call('HDEL', KEYS[2], ARGV[1])
369
+ return removed
370
+ `;
371
+ var RECLAIM_JOBS = `${SCRIPT_HEADER}
372
+ local err = checkType(KEYS[1], 'list') or checkType(KEYS[2], 'list') or checkType(KEYS[3], 'hash')
373
+ if err then return err end
374
+ local now = nowMs()
375
+ local reclaimed = 0
376
+ for _, record in ipairs(redis.call('LRANGE', KEYS[2], 0, -1)) do
377
+ local payload, field = record, record
378
+ local modern = false
379
+ local ok, reservation = pcall(cjson.decode, record)
380
+ if ok and type(reservation) == 'table' and reservation.strataReservation == 1 then
381
+ payload, field, modern = reservation.payload, reservation.id, true
382
+ end
383
+ local raw = redis.call('HGET', KEYS[3], field)
384
+ local expired = true
385
+ if raw then
386
+ if modern then
387
+ local leaseOk, lease = pcall(cjson.decode, raw)
388
+ expired = not leaseOk or type(lease) ~= 'table' or not tonumber(lease.expiresAt) or tonumber(lease.expiresAt) <= now
389
+ else
390
+ local reservedAt = tonumber(raw)
391
+ expired = not reservedAt or now - reservedAt >= tonumber(ARGV[1])
392
+ end
393
+ end
394
+ if expired then
395
+ redis.call('LPUSH', KEYS[1], payload)
396
+ redis.call('LREM', KEYS[2], 1, record)
397
+ redis.call('HDEL', KEYS[3], field)
398
+ reclaimed = reclaimed + 1
399
+ end
400
+ end
401
+ return reclaimed
402
+ `;
403
+ function queueInvalidKey(queueKey) {
404
+ return `${queueKey}:invalid`;
405
+ }
406
+ async function reserveQueueJob(client, queueKey, owner) {
407
+ const raw = await client.send("EVAL", [
408
+ RESERVE_JOB,
409
+ "3",
410
+ queueKey,
411
+ queueProcessingKey(queueKey),
412
+ queueProcessingLeaseKey(queueKey),
413
+ crypto.randomUUID(),
414
+ owner,
415
+ String(readVisibilityMs())
416
+ ]);
417
+ if (typeof raw !== "string")
418
+ return null;
419
+ const reservation = JSON.parse(raw);
420
+ return { ...reservation, record: raw };
421
+ }
422
+ async function updateQueueReservation(client, queueKey, reservation, action) {
423
+ return Number(await client.send("EVAL", [
424
+ RENEW_OR_ACK,
425
+ "3",
426
+ queueProcessingKey(queueKey),
427
+ queueProcessingLeaseKey(queueKey),
428
+ queueInvalidKey(queueKey),
429
+ reservation.id,
430
+ reservation.owner,
431
+ reservation.record,
432
+ action,
433
+ String(readVisibilityMs()),
434
+ reservation.payload
435
+ ])) === 1;
436
+ }
437
+ async function reclaimExpiredQueueReservations(client, queueKeys = QUEUE_KEYS) {
438
+ let reclaimed = 0;
439
+ for (const key of queueKeys) {
440
+ reclaimed += Number(await client.send("EVAL", [
441
+ RECLAIM_JOBS,
442
+ "3",
443
+ key,
444
+ queueProcessingKey(key),
445
+ queueProcessingLeaseKey(key),
446
+ String(readVisibilityMs())
447
+ ]));
448
+ }
449
+ return reclaimed;
450
+ }
303
451
 
304
452
  class RedisQueue {
305
453
  client;
@@ -324,12 +472,15 @@ class RedisQueue {
324
472
  class QueueWorker {
325
473
  failedJobs;
326
474
  timeoutSeconds;
475
+ queueKeys;
476
+ owner = crypto.randomUUID();
327
477
  running = false;
328
478
  stopping = false;
329
479
  client;
330
- constructor(redisUrl, failedJobs, timeoutSeconds = 5) {
480
+ constructor(redisUrl, failedJobs, timeoutSeconds = 5, queueKeys = QUEUE_KEYS) {
331
481
  this.failedJobs = failedJobs;
332
482
  this.timeoutSeconds = timeoutSeconds;
483
+ this.queueKeys = queueKeys;
333
484
  this.client = new RedisClient(redisUrl);
334
485
  }
335
486
  requestStop() {
@@ -338,38 +489,81 @@ class QueueWorker {
338
489
  isRunning() {
339
490
  return this.running;
340
491
  }
341
- async processNext() {
342
- let result = null;
343
- for (const queueKey of QUEUE_KEYS) {
344
- result = await this.client.brpop(queueKey, 1);
345
- if (result) {
346
- break;
492
+ async reserve() {
493
+ const deadline = performance.now() + this.timeoutSeconds * 1000;
494
+ do {
495
+ for (const key of this.queueKeys) {
496
+ if (this.stopping)
497
+ return null;
498
+ const reservation = await reserveQueueJob(this.client, key, this.owner);
499
+ if (reservation)
500
+ return { queueKey: key, reservation };
347
501
  }
348
- }
349
- if (!result) {
350
- result = await this.client.brpop(QUEUE_LIST_KEY, this.timeoutSeconds);
351
- }
352
- if (!result) {
502
+ if (this.stopping || performance.now() >= deadline)
503
+ return null;
504
+ await Bun.sleep(Math.min(100, Math.max(1, deadline - performance.now())));
505
+ } while (!this.stopping);
506
+ return null;
507
+ }
508
+ async processNext() {
509
+ if (this.stopping)
353
510
  return false;
354
- }
355
- const [, rawPayload] = result;
356
- const envelope = parseQueueJobEnvelope(rawPayload);
511
+ await reclaimExpiredQueueReservations(this.client, this.queueKeys);
512
+ const reserved = await this.reserve();
513
+ if (!reserved)
514
+ return false;
515
+ const { queueKey, reservation } = reserved;
516
+ const envelope = parseQueueJobEnvelope(reservation.payload);
357
517
  if (!envelope) {
518
+ await updateQueueReservation(this.client, queueKey, reservation, "quarantine");
358
519
  return true;
359
520
  }
521
+ let acknowledge = false;
522
+ let ownershipLost = false;
523
+ let renewal;
524
+ const heartbeat = setInterval(() => {
525
+ if (renewal || ownershipLost)
526
+ return;
527
+ renewal = (async () => {
528
+ try {
529
+ if (!await updateQueueReservation(this.client, queueKey, reservation, "renew")) {
530
+ ownershipLost = true;
531
+ }
532
+ } catch (error) {
533
+ ownershipLost = true;
534
+ console.error("[QueueWorker] Lease renewal failed:", error);
535
+ }
536
+ })().finally(() => {
537
+ renewal = undefined;
538
+ });
539
+ }, Math.max(1, Math.floor(readVisibilityMs() / 3)));
360
540
  try {
361
- await runQueueJob(envelope, this.failedJobs);
541
+ await runQueueJob(envelope, this.failedJobs, {
542
+ onFailureRecorded: () => {
543
+ acknowledge = true;
544
+ }
545
+ });
546
+ acknowledge = true;
362
547
  } catch (error) {
363
548
  console.error("[QueueWorker] Job failed:", error);
549
+ } finally {
550
+ clearInterval(heartbeat);
551
+ await renewal;
552
+ }
553
+ if (acknowledge && !ownershipLost) {
554
+ await updateQueueReservation(this.client, queueKey, reservation, "ack");
364
555
  }
365
556
  return true;
366
557
  }
367
558
  async run() {
368
559
  this.running = true;
369
- while (!this.stopping) {
370
- await this.processNext();
560
+ try {
561
+ while (!this.stopping) {
562
+ await this.processNext();
563
+ }
564
+ } finally {
565
+ this.running = false;
371
566
  }
372
- this.running = false;
373
567
  }
374
568
  close() {
375
569
  this.client.close();