@trigger.dev/redis-worker 4.7.3 → 4.7.5

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.
package/dist/index.cjs CHANGED
@@ -9644,6 +9644,39 @@ function createRedisClient(options, handlers) {
9644
9644
  });
9645
9645
  return client;
9646
9646
  }
9647
+ /**
9648
+ * Cluster-mode client. `defaultOptions` go on the INNER per-node options, so a role swap gets the
9649
+ * same reconnect-and-retry treatment a single-node client already gets.
9650
+ */
9651
+ function createRedisClusterClient(options, handlers) {
9652
+ const client = new import_built.Redis.Cluster(options.nodes, {
9653
+ ...options.failFast && { enableOfflineQueue: false },
9654
+ ...options.clusterOptions,
9655
+ redisOptions: {
9656
+ ...defaultOptions,
9657
+ ...options.failFast && { enableOfflineQueue: false },
9658
+ ...options.redisOptions
9659
+ }
9660
+ });
9661
+ if (process.env.VITEST) {
9662
+ client.on("error", () => {});
9663
+ return client;
9664
+ }
9665
+ client.on("error", (error) => {
9666
+ if (handlers?.onError) handlers.onError(error);
9667
+ else logger.error(`Redis cluster client error:`, {
9668
+ error,
9669
+ keyPrefix: options.redisOptions?.keyPrefix
9670
+ });
9671
+ });
9672
+ client.on("node error", (error, node) => {
9673
+ logger.warn(`Redis cluster node error:`, {
9674
+ node,
9675
+ error: error.message
9676
+ });
9677
+ });
9678
+ return client;
9679
+ }
9647
9680
 
9648
9681
  //#endregion
9649
9682
  //#region ../../node_modules/.pnpm/nanoid@5.1.16/node_modules/nanoid/url-alphabet/index.js
@@ -9729,6 +9762,23 @@ var SimpleQueue = class {
9729
9762
  throw e;
9730
9763
  }
9731
9764
  }
9765
+ /**
9766
+ * Re-enqueues an item for a retry, unless since it was dequeued a newer enqueue replaced it (its
9767
+ * stored deduplication key no longer matches) or it was removed by an ack or cancel. Returns
9768
+ * false when it skipped, so a failed attempt never overwrites the newer item or revives a removed
9769
+ * one. A newer enqueue starts its own retry budget: an item a handler re-enqueues before failing
9770
+ * restarts at attempt 0 rather than moving towards the DLQ.
9771
+ */
9772
+ async requeue({ id, job, item, attempt, availableAt, visibilityTimeoutMs, deduplicationKey }) {
9773
+ const serializedItem = JSON.stringify({
9774
+ job,
9775
+ item,
9776
+ visibilityTimeoutMs,
9777
+ attempt,
9778
+ deduplicationKey: nanoid()
9779
+ });
9780
+ return await this.redis.requeueItem(`queue`, `items`, id, availableAt.getTime(), serializedItem, deduplicationKey ?? "") === 1;
9781
+ }
9732
9782
  async enqueueOnce({ id, job, item, attempt, availableAt, visibilityTimeoutMs }) {
9733
9783
  if (!id) throw new Error("enqueueOnce requires an id");
9734
9784
  try {
@@ -9896,14 +9946,21 @@ var SimpleQueue = class {
9896
9946
  deduplicationKey: item.deduplicationKey ?? void 0
9897
9947
  };
9898
9948
  }
9899
- async moveToDeadLetterQueue(id, errorMessage) {
9949
+ /**
9950
+ * Moves an item to the dead letter queue. With a deduplication key, an item a newer enqueue has
9951
+ * replaced is left in the queue and false is returned.
9952
+ */
9953
+ async moveToDeadLetterQueue(id, errorMessage, deduplicationKey) {
9900
9954
  try {
9901
9955
  this.logger.debug(`SimpleQueue ${this.name}.moveToDeadLetterQueue(): moving item to DLQ`, {
9902
9956
  queue: this.name,
9903
9957
  id,
9904
9958
  errorMessage
9905
9959
  });
9906
- if (await this.redis.moveToDeadLetterQueue(`queue`, `items`, `dlq`, `dlq:items`, id, errorMessage) !== 1) throw new Error("Move to Dead Letter Queue operation failed");
9960
+ const result = await this.redis.moveToDeadLetterQueue(`queue`, `items`, `dlq`, `dlq:items`, id, errorMessage, deduplicationKey ?? "");
9961
+ if (result === 2) return false;
9962
+ if (result !== 1) throw new Error("Move to Dead Letter Queue operation failed");
9963
+ return true;
9907
9964
  } catch (e) {
9908
9965
  this.logger.error(`SimpleQueue ${this.name}.moveToDeadLetterQueue(): error moving item to DLQ`, {
9909
9966
  queue: this.name,
@@ -10089,6 +10146,35 @@ var SimpleQueue = class {
10089
10146
  -- Remove from sorted set and hash
10090
10147
  redis.call('ZREM', queueKey, id)
10091
10148
  redis.call('HDEL', itemsKey, id)
10149
+ return 1
10150
+ `
10151
+ });
10152
+ this.redis.defineCommand("requeueItem", {
10153
+ numberOfKeys: 2,
10154
+ lua: `
10155
+ local queue = KEYS[1]
10156
+ local items = KEYS[2]
10157
+ local id = ARGV[1]
10158
+ local score = ARGV[2]
10159
+ local serializedItem = ARGV[3]
10160
+ local deduplicationKey = ARGV[4]
10161
+
10162
+ -- Leave the item alone if, while this attempt ran, it was removed (acked or cancelled)
10163
+ -- or a newer enqueue replaced it.
10164
+ if deduplicationKey ~= "" then
10165
+ local current = redis.call('HGET', items, id)
10166
+ if not current then
10167
+ return 2
10168
+ end
10169
+ local success, parsed = pcall(cjson.decode, current)
10170
+ if success and parsed.deduplicationKey and parsed.deduplicationKey ~= deduplicationKey then
10171
+ return 2
10172
+ end
10173
+ end
10174
+
10175
+ redis.call('ZADD', queue, score, id)
10176
+ redis.call('HSET', items, id, serializedItem)
10177
+
10092
10178
  return 1
10093
10179
  `
10094
10180
  });
@@ -10101,6 +10187,7 @@ var SimpleQueue = class {
10101
10187
  local dlqItems = KEYS[4]
10102
10188
  local id = ARGV[1]
10103
10189
  local errorMessage = ARGV[2]
10190
+ local deduplicationKey = ARGV[3]
10104
10191
 
10105
10192
  local item = redis.call('HGET', items, id)
10106
10193
  if not item then
@@ -10108,6 +10195,9 @@ var SimpleQueue = class {
10108
10195
  end
10109
10196
 
10110
10197
  local parsedItem = cjson.decode(item)
10198
+ if deduplicationKey ~= "" and parsedItem.deduplicationKey and parsedItem.deduplicationKey ~= deduplicationKey then
10199
+ return 2
10200
+ end
10111
10201
  parsedItem.errorMessage = errorMessage
10112
10202
 
10113
10203
  local time = redis.call('TIME')
@@ -11831,7 +11921,7 @@ var Worker = class Worker {
11831
11921
  else if (errorLogLevel === "warn") this.logger.warn(`Worker batch item reached max attempts. Moving to DLQ.`, dlqLogAttributes);
11832
11922
  else if (errorLogLevel === "info") this.logger.info(`Worker batch item reached max attempts. Moving to DLQ.`, dlqLogAttributes);
11833
11923
  else this.logger.error(`Worker batch item reached max attempts. Moving to DLQ.`, dlqLogAttributes);
11834
- await this.queue.moveToDeadLetterQueue(item.id, errorMessage);
11924
+ if (!await this.queue.moveToDeadLetterQueue(item.id, errorMessage, item.deduplicationKey)) this.#logSuperseded("dead-letter", item.id, jobType);
11835
11925
  continue;
11836
11926
  }
11837
11927
  const retryDate = new Date(Date.now() + retryDelay);
@@ -11843,14 +11933,15 @@ var Worker = class Worker {
11843
11933
  retryDelay,
11844
11934
  attempt: newAttempt
11845
11935
  });
11846
- await this.queue.enqueue({
11936
+ if (!await this.queue.requeue({
11847
11937
  id: item.id,
11848
11938
  job: item.job,
11849
11939
  item: item.item,
11850
11940
  availableAt: retryDate,
11851
11941
  attempt: newAttempt,
11852
- visibilityTimeoutMs: item.visibilityTimeoutMs
11853
- });
11942
+ visibilityTimeoutMs: item.visibilityTimeoutMs,
11943
+ deduplicationKey: item.deduplicationKey
11944
+ })) this.#logSuperseded("retry", item.id, jobType);
11854
11945
  } catch (requeueError) {
11855
11946
  this.logger.error(`Worker failed to requeue batch item. It will be retried after the visibility timeout.`, {
11856
11947
  name: this.options.name,
@@ -11865,6 +11956,13 @@ var Worker = class Worker {
11865
11956
  /**
11866
11957
  * Processes a single item.
11867
11958
  */
11959
+ #logSuperseded(action, id, job) {
11960
+ this.logger.info(`Worker skipped ${action} for an item replaced or removed while it ran`, {
11961
+ name: this.options.name,
11962
+ id,
11963
+ job
11964
+ });
11965
+ }
11868
11966
  async processItem({ id, job, item, visibilityTimeoutMs, attempt, timestamp, deduplicationKey }, batchSize, workerId, limiter) {
11869
11967
  const catalogItem = this.options.catalog[job];
11870
11968
  const handler = this.jobs[job];
@@ -11941,7 +12039,7 @@ var Worker = class Worker {
11941
12039
  ...logAttributes,
11942
12040
  attempt: newAttempt
11943
12041
  });
11944
- await this.queue.moveToDeadLetterQueue(id, errorMessage);
12042
+ if (!await this.queue.moveToDeadLetterQueue(id, errorMessage, deduplicationKey)) this.#logSuperseded("dead-letter", id, job);
11945
12043
  if (catalogItem.cron) await this.rescheduleCronJob(job, catalogItem, item);
11946
12044
  return;
11947
12045
  }
@@ -11955,14 +12053,15 @@ var Worker = class Worker {
11955
12053
  visibilityTimeoutMs,
11956
12054
  attempt: newAttempt
11957
12055
  });
11958
- await this.queue.enqueue({
12056
+ if (!await this.queue.requeue({
11959
12057
  id,
11960
12058
  job,
11961
12059
  item,
11962
12060
  availableAt: retryDate,
11963
12061
  attempt: newAttempt,
11964
- visibilityTimeoutMs
11965
- });
12062
+ visibilityTimeoutMs,
12063
+ deduplicationKey
12064
+ })) this.#logSuperseded("retry", id, job);
11966
12065
  } catch (requeueError) {
11967
12066
  this.logger.error(`Worker failed to requeue item. It will be retried after the visibility timeout.`, {
11968
12067
  name: this.options.name,
@@ -12121,6 +12220,33 @@ function isAbortError(error) {
12121
12220
  return error instanceof Error && (error.name === "AbortError" || error.message === "AbortError");
12122
12221
  }
12123
12222
 
12223
+ //#endregion
12224
+ //#region src/fair-queue/connection.ts
12225
+ function isClusterConnection(options) {
12226
+ return "cluster" in options && options.cluster !== void 0;
12227
+ }
12228
+ function createFairQueueRedisClient(options) {
12229
+ return isClusterConnection(options) ? createRedisClusterClient(options.cluster) : createRedisClient(options);
12230
+ }
12231
+ function keyPrefixOf(options) {
12232
+ return isClusterConnection(options) ? options.cluster.clusterOptions?.keyPrefix ?? "" : options.keyPrefix ?? "";
12233
+ }
12234
+ /**
12235
+ * Every key matching `pattern` (which must include the key prefix), page by page. A cluster doesn't
12236
+ * fan SCAN out to its nodes, so this scans each primary in turn.
12237
+ */
12238
+ async function* scanKeys(client, pattern, count = 1e3) {
12239
+ const nodes = client instanceof import_built.Cluster ? client.nodes("master") : [client];
12240
+ for (const node of nodes) {
12241
+ let cursor = "0";
12242
+ do {
12243
+ const [next, keys] = await node.scan(cursor, "MATCH", pattern, "COUNT", count);
12244
+ cursor = next;
12245
+ if (keys.length > 0) yield keys;
12246
+ } while (cursor !== "0");
12247
+ }
12248
+ }
12249
+
12124
12250
  //#endregion
12125
12251
  //#region src/fair-queue/concurrency.ts
12126
12252
  /**
@@ -12147,7 +12273,7 @@ var ConcurrencyManager = class {
12147
12273
  logger;
12148
12274
  constructor(options) {
12149
12275
  this.options = options;
12150
- this.redis = createRedisClient(options.redis);
12276
+ this.redis = createFairQueueRedisClient(options.redis);
12151
12277
  this.keys = options.keys;
12152
12278
  this.groups = options.groups;
12153
12279
  this.groupsByName = new Map(options.groups.map((g) => [g.name, g]));
@@ -12235,7 +12361,10 @@ var ConcurrencyManager = class {
12235
12361
  * record can only be a leak; if the message is about to be re-claimed, reserve simply
12236
12362
  * re-adds the member.
12237
12363
  *
12238
- * @param inflightDataKeys - The in-flight data hash keys for every shard. The sweep
12364
+ * With a tenant-sharded key producer, each shard's sets are checked against that shard's
12365
+ * in-flight data only, so the check stays in one cluster slot.
12366
+ *
12367
+ * @param inflightDataKeys - The in-flight data hash keys for every shard, in shard order. The sweep
12239
12368
  * refuses to run when this is empty, since with nowhere to look for running messages
12240
12369
  * every member would look orphaned and all concurrency accounting would be erased.
12241
12370
  * @returns The message ids that were removed, and how many sets were checked
@@ -12248,46 +12377,49 @@ var ConcurrencyManager = class {
12248
12377
  removed: []
12249
12378
  };
12250
12379
  }
12251
- const keyPrefix = this.options.redis.keyPrefix ?? "";
12380
+ const keyPrefix = keyPrefixOf(this.options.redis);
12252
12381
  let scannedSets = 0;
12253
12382
  const removed = [];
12254
- for (const group of this.groups) {
12255
- const pattern = `${keyPrefix}${this.keys.concurrencyKey(group.name, "*")}`;
12256
- let cursor = "0";
12257
- do {
12258
- const [nextCursor, foundKeys] = await this.redis.scan(cursor, "MATCH", pattern, "COUNT", 1e3);
12259
- cursor = nextCursor;
12260
- for (const fullKey of foundKeys) {
12261
- const key = keyPrefix && fullKey.startsWith(keyPrefix) ? fullKey.slice(keyPrefix.length) : fullKey;
12262
- try {
12263
- let sawMembers = false;
12264
- let memberCursor = "0";
12265
- do {
12266
- const [nextMemberCursor, page] = await this.redis.sscan(key, memberCursor, "COUNT", SWEEP_MEMBER_CHUNK_SIZE);
12267
- memberCursor = nextMemberCursor;
12268
- if (page.length === 0) continue;
12269
- sawMembers = true;
12270
- for (let i = 0; i < page.length; i += SWEEP_MEMBER_CHUNK_SIZE) {
12271
- const chunk = page.slice(i, i + SWEEP_MEMBER_CHUNK_SIZE);
12272
- const removedIds = await this.redis.removeOrphanedConcurrencySlots(1 + inflightDataKeys.length, [key, ...inflightDataKeys], ...chunk);
12273
- removed.push(...removedIds);
12274
- }
12275
- } while (memberCursor !== "0");
12276
- if (sawMembers) scannedSets++;
12277
- } catch (error) {
12278
- this.logger.error("Failed to sweep concurrency set, skipping it", {
12279
- key,
12280
- error: error instanceof Error ? error.message : String(error)
12281
- });
12383
+ for (const scope of this.#sweepScopes(inflightDataKeys)) for await (const foundKeys of scanKeys(this.redis, `${keyPrefix}${scope.pattern}`)) for (const fullKey of foundKeys) {
12384
+ const key = keyPrefix && fullKey.startsWith(keyPrefix) ? fullKey.slice(keyPrefix.length) : fullKey;
12385
+ try {
12386
+ let sawMembers = false;
12387
+ let memberCursor = "0";
12388
+ do {
12389
+ const [nextMemberCursor, page] = await this.redis.sscan(key, memberCursor, "COUNT", SWEEP_MEMBER_CHUNK_SIZE);
12390
+ memberCursor = nextMemberCursor;
12391
+ if (page.length === 0) continue;
12392
+ sawMembers = true;
12393
+ for (let i = 0; i < page.length; i += SWEEP_MEMBER_CHUNK_SIZE) {
12394
+ const chunk = page.slice(i, i + SWEEP_MEMBER_CHUNK_SIZE);
12395
+ const removedIds = await this.redis.removeOrphanedConcurrencySlots(1 + scope.inflightDataKeys.length, [key, ...scope.inflightDataKeys], ...chunk);
12396
+ removed.push(...removedIds);
12282
12397
  }
12283
- }
12284
- } while (cursor !== "0");
12398
+ } while (memberCursor !== "0");
12399
+ if (sawMembers) scannedSets++;
12400
+ } catch (error) {
12401
+ this.logger.error("Failed to sweep concurrency set, skipping it", {
12402
+ key,
12403
+ error: error instanceof Error ? error.message : String(error)
12404
+ });
12405
+ }
12285
12406
  }
12286
12407
  return {
12287
12408
  scannedSets,
12288
12409
  removed
12289
12410
  };
12290
12411
  }
12412
+ #sweepScopes(inflightDataKeys) {
12413
+ const keys = this.keys;
12414
+ if (keys.tenantShard && keys.concurrencyKeyPattern) return this.groups.flatMap((group) => inflightDataKeys.map((inflightDataKey, shardId) => ({
12415
+ pattern: keys.concurrencyKeyPattern(group.name, shardId),
12416
+ inflightDataKeys: [inflightDataKey]
12417
+ })));
12418
+ return this.groups.map((group) => ({
12419
+ pattern: keys.concurrencyKey(group.name, "*"),
12420
+ inflightDataKeys
12421
+ }));
12422
+ }
12291
12423
  /**
12292
12424
  * Get current concurrency for a specific group.
12293
12425
  */
@@ -12448,7 +12580,7 @@ var MasterQueue = class {
12448
12580
  shardCount;
12449
12581
  constructor(options) {
12450
12582
  this.options = options;
12451
- this.redis = createRedisClient(options.redis);
12583
+ this.redis = createFairQueueRedisClient(options.redis);
12452
12584
  this.keys = options.keys;
12453
12585
  this.shardCount = Math.max(1, options.shardCount);
12454
12586
  this.#registerCommands();
@@ -12568,6 +12700,8 @@ var MasterQueue = class {
12568
12700
  * Provides better distribution than djb2 and minimal reshuffling when shard count changes.
12569
12701
  */
12570
12702
  #hashToShard(queueId) {
12703
+ const keys = this.options.keys;
12704
+ if (keys.tenantShard) return keys.tenantShard(keys.extractTenantId(queueId));
12571
12705
  return (0, _trigger_dev_core_v3_serverOnly.jumpHash)(queueId, this.shardCount);
12572
12706
  }
12573
12707
  #registerCommands() {
@@ -12810,6 +12944,16 @@ var FairQueueTelemetry = class {
12810
12944
  }
12811
12945
  });
12812
12946
  }
12947
+ if (callbacks.getOldestMessageAge && callbacks.shardCount) {
12948
+ const getOldestMessageAge = callbacks.getOldestMessageAge;
12949
+ const shardCount = callbacks.shardCount;
12950
+ this.metrics.oldestMessageAge.addCallback(async (observableResult) => {
12951
+ for (let shardId = 0; shardId < shardCount; shardId++) {
12952
+ const age = await getOldestMessageAge(shardId);
12953
+ observableResult.observe(age, { [FairQueueAttributes.SHARD_ID]: shardId.toString() });
12954
+ }
12955
+ });
12956
+ }
12813
12957
  if (callbacks.getDLQLength && callbacks.observedTenants) {
12814
12958
  const getDLQLength = callbacks.getDLQLength;
12815
12959
  const tenants = callbacks.observedTenants;
@@ -12903,6 +13047,10 @@ var FairQueueTelemetry = class {
12903
13047
  dlqLength: this.meter.createObservableGauge(`${this.name}.dlq.length`, {
12904
13048
  description: "Number of messages in dead letter queue",
12905
13049
  unit: "messages"
13050
+ }),
13051
+ oldestMessageAge: this.meter.createObservableGauge(`${this.name}.oldest_message_age`, {
13052
+ description: "Age of the oldest due message not yet completed in a shard, including claimed messages until their queue next completes or empties",
13053
+ unit: "ms"
12906
13054
  })
12907
13055
  };
12908
13056
  }
@@ -13120,7 +13268,7 @@ var TenantDispatch = class {
13120
13268
  shardCount;
13121
13269
  constructor(options) {
13122
13270
  this.options = options;
13123
- this.redis = createRedisClient(options.redis);
13271
+ this.redis = createFairQueueRedisClient(options.redis);
13124
13272
  this.keys = options.keys;
13125
13273
  this.shardCount = Math.max(1, options.shardCount);
13126
13274
  }
@@ -13130,6 +13278,7 @@ var TenantDispatch = class {
13130
13278
  * always maps to exactly one dispatch shard.
13131
13279
  */
13132
13280
  getShardForTenant(tenantId) {
13281
+ if (this.options.keys.tenantShard) return this.options.keys.tenantShard(tenantId);
13133
13282
  return (0, _trigger_dev_core_v3_serverOnly.jumpHash)(tenantId, this.shardCount);
13134
13283
  }
13135
13284
  /**
@@ -13245,7 +13394,7 @@ var VisibilityManager = class {
13245
13394
  logger;
13246
13395
  constructor(options) {
13247
13396
  this.options = options;
13248
- this.redis = createRedisClient(options.redis);
13397
+ this.redis = createFairQueueRedisClient(options.redis);
13249
13398
  this.keys = options.keys;
13250
13399
  this.shardCount = options.shardCount;
13251
13400
  this.defaultTimeoutMs = options.defaultTimeoutMs;
@@ -13558,6 +13707,7 @@ var VisibilityManager = class {
13558
13707
  * Must use same algorithm as MasterQueue for consistency.
13559
13708
  */
13560
13709
  #getShardForQueue(queueId) {
13710
+ if (this.keys.tenantShard) return this.keys.tenantShard(this.keys.extractTenantId(queueId));
13561
13711
  return (0, _trigger_dev_core_v3_serverOnly.jumpHash)(queueId, this.shardCount);
13562
13712
  }
13563
13713
  #makeMember(messageId, queueId) {
@@ -13836,7 +13986,7 @@ var WorkerQueueManager = class {
13836
13986
  logger;
13837
13987
  constructor(options) {
13838
13988
  this.options = options;
13839
- this.redis = createRedisClient(options.redis);
13989
+ this.redis = createFairQueueRedisClient(options.redis);
13840
13990
  this.keys = options.keys;
13841
13991
  this.logger = options.logger ?? {
13842
13992
  debug: () => {},
@@ -13887,6 +14037,9 @@ var WorkerQueueManager = class {
13887
14037
  */
13888
14038
  async blockingPop(workerQueueId, timeoutSeconds, signal) {
13889
14039
  const workerQueueKey = this.keys.workerQueueKey(workerQueueId);
14040
+ if (signal?.aborted) return null;
14041
+ const readyMessageKey = await this.redis.lpop(workerQueueKey);
14042
+ if (readyMessageKey) return readyMessageKey;
13890
14043
  const blockingClient = this.redis.duplicate();
13891
14044
  const cleanup = signal ? () => {
13892
14045
  blockingClient.disconnect();
@@ -14132,6 +14285,100 @@ var CallbackFairQueueKeyProducer = class extends DefaultFairQueueKeyProducer {
14132
14285
  return this.groupExtractor(groupName, queueId);
14133
14286
  }
14134
14287
  };
14288
+ /**
14289
+ * Key producer for running a fair queue on a Redis cluster. Each tenant belongs to one shard,
14290
+ * `jumpHash(tenantId, shardCount)`, and every key of a shard carries the hash tag
14291
+ * `{prefix:shard}`, so a shard's queues, items, payloads, tenant indexes, dispatch index,
14292
+ * in-flight set and concurrency sets share a slot and every script stays single-slot.
14293
+ *
14294
+ * Concurrency groups must be keyed by tenant id. The shard count can't change without moving keys.
14295
+ *
14296
+ * Key structure (S = the tenant's shard):
14297
+ * - Dispatch index: {prefix:S}:dispatch
14298
+ * - Tenant queue index: {prefix:S}:tenantq:{tenantId}
14299
+ * - Queue: {prefix:S}:queue:{queueId}, items {prefix:S}:queue:{queueId}:items
14300
+ * - Payload: {prefix:S}:payload:{queueId}:{messageId}
14301
+ * - Payload bytes per tenant: {prefix:S}:payloadbytes:{tenantId}
14302
+ * - Concurrency: {prefix:S}:concurrency:{groupName}:{tenantId}
14303
+ * - In-flight: {prefix:S}:inflight, data {prefix:S}:inflight:data
14304
+ * - Dead letter queue: {prefix:S}:dlq:{tenantId}, data {prefix:S}:dlq:{tenantId}:data
14305
+ * - Legacy master queue: {prefix:S}:master
14306
+ * - Worker queue: {prefix}:worker:{consumerId} (its own slot)
14307
+ * - DRR deficits: {prefix}:drr:deficit (its own slot)
14308
+ */
14309
+ var TenantShardedFairQueueKeyProducer = class {
14310
+ shardCount;
14311
+ prefix;
14312
+ tenantExtractor;
14313
+ groupExtractor;
14314
+ constructor(options) {
14315
+ if (!Number.isInteger(options.shardCount) || options.shardCount < 1) throw new Error(`shardCount must be a positive integer, got ${options.shardCount}`);
14316
+ this.prefix = options.prefix;
14317
+ this.shardCount = options.shardCount;
14318
+ this.tenantExtractor = options.extractTenantId;
14319
+ this.groupExtractor = options.extractGroupId;
14320
+ }
14321
+ tenantShard(tenantId) {
14322
+ return (0, _trigger_dev_core_v3_serverOnly.jumpHash)(tenantId, this.shardCount);
14323
+ }
14324
+ masterQueueKey(shardId) {
14325
+ return `${this.#tag(shardId)}:master`;
14326
+ }
14327
+ queueKey(queueId) {
14328
+ return `${this.#queueTag(queueId)}:queue:${queueId}`;
14329
+ }
14330
+ queueItemsKey(queueId) {
14331
+ return `${this.#queueTag(queueId)}:queue:${queueId}:items`;
14332
+ }
14333
+ payloadKey(queueId, messageId) {
14334
+ return `${this.#queueTag(queueId)}:payload:${queueId}:${messageId}`;
14335
+ }
14336
+ tenantPayloadBytesKey(tenantId) {
14337
+ return `${this.#tag(this.tenantShard(tenantId))}:payloadbytes:${tenantId}`;
14338
+ }
14339
+ concurrencyKey(groupName, groupId) {
14340
+ return `${this.#tag(this.tenantShard(groupId))}:concurrency:${groupName}:${groupId}`;
14341
+ }
14342
+ concurrencyKeyPattern(groupName, shardId) {
14343
+ return `${this.#tag(shardId)}:concurrency:${groupName}:*`;
14344
+ }
14345
+ inflightKey(shardId) {
14346
+ return `${this.#tag(shardId)}:inflight`;
14347
+ }
14348
+ inflightDataKey(shardId) {
14349
+ return `${this.#tag(shardId)}:inflight:data`;
14350
+ }
14351
+ workerQueueKey(consumerId) {
14352
+ return `${this.prefix}:worker:${consumerId}`;
14353
+ }
14354
+ deadLetterQueueKey(tenantId) {
14355
+ return `${this.#tag(this.tenantShard(tenantId))}:dlq:${tenantId}`;
14356
+ }
14357
+ deadLetterQueueDataKey(tenantId) {
14358
+ return `${this.#tag(this.tenantShard(tenantId))}:dlq:${tenantId}:data`;
14359
+ }
14360
+ dispatchKey(shardId) {
14361
+ return `${this.#tag(shardId)}:dispatch`;
14362
+ }
14363
+ tenantQueueIndexKey(tenantId) {
14364
+ return `${this.#tag(this.tenantShard(tenantId))}:tenantq:${tenantId}`;
14365
+ }
14366
+ drrDeficitKey() {
14367
+ return `${this.prefix}:drr:deficit`;
14368
+ }
14369
+ extractTenantId(queueId) {
14370
+ return this.tenantExtractor(queueId);
14371
+ }
14372
+ extractGroupId(groupName, queueId) {
14373
+ return this.groupExtractor(groupName, queueId);
14374
+ }
14375
+ #queueTag(queueId) {
14376
+ return this.#tag(this.tenantShard(this.tenantExtractor(queueId)));
14377
+ }
14378
+ #tag(shardId) {
14379
+ return `{${this.prefix}:${shardId}}`;
14380
+ }
14381
+ };
14135
14382
 
14136
14383
  //#endregion
14137
14384
  //#region src/fair-queue/retry.ts
@@ -14348,7 +14595,7 @@ var DRRScheduler = class extends BaseScheduler {
14348
14595
  constructor(config) {
14349
14596
  super();
14350
14597
  this.config = config;
14351
- this.redis = createRedisClient(config.redis);
14598
+ this.redis = createFairQueueRedisClient(config.redis);
14352
14599
  this.keys = config.keys;
14353
14600
  this.quantum = config.quantum;
14354
14601
  this.maxDeficit = config.maxDeficit;
@@ -14491,6 +14738,7 @@ var DRRScheduler = class extends BaseScheduler {
14491
14738
  return result;
14492
14739
  }
14493
14740
  #deficitKey() {
14741
+ if (this.keys.drrDeficitKey) return this.keys.drrDeficitKey();
14494
14742
  return `${this.keys.masterQueueKey(0).split(":")[0]}:drr:deficit`;
14495
14743
  }
14496
14744
  async #getTenantsFromDispatch(dispatchKey) {
@@ -14649,7 +14897,7 @@ var WeightedScheduler = class extends BaseScheduler {
14649
14897
  constructor(config) {
14650
14898
  super();
14651
14899
  this.config = config;
14652
- this.redis = createRedisClient(config.redis);
14900
+ this.redis = createFairQueueRedisClient(config.redis);
14653
14901
  this.keys = config.keys;
14654
14902
  this.rng = (0, seedrandom.default)(config.seed);
14655
14903
  this.biases = config.biases ?? defaultBiases;
@@ -14904,7 +15152,7 @@ var RoundRobinScheduler = class extends BaseScheduler {
14904
15152
  constructor(config) {
14905
15153
  super();
14906
15154
  this.config = config;
14907
- this.redis = createRedisClient(config.redis);
15155
+ this.redis = createFairQueueRedisClient(config.redis);
14908
15156
  this.keys = config.keys;
14909
15157
  this.masterQueueLimit = config.masterQueueLimit ?? 1e3;
14910
15158
  }
@@ -14976,6 +15224,17 @@ var RoundRobinScheduler = class extends BaseScheduler {
14976
15224
 
14977
15225
  //#endregion
14978
15226
  //#region src/fair-queue/index.ts
15227
+ /** An enqueue that would take a tenant past `tenantPayloadBytesLimit`. */
15228
+ var TenantQueueFullError = class extends Error {
15229
+ tenantId;
15230
+ limitBytes;
15231
+ constructor(tenantId, limitBytes) {
15232
+ super(`Tenant ${tenantId} is at its limit of ${limitBytes} queued payload bytes`);
15233
+ this.tenantId = tenantId;
15234
+ this.limitBytes = limitBytes;
15235
+ this.name = "TenantQueueFullError";
15236
+ }
15237
+ };
14979
15238
  /**
14980
15239
  * FairQueue is the main orchestrator for fair queue message routing.
14981
15240
  *
@@ -14994,7 +15253,7 @@ var RoundRobinScheduler = class extends BaseScheduler {
14994
15253
  *
14995
15254
  * @typeParam TPayloadSchema - Zod schema for message payload validation
14996
15255
  */
14997
- var FairQueue = class {
15256
+ var FairQueue = class FairQueue {
14998
15257
  options;
14999
15258
  redis;
15000
15259
  keys;
@@ -15018,6 +15277,7 @@ var FairQueue = class {
15018
15277
  reconcileIntervalMs;
15019
15278
  workerQueueResolver;
15020
15279
  batchClaimSize;
15280
+ separatePayloads;
15021
15281
  cooloffEnabled;
15022
15282
  cooloffThreshold;
15023
15283
  cooloffPeriodMs;
@@ -15037,8 +15297,10 @@ var FairQueue = class {
15037
15297
  tenantDispatch;
15038
15298
  constructor(options) {
15039
15299
  this.options = options;
15040
- this.redis = createRedisClient(options.redis);
15300
+ FairQueue.#validateLayout(options);
15301
+ this.redis = createFairQueueRedisClient(options.redis);
15041
15302
  this.keys = options.keys;
15303
+ this.separatePayloads = options.separatePayloads ?? false;
15042
15304
  this.scheduler = options.scheduler;
15043
15305
  this.logger = options.logger ?? new _trigger_dev_core_logger.Logger("FairQueue", "info");
15044
15306
  this.abortController = new AbortController();
@@ -15046,7 +15308,7 @@ var FairQueue = class {
15046
15308
  this.validateOnEnqueue = options.validateOnEnqueue ?? false;
15047
15309
  this.retryStrategy = options.retry?.strategy;
15048
15310
  this.deadLetterQueueEnabled = options.retry?.deadLetterQueue ?? true;
15049
- this.shardCount = options.shardCount ?? 1;
15311
+ this.shardCount = options.keys.shardCount ?? options.shardCount ?? 1;
15050
15312
  this.consumerCount = options.consumerCount ?? 1;
15051
15313
  this.consumerIntervalMs = options.consumerIntervalMs ?? 100;
15052
15314
  this.visibilityTimeoutMs = options.visibilityTimeoutMs ?? 3e4;
@@ -15138,6 +15400,9 @@ var FairQueue = class {
15138
15400
  getDLQLength: async (tenantId) => {
15139
15401
  return await this.getDeadLetterQueueLength(tenantId);
15140
15402
  },
15403
+ getOldestMessageAge: async (shardId) => {
15404
+ return await this.getOldestMessageAge(shardId);
15405
+ },
15141
15406
  shardCount: this.shardCount,
15142
15407
  observedTenants: options?.observedTenants
15143
15408
  });
@@ -15182,7 +15447,13 @@ var FairQueue = class {
15182
15447
  }) : void 0,
15183
15448
  metadata: options.metadata
15184
15449
  };
15185
- await this.redis.enqueueMessageAtomicV2(queueKey, queueItemsKey, tenantQueueIndexKey, dispatchKey, options.queueId, messageId, timestamp.toString(), JSON.stringify(storedMessage), options.tenantId);
15450
+ if (this.separatePayloads) {
15451
+ const { payload, ...envelope } = storedMessage;
15452
+ const limit = options.ignorePayloadBytesLimit ? 0 : await this.options.tenantPayloadBytesLimit?.(options.tenantId) ?? 0;
15453
+ const result = await this.redis.enqueueMessageSeparatePayload(queueKey, queueItemsKey, tenantQueueIndexKey, dispatchKey, this.keys.payloadKey(options.queueId, messageId), this.keys.inflightKey(this.masterQueue.getShardForQueue(options.queueId)), this.keys.tenantPayloadBytesKey(options.tenantId), options.queueId, messageId, timestamp.toString(), JSON.stringify(envelope), options.tenantId, JSON.stringify(payload), `${messageId}:${options.queueId}`, String(limit));
15454
+ if (result === -1) throw new TenantQueueFullError(options.tenantId, limit);
15455
+ span.setAttribute("enqueued", result === 1);
15456
+ } else await this.redis.enqueueMessageAtomicV2(queueKey, queueItemsKey, tenantQueueIndexKey, dispatchKey, options.queueId, messageId, timestamp.toString(), JSON.stringify(storedMessage), options.tenantId);
15186
15457
  span.setAttributes({
15187
15458
  [FairQueueAttributes.QUEUE_ID]: options.queueId,
15188
15459
  [FairQueueAttributes.TENANT_ID]: options.tenantId,
@@ -15206,6 +15477,7 @@ var FairQueue = class {
15206
15477
  */
15207
15478
  async enqueueBatch(options) {
15208
15479
  return this.telemetry.trace("enqueueBatch", async (span) => {
15480
+ if (this.separatePayloads) throw new Error("enqueueBatch isn't supported with separatePayloads");
15209
15481
  const queueKey = this.keys.queueKey(options.queueId);
15210
15482
  const queueItemsKey = this.keys.queueItemsKey(options.queueId);
15211
15483
  const dispatchShardId = this.tenantDispatch.getShardForTenant(options.tenantId);
@@ -15460,6 +15732,17 @@ var FairQueue = class {
15460
15732
  return dispatchCount + legacyCount;
15461
15733
  }
15462
15734
  /**
15735
+ * Get the age in ms of the oldest due message not yet completed in a shard, or 0 if none.
15736
+ * Claims don't refresh the index scores, so claimed messages count until their queue next completes or empties.
15737
+ */
15738
+ async getOldestMessageAge(shardId) {
15739
+ const now = Date.now();
15740
+ const [[oldestTenant], [oldestLegacyQueue]] = await Promise.all([this.tenantDispatch.getTenantsFromShard(shardId, 1, now), this.masterQueue.getQueuesFromShard(shardId, 1, now)]);
15741
+ const oldestScore = Math.min(oldestTenant?.score ?? Infinity, oldestLegacyQueue?.score ?? Infinity);
15742
+ if (!Number.isFinite(oldestScore)) return 0;
15743
+ return Math.max(0, now - oldestScore);
15744
+ }
15745
+ /**
15463
15746
  * Get total in-flight message count.
15464
15747
  */
15465
15748
  async getTotalInflightCount() {
@@ -15722,10 +16005,22 @@ var FairQueue = class {
15722
16005
  async getMessageData(messageId, queueId) {
15723
16006
  const shardId = this.masterQueue.getShardForQueue(queueId);
15724
16007
  const inflightDataKey = this.keys.inflightDataKey(shardId);
15725
- const dataJson = await this.redis.hget(inflightDataKey, messageId);
16008
+ const [dataJson, payloadJson] = await Promise.all([this.redis.hget(inflightDataKey, messageId), this.separatePayloads ? this.redis.get(this.keys.payloadKey(queueId, messageId)) : Promise.resolve(null)]);
15726
16009
  if (!dataJson) return null;
15727
16010
  try {
15728
- return JSON.parse(dataJson);
16011
+ const message = JSON.parse(dataJson);
16012
+ if (!this.separatePayloads) return message;
16013
+ if (payloadJson === null) {
16014
+ this.logger.error("In-flight message has no payload", {
16015
+ messageId,
16016
+ queueId
16017
+ });
16018
+ return null;
16019
+ }
16020
+ return {
16021
+ ...message,
16022
+ payload: JSON.parse(payloadJson)
16023
+ };
15729
16024
  } catch {
15730
16025
  this.logger.error("Failed to parse message data", {
15731
16026
  messageId,
@@ -15766,7 +16061,7 @@ var FairQueue = class {
15766
16061
  metadata: storedMessage?.metadata ?? {}
15767
16062
  };
15768
16063
  await this.#releaseConcurrencySafely(descriptor, messageId, "completeMessage");
15769
- await this.visibilityManager.complete(messageId, queueId);
16064
+ await this.#completeInVisibility(messageId, queueId, descriptor.tenantId);
15770
16065
  const { queueEmpty } = await this.#updateAllIndexesAfterDequeue(queueId, descriptor.tenantId);
15771
16066
  if (queueEmpty) {
15772
16067
  this.queueDescriptorCache.delete(queueId);
@@ -15916,9 +16211,16 @@ var FairQueue = class {
15916
16211
  }
15917
16212
  async #moveToDeadLetterQueue(storedMessage, errorMessage) {
15918
16213
  if (!this.deadLetterQueueEnabled) {
15919
- await this.visibilityManager.complete(storedMessage.id, storedMessage.queueId);
16214
+ await this.#completeInVisibility(storedMessage.id, storedMessage.queueId, storedMessage.tenantId);
15920
16215
  return;
15921
16216
  }
16217
+ if (this.separatePayloads) {
16218
+ const payloadJson = await this.redis.get(this.keys.payloadKey(storedMessage.queueId, storedMessage.id));
16219
+ storedMessage = {
16220
+ ...storedMessage,
16221
+ payload: payloadJson === null ? void 0 : JSON.parse(payloadJson)
16222
+ };
16223
+ }
15922
16224
  const dlqKey = this.keys.deadLetterQueueKey(storedMessage.tenantId);
15923
16225
  const dlqDataKey = this.keys.deadLetterQueueDataKey(storedMessage.tenantId);
15924
16226
  this.masterQueue.getShardForQueue(storedMessage.queueId);
@@ -15932,7 +16234,7 @@ var FairQueue = class {
15932
16234
  lastError: errorMessage,
15933
16235
  originalTimestamp: storedMessage.timestamp
15934
16236
  };
15935
- await this.visibilityManager.complete(storedMessage.id, storedMessage.queueId);
16237
+ await this.#completeInVisibility(storedMessage.id, storedMessage.queueId, storedMessage.tenantId);
15936
16238
  const pipeline = this.redis.pipeline();
15937
16239
  pipeline.zadd(dlqKey, dlqMessage.deadLetteredAt, storedMessage.id);
15938
16240
  pipeline.hset(dlqDataKey, storedMessage.id, JSON.stringify(dlqMessage));
@@ -16078,6 +16380,40 @@ var FairQueue = class {
16078
16380
  const removedFromDispatch = await this.redis.updateDispatchIndexes(queueKey, tenantQueueIndexKey, dispatchKey, queueId, tenantId);
16079
16381
  return { queueEmpty: removedFromMaster === 1 || removedFromDispatch === 1 };
16080
16382
  }
16383
+ /**
16384
+ * Remove a settled message from in-flight. With separate payloads this also deletes the payload
16385
+ * and takes its bytes off the tenant's count, in one script, so the count never drifts.
16386
+ */
16387
+ async #completeInVisibility(messageId, queueId, tenantId) {
16388
+ if (!this.separatePayloads) {
16389
+ await this.visibilityManager.complete(messageId, queueId);
16390
+ return;
16391
+ }
16392
+ const shardId = this.masterQueue.getShardForQueue(queueId);
16393
+ await this.redis.completeSeparatePayload(this.keys.inflightKey(shardId), this.keys.inflightDataKey(shardId), this.keys.payloadKey(queueId, messageId), this.keys.tenantPayloadBytesKey(tenantId), `${messageId}:${queueId}`, messageId);
16394
+ }
16395
+ /** A tenant's queued and in-flight payload bytes, with `separatePayloads`. */
16396
+ async getTenantPayloadBytes(tenantId) {
16397
+ if (!this.separatePayloads) return 0;
16398
+ return Number(await this.redis.get(this.keys.tenantPayloadBytesKey(tenantId)) ?? 0);
16399
+ }
16400
+ /**
16401
+ * A cluster needs every script's keys in one slot, which only a tenant-sharded key producer
16402
+ * gives, and the concurrency reservation touches every group's set at once, so it allows one
16403
+ * group, keyed by tenant.
16404
+ */
16405
+ static #validateLayout(options) {
16406
+ const keys = options.keys;
16407
+ if (options.separatePayloads && (!keys.payloadKey || !keys.tenantPayloadBytesKey)) throw new Error("separatePayloads needs a key producer with payloadKey and tenantPayloadBytesKey");
16408
+ if (keys.tenantShard) {
16409
+ if (!keys.shardCount) throw new Error("A key producer with tenantShard must set shardCount");
16410
+ if (options.shardCount !== void 0 && options.shardCount !== keys.shardCount) throw new Error(`shardCount ${options.shardCount} doesn't match the key producer's ${keys.shardCount}`);
16411
+ }
16412
+ if (isClusterConnection(options.redis)) {
16413
+ if (!keys.tenantShard) throw new Error("A cluster connection needs a tenant-sharded key producer");
16414
+ if ((options.concurrencyGroups?.length ?? 0) > 1) throw new Error("A cluster connection supports at most one concurrency group");
16415
+ }
16416
+ }
16081
16417
  #createSchedulerContext() {
16082
16418
  return {
16083
16419
  getCurrentConcurrency: async (groupName, groupId) => {
@@ -16236,6 +16572,77 @@ if #tenantOldest >= 2 then
16236
16572
  end
16237
16573
 
16238
16574
  return (#ARGV - 2) / 3
16575
+ `
16576
+ });
16577
+ this.redis.defineCommand("enqueueMessageSeparatePayload", {
16578
+ numberOfKeys: 7,
16579
+ lua: `#!lua
16580
+ local queueKey = KEYS[1]
16581
+ local queueItemsKey = KEYS[2]
16582
+ local tenantQueueIndexKey = KEYS[3]
16583
+ local dispatchKey = KEYS[4]
16584
+ local payloadKey = KEYS[5]
16585
+ local inflightKey = KEYS[6]
16586
+ local bytesKey = KEYS[7]
16587
+
16588
+ local queueId = ARGV[1]
16589
+ local messageId = ARGV[2]
16590
+ local timestamp = tonumber(ARGV[3])
16591
+ local envelope = ARGV[4]
16592
+ local tenantId = ARGV[5]
16593
+ local payload = ARGV[6]
16594
+ local inflightMember = ARGV[7]
16595
+ local limit = tonumber(ARGV[8])
16596
+
16597
+ if redis.call('HEXISTS', queueItemsKey, messageId) == 1 then
16598
+ return 0
16599
+ end
16600
+ if redis.call('ZSCORE', inflightKey, inflightMember) then
16601
+ return 0
16602
+ end
16603
+
16604
+ local size = string.len(payload)
16605
+ if limit > 0 and tonumber(redis.call('GET', bytesKey) or '0') + size > limit then
16606
+ return -1
16607
+ end
16608
+
16609
+ redis.call('SET', payloadKey, payload)
16610
+ redis.call('INCRBY', bytesKey, size)
16611
+ redis.call('ZADD', queueKey, timestamp, messageId)
16612
+ redis.call('HSET', queueItemsKey, messageId, envelope)
16613
+
16614
+ local oldest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES')
16615
+ if #oldest >= 2 then
16616
+ redis.call('ZADD', tenantQueueIndexKey, oldest[2], queueId)
16617
+ end
16618
+
16619
+ local tenantOldest = redis.call('ZRANGE', tenantQueueIndexKey, 0, 0, 'WITHSCORES')
16620
+ if #tenantOldest >= 2 then
16621
+ redis.call('ZADD', dispatchKey, tenantOldest[2], tenantId)
16622
+ end
16623
+
16624
+ return 1
16625
+ `
16626
+ });
16627
+ this.redis.defineCommand("completeSeparatePayload", {
16628
+ numberOfKeys: 4,
16629
+ lua: `
16630
+ local inflightKey = KEYS[1]
16631
+ local inflightDataKey = KEYS[2]
16632
+ local payloadKey = KEYS[3]
16633
+ local bytesKey = KEYS[4]
16634
+
16635
+ redis.call('ZREM', inflightKey, ARGV[1])
16636
+ redis.call('HDEL', inflightDataKey, ARGV[2])
16637
+
16638
+ local size = redis.call('STRLEN', payloadKey)
16639
+ if size > 0 then
16640
+ redis.call('DEL', payloadKey)
16641
+ if redis.call('DECRBY', bytesKey, size) <= 0 then
16642
+ redis.call('DEL', bytesKey)
16643
+ end
16644
+ end
16645
+ return size
16239
16646
  `
16240
16647
  });
16241
16648
  this.redis.defineCommand("updateDispatchIndexes", {
@@ -17385,16 +17792,22 @@ exports.NoopScheduler = NoopScheduler;
17385
17792
  exports.RoundRobinScheduler = RoundRobinScheduler;
17386
17793
  exports.SimpleQueue = SimpleQueue;
17387
17794
  exports.TenantDispatch = TenantDispatch;
17795
+ exports.TenantQueueFullError = TenantQueueFullError;
17796
+ exports.TenantShardedFairQueueKeyProducer = TenantShardedFairQueueKeyProducer;
17388
17797
  exports.VisibilityManager = VisibilityManager;
17389
17798
  exports.WeightedScheduler = WeightedScheduler;
17390
17799
  exports.Worker = Worker;
17391
17800
  exports.WorkerQueueManager = WorkerQueueManager;
17392
17801
  exports.createDefaultRetryStrategy = createDefaultRetryStrategy;
17802
+ exports.createFairQueueRedisClient = createFairQueueRedisClient;
17393
17803
  exports.defaultRetryOptions = defaultRetryOptions;
17394
17804
  exports.deserialiseSnapshot = deserialiseSnapshot;
17395
17805
  exports.idempotencyLookupKeyFor = idempotencyLookupKeyFor;
17396
17806
  exports.isAbortError = isAbortError;
17807
+ exports.isClusterConnection = isClusterConnection;
17808
+ exports.keyPrefixOf = keyPrefixOf;
17397
17809
  exports.makeIdempotencyClaimKey = makeIdempotencyClaimKey;
17398
17810
  exports.noopTelemetry = noopTelemetry;
17811
+ exports.scanKeys = scanKeys;
17399
17812
  exports.serialiseSnapshot = serialiseSnapshot;
17400
17813
  //# sourceMappingURL=index.cjs.map