@getstrata/core 2.2.14 → 2.2.16

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.
@@ -1324,21 +1324,6 @@ redis.call('XDEL', KEYS[1], ARGV[2])
1324
1324
  if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[3]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[3]) end
1325
1325
  return 1
1326
1326
  `;
1327
- var PROMOTE_RETRIES = `
1328
- local kinds = {'stream','zset'}
1329
- for i=1,2 do
1330
- local kind = redis.call('TYPE', KEYS[i]).ok
1331
- if kind ~= 'none' and kind ~= kinds[i] then return redis.error_reply('Invalid stream retry promotion key type') end
1332
- end
1333
- local now = redis.call('TIME')
1334
- local due = tonumber(now[1])*1000 + math.floor(tonumber(now[2])/1000)
1335
- local entries = redis.call('ZRANGE', KEYS[2], '-inf', due, 'BYSCORE', 'LIMIT', 0, 100)
1336
- for _, payload in ipairs(entries) do
1337
- redis.call('XADD', KEYS[1], '*', 'payload', payload)
1338
- redis.call('ZREM', KEYS[2], payload)
1339
- end
1340
- return #entries
1341
- `;
1342
1327
  async function scheduleStreamRetry(client, key, reservation, envelope, delayMs) {
1343
1328
  if (!Number.isSafeInteger(delayMs) || delayMs < 0 || delayMs > Number.MAX_SAFE_INTEGER / 2)
1344
1329
  throw new Error("Invalid Streams retry delay");
@@ -1360,8 +1345,93 @@ async function scheduleStreamRetry(client, key, reservation, envelope, delayMs)
1360
1345
  if (Number(scheduled) !== 1)
1361
1346
  throw new Error("Queue lease lost while scheduling retry");
1362
1347
  }
1363
- async function promoteStreamRetries(client, key) {
1364
- return Number(await client.send("EVAL", [PROMOTE_RETRIES, "2", streamQueueKey(key), streamRetryKey(key)]));
1348
+ var POLL_STREAM_PRIORITIES = `
1349
+ local group = ARGV[1]
1350
+ local owner = ARGV[2]
1351
+ local visibility = ARGV[3]
1352
+ local function payloadOf(fields)
1353
+ for index = 1, #fields, 2 do
1354
+ if fields[index] == 'payload' and type(fields[index + 1]) == 'string' then return fields[index + 1] end
1355
+ end
1356
+ return ''
1357
+ end
1358
+ local cursors = {}
1359
+ for i = 1, #KEYS / 2 do cursors[i] = ARGV[3 + i] end
1360
+ for i = 1, #KEYS / 2 do
1361
+ local stream = KEYS[(i - 1) * 2 + 1]
1362
+ local retries = KEYS[(i - 1) * 2 + 2]
1363
+ local cursor = ARGV[3 + i]
1364
+ local streamKind = redis.call('TYPE', stream).ok
1365
+ local retryKind = redis.call('TYPE', retries).ok
1366
+ if streamKind ~= 'none' and streamKind ~= 'stream' then return redis.error_reply('Invalid stream retry promotion key type') end
1367
+ if retryKind ~= 'none' and retryKind ~= 'zset' then return redis.error_reply('Invalid stream retry promotion key type') end
1368
+ if streamKind == 'stream' then
1369
+ local now = redis.call('TIME')
1370
+ local due = tonumber(now[1]) * 1000 + math.floor(tonumber(now[2]) / 1000)
1371
+ local ready = redis.call('ZRANGE', retries, '-inf', due, 'BYSCORE', 'LIMIT', 0, 100)
1372
+ for _, payload in ipairs(ready) do
1373
+ redis.call('XADD', stream, '*', 'payload', payload)
1374
+ redis.call('ZREM', retries, payload)
1375
+ end
1376
+ local window = redis.call('XPENDING', stream, group, cursor, '+', 10)
1377
+ local claimed = redis.call('XAUTOCLAIM', stream, group, owner, visibility, cursor, 'COUNT', 1)
1378
+ for _, entry in ipairs(window) do
1379
+ if entry[2] ~= owner and #redis.call('XPENDING', stream, group, '-', '+', 1, entry[2]) == 0 then
1380
+ redis.call('XGROUP', 'DELCONSUMER', stream, group, entry[2])
1381
+ end
1382
+ end
1383
+ if #redis.call('XPENDING', stream, group, '-', '+', 1, owner) == 0 then
1384
+ redis.call('XGROUP', 'DELCONSUMER', stream, group, owner)
1385
+ end
1386
+ cursors[i] = claimed[1]
1387
+ if #claimed[2] > 0 then
1388
+ return {'1', tostring(i), claimed[2][1][1], payloadOf(claimed[2][1][2]), unpack(cursors)}
1389
+ end
1390
+ local fresh = redis.call('XREADGROUP', 'GROUP', group, owner, 'COUNT', 1, 'STREAMS', stream, '>')
1391
+ if not fresh and #redis.call('XPENDING', stream, group, '-', '+', 1, owner) == 0 then
1392
+ redis.call('XGROUP', 'DELCONSUMER', stream, group, owner)
1393
+ end
1394
+ if fresh and #fresh > 0 and #fresh[1][2] > 0 then
1395
+ return {'1', tostring(i), fresh[1][2][1][1], payloadOf(fresh[1][2][1][2]), unpack(cursors)}
1396
+ end
1397
+ end
1398
+ end
1399
+ return {'0', unpack(cursors)}
1400
+ `;
1401
+ async function pollStreamPriorities(client, queueKeys, owner, cursors) {
1402
+ const keys = [];
1403
+ for (const key of queueKeys)
1404
+ keys.push(streamQueueKey(key), streamRetryKey(key));
1405
+ const raw = await client.send("EVAL", [
1406
+ POLL_STREAM_PRIORITIES,
1407
+ String(keys.length),
1408
+ ...keys,
1409
+ STREAM_GROUP,
1410
+ owner,
1411
+ String(readQueueVisibilityMs()),
1412
+ ...cursors
1413
+ ]);
1414
+ if (!Array.isArray(raw))
1415
+ throw new Error("Invalid Redis stream poll response");
1416
+ const flag = String(raw[0]);
1417
+ const cursorCount = queueKeys.length;
1418
+ if (flag === "0") {
1419
+ if (raw.length !== cursorCount + 1)
1420
+ throw new Error("Invalid Redis stream poll response");
1421
+ return { cursors: raw.slice(1).map(String), hit: null };
1422
+ }
1423
+ if (flag !== "1" || raw.length !== cursorCount + 4)
1424
+ throw new Error("Invalid Redis stream poll response");
1425
+ const index = Number(raw[1]);
1426
+ if (!Number.isInteger(index) || index < 1 || index > queueKeys.length)
1427
+ throw new Error("Invalid Redis stream poll response");
1428
+ return {
1429
+ cursors: raw.slice(4).map(String),
1430
+ hit: {
1431
+ index: index - 1,
1432
+ reservation: { id: String(raw[2]), owner, payload: String(raw[3]) }
1433
+ }
1434
+ };
1365
1435
  }
1366
1436
  async function readStreamQueueDepth(client, key) {
1367
1437
  return Number(await client.send("EVAL", [
@@ -1410,20 +1480,6 @@ end
1410
1480
  if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[3]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[3]) end
1411
1481
  return acknowledged
1412
1482
  `;
1413
- var CLAIM_STREAM = `
1414
- local window = redis.call('XPENDING', KEYS[1], ARGV[1], ARGV[4], '+', 10)
1415
- local claimed = redis.call('XAUTOCLAIM', KEYS[1], ARGV[1], ARGV[2], ARGV[3], ARGV[4], 'COUNT', 1)
1416
- for _, entry in ipairs(window) do
1417
- 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
1418
- end
1419
- if #redis.call('XPENDING', KEYS[1], ARGV[1], '-', '+', 1, ARGV[2]) == 0 then redis.call('XGROUP', 'DELCONSUMER', KEYS[1], ARGV[1], ARGV[2]) end
1420
- return claimed
1421
- `;
1422
- var READ_STREAM = `
1423
- local entries = redis.call('XREADGROUP', 'GROUP', ARGV[1], ARGV[2], 'COUNT', 1, 'STREAMS', KEYS[1], '>')
1424
- 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
1425
- return entries
1426
- `;
1427
1483
  async function ensureStreamGroup(client, key) {
1428
1484
  await assertLegacyDrained(client, key);
1429
1485
  try {
@@ -1433,17 +1489,6 @@ async function ensureStreamGroup(client, key) {
1433
1489
  throw error;
1434
1490
  }
1435
1491
  }
1436
- function readStreamEntry(value, owner) {
1437
- if (!Array.isArray(value) || typeof value[0] !== "string" || !Array.isArray(value[1]))
1438
- throw new Error("Invalid Redis stream entry response");
1439
- const fields = value[1];
1440
- let payload;
1441
- for (let index = 0;index < fields.length; index += 2) {
1442
- if (fields[index] === "payload" && typeof fields[index + 1] === "string")
1443
- payload = fields[index + 1];
1444
- }
1445
- return { id: value[0], owner, payload: payload ?? "" };
1446
- }
1447
1492
  async function updateStreamReservation(client, key, reservation, action, controlKey) {
1448
1493
  return Number(await client.send("EVAL", [
1449
1494
  UPDATE_STREAM,
@@ -1460,35 +1505,6 @@ async function updateStreamReservation(client, key, reservation, action, control
1460
1505
  controlKey ? "1" : "0"
1461
1506
  ])) === 1;
1462
1507
  }
1463
- async function reserveStreamJob(client, key, owner, cursor = "0-0") {
1464
- const claimed = await client.send("EVAL", [
1465
- CLAIM_STREAM,
1466
- "1",
1467
- streamQueueKey(key),
1468
- STREAM_GROUP,
1469
- owner,
1470
- String(readQueueVisibilityMs()),
1471
- cursor
1472
- ]);
1473
- if (!Array.isArray(claimed) || typeof claimed[0] !== "string" || !Array.isArray(claimed[1]))
1474
- throw new Error("Invalid Redis XAUTOCLAIM response");
1475
- const nextCursor = claimed[0];
1476
- if (claimed[1].length > 0)
1477
- return { reservation: readStreamEntry(claimed[1][0], owner), cursor: nextCursor };
1478
- const fresh = await client.send("EVAL", [
1479
- READ_STREAM,
1480
- "1",
1481
- streamQueueKey(key),
1482
- STREAM_GROUP,
1483
- owner
1484
- ]);
1485
- if (fresh === null)
1486
- return { reservation: null, cursor: nextCursor };
1487
- if (!Array.isArray(fresh) || !Array.isArray(fresh[0]) || !Array.isArray(fresh[0][1]))
1488
- throw new Error("Invalid Redis XREADGROUP response");
1489
- return { reservation: readStreamEntry(fresh[0][1][0], owner), cursor: nextCursor };
1490
- }
1491
-
1492
1508
  class RedisStreamsQueue {
1493
1509
  client;
1494
1510
  constructor(redisUrl) {
@@ -1586,12 +1602,6 @@ class RedisStreamsWorker {
1586
1602
  await ensureStreamGroup(this.client, key);
1587
1603
  this.initialized = true;
1588
1604
  }
1589
- async reserve(key) {
1590
- await promoteStreamRetries(this.client, key);
1591
- const result = await reserveStreamJob(this.client, key, this.owner, this.cursors.get(key));
1592
- this.cursors.set(key, result.cursor);
1593
- return result.reservation;
1594
- }
1595
1605
  async processNext() {
1596
1606
  if (this.processing)
1597
1607
  throw new Error("Queue worker already processing a job.");
@@ -1608,13 +1618,19 @@ class RedisStreamsWorker {
1608
1618
  await this.initialize();
1609
1619
  const deadline = performance.now() + this.timeoutSeconds * 1000;
1610
1620
  do {
1611
- for (const key of this.queueKeys) {
1612
- if (this.stopping)
1613
- return false;
1614
- const reservation = await this.reserve(key);
1615
- if (!reservation)
1616
- continue;
1617
- await this.handle(key, reservation);
1621
+ if (this.stopping)
1622
+ return false;
1623
+ const polled = await pollStreamPriorities(this.client, this.queueKeys, this.owner, this.queueKeys.map((key) => this.cursors.get(key) ?? "0-0"));
1624
+ this.queueKeys.forEach((key, index) => {
1625
+ const cursor = polled.cursors[index];
1626
+ if (cursor)
1627
+ this.cursors.set(key, cursor);
1628
+ });
1629
+ if (polled.hit) {
1630
+ const key = this.queueKeys[polled.hit.index];
1631
+ if (!key)
1632
+ throw new Error("Invalid Redis stream poll response");
1633
+ await this.handle(key, polled.hit.reservation);
1618
1634
  return true;
1619
1635
  }
1620
1636
  if (performance.now() >= deadline || this.stopping)
@@ -1887,6 +1903,66 @@ for _, record in ipairs(redis.call('LRANGE', KEYS[2], 0, -1)) do
1887
1903
  end
1888
1904
  return reclaimed
1889
1905
  `;
1906
+ var POLL_LIST_PRIORITIES = `${SCRIPT_HEADER}
1907
+ local owner = ARGV[1]
1908
+ local visibility = ARGV[2]
1909
+ local reservationId = ARGV[3]
1910
+ local reclaim = ARGV[4] == '1'
1911
+ local function reclaimQueue(listKey, processingKey, leaseKey)
1912
+ local err = checkType(listKey, 'list') or checkType(processingKey, 'list') or checkType(leaseKey, 'hash')
1913
+ if err then return err end
1914
+ local now = nowMs()
1915
+ for _, record in ipairs(redis.call('LRANGE', processingKey, 0, -1)) do
1916
+ local payload, field = record, record
1917
+ local modern = false
1918
+ local ok, reservation = pcall(cjson.decode, record)
1919
+ if ok and type(reservation) == 'table' and reservation.strataReservation == 1 then
1920
+ payload, field, modern = reservation.payload, reservation.id, true
1921
+ end
1922
+ local raw = redis.call('HGET', leaseKey, field)
1923
+ local expired = true
1924
+ if raw then
1925
+ if modern then
1926
+ local leaseOk, lease = pcall(cjson.decode, raw)
1927
+ expired = not leaseOk or type(lease) ~= 'table' or not tonumber(lease.expiresAt) or tonumber(lease.expiresAt) <= now
1928
+ else
1929
+ local reservedAt = tonumber(raw)
1930
+ expired = not reservedAt or now - reservedAt >= tonumber(visibility)
1931
+ end
1932
+ end
1933
+ if expired then
1934
+ redis.call('LPUSH', listKey, payload)
1935
+ redis.call('LREM', processingKey, 1, record)
1936
+ redis.call('HDEL', leaseKey, field)
1937
+ end
1938
+ end
1939
+ end
1940
+ local function reserveQueue(listKey, processingKey, leaseKey)
1941
+ local err = checkType(listKey, 'list') or checkType(processingKey, 'list') or checkType(leaseKey, 'hash')
1942
+ if err then return err end
1943
+ local payload = redis.call('LINDEX', listKey, -1)
1944
+ if not payload then return nil end
1945
+ local record = cjson.encode({strataReservation=1, id=reservationId, owner=owner, payload=payload})
1946
+ local lease = cjson.encode({owner=owner, expiresAt=nowMs()+tonumber(visibility)})
1947
+ redis.call('LPUSH', processingKey, record)
1948
+ redis.call('HSET', leaseKey, reservationId, lease)
1949
+ redis.call('RPOP', listKey)
1950
+ return record
1951
+ end
1952
+ local count = #KEYS / 3
1953
+ if reclaim then
1954
+ for i = 1, count do
1955
+ local err = reclaimQueue(KEYS[(i - 1) * 3 + 1], KEYS[(i - 1) * 3 + 2], KEYS[(i - 1) * 3 + 3])
1956
+ if err then return err end
1957
+ end
1958
+ end
1959
+ for i = 1, count do
1960
+ local record = reserveQueue(KEYS[(i - 1) * 3 + 1], KEYS[(i - 1) * 3 + 2], KEYS[(i - 1) * 3 + 3])
1961
+ if type(record) == 'string' then return {'1', tostring(i), record} end
1962
+ if record then return record end
1963
+ end
1964
+ return {'0'}
1965
+ `;
1890
1966
  function queueInvalidKey(queueKey) {
1891
1967
  return `${queueKey}:invalid`;
1892
1968
  }
@@ -1921,6 +1997,41 @@ async function updateQueueReservation(client, queueKey, reservation, action) {
1921
1997
  reservation.payload
1922
1998
  ])) === 1;
1923
1999
  }
2000
+ async function pollListPriorities(client, queueKeys, owner, reservationId, reclaim) {
2001
+ const keys = [];
2002
+ for (const key of queueKeys) {
2003
+ keys.push(key, queueProcessingKey(key), queueProcessingLeaseKey(key));
2004
+ }
2005
+ const raw = await client.send("EVAL", [
2006
+ POLL_LIST_PRIORITIES,
2007
+ String(keys.length),
2008
+ ...keys,
2009
+ owner,
2010
+ String(readQueueVisibilityMs()),
2011
+ reservationId,
2012
+ reclaim ? "1" : "0"
2013
+ ]);
2014
+ if (!Array.isArray(raw))
2015
+ throw new Error("Invalid Redis list poll response");
2016
+ const flag = String(raw[0]);
2017
+ if (flag === "0") {
2018
+ if (raw.length !== 1)
2019
+ throw new Error("Invalid Redis list poll response");
2020
+ return null;
2021
+ }
2022
+ if (flag !== "1" || raw.length !== 3)
2023
+ throw new Error("Invalid Redis list poll response");
2024
+ const index = Number(raw[1]);
2025
+ const record = raw[2];
2026
+ if (!Number.isInteger(index) || index < 1 || index > queueKeys.length || typeof record !== "string") {
2027
+ throw new Error("Invalid Redis list poll response");
2028
+ }
2029
+ const queueKey = queueKeys[index - 1];
2030
+ if (!queueKey)
2031
+ throw new Error("Invalid Redis list poll response");
2032
+ const reservation = JSON.parse(record);
2033
+ return { queueKey, reservation: { ...reservation, record } };
2034
+ }
1924
2035
  async function reclaimExpiredQueueReservations(client, queueKeys = QUEUE_KEYS) {
1925
2036
  let reclaimed = 0;
1926
2037
  for (const key of queueKeys) {
@@ -1981,14 +2092,14 @@ class QueueWorker {
1981
2092
  }
1982
2093
  async reserve() {
1983
2094
  const deadline = performance.now() + this.timeoutSeconds * 1000;
2095
+ let reclaim = true;
1984
2096
  do {
1985
- for (const key of this.queueKeys) {
1986
- if (this.stopping)
1987
- return null;
1988
- const reservation = await reserveQueueJob(this.client, key, this.owner);
1989
- if (reservation)
1990
- return { queueKey: key, reservation };
1991
- }
2097
+ if (this.stopping)
2098
+ return null;
2099
+ const reserved = await pollListPriorities(this.client, this.queueKeys, this.owner, crypto.randomUUID(), reclaim);
2100
+ reclaim = false;
2101
+ if (reserved)
2102
+ return reserved;
1992
2103
  if (this.stopping || performance.now() >= deadline)
1993
2104
  return null;
1994
2105
  await Bun.sleep(Math.min(100, Math.max(1, deadline - performance.now())));
@@ -1998,7 +2109,6 @@ class QueueWorker {
1998
2109
  async processNext() {
1999
2110
  if (this.stopping)
2000
2111
  return false;
2001
- await reclaimExpiredQueueReservations(this.client, this.queueKeys);
2002
2112
  const reserved = await this.reserve();
2003
2113
  if (!reserved)
2004
2114
  return false;