@getstrata/core 2.2.14 → 2.2.15

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.
@@ -380,21 +380,6 @@ redis.call('XDEL', KEYS[1], ARGV[2])
380
380
  if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[3]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[3]) end
381
381
  return 1
382
382
  `;
383
- var PROMOTE_RETRIES = `
384
- local kinds = {'stream','zset'}
385
- for i=1,2 do
386
- local kind = redis.call('TYPE', KEYS[i]).ok
387
- if kind ~= 'none' and kind ~= kinds[i] then return redis.error_reply('Invalid stream retry promotion key type') end
388
- end
389
- local now = redis.call('TIME')
390
- local due = tonumber(now[1])*1000 + math.floor(tonumber(now[2])/1000)
391
- local entries = redis.call('ZRANGE', KEYS[2], '-inf', due, 'BYSCORE', 'LIMIT', 0, 100)
392
- for _, payload in ipairs(entries) do
393
- redis.call('XADD', KEYS[1], '*', 'payload', payload)
394
- redis.call('ZREM', KEYS[2], payload)
395
- end
396
- return #entries
397
- `;
398
383
  async function scheduleStreamRetry(client, key, reservation, envelope, delayMs) {
399
384
  if (!Number.isSafeInteger(delayMs) || delayMs < 0 || delayMs > Number.MAX_SAFE_INTEGER / 2)
400
385
  throw new Error("Invalid Streams retry delay");
@@ -416,8 +401,93 @@ async function scheduleStreamRetry(client, key, reservation, envelope, delayMs)
416
401
  if (Number(scheduled) !== 1)
417
402
  throw new Error("Queue lease lost while scheduling retry");
418
403
  }
419
- async function promoteStreamRetries(client, key) {
420
- return Number(await client.send("EVAL", [PROMOTE_RETRIES, "2", streamQueueKey(key), streamRetryKey(key)]));
404
+ var POLL_STREAM_PRIORITIES = `
405
+ local group = ARGV[1]
406
+ local owner = ARGV[2]
407
+ local visibility = ARGV[3]
408
+ local function payloadOf(fields)
409
+ for index = 1, #fields, 2 do
410
+ if fields[index] == 'payload' and type(fields[index + 1]) == 'string' then return fields[index + 1] end
411
+ end
412
+ return ''
413
+ end
414
+ local cursors = {}
415
+ for i = 1, #KEYS / 2 do cursors[i] = ARGV[3 + i] end
416
+ for i = 1, #KEYS / 2 do
417
+ local stream = KEYS[(i - 1) * 2 + 1]
418
+ local retries = KEYS[(i - 1) * 2 + 2]
419
+ local cursor = ARGV[3 + i]
420
+ local streamKind = redis.call('TYPE', stream).ok
421
+ local retryKind = redis.call('TYPE', retries).ok
422
+ if streamKind ~= 'none' and streamKind ~= 'stream' then return redis.error_reply('Invalid stream retry promotion key type') end
423
+ if retryKind ~= 'none' and retryKind ~= 'zset' then return redis.error_reply('Invalid stream retry promotion key type') end
424
+ if streamKind == 'stream' then
425
+ local now = redis.call('TIME')
426
+ local due = tonumber(now[1]) * 1000 + math.floor(tonumber(now[2]) / 1000)
427
+ local ready = redis.call('ZRANGE', retries, '-inf', due, 'BYSCORE', 'LIMIT', 0, 100)
428
+ for _, payload in ipairs(ready) do
429
+ redis.call('XADD', stream, '*', 'payload', payload)
430
+ redis.call('ZREM', retries, payload)
431
+ end
432
+ local window = redis.call('XPENDING', stream, group, cursor, '+', 10)
433
+ local claimed = redis.call('XAUTOCLAIM', stream, group, owner, visibility, cursor, 'COUNT', 1)
434
+ for _, entry in ipairs(window) do
435
+ if entry[2] ~= owner and #redis.call('XPENDING', stream, group, '-', '+', 1, entry[2]) == 0 then
436
+ redis.call('XGROUP', 'DELCONSUMER', stream, group, entry[2])
437
+ end
438
+ end
439
+ if #redis.call('XPENDING', stream, group, '-', '+', 1, owner) == 0 then
440
+ redis.call('XGROUP', 'DELCONSUMER', stream, group, owner)
441
+ end
442
+ cursors[i] = claimed[1]
443
+ if #claimed[2] > 0 then
444
+ return {'1', tostring(i), claimed[2][1][1], payloadOf(claimed[2][1][2]), unpack(cursors)}
445
+ end
446
+ local fresh = redis.call('XREADGROUP', 'GROUP', group, owner, 'COUNT', 1, 'STREAMS', stream, '>')
447
+ if not fresh and #redis.call('XPENDING', stream, group, '-', '+', 1, owner) == 0 then
448
+ redis.call('XGROUP', 'DELCONSUMER', stream, group, owner)
449
+ end
450
+ if fresh and #fresh > 0 and #fresh[1][2] > 0 then
451
+ return {'1', tostring(i), fresh[1][2][1][1], payloadOf(fresh[1][2][1][2]), unpack(cursors)}
452
+ end
453
+ end
454
+ end
455
+ return {'0', unpack(cursors)}
456
+ `;
457
+ async function pollStreamPriorities(client, queueKeys, owner, cursors) {
458
+ const keys = [];
459
+ for (const key of queueKeys)
460
+ keys.push(streamQueueKey(key), streamRetryKey(key));
461
+ const raw = await client.send("EVAL", [
462
+ POLL_STREAM_PRIORITIES,
463
+ String(keys.length),
464
+ ...keys,
465
+ STREAM_GROUP,
466
+ owner,
467
+ String(readQueueVisibilityMs()),
468
+ ...cursors
469
+ ]);
470
+ if (!Array.isArray(raw))
471
+ throw new Error("Invalid Redis stream poll response");
472
+ const flag = String(raw[0]);
473
+ const cursorCount = queueKeys.length;
474
+ if (flag === "0") {
475
+ if (raw.length !== cursorCount + 1)
476
+ throw new Error("Invalid Redis stream poll response");
477
+ return { cursors: raw.slice(1).map(String), hit: null };
478
+ }
479
+ if (flag !== "1" || raw.length !== cursorCount + 4)
480
+ throw new Error("Invalid Redis stream poll response");
481
+ const index = Number(raw[1]);
482
+ if (!Number.isInteger(index) || index < 1 || index > queueKeys.length)
483
+ throw new Error("Invalid Redis stream poll response");
484
+ return {
485
+ cursors: raw.slice(4).map(String),
486
+ hit: {
487
+ index: index - 1,
488
+ reservation: { id: String(raw[2]), owner, payload: String(raw[3]) }
489
+ }
490
+ };
421
491
  }
422
492
  async function readStreamQueueDepth(client, key) {
423
493
  return Number(await client.send("EVAL", [
@@ -466,20 +536,6 @@ end
466
536
  if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[3]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[3]) end
467
537
  return acknowledged
468
538
  `;
469
- var CLAIM_STREAM = `
470
- local window = redis.call('XPENDING', KEYS[1], ARGV[1], ARGV[4], '+', 10)
471
- local claimed = redis.call('XAUTOCLAIM', KEYS[1], ARGV[1], ARGV[2], ARGV[3], ARGV[4], 'COUNT', 1)
472
- for _, entry in ipairs(window) do
473
- if entry[2] ~= ARGV[2] and #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, entry[2]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], entry[2]) end
474
- end
475
- if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[2]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[2]) end
476
- return claimed
477
- `;
478
- var READ_STREAM = `
479
- local entries = redis.call('XREADGROUP', 'GROUP', ARGV[1], ARGV[2], 'COUNT', 1, 'STREAMS', KEYS[1], '>')
480
- if not entries and #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[2]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[2]) end
481
- return entries
482
- `;
483
539
  async function ensureStreamGroup(client, key) {
484
540
  await assertLegacyDrained(client, key);
485
541
  try {
@@ -489,17 +545,6 @@ async function ensureStreamGroup(client, key) {
489
545
  throw error;
490
546
  }
491
547
  }
492
- function readStreamEntry(value, owner) {
493
- if (!Array.isArray(value) || typeof value[0] !== "string" || !Array.isArray(value[1]))
494
- throw new Error("Invalid Redis stream entry response");
495
- const fields = value[1];
496
- let payload;
497
- for (let index = 0;index < fields.length; index += 2) {
498
- if (fields[index] === "payload" && typeof fields[index + 1] === "string")
499
- payload = fields[index + 1];
500
- }
501
- return { id: value[0], owner, payload: payload ?? "" };
502
- }
503
548
  async function updateStreamReservation(client, key, reservation, action, controlKey) {
504
549
  return Number(await client.send("EVAL", [
505
550
  UPDATE_STREAM,
@@ -516,35 +561,6 @@ async function updateStreamReservation(client, key, reservation, action, control
516
561
  controlKey ? "1" : "0"
517
562
  ])) === 1;
518
563
  }
519
- async function reserveStreamJob(client, key, owner, cursor = "0-0") {
520
- const claimed = await client.send("EVAL", [
521
- CLAIM_STREAM,
522
- "1",
523
- streamQueueKey(key),
524
- STREAM_GROUP,
525
- owner,
526
- String(readQueueVisibilityMs()),
527
- cursor
528
- ]);
529
- if (!Array.isArray(claimed) || typeof claimed[0] !== "string" || !Array.isArray(claimed[1]))
530
- throw new Error("Invalid Redis XAUTOCLAIM response");
531
- const nextCursor = claimed[0];
532
- if (claimed[1].length > 0)
533
- return { reservation: readStreamEntry(claimed[1][0], owner), cursor: nextCursor };
534
- const fresh = await client.send("EVAL", [
535
- READ_STREAM,
536
- "1",
537
- streamQueueKey(key),
538
- STREAM_GROUP,
539
- owner
540
- ]);
541
- if (fresh === null)
542
- return { reservation: null, cursor: nextCursor };
543
- if (!Array.isArray(fresh) || !Array.isArray(fresh[0]) || !Array.isArray(fresh[0][1]))
544
- throw new Error("Invalid Redis XREADGROUP response");
545
- return { reservation: readStreamEntry(fresh[0][1][0], owner), cursor: nextCursor };
546
- }
547
-
548
564
  class RedisStreamsQueue {
549
565
  client;
550
566
  constructor(redisUrl) {
@@ -642,12 +658,6 @@ class RedisStreamsWorker {
642
658
  await ensureStreamGroup(this.client, key);
643
659
  this.initialized = true;
644
660
  }
645
- async reserve(key) {
646
- await promoteStreamRetries(this.client, key);
647
- const result = await reserveStreamJob(this.client, key, this.owner, this.cursors.get(key));
648
- this.cursors.set(key, result.cursor);
649
- return result.reservation;
650
- }
651
661
  async processNext() {
652
662
  if (this.processing)
653
663
  throw new Error("Queue worker already processing a job.");
@@ -664,13 +674,19 @@ class RedisStreamsWorker {
664
674
  await this.initialize();
665
675
  const deadline = performance.now() + this.timeoutSeconds * 1000;
666
676
  do {
667
- for (const key of this.queueKeys) {
668
- if (this.stopping)
669
- return false;
670
- const reservation = await this.reserve(key);
671
- if (!reservation)
672
- continue;
673
- await this.handle(key, reservation);
677
+ if (this.stopping)
678
+ return false;
679
+ const polled = await pollStreamPriorities(this.client, this.queueKeys, this.owner, this.queueKeys.map((key) => this.cursors.get(key) ?? "0-0"));
680
+ this.queueKeys.forEach((key, index) => {
681
+ const cursor = polled.cursors[index];
682
+ if (cursor)
683
+ this.cursors.set(key, cursor);
684
+ });
685
+ if (polled.hit) {
686
+ const key = this.queueKeys[polled.hit.index];
687
+ if (!key)
688
+ throw new Error("Invalid Redis stream poll response");
689
+ await this.handle(key, polled.hit.reservation);
674
690
  return true;
675
691
  }
676
692
  if (performance.now() >= deadline || this.stopping)
@@ -943,6 +959,66 @@ for _, record in ipairs(redis.call('LRANGE', KEYS[2], 0, -1)) do
943
959
  end
944
960
  return reclaimed
945
961
  `;
962
+ var POLL_LIST_PRIORITIES = `${SCRIPT_HEADER}
963
+ local owner = ARGV[1]
964
+ local visibility = ARGV[2]
965
+ local reservationId = ARGV[3]
966
+ local reclaim = ARGV[4] == '1'
967
+ local function reclaimQueue(listKey, processingKey, leaseKey)
968
+ local err = checkType(listKey, 'list') or checkType(processingKey, 'list') or checkType(leaseKey, 'hash')
969
+ if err then return err end
970
+ local now = nowMs()
971
+ for _, record in ipairs(redis.call('LRANGE', processingKey, 0, -1)) do
972
+ local payload, field = record, record
973
+ local modern = false
974
+ local ok, reservation = pcall(cjson.decode, record)
975
+ if ok and type(reservation) == 'table' and reservation.strataReservation == 1 then
976
+ payload, field, modern = reservation.payload, reservation.id, true
977
+ end
978
+ local raw = redis.call('HGET', leaseKey, field)
979
+ local expired = true
980
+ if raw then
981
+ if modern then
982
+ local leaseOk, lease = pcall(cjson.decode, raw)
983
+ expired = not leaseOk or type(lease) ~= 'table' or not tonumber(lease.expiresAt) or tonumber(lease.expiresAt) <= now
984
+ else
985
+ local reservedAt = tonumber(raw)
986
+ expired = not reservedAt or now - reservedAt >= tonumber(visibility)
987
+ end
988
+ end
989
+ if expired then
990
+ redis.call('LPUSH', listKey, payload)
991
+ redis.call('LREM', processingKey, 1, record)
992
+ redis.call('HDEL', leaseKey, field)
993
+ end
994
+ end
995
+ end
996
+ local function reserveQueue(listKey, processingKey, leaseKey)
997
+ local err = checkType(listKey, 'list') or checkType(processingKey, 'list') or checkType(leaseKey, 'hash')
998
+ if err then return err end
999
+ local payload = redis.call('LINDEX', listKey, -1)
1000
+ if not payload then return nil end
1001
+ local record = cjson.encode({strataReservation=1, id=reservationId, owner=owner, payload=payload})
1002
+ local lease = cjson.encode({owner=owner, expiresAt=nowMs()+tonumber(visibility)})
1003
+ redis.call('LPUSH', processingKey, record)
1004
+ redis.call('HSET', leaseKey, reservationId, lease)
1005
+ redis.call('RPOP', listKey)
1006
+ return record
1007
+ end
1008
+ local count = #KEYS / 3
1009
+ if reclaim then
1010
+ for i = 1, count do
1011
+ local err = reclaimQueue(KEYS[(i - 1) * 3 + 1], KEYS[(i - 1) * 3 + 2], KEYS[(i - 1) * 3 + 3])
1012
+ if err then return err end
1013
+ end
1014
+ end
1015
+ for i = 1, count do
1016
+ local record = reserveQueue(KEYS[(i - 1) * 3 + 1], KEYS[(i - 1) * 3 + 2], KEYS[(i - 1) * 3 + 3])
1017
+ if type(record) == 'string' then return {'1', tostring(i), record} end
1018
+ if record then return record end
1019
+ end
1020
+ return {'0'}
1021
+ `;
946
1022
  function queueInvalidKey(queueKey) {
947
1023
  return `${queueKey}:invalid`;
948
1024
  }
@@ -977,6 +1053,41 @@ async function updateQueueReservation(client, queueKey, reservation, action) {
977
1053
  reservation.payload
978
1054
  ])) === 1;
979
1055
  }
1056
+ async function pollListPriorities(client, queueKeys, owner, reservationId, reclaim) {
1057
+ const keys = [];
1058
+ for (const key of queueKeys) {
1059
+ keys.push(key, queueProcessingKey(key), queueProcessingLeaseKey(key));
1060
+ }
1061
+ const raw = await client.send("EVAL", [
1062
+ POLL_LIST_PRIORITIES,
1063
+ String(keys.length),
1064
+ ...keys,
1065
+ owner,
1066
+ String(readQueueVisibilityMs()),
1067
+ reservationId,
1068
+ reclaim ? "1" : "0"
1069
+ ]);
1070
+ if (!Array.isArray(raw))
1071
+ throw new Error("Invalid Redis list poll response");
1072
+ const flag = String(raw[0]);
1073
+ if (flag === "0") {
1074
+ if (raw.length !== 1)
1075
+ throw new Error("Invalid Redis list poll response");
1076
+ return null;
1077
+ }
1078
+ if (flag !== "1" || raw.length !== 3)
1079
+ throw new Error("Invalid Redis list poll response");
1080
+ const index = Number(raw[1]);
1081
+ const record = raw[2];
1082
+ if (!Number.isInteger(index) || index < 1 || index > queueKeys.length || typeof record !== "string") {
1083
+ throw new Error("Invalid Redis list poll response");
1084
+ }
1085
+ const queueKey = queueKeys[index - 1];
1086
+ if (!queueKey)
1087
+ throw new Error("Invalid Redis list poll response");
1088
+ const reservation = JSON.parse(record);
1089
+ return { queueKey, reservation: { ...reservation, record } };
1090
+ }
980
1091
  async function reclaimExpiredQueueReservations(client, queueKeys = QUEUE_KEYS) {
981
1092
  let reclaimed = 0;
982
1093
  for (const key of queueKeys) {
@@ -1037,14 +1148,14 @@ class QueueWorker {
1037
1148
  }
1038
1149
  async reserve() {
1039
1150
  const deadline = performance.now() + this.timeoutSeconds * 1000;
1151
+ let reclaim = true;
1040
1152
  do {
1041
- for (const key of this.queueKeys) {
1042
- if (this.stopping)
1043
- return null;
1044
- const reservation = await reserveQueueJob(this.client, key, this.owner);
1045
- if (reservation)
1046
- return { queueKey: key, reservation };
1047
- }
1153
+ if (this.stopping)
1154
+ return null;
1155
+ const reserved = await pollListPriorities(this.client, this.queueKeys, this.owner, crypto.randomUUID(), reclaim);
1156
+ reclaim = false;
1157
+ if (reserved)
1158
+ return reserved;
1048
1159
  if (this.stopping || performance.now() >= deadline)
1049
1160
  return null;
1050
1161
  await Bun.sleep(Math.min(100, Math.max(1, deadline - performance.now())));
@@ -1054,7 +1165,6 @@ class QueueWorker {
1054
1165
  async processNext() {
1055
1166
  if (this.stopping)
1056
1167
  return false;
1057
- await reclaimExpiredQueueReservations(this.client, this.queueKeys);
1058
1168
  const reserved = await this.reserve();
1059
1169
  if (!reserved)
1060
1170
  return false;
@@ -1139,6 +1249,7 @@ export {
1139
1249
  countPendingQueueJobs,
1140
1250
  migrateLegacyQueueToStreams,
1141
1251
  parseQueueJobEnvelope,
1252
+ pollListPriorities,
1142
1253
  queueInvalidKey,
1143
1254
  queueKeyForPriority,
1144
1255
  queueProcessingKey,
@@ -42,7 +42,7 @@ export { bindDatabaseConnection, getBoundDatabaseConnection, resetBoundDatabaseC
42
42
  export { bindBunSql, createBunSqlPool } from "../core/database/bunSql.ts";
43
43
  export { createDatabaseConnection } from "../core/database/connection.ts";
44
44
  export { getActiveDatabaseConnection, hasActiveDatabaseConnection, runWithDatabaseConnection, } from "../core/database/connectionContext.ts";
45
- export { getDefaultDatabasePool, getDefaultDatabaseQuery, registerDefaultDatabasePool, resetDefaultDatabasePoolForTests, } from "../core/database/defaultConnection.ts";
45
+ export { getDefaultDatabasePool, getDefaultDatabaseQuery, isDefaultDatabaseConnection, registerDefaultDatabasePool, resetDefaultDatabasePoolForTests, } from "../core/database/defaultConnection.ts";
46
46
  export type { SqlDialect } from "../core/database/dialect.ts";
47
47
  export { currentSqlDialect, dialectFor, resetSqlDialect, runWithSqlDialect, sqlTimestamp, useSqlDialect, } from "../core/database/dialect.ts";
48
48
  export { mapDatabaseError, withDatabaseErrorHandling } from "../core/database/errors.ts";