@trigger.dev/redis-worker 4.4.0 → 4.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -11168,6 +11168,8 @@ var Worker = class _Worker {
11168
11168
  shutdownTimeoutMs;
11169
11169
  // The p-limit limiter to control overall concurrency.
11170
11170
  limiters = {};
11171
+ // Batch accumulators: one per batch-enabled job type
11172
+ batchAccumulators = /* @__PURE__ */ new Map();
11171
11173
  async #updateQueueSizeMetric(observableResult) {
11172
11174
  const queueSize = await this.queue.size();
11173
11175
  observableResult.observe(queueSize, {
@@ -11370,6 +11372,31 @@ var Worker = class _Worker {
11370
11372
  async getJob(id) {
11371
11373
  return this.queue.getJob(id);
11372
11374
  }
11375
+ /**
11376
+ * Returns true if the given job type has batch config in the catalog.
11377
+ */
11378
+ isBatchJob(jobType) {
11379
+ const catalogItem = this.options.catalog[jobType];
11380
+ return !!catalogItem?.batch;
11381
+ }
11382
+ /**
11383
+ * Returns the batch config for a job type, or undefined if not batch-enabled.
11384
+ */
11385
+ getBatchConfig(jobType) {
11386
+ const catalogItem = this.options.catalog[jobType];
11387
+ return catalogItem?.batch;
11388
+ }
11389
+ /**
11390
+ * The max dequeue count: the largest batch maxSize across catalog entries,
11391
+ * falling back to tasksPerWorker for non-batch catalogs.
11392
+ */
11393
+ get maxDequeueCount() {
11394
+ const batchSizes = Object.values(this.options.catalog).filter((entry) => !!entry.batch).map((entry) => entry.batch.maxSize);
11395
+ if (batchSizes.length > 0) {
11396
+ return Math.max(...batchSizes);
11397
+ }
11398
+ return this.concurrency.tasksPerWorker;
11399
+ }
11373
11400
  /**
11374
11401
  * The main loop that each worker runs. It repeatedly polls for items,
11375
11402
  * processes them, and then waits before the next iteration.
@@ -11393,6 +11420,7 @@ var Worker = class _Worker {
11393
11420
  concurrencyOptions: this.concurrency
11394
11421
  });
11395
11422
  while (!this.isShuttingDown) {
11423
+ await this.flushTimedOutBatches(workerId, limiter);
11396
11424
  if (limiter.activeCount + limiter.pendingCount >= this.concurrency.limit) {
11397
11425
  this.logger.debug("Worker at capacity, waiting", {
11398
11426
  workerId,
@@ -11404,9 +11432,10 @@ var Worker = class _Worker {
11404
11432
  continue;
11405
11433
  }
11406
11434
  const $taskCount = Math.min(
11407
- taskCount,
11435
+ this.maxDequeueCount,
11408
11436
  this.concurrency.limit - limiter.activeCount - limiter.pendingCount
11409
11437
  );
11438
+ let itemsFound = false;
11410
11439
  try {
11411
11440
  const items = await this.withHistogram(
11412
11441
  this.metrics.dequeueDuration,
@@ -11416,7 +11445,8 @@ var Worker = class _Worker {
11416
11445
  task_count: $taskCount
11417
11446
  }
11418
11447
  );
11419
- if (items.length === 0) {
11448
+ itemsFound = items.length > 0;
11449
+ if (items.length === 0 && this.batchAccumulators.size === 0) {
11420
11450
  this.logger.debug("No items to dequeue", {
11421
11451
  workerId,
11422
11452
  concurrencyOptions: this.concurrency,
@@ -11426,29 +11456,226 @@ var Worker = class _Worker {
11426
11456
  await _Worker.delay(pollIntervalMs);
11427
11457
  continue;
11428
11458
  }
11429
- this.logger.debug("Dequeued items", {
11430
- workerId,
11431
- itemCount: items.length,
11432
- concurrencyOptions: this.concurrency,
11433
- activeCount: limiter.activeCount,
11434
- pendingCount: limiter.pendingCount
11435
- });
11436
- for (const item of items) {
11437
- limiter(
11438
- () => this.processItem(item, items.length, workerId, limiter)
11439
- ).catch((err) => {
11440
- this.logger.error("Unhandled error in processItem:", { error: err, workerId, item });
11459
+ if (items.length > 0) {
11460
+ this.logger.debug("Dequeued items", {
11461
+ workerId,
11462
+ itemCount: items.length,
11463
+ concurrencyOptions: this.concurrency,
11464
+ activeCount: limiter.activeCount,
11465
+ pendingCount: limiter.pendingCount
11441
11466
  });
11442
11467
  }
11468
+ for (const item of items) {
11469
+ const queueItem = item;
11470
+ if (this.isBatchJob(queueItem.job)) {
11471
+ this.addToAccumulator(queueItem);
11472
+ const batchConfig = this.getBatchConfig(queueItem.job);
11473
+ const accumulator = this.batchAccumulators.get(queueItem.job);
11474
+ if (accumulator && accumulator.items.length >= batchConfig.maxSize) {
11475
+ await this.flushBatch(queueItem.job, workerId, limiter);
11476
+ }
11477
+ } else {
11478
+ limiter(
11479
+ () => this.processItem(queueItem, items.length, workerId, limiter)
11480
+ ).catch((err) => {
11481
+ this.logger.error("Unhandled error in processItem:", {
11482
+ error: err,
11483
+ workerId,
11484
+ item
11485
+ });
11486
+ });
11487
+ }
11488
+ }
11443
11489
  } catch (error) {
11444
11490
  this.logger.error("Error dequeuing items:", { name: this.options.name, error });
11445
11491
  await _Worker.delay(pollIntervalMs);
11446
11492
  continue;
11447
11493
  }
11448
- await _Worker.delay(immediatePollIntervalMs);
11494
+ if (itemsFound || this.batchAccumulators.size > 0) {
11495
+ await _Worker.delay(immediatePollIntervalMs);
11496
+ } else {
11497
+ await _Worker.delay(pollIntervalMs);
11498
+ }
11449
11499
  }
11500
+ await this.flushAllBatches(workerId, limiter);
11450
11501
  this.logger.info("Worker loop finished", { workerId });
11451
11502
  }
11503
+ /**
11504
+ * Adds an item to the batch accumulator for its job type.
11505
+ */
11506
+ addToAccumulator(item) {
11507
+ let accumulator = this.batchAccumulators.get(item.job);
11508
+ if (!accumulator) {
11509
+ accumulator = { items: [], firstItemAt: Date.now() };
11510
+ this.batchAccumulators.set(item.job, accumulator);
11511
+ }
11512
+ accumulator.items.push(item);
11513
+ }
11514
+ /**
11515
+ * Flushes any batch accumulators that have exceeded their maxWaitMs.
11516
+ */
11517
+ async flushTimedOutBatches(workerId, limiter) {
11518
+ const now = Date.now();
11519
+ for (const [jobType, accumulator] of this.batchAccumulators) {
11520
+ const batchConfig = this.getBatchConfig(jobType);
11521
+ if (!batchConfig) continue;
11522
+ if (now - accumulator.firstItemAt >= batchConfig.maxWaitMs) {
11523
+ await this.flushBatch(jobType, workerId, limiter);
11524
+ }
11525
+ }
11526
+ }
11527
+ /**
11528
+ * Flushes all batch accumulators (used during shutdown).
11529
+ */
11530
+ async flushAllBatches(workerId, limiter) {
11531
+ for (const jobType of this.batchAccumulators.keys()) {
11532
+ await this.flushBatch(jobType, workerId, limiter);
11533
+ }
11534
+ }
11535
+ /**
11536
+ * Flushes the batch accumulator for a specific job type.
11537
+ * Removes items from the accumulator and submits them to the limiter as a single batch.
11538
+ */
11539
+ async flushBatch(jobType, workerId, limiter) {
11540
+ const accumulator = this.batchAccumulators.get(jobType);
11541
+ if (!accumulator || accumulator.items.length === 0) return;
11542
+ const items = accumulator.items;
11543
+ this.batchAccumulators.delete(jobType);
11544
+ this.logger.debug("Flushing batch", {
11545
+ jobType,
11546
+ batchSize: items.length,
11547
+ workerId,
11548
+ accumulatedMs: Date.now() - accumulator.firstItemAt
11549
+ });
11550
+ limiter(() => this.processBatch(items, jobType, workerId, limiter)).catch((err) => {
11551
+ this.logger.error("Unhandled error in processBatch:", {
11552
+ error: err,
11553
+ workerId,
11554
+ jobType,
11555
+ batchSize: items.length
11556
+ });
11557
+ });
11558
+ }
11559
+ /**
11560
+ * Processes a batch of items for a batch-enabled job type.
11561
+ */
11562
+ async processBatch(items, jobType, workerId, limiter) {
11563
+ const catalogItem = this.options.catalog[jobType];
11564
+ const handler = this.jobs[jobType];
11565
+ if (!handler) {
11566
+ this.logger.error(`Worker no handler found for batch job type: ${jobType}`);
11567
+ return;
11568
+ }
11569
+ if (!catalogItem) {
11570
+ this.logger.error(`Worker no catalog item found for batch job type: ${jobType}`);
11571
+ return;
11572
+ }
11573
+ const batchParams = items.map((item) => ({
11574
+ id: item.id,
11575
+ payload: item.item,
11576
+ visibilityTimeoutMs: item.visibilityTimeoutMs,
11577
+ attempt: item.attempt,
11578
+ deduplicationKey: item.deduplicationKey
11579
+ }));
11580
+ await startSpan(
11581
+ this.tracer,
11582
+ "processBatch",
11583
+ async () => {
11584
+ await this.withHistogram(this.metrics.jobDuration, handler(batchParams), {
11585
+ worker_id: workerId,
11586
+ batch_size: items.length,
11587
+ job_type: jobType
11588
+ });
11589
+ await Promise.all(items.map((item) => this.queue.ack(item.id, item.deduplicationKey)));
11590
+ },
11591
+ {
11592
+ kind: SpanKind.CONSUMER,
11593
+ attributes: {
11594
+ job_type: jobType,
11595
+ batch_size: items.length,
11596
+ worker_id: workerId,
11597
+ worker_name: this.options.name
11598
+ }
11599
+ }
11600
+ ).catch(async (error) => {
11601
+ const errorMessage = error instanceof Error ? error.message : String(error);
11602
+ const shouldLogError = catalogItem.logErrors ?? true;
11603
+ if (shouldLogError) {
11604
+ this.logger.error(`Worker error processing batch`, {
11605
+ name: this.options.name,
11606
+ jobType,
11607
+ batchSize: items.length,
11608
+ error,
11609
+ errorMessage
11610
+ });
11611
+ } else {
11612
+ this.logger.info(`Worker failed to process batch`, {
11613
+ name: this.options.name,
11614
+ jobType,
11615
+ batchSize: items.length,
11616
+ error,
11617
+ errorMessage
11618
+ });
11619
+ }
11620
+ for (const item of items) {
11621
+ try {
11622
+ const newAttempt = item.attempt + 1;
11623
+ const retrySettings = {
11624
+ ...defaultRetrySettings,
11625
+ ...catalogItem?.retry
11626
+ };
11627
+ const retryDelay = v3.calculateNextRetryDelay(retrySettings, newAttempt);
11628
+ if (!retryDelay) {
11629
+ if (shouldLogError) {
11630
+ this.logger.error(`Worker batch item reached max attempts. Moving to DLQ.`, {
11631
+ name: this.options.name,
11632
+ id: item.id,
11633
+ jobType,
11634
+ attempt: newAttempt
11635
+ });
11636
+ } else {
11637
+ this.logger.info(`Worker batch item reached max attempts. Moving to DLQ.`, {
11638
+ name: this.options.name,
11639
+ id: item.id,
11640
+ jobType,
11641
+ attempt: newAttempt
11642
+ });
11643
+ }
11644
+ await this.queue.moveToDeadLetterQueue(item.id, errorMessage);
11645
+ continue;
11646
+ }
11647
+ const retryDate = new Date(Date.now() + retryDelay);
11648
+ this.logger.info(`Worker requeuing failed batch item with delay`, {
11649
+ name: this.options.name,
11650
+ id: item.id,
11651
+ jobType,
11652
+ retryDate,
11653
+ retryDelay,
11654
+ attempt: newAttempt
11655
+ });
11656
+ await this.queue.enqueue({
11657
+ id: item.id,
11658
+ job: item.job,
11659
+ item: item.item,
11660
+ availableAt: retryDate,
11661
+ attempt: newAttempt,
11662
+ visibilityTimeoutMs: item.visibilityTimeoutMs
11663
+ });
11664
+ } catch (requeueError) {
11665
+ this.logger.error(
11666
+ `Worker failed to requeue batch item. It will be retried after the visibility timeout.`,
11667
+ {
11668
+ name: this.options.name,
11669
+ id: item.id,
11670
+ jobType,
11671
+ visibilityTimeoutMs: item.visibilityTimeoutMs,
11672
+ error: requeueError
11673
+ }
11674
+ );
11675
+ }
11676
+ }
11677
+ });
11678
+ }
11452
11679
  /**
11453
11680
  * Processes a single item.
11454
11681
  */
@@ -12159,6 +12386,135 @@ return 1
12159
12386
  });
12160
12387
  }
12161
12388
  };
12389
+ var TenantDispatch = class {
12390
+ constructor(options) {
12391
+ this.options = options;
12392
+ this.redis = createRedisClient(options.redis);
12393
+ this.keys = options.keys;
12394
+ this.shardCount = Math.max(1, options.shardCount);
12395
+ }
12396
+ redis;
12397
+ keys;
12398
+ shardCount;
12399
+ /**
12400
+ * Get the dispatch shard ID for a tenant.
12401
+ * Uses jump consistent hash on the tenant ID so each tenant
12402
+ * always maps to exactly one dispatch shard.
12403
+ */
12404
+ getShardForTenant(tenantId) {
12405
+ return serverOnly.jumpHash(tenantId, this.shardCount);
12406
+ }
12407
+ /**
12408
+ * Get eligible tenants from a dispatch shard (Level 1).
12409
+ * Returns tenants ordered by oldest message (lowest score first).
12410
+ */
12411
+ async getTenantsFromShard(shardId, limit = 1e3, maxScore) {
12412
+ const dispatchKey = this.keys.dispatchKey(shardId);
12413
+ const score = maxScore ?? Date.now();
12414
+ const results = await this.redis.zrangebyscore(
12415
+ dispatchKey,
12416
+ "-inf",
12417
+ score,
12418
+ "WITHSCORES",
12419
+ "LIMIT",
12420
+ 0,
12421
+ limit
12422
+ );
12423
+ const tenants = [];
12424
+ for (let i = 0; i < results.length; i += 2) {
12425
+ const tenantId = results[i];
12426
+ const scoreStr = results[i + 1];
12427
+ if (tenantId && scoreStr) {
12428
+ tenants.push({
12429
+ tenantId,
12430
+ score: parseFloat(scoreStr)
12431
+ });
12432
+ }
12433
+ }
12434
+ return tenants;
12435
+ }
12436
+ /**
12437
+ * Get queues for a specific tenant (Level 2).
12438
+ * Returns queues ordered by oldest message (lowest score first).
12439
+ */
12440
+ async getQueuesForTenant(tenantId, limit = 1e3, maxScore) {
12441
+ const tenantQueueKey = this.keys.tenantQueueIndexKey(tenantId);
12442
+ const score = maxScore ?? Date.now();
12443
+ const results = await this.redis.zrangebyscore(
12444
+ tenantQueueKey,
12445
+ "-inf",
12446
+ score,
12447
+ "WITHSCORES",
12448
+ "LIMIT",
12449
+ 0,
12450
+ limit
12451
+ );
12452
+ const queues = [];
12453
+ for (let i = 0; i < results.length; i += 2) {
12454
+ const queueId = results[i];
12455
+ const scoreStr = results[i + 1];
12456
+ if (queueId && scoreStr) {
12457
+ queues.push({
12458
+ queueId,
12459
+ score: parseFloat(scoreStr),
12460
+ tenantId
12461
+ });
12462
+ }
12463
+ }
12464
+ return queues;
12465
+ }
12466
+ /**
12467
+ * Get the number of tenants in a dispatch shard.
12468
+ */
12469
+ async getShardTenantCount(shardId) {
12470
+ const dispatchKey = this.keys.dispatchKey(shardId);
12471
+ return await this.redis.zcard(dispatchKey);
12472
+ }
12473
+ /**
12474
+ * Get total tenant count across all dispatch shards.
12475
+ * Note: tenants may appear in multiple shards, so this may overcount.
12476
+ */
12477
+ async getTotalTenantCount() {
12478
+ const counts = await Promise.all(
12479
+ Array.from({ length: this.shardCount }, (_, i) => this.getShardTenantCount(i))
12480
+ );
12481
+ return counts.reduce((sum, count) => sum + count, 0);
12482
+ }
12483
+ /**
12484
+ * Get the number of queues for a tenant.
12485
+ */
12486
+ async getTenantQueueCount(tenantId) {
12487
+ const tenantQueueKey = this.keys.tenantQueueIndexKey(tenantId);
12488
+ return await this.redis.zcard(tenantQueueKey);
12489
+ }
12490
+ /**
12491
+ * Remove a tenant from a specific dispatch shard.
12492
+ */
12493
+ async removeTenantFromShard(shardId, tenantId) {
12494
+ const dispatchKey = this.keys.dispatchKey(shardId);
12495
+ await this.redis.zrem(dispatchKey, tenantId);
12496
+ }
12497
+ /**
12498
+ * Add a tenant to a dispatch shard with the given score.
12499
+ */
12500
+ async addTenantToShard(shardId, tenantId, score) {
12501
+ const dispatchKey = this.keys.dispatchKey(shardId);
12502
+ await this.redis.zadd(dispatchKey, score, tenantId);
12503
+ }
12504
+ /**
12505
+ * Remove a queue from a tenant's queue index.
12506
+ */
12507
+ async removeQueueFromTenant(tenantId, queueId) {
12508
+ const tenantQueueKey = this.keys.tenantQueueIndexKey(tenantId);
12509
+ await this.redis.zrem(tenantQueueKey, queueId);
12510
+ }
12511
+ /**
12512
+ * Close the Redis connection.
12513
+ */
12514
+ async close() {
12515
+ await this.redis.quit();
12516
+ }
12517
+ };
12162
12518
 
12163
12519
  // src/fair-queue/telemetry.ts
12164
12520
  var FairQueueAttributes = {
@@ -12340,6 +12696,18 @@ var FairQueueTelemetry = class {
12340
12696
  }
12341
12697
  });
12342
12698
  }
12699
+ if (callbacks.getDispatchLength && callbacks.shardCount) {
12700
+ const getDispatchLength = callbacks.getDispatchLength;
12701
+ const shardCount = callbacks.shardCount;
12702
+ this.metrics.dispatchLength.addCallback(async (observableResult) => {
12703
+ for (let shardId = 0; shardId < shardCount; shardId++) {
12704
+ const length = await getDispatchLength(shardId);
12705
+ observableResult.observe(length, {
12706
+ [FairQueueAttributes.SHARD_ID]: shardId.toString()
12707
+ });
12708
+ }
12709
+ });
12710
+ }
12343
12711
  if (callbacks.getInflightCount && callbacks.shardCount) {
12344
12712
  const getInflightCount = callbacks.getInflightCount;
12345
12713
  const shardCount = callbacks.shardCount;
@@ -12442,9 +12810,13 @@ var FairQueueTelemetry = class {
12442
12810
  unit: "messages"
12443
12811
  }),
12444
12812
  masterQueueLength: this.meter.createObservableGauge(`${this.name}.master_queue.length`, {
12445
- description: "Number of queues in master queue shard",
12813
+ description: "Number of queues in legacy master queue shard (draining)",
12446
12814
  unit: "queues"
12447
12815
  }),
12816
+ dispatchLength: this.meter.createObservableGauge(`${this.name}.dispatch.length`, {
12817
+ description: "Number of tenants in dispatch index shard",
12818
+ unit: "tenants"
12819
+ }),
12448
12820
  inflightCount: this.meter.createObservableGauge(`${this.name}.inflight.count`, {
12449
12821
  description: "Number of messages currently being processed",
12450
12822
  unit: "messages"
@@ -12848,10 +13220,12 @@ var VisibilityManager = class {
12848
13220
  * @param queueId - The queue ID
12849
13221
  * @param queueKey - The Redis key for the queue
12850
13222
  * @param queueItemsKey - The Redis key for the queue items hash
12851
- * @param masterQueueKey - The Redis key for the master queue
13223
+ * @param tenantQueueIndexKey - The Redis key for the tenant queue index (Level 2)
13224
+ * @param dispatchKey - The Redis key for the dispatch index (Level 1)
13225
+ * @param tenantId - The tenant ID
12852
13226
  * @param score - Optional score for the message (defaults to now)
12853
13227
  */
12854
- async release(messageId, queueId, queueKey, queueItemsKey, masterQueueKey, score) {
13228
+ async release(messageId, queueId, queueKey, queueItemsKey, tenantQueueIndexKey, dispatchKey, tenantId, score, updatedData) {
12855
13229
  const shardId = this.#getShardForQueue(queueId);
12856
13230
  const inflightKey = this.keys.inflightKey(shardId);
12857
13231
  const inflightDataKey = this.keys.inflightDataKey(shardId);
@@ -12862,11 +13236,14 @@ var VisibilityManager = class {
12862
13236
  inflightDataKey,
12863
13237
  queueKey,
12864
13238
  queueItemsKey,
12865
- masterQueueKey,
13239
+ tenantQueueIndexKey,
13240
+ dispatchKey,
12866
13241
  member,
12867
13242
  messageId,
12868
13243
  messageScore.toString(),
12869
- queueId
13244
+ queueId,
13245
+ updatedData ?? "",
13246
+ tenantId
12870
13247
  );
12871
13248
  this.logger.debug("Message released", {
12872
13249
  messageId,
@@ -12883,10 +13260,12 @@ var VisibilityManager = class {
12883
13260
  * @param queueId - The queue ID
12884
13261
  * @param queueKey - The Redis key for the queue
12885
13262
  * @param queueItemsKey - The Redis key for the queue items hash
12886
- * @param masterQueueKey - The Redis key for the master queue
13263
+ * @param tenantQueueIndexKey - The Redis key for the tenant queue index (Level 2)
13264
+ * @param dispatchKey - The Redis key for the dispatch index (Level 1)
13265
+ * @param tenantId - The tenant ID
12887
13266
  * @param score - Optional score for the messages (defaults to now)
12888
13267
  */
12889
- async releaseBatch(messages, queueId, queueKey, queueItemsKey, masterQueueKey, score) {
13268
+ async releaseBatch(messages, queueId, queueKey, queueItemsKey, tenantQueueIndexKey, dispatchKey, tenantId, score) {
12890
13269
  if (messages.length === 0) {
12891
13270
  return;
12892
13271
  }
@@ -12901,9 +13280,11 @@ var VisibilityManager = class {
12901
13280
  inflightDataKey,
12902
13281
  queueKey,
12903
13282
  queueItemsKey,
12904
- masterQueueKey,
13283
+ tenantQueueIndexKey,
13284
+ dispatchKey,
12905
13285
  messageScore.toString(),
12906
13286
  queueId,
13287
+ tenantId,
12907
13288
  ...members,
12908
13289
  ...messageIds
12909
13290
  );
@@ -12943,7 +13324,7 @@ var VisibilityManager = class {
12943
13324
  continue;
12944
13325
  }
12945
13326
  const { messageId, queueId } = this.#parseMember(member);
12946
- const { queueKey, queueItemsKey, masterQueueKey } = getQueueKeys(queueId);
13327
+ const { queueKey, queueItemsKey, tenantQueueIndexKey, dispatchKey, tenantId } = getQueueKeys(queueId);
12947
13328
  try {
12948
13329
  const dataJson = await this.redis.hget(inflightDataKey, messageId);
12949
13330
  let storedMessage = null;
@@ -12959,11 +13340,14 @@ var VisibilityManager = class {
12959
13340
  inflightDataKey,
12960
13341
  queueKey,
12961
13342
  queueItemsKey,
12962
- masterQueueKey,
13343
+ tenantQueueIndexKey,
13344
+ dispatchKey,
12963
13345
  member,
12964
13346
  messageId,
12965
13347
  score.toString(),
12966
- queueId
13348
+ queueId,
13349
+ "",
13350
+ tenantId
12967
13351
  );
12968
13352
  if (storedMessage) {
12969
13353
  reclaimedMessages.push({
@@ -13164,18 +13548,21 @@ return results
13164
13548
  `
13165
13549
  });
13166
13550
  this.redis.defineCommand("releaseMessage", {
13167
- numberOfKeys: 5,
13551
+ numberOfKeys: 6,
13168
13552
  lua: `
13169
13553
  local inflightKey = KEYS[1]
13170
13554
  local inflightDataKey = KEYS[2]
13171
13555
  local queueKey = KEYS[3]
13172
13556
  local queueItemsKey = KEYS[4]
13173
- local masterQueueKey = KEYS[5]
13557
+ local tenantQueueIndexKey = KEYS[5]
13558
+ local dispatchKey = KEYS[6]
13174
13559
 
13175
13560
  local member = ARGV[1]
13176
13561
  local messageId = ARGV[2]
13177
13562
  local score = tonumber(ARGV[3])
13178
13563
  local queueId = ARGV[4]
13564
+ local updatedData = ARGV[5]
13565
+ local tenantId = ARGV[6]
13179
13566
 
13180
13567
  -- Get message data from in-flight
13181
13568
  local payload = redis.call('HGET', inflightDataKey, messageId)
@@ -13184,6 +13571,12 @@ if not payload then
13184
13571
  return 0
13185
13572
  end
13186
13573
 
13574
+ -- Use updatedData if provided (e.g. incremented attempt count for retries),
13575
+ -- otherwise use the original in-flight data
13576
+ if updatedData and updatedData ~= "" then
13577
+ payload = updatedData
13578
+ end
13579
+
13187
13580
  -- Remove from in-flight
13188
13581
  redis.call('ZREM', inflightKey, member)
13189
13582
  redis.call('HDEL', inflightDataKey, messageId)
@@ -13192,33 +13585,39 @@ redis.call('HDEL', inflightDataKey, messageId)
13192
13585
  redis.call('ZADD', queueKey, score, messageId)
13193
13586
  redis.call('HSET', queueItemsKey, messageId, payload)
13194
13587
 
13195
- -- Update master queue with oldest message timestamp
13196
- -- This ensures delayed messages don't push the queue priority to the future
13197
- -- when there are other ready messages in the queue
13588
+ -- Update tenant queue index (Level 2) with queue's oldest message
13198
13589
  local oldest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES')
13199
13590
  if #oldest >= 2 then
13200
- redis.call('ZADD', masterQueueKey, oldest[2], queueId)
13591
+ redis.call('ZADD', tenantQueueIndexKey, oldest[2], queueId)
13592
+ end
13593
+
13594
+ -- Update dispatch index (Level 1) with tenant's oldest across all queues
13595
+ local tenantOldest = redis.call('ZRANGE', tenantQueueIndexKey, 0, 0, 'WITHSCORES')
13596
+ if #tenantOldest >= 2 then
13597
+ redis.call('ZADD', dispatchKey, tenantOldest[2], tenantId)
13201
13598
  end
13202
13599
 
13203
13600
  return 1
13204
13601
  `
13205
13602
  });
13206
13603
  this.redis.defineCommand("releaseMessageBatch", {
13207
- numberOfKeys: 5,
13604
+ numberOfKeys: 6,
13208
13605
  lua: `
13209
13606
  local inflightKey = KEYS[1]
13210
13607
  local inflightDataKey = KEYS[2]
13211
13608
  local queueKey = KEYS[3]
13212
13609
  local queueItemsKey = KEYS[4]
13213
- local masterQueueKey = KEYS[5]
13610
+ local tenantQueueIndexKey = KEYS[5]
13611
+ local dispatchKey = KEYS[6]
13214
13612
 
13215
13613
  local score = tonumber(ARGV[1])
13216
13614
  local queueId = ARGV[2]
13615
+ local tenantId = ARGV[3]
13217
13616
 
13218
13617
  -- Remaining args are: members..., messageIds...
13219
13618
  -- Calculate how many messages we have
13220
- local numMessages = (table.getn(ARGV) - 2) / 2
13221
- local membersStart = 3
13619
+ local numMessages = (table.getn(ARGV) - 3) / 2
13620
+ local membersStart = 4
13222
13621
  local messageIdsStart = membersStart + numMessages
13223
13622
 
13224
13623
  local releasedCount = 0
@@ -13226,27 +13625,33 @@ local releasedCount = 0
13226
13625
  for i = 0, numMessages - 1 do
13227
13626
  local member = ARGV[membersStart + i]
13228
13627
  local messageId = ARGV[messageIdsStart + i]
13229
-
13628
+
13230
13629
  -- Get message data from in-flight
13231
13630
  local payload = redis.call('HGET', inflightDataKey, messageId)
13232
13631
  if payload then
13233
13632
  -- Remove from in-flight
13234
13633
  redis.call('ZREM', inflightKey, member)
13235
13634
  redis.call('HDEL', inflightDataKey, messageId)
13236
-
13635
+
13237
13636
  -- Add back to queue
13238
13637
  redis.call('ZADD', queueKey, score, messageId)
13239
13638
  redis.call('HSET', queueItemsKey, messageId, payload)
13240
-
13639
+
13241
13640
  releasedCount = releasedCount + 1
13242
13641
  end
13243
13642
  end
13244
13643
 
13245
- -- Update master queue with oldest message timestamp (only once at the end)
13644
+ -- Update dispatch indexes (only once at the end)
13246
13645
  if releasedCount > 0 then
13646
+ -- Update tenant queue index (Level 2)
13247
13647
  local oldest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES')
13248
13648
  if #oldest >= 2 then
13249
- redis.call('ZADD', masterQueueKey, oldest[2], queueId)
13649
+ redis.call('ZADD', tenantQueueIndexKey, oldest[2], queueId)
13650
+ end
13651
+ -- Update dispatch index (Level 1)
13652
+ local tenantOldest = redis.call('ZRANGE', tenantQueueIndexKey, 0, 0, 'WITHSCORES')
13653
+ if #tenantOldest >= 2 then
13654
+ redis.call('ZADD', dispatchKey, tenantOldest[2], tenantId)
13250
13655
  end
13251
13656
  end
13252
13657
 
@@ -13534,6 +13939,15 @@ var DefaultFairQueueKeyProducer = class {
13534
13939
  return this.#buildKey("worker", consumerId);
13535
13940
  }
13536
13941
  // ============================================================================
13942
+ // Tenant Dispatch Keys (Two-Level Index)
13943
+ // ============================================================================
13944
+ dispatchKey(shardId) {
13945
+ return this.#buildKey("dispatch", shardId.toString());
13946
+ }
13947
+ tenantQueueIndexKey(tenantId) {
13948
+ return this.#buildKey("tenantq", tenantId);
13949
+ }
13950
+ // ============================================================================
13537
13951
  // Dead Letter Queue Keys
13538
13952
  // ============================================================================
13539
13953
  deadLetterQueueKey(tenantId) {
@@ -13746,6 +14160,44 @@ var DRRScheduler = class extends BaseScheduler {
13746
14160
  queues: t.queues
13747
14161
  }));
13748
14162
  }
14163
+ /**
14164
+ * Select queues using the two-level tenant dispatch index.
14165
+ *
14166
+ * Algorithm:
14167
+ * 1. ZRANGEBYSCORE on dispatch index (gets only tenants with queues - much smaller)
14168
+ * 2. Add quantum to each tenant's deficit (atomically)
14169
+ * 3. Check capacity as safety net (dispatch should only have tenants with capacity)
14170
+ * 4. Select tenants with deficit >= 1, sorted by deficit (highest first)
14171
+ * 5. For each tenant, fetch their queues from Level 2 index
14172
+ */
14173
+ async selectQueuesFromDispatch(dispatchShardKey, consumerId, context2) {
14174
+ const tenants = await this.#getTenantsFromDispatch(dispatchShardKey);
14175
+ if (tenants.length === 0) {
14176
+ return [];
14177
+ }
14178
+ const tenantIds = tenants.map((t) => t.tenantId);
14179
+ const deficits = await this.#addQuantumToTenants(tenantIds);
14180
+ const candidates = tenantIds.map((tenantId, index) => ({ tenantId, deficit: deficits[index] ?? 0 })).filter((t) => t.deficit >= 1);
14181
+ candidates.sort((a, b) => b.deficit - a.deficit);
14182
+ for (const { tenantId, deficit } of candidates) {
14183
+ const isAtCapacity = await context2.isAtCapacity("tenant", tenantId);
14184
+ if (isAtCapacity) continue;
14185
+ const queueLimit = Math.ceil(deficit);
14186
+ const queues = await context2.getQueuesForTenant(tenantId, queueLimit);
14187
+ if (queues.length > 0) {
14188
+ this.logger.debug("DRR dispatch: selected tenant", {
14189
+ dispatchTenants: tenants.length,
14190
+ candidates: candidates.length,
14191
+ selectedTenant: tenantId,
14192
+ deficit,
14193
+ queueLimit,
14194
+ queuesReturned: queues.length
14195
+ });
14196
+ return [{ tenantId, queues: queues.map((q) => q.queueId) }];
14197
+ }
14198
+ }
14199
+ return [];
14200
+ }
13749
14201
  /**
13750
14202
  * Record that a message was processed from a tenant.
13751
14203
  * Decrements the tenant's deficit.
@@ -13800,6 +14252,30 @@ var DRRScheduler = class extends BaseScheduler {
13800
14252
  #deficitKey() {
13801
14253
  return `${this.keys.masterQueueKey(0).split(":")[0]}:drr:deficit`;
13802
14254
  }
14255
+ async #getTenantsFromDispatch(dispatchKey) {
14256
+ const now = Date.now();
14257
+ const results = await this.redis.zrangebyscore(
14258
+ dispatchKey,
14259
+ "-inf",
14260
+ now,
14261
+ "WITHSCORES",
14262
+ "LIMIT",
14263
+ 0,
14264
+ this.masterQueueLimit
14265
+ );
14266
+ const tenants = [];
14267
+ for (let i = 0; i < results.length; i += 2) {
14268
+ const tenantId = results[i];
14269
+ const scoreStr = results[i + 1];
14270
+ if (tenantId && scoreStr) {
14271
+ tenants.push({
14272
+ tenantId,
14273
+ score: parseFloat(scoreStr)
14274
+ });
14275
+ }
14276
+ }
14277
+ return tenants;
14278
+ }
13803
14279
  async #getQueuesFromShard(shardKey) {
13804
14280
  const now = Date.now();
13805
14281
  const results = await this.redis.zrangebyscore(
@@ -14433,7 +14909,8 @@ var FairQueue = class {
14433
14909
  this.cooloffThreshold = options.cooloff?.threshold ?? 10;
14434
14910
  this.cooloffPeriodMs = options.cooloff?.periodMs ?? 1e4;
14435
14911
  this.maxCooloffStatesSize = options.cooloff?.maxStatesSize ?? 1e3;
14436
- this.globalRateLimiter = options.globalRateLimiter;
14912
+ this.workerQueueMaxDepth = options.workerQueueMaxDepth ?? 0;
14913
+ this.workerQueueDepthCheckId = options.workerQueueDepthCheckId;
14437
14914
  this.consumerTraceMaxIterations = options.consumerTraceMaxIterations ?? 500;
14438
14915
  this.consumerTraceTimeoutSeconds = options.consumerTraceTimeoutSeconds ?? 60;
14439
14916
  this.telemetry = new FairQueueTelemetry({
@@ -14456,6 +14933,11 @@ var FairQueue = class {
14456
14933
  keys: options.keys,
14457
14934
  shardCount: this.shardCount
14458
14935
  });
14936
+ this.tenantDispatch = new TenantDispatch({
14937
+ redis: options.redis,
14938
+ keys: options.keys,
14939
+ shardCount: this.shardCount
14940
+ });
14459
14941
  if (options.concurrencyGroups && options.concurrencyGroups.length > 0) {
14460
14942
  this.concurrencyManager = new ConcurrencyManager({
14461
14943
  redis: options.redis,
@@ -14514,8 +14996,9 @@ var FairQueue = class {
14514
14996
  cooloffPeriodMs;
14515
14997
  maxCooloffStatesSize;
14516
14998
  queueCooloffStates = /* @__PURE__ */ new Map();
14517
- // Global rate limiter
14518
- globalRateLimiter;
14999
+ // Worker queue backpressure
15000
+ workerQueueMaxDepth;
15001
+ workerQueueDepthCheckId;
14519
15002
  // Consumer tracing
14520
15003
  consumerTraceMaxIterations;
14521
15004
  consumerTraceTimeoutSeconds;
@@ -14527,6 +15010,8 @@ var FairQueue = class {
14527
15010
  reclaimLoop;
14528
15011
  // Queue descriptor cache for message processing
14529
15012
  queueDescriptorCache = /* @__PURE__ */ new Map();
15013
+ // Two-level tenant dispatch
15014
+ tenantDispatch;
14530
15015
  // ============================================================================
14531
15016
  // Public API - Telemetry
14532
15017
  // ============================================================================
@@ -14541,6 +15026,9 @@ var FairQueue = class {
14541
15026
  getMasterQueueLength: async (shardId) => {
14542
15027
  return await this.masterQueue.getShardQueueCount(shardId);
14543
15028
  },
15029
+ getDispatchLength: async (shardId) => {
15030
+ return await this.tenantDispatch.getShardTenantCount(shardId);
15031
+ },
14544
15032
  getInflightCount: async (shardId) => {
14545
15033
  return await this.visibilityManager.getInflightCount(shardId);
14546
15034
  },
@@ -14565,8 +15053,9 @@ var FairQueue = class {
14565
15053
  const timestamp = options.timestamp ?? Date.now();
14566
15054
  const queueKey = this.keys.queueKey(options.queueId);
14567
15055
  const queueItemsKey = this.keys.queueItemsKey(options.queueId);
14568
- const shardId = this.masterQueue.getShardForQueue(options.queueId);
14569
- const masterQueueKey = this.keys.masterQueueKey(shardId);
15056
+ const dispatchShardId = this.tenantDispatch.getShardForTenant(options.tenantId);
15057
+ const tenantQueueIndexKey = this.keys.tenantQueueIndexKey(options.tenantId);
15058
+ const dispatchKey = this.keys.dispatchKey(dispatchShardId);
14570
15059
  if (this.validateOnEnqueue && this.payloadSchema) {
14571
15060
  const result = this.payloadSchema.safeParse(options.payload);
14572
15061
  if (!result.success) {
@@ -14597,20 +15086,22 @@ var FairQueue = class {
14597
15086
  }) : void 0,
14598
15087
  metadata: options.metadata
14599
15088
  };
14600
- await this.redis.enqueueMessageAtomic(
15089
+ await this.redis.enqueueMessageAtomicV2(
14601
15090
  queueKey,
14602
15091
  queueItemsKey,
14603
- masterQueueKey,
15092
+ tenantQueueIndexKey,
15093
+ dispatchKey,
14604
15094
  options.queueId,
14605
15095
  messageId,
14606
15096
  timestamp.toString(),
14607
- JSON.stringify(storedMessage)
15097
+ JSON.stringify(storedMessage),
15098
+ options.tenantId
14608
15099
  );
14609
15100
  span.setAttributes({
14610
15101
  [FairQueueAttributes.QUEUE_ID]: options.queueId,
14611
15102
  [FairQueueAttributes.TENANT_ID]: options.tenantId,
14612
15103
  [FairQueueAttributes.MESSAGE_ID]: messageId,
14613
- [FairQueueAttributes.SHARD_ID]: shardId.toString()
15104
+ [FairQueueAttributes.SHARD_ID]: dispatchShardId.toString()
14614
15105
  });
14615
15106
  this.telemetry.recordEnqueue();
14616
15107
  this.logger.debug("Message enqueued", {
@@ -14637,8 +15128,9 @@ var FairQueue = class {
14637
15128
  async (span) => {
14638
15129
  const queueKey = this.keys.queueKey(options.queueId);
14639
15130
  const queueItemsKey = this.keys.queueItemsKey(options.queueId);
14640
- const shardId = this.masterQueue.getShardForQueue(options.queueId);
14641
- const masterQueueKey = this.keys.masterQueueKey(shardId);
15131
+ const dispatchShardId = this.tenantDispatch.getShardForTenant(options.tenantId);
15132
+ const tenantQueueIndexKey = this.keys.tenantQueueIndexKey(options.tenantId);
15133
+ const dispatchKey = this.keys.dispatchKey(dispatchShardId);
14642
15134
  const now = Date.now();
14643
15135
  const descriptor = {
14644
15136
  id: options.queueId,
@@ -14680,18 +15172,20 @@ var FairQueue = class {
14680
15172
  messageIds.push(messageId);
14681
15173
  args.push(messageId, timestamp.toString(), JSON.stringify(storedMessage));
14682
15174
  }
14683
- await this.redis.enqueueBatchAtomic(
15175
+ await this.redis.enqueueBatchAtomicV2(
14684
15176
  queueKey,
14685
15177
  queueItemsKey,
14686
- masterQueueKey,
15178
+ tenantQueueIndexKey,
15179
+ dispatchKey,
14687
15180
  options.queueId,
15181
+ options.tenantId,
14688
15182
  ...args
14689
15183
  );
14690
15184
  span.setAttributes({
14691
15185
  [FairQueueAttributes.QUEUE_ID]: options.queueId,
14692
15186
  [FairQueueAttributes.TENANT_ID]: options.tenantId,
14693
15187
  [FairQueueAttributes.MESSAGE_COUNT]: messageIds.length,
14694
- [FairQueueAttributes.SHARD_ID]: shardId.toString()
15188
+ [FairQueueAttributes.SHARD_ID]: dispatchShardId.toString()
14695
15189
  });
14696
15190
  this.telemetry.recordEnqueueBatch(messageIds.length);
14697
15191
  this.logger.debug("Batch enqueued", {
@@ -14893,6 +15387,7 @@ var FairQueue = class {
14893
15387
  this.batchedSpanManager.cleanupAll();
14894
15388
  await Promise.all([
14895
15389
  this.masterQueue.close(),
15390
+ this.tenantDispatch.close(),
14896
15391
  this.concurrencyManager?.close(),
14897
15392
  this.visibilityManager.close(),
14898
15393
  this.workerQueueManager.close(),
@@ -14911,10 +15406,14 @@ var FairQueue = class {
14911
15406
  return await this.redis.zcard(queueKey);
14912
15407
  }
14913
15408
  /**
14914
- * Get total queue count across all shards.
15409
+ * Get total tenant count across dispatch shards plus any legacy queues still draining.
14915
15410
  */
14916
15411
  async getTotalQueueCount() {
14917
- return await this.masterQueue.getTotalQueueCount();
15412
+ const [dispatchCount, legacyCount] = await Promise.all([
15413
+ this.tenantDispatch.getTotalTenantCount(),
15414
+ this.masterQueue.getTotalQueueCount()
15415
+ ]);
15416
+ return dispatchCount + legacyCount;
14918
15417
  }
14919
15418
  /**
14920
15419
  * Get total in-flight message count.
@@ -14945,7 +15444,7 @@ var FairQueue = class {
14945
15444
  loopId,
14946
15445
  async (span) => {
14947
15446
  span.setAttribute("shard_id", shardId);
14948
- return await this.#processMasterQueueShard(loopId, shardId, span);
15447
+ return await this.#processShardIteration(loopId, shardId, span);
14949
15448
  },
14950
15449
  {
14951
15450
  iterationSpanName: "processMasterQueueShard",
@@ -14984,32 +15483,142 @@ var FairQueue = class {
14984
15483
  this.batchedSpanManager.cleanup(loopId);
14985
15484
  }
14986
15485
  }
14987
- async #processMasterQueueShard(loopId, shardId, parentSpan) {
15486
+ /**
15487
+ * Process a shard iteration. Runs both the new tenant dispatch path
15488
+ * and the legacy master queue drain path.
15489
+ */
15490
+ async #processShardIteration(loopId, shardId, parentSpan) {
15491
+ let hadWork = false;
15492
+ hadWork = await this.#processDispatchShard(loopId, shardId, parentSpan);
15493
+ const legacyCount = await this.masterQueue.getShardQueueCount(shardId);
15494
+ if (legacyCount > 0) {
15495
+ const drainHadWork = await this.#drainLegacyMasterQueueShard(loopId, shardId, parentSpan);
15496
+ hadWork = hadWork || drainHadWork;
15497
+ }
15498
+ return hadWork;
15499
+ }
15500
+ /**
15501
+ * Main path: process queues using the two-level tenant dispatch index.
15502
+ * Level 1: dispatch index → tenantIds. Level 2: per-tenant → queueIds.
15503
+ */
15504
+ async #processDispatchShard(loopId, shardId, parentSpan) {
15505
+ const dispatchKey = this.keys.dispatchKey(shardId);
15506
+ const dispatchSize = await this.tenantDispatch.getShardTenantCount(shardId);
15507
+ parentSpan?.setAttribute("dispatch_size", dispatchSize);
15508
+ this.batchedSpanManager.incrementStat(loopId, "dispatch_size_sum", dispatchSize);
15509
+ const schedulerContext = {
15510
+ ...this.#createSchedulerContext(),
15511
+ getQueuesForTenant: async (tenantId, limit) => {
15512
+ return this.tenantDispatch.getQueuesForTenant(tenantId, limit);
15513
+ }
15514
+ };
15515
+ let tenantQueues;
15516
+ if (this.scheduler.selectQueuesFromDispatch) {
15517
+ tenantQueues = await this.telemetry.trace(
15518
+ "selectQueuesFromDispatch",
15519
+ async (span) => {
15520
+ span.setAttribute(FairQueueAttributes.SHARD_ID, shardId.toString());
15521
+ span.setAttribute(FairQueueAttributes.CONSUMER_ID, loopId);
15522
+ span.setAttribute("dispatch_size", dispatchSize);
15523
+ const result = await this.scheduler.selectQueuesFromDispatch(
15524
+ dispatchKey,
15525
+ loopId,
15526
+ schedulerContext
15527
+ );
15528
+ span.setAttribute("tenant_count", result.length);
15529
+ span.setAttribute(
15530
+ "queue_count",
15531
+ result.reduce((acc, t) => acc + t.queues.length, 0)
15532
+ );
15533
+ return result;
15534
+ },
15535
+ { kind: SpanKind.INTERNAL }
15536
+ );
15537
+ } else {
15538
+ tenantQueues = await this.#fallbackDispatchToLegacyScheduler(
15539
+ loopId,
15540
+ shardId,
15541
+ schedulerContext,
15542
+ parentSpan
15543
+ );
15544
+ }
15545
+ if (tenantQueues.length === 0) {
15546
+ this.batchedSpanManager.incrementStat(loopId, "empty_iterations");
15547
+ return false;
15548
+ }
15549
+ return this.#processSelectedQueues(loopId, shardId, tenantQueues);
15550
+ }
15551
+ /**
15552
+ * Drain path: process remaining messages from the legacy master queue shard.
15553
+ * Uses simple ZRANGEBYSCORE without DRR - just flushing pre-deploy messages.
15554
+ */
15555
+ async #drainLegacyMasterQueueShard(loopId, shardId, parentSpan) {
14988
15556
  const masterQueueKey = this.keys.masterQueueKey(shardId);
14989
- const masterQueueSize = await this.masterQueue.getShardQueueCount(shardId);
14990
- parentSpan?.setAttribute("master_queue_size", masterQueueSize);
14991
- this.batchedSpanManager.incrementStat(loopId, "master_queue_size_sum", masterQueueSize);
14992
- const schedulerContext = this.#createSchedulerContext();
14993
- const tenantQueues = await this.telemetry.trace(
14994
- "selectQueues",
14995
- async (span) => {
14996
- span.setAttribute(FairQueueAttributes.SHARD_ID, shardId.toString());
14997
- span.setAttribute(FairQueueAttributes.CONSUMER_ID, loopId);
14998
- span.setAttribute("master_queue_size", masterQueueSize);
14999
- const result = await this.scheduler.selectQueues(masterQueueKey, loopId, schedulerContext);
15000
- span.setAttribute("tenant_count", result.length);
15001
- span.setAttribute(
15002
- "queue_count",
15003
- result.reduce((acc, t) => acc + t.queues.length, 0)
15004
- );
15005
- return result;
15006
- },
15007
- { kind: SpanKind.INTERNAL }
15557
+ const now = Date.now();
15558
+ const results = await this.redis.zrangebyscore(
15559
+ masterQueueKey,
15560
+ "-inf",
15561
+ now,
15562
+ "WITHSCORES",
15563
+ "LIMIT",
15564
+ 0,
15565
+ 100
15008
15566
  );
15567
+ if (results.length === 0) {
15568
+ return false;
15569
+ }
15570
+ const byTenant = /* @__PURE__ */ new Map();
15571
+ for (let i = 0; i < results.length; i += 2) {
15572
+ const queueId = results[i];
15573
+ const _score = results[i + 1];
15574
+ if (queueId && _score) {
15575
+ const tenantId = this.keys.extractTenantId(queueId);
15576
+ const existing = byTenant.get(tenantId) ?? [];
15577
+ existing.push(queueId);
15578
+ byTenant.set(tenantId, existing);
15579
+ }
15580
+ }
15581
+ const tenantQueues = [];
15582
+ for (const [tenantId, queueIds] of byTenant) {
15583
+ if (this.concurrencyManager) {
15584
+ const atCapacity = await this.concurrencyManager.isAtCapacity("tenant", tenantId);
15585
+ if (atCapacity) continue;
15586
+ }
15587
+ tenantQueues.push({ tenantId, queues: queueIds });
15588
+ }
15009
15589
  if (tenantQueues.length === 0) {
15010
- this.batchedSpanManager.incrementStat(loopId, "empty_iterations");
15011
15590
  return false;
15012
15591
  }
15592
+ parentSpan?.setAttribute("drain_tenants", tenantQueues.length);
15593
+ this.batchedSpanManager.incrementStat(loopId, "drain_tenants", tenantQueues.length);
15594
+ return this.#processSelectedQueues(loopId, shardId, tenantQueues);
15595
+ }
15596
+ /**
15597
+ * Fallback for schedulers that don't implement selectQueuesFromDispatch.
15598
+ * Reads dispatch index, fetches per-tenant queues, groups by tenant,
15599
+ * and filters at-capacity tenants. No DRR deficit tracking in this path.
15600
+ */
15601
+ async #fallbackDispatchToLegacyScheduler(loopId, shardId, context2, parentSpan) {
15602
+ const tenants = await this.tenantDispatch.getTenantsFromShard(shardId);
15603
+ if (tenants.length === 0) return [];
15604
+ const tenantQueues = [];
15605
+ for (const { tenantId } of tenants) {
15606
+ if (this.concurrencyManager) {
15607
+ const atCapacity = await this.concurrencyManager.isAtCapacity("tenant", tenantId);
15608
+ if (atCapacity) continue;
15609
+ }
15610
+ const queues = await this.tenantDispatch.getQueuesForTenant(tenantId);
15611
+ if (queues.length > 0) {
15612
+ tenantQueues.push({ tenantId, queues: queues.map((q) => q.queueId) });
15613
+ }
15614
+ }
15615
+ return tenantQueues;
15616
+ }
15617
+ /**
15618
+ * Shared claim loop: process selected queues from either dispatch or drain path.
15619
+ * Claims messages and pushes to worker queues.
15620
+ */
15621
+ async #processSelectedQueues(loopId, shardId, tenantQueues) {
15013
15622
  this.batchedSpanManager.incrementStat(loopId, "tenants_selected", tenantQueues.length);
15014
15623
  this.batchedSpanManager.incrementStat(
15015
15624
  loopId,
@@ -15076,10 +15685,10 @@ var FairQueue = class {
15076
15685
  }
15077
15686
  return messagesProcessed > 0;
15078
15687
  }
15079
- async #claimAndPushToWorkerQueue(loopId, queueId, tenantId, shardId) {
15688
+ async #claimAndPushToWorkerQueue(loopId, queueId, tenantId, _consumerShardId) {
15689
+ const dispatchShardId = this.tenantDispatch.getShardForTenant(tenantId);
15080
15690
  const queueKey = this.keys.queueKey(queueId);
15081
15691
  const queueItemsKey = this.keys.queueItemsKey(queueId);
15082
- const masterQueueKey = this.keys.masterQueueKey(shardId);
15083
15692
  const descriptor = this.queueDescriptorCache.get(queueId) ?? {
15084
15693
  id: queueId,
15085
15694
  tenantId,
@@ -15089,28 +15698,23 @@ var FairQueue = class {
15089
15698
  if (this.concurrencyManager) {
15090
15699
  const availableCapacity = await this.concurrencyManager.getAvailableCapacity(descriptor);
15091
15700
  if (availableCapacity === 0) {
15092
- this.#incrementCooloff(queueId);
15093
15701
  return 0;
15094
15702
  }
15095
15703
  maxClaimCount = Math.min(maxClaimCount, availableCapacity);
15096
15704
  }
15097
- if (this.globalRateLimiter) {
15098
- const result = await this.globalRateLimiter.limit();
15099
- if (!result.allowed && result.resetAt) {
15100
- const waitMs = Math.max(0, result.resetAt - Date.now());
15101
- if (waitMs > 0) {
15102
- this.logger.debug("Global rate limit reached, waiting", { waitMs, loopId });
15103
- await new Promise((resolve) => setTimeout(resolve, waitMs));
15104
- }
15705
+ if (this.workerQueueMaxDepth > 0 && this.workerQueueDepthCheckId) {
15706
+ const depth = await this.workerQueueManager.getLength(this.workerQueueDepthCheckId);
15707
+ if (depth >= this.workerQueueMaxDepth) {
15708
+ return 0;
15105
15709
  }
15710
+ const remainingCapacity = this.workerQueueMaxDepth - depth;
15711
+ maxClaimCount = Math.min(maxClaimCount, remainingCapacity);
15106
15712
  }
15107
15713
  const claimedMessages = await this.visibilityManager.claimBatch(queueId, queueKey, queueItemsKey, loopId, maxClaimCount, this.visibilityTimeoutMs);
15108
15714
  if (claimedMessages.length === 0) {
15109
- const removed = await this.redis.updateMasterQueueIfEmpty(masterQueueKey, queueKey, queueId);
15110
- if (removed === 1) {
15111
- this.queueDescriptorCache.delete(queueId);
15112
- this.queueCooloffStates.delete(queueId);
15113
- }
15715
+ await this.#updateAllIndexesAfterDequeue(queueId, tenantId);
15716
+ this.queueDescriptorCache.delete(queueId);
15717
+ this.queueCooloffStates.delete(queueId);
15114
15718
  return 0;
15115
15719
  }
15116
15720
  let processedCount = 0;
@@ -15119,12 +15723,16 @@ var FairQueue = class {
15119
15723
  if (this.concurrencyManager) {
15120
15724
  const reserved = await this.concurrencyManager.reserve(descriptor, message.messageId);
15121
15725
  if (!reserved) {
15726
+ const tenantQueueIndexKey = this.keys.tenantQueueIndexKey(tenantId);
15727
+ const dispatchKey = this.keys.dispatchKey(dispatchShardId);
15122
15728
  await this.visibilityManager.releaseBatch(
15123
15729
  claimedMessages.slice(i),
15124
15730
  queueId,
15125
15731
  queueKey,
15126
15732
  queueItemsKey,
15127
- masterQueueKey
15733
+ tenantQueueIndexKey,
15734
+ dispatchKey,
15735
+ tenantId
15128
15736
  );
15129
15737
  break;
15130
15738
  }
@@ -15184,8 +15792,6 @@ var FairQueue = class {
15184
15792
  */
15185
15793
  async completeMessage(messageId, queueId) {
15186
15794
  const shardId = this.masterQueue.getShardForQueue(queueId);
15187
- const queueKey = this.keys.queueKey(queueId);
15188
- const masterQueueKey = this.keys.masterQueueKey(shardId);
15189
15795
  const inflightDataKey = this.keys.inflightDataKey(shardId);
15190
15796
  const dataJson = await this.redis.hget(inflightDataKey, messageId);
15191
15797
  let storedMessage = null;
@@ -15199,13 +15805,16 @@ var FairQueue = class {
15199
15805
  id: queueId,
15200
15806
  tenantId: storedMessage.tenantId,
15201
15807
  metadata: storedMessage.metadata ?? {}
15202
- } : { id: queueId, tenantId: "", metadata: {} };
15808
+ } : { id: queueId, tenantId: this.keys.extractTenantId(queueId), metadata: {} };
15203
15809
  await this.visibilityManager.complete(messageId, queueId);
15204
15810
  if (this.concurrencyManager && storedMessage) {
15205
15811
  await this.concurrencyManager.release(descriptor, messageId);
15206
15812
  }
15207
- const removed = await this.redis.updateMasterQueueIfEmpty(masterQueueKey, queueKey, queueId);
15208
- if (removed === 1) {
15813
+ const { queueEmpty } = await this.#updateAllIndexesAfterDequeue(
15814
+ queueId,
15815
+ descriptor.tenantId
15816
+ );
15817
+ if (queueEmpty) {
15209
15818
  this.queueDescriptorCache.delete(queueId);
15210
15819
  this.queueCooloffStates.delete(queueId);
15211
15820
  }
@@ -15226,7 +15835,6 @@ var FairQueue = class {
15226
15835
  const shardId = this.masterQueue.getShardForQueue(queueId);
15227
15836
  const queueKey = this.keys.queueKey(queueId);
15228
15837
  const queueItemsKey = this.keys.queueItemsKey(queueId);
15229
- const masterQueueKey = this.keys.masterQueueKey(shardId);
15230
15838
  const inflightDataKey = this.keys.inflightDataKey(shardId);
15231
15839
  const dataJson = await this.redis.hget(inflightDataKey, messageId);
15232
15840
  let storedMessage = null;
@@ -15240,13 +15848,18 @@ var FairQueue = class {
15240
15848
  id: queueId,
15241
15849
  tenantId: storedMessage.tenantId,
15242
15850
  metadata: storedMessage.metadata ?? {}
15243
- } : { id: queueId, tenantId: "", metadata: {} };
15851
+ } : { id: queueId, tenantId: this.keys.extractTenantId(queueId), metadata: {} };
15852
+ const dispatchShardId = this.tenantDispatch.getShardForTenant(descriptor.tenantId);
15853
+ const tenantQueueIndexKey = this.keys.tenantQueueIndexKey(descriptor.tenantId);
15854
+ const dispatchKey = this.keys.dispatchKey(dispatchShardId);
15244
15855
  await this.visibilityManager.release(
15245
15856
  messageId,
15246
15857
  queueId,
15247
15858
  queueKey,
15248
15859
  queueItemsKey,
15249
- masterQueueKey,
15860
+ tenantQueueIndexKey,
15861
+ dispatchKey,
15862
+ descriptor.tenantId,
15250
15863
  Date.now()
15251
15864
  // Put at back of queue
15252
15865
  );
@@ -15270,7 +15883,6 @@ var FairQueue = class {
15270
15883
  const shardId = this.masterQueue.getShardForQueue(queueId);
15271
15884
  const queueKey = this.keys.queueKey(queueId);
15272
15885
  const queueItemsKey = this.keys.queueItemsKey(queueId);
15273
- const masterQueueKey = this.keys.masterQueueKey(shardId);
15274
15886
  const inflightDataKey = this.keys.inflightDataKey(shardId);
15275
15887
  const dataJson = await this.redis.hget(inflightDataKey, messageId);
15276
15888
  if (!dataJson) {
@@ -15292,12 +15904,13 @@ var FairQueue = class {
15292
15904
  tenantId: storedMessage.tenantId,
15293
15905
  metadata: storedMessage.metadata ?? {}
15294
15906
  };
15907
+ const dispatchShardId = this.tenantDispatch.getShardForTenant(descriptor.tenantId);
15295
15908
  await this.#handleMessageFailure(
15296
15909
  storedMessage,
15297
15910
  queueId,
15298
15911
  queueKey,
15299
15912
  queueItemsKey,
15300
- masterQueueKey,
15913
+ dispatchShardId,
15301
15914
  descriptor,
15302
15915
  error
15303
15916
  );
@@ -15305,7 +15918,7 @@ var FairQueue = class {
15305
15918
  // ============================================================================
15306
15919
  // Private - Message Processing Helpers
15307
15920
  // ============================================================================
15308
- async #handleMessageFailure(storedMessage, queueId, queueKey, queueItemsKey, masterQueueKey, descriptor, error) {
15921
+ async #handleMessageFailure(storedMessage, queueId, queueKey, queueItemsKey, dispatchShardId, descriptor, error) {
15309
15922
  this.telemetry.recordFailure();
15310
15923
  if (this.retryStrategy) {
15311
15924
  const nextDelay = this.retryStrategy.getNextDelay(storedMessage.attempt, error);
@@ -15314,15 +15927,19 @@ var FairQueue = class {
15314
15927
  ...storedMessage,
15315
15928
  attempt: storedMessage.attempt + 1
15316
15929
  };
15930
+ const tenantQueueIndexKey = this.keys.tenantQueueIndexKey(descriptor.tenantId);
15931
+ const dispatchKey = this.keys.dispatchKey(dispatchShardId);
15317
15932
  await this.visibilityManager.release(
15318
15933
  storedMessage.id,
15319
15934
  queueId,
15320
15935
  queueKey,
15321
15936
  queueItemsKey,
15322
- masterQueueKey,
15323
- Date.now() + nextDelay
15937
+ tenantQueueIndexKey,
15938
+ dispatchKey,
15939
+ descriptor.tenantId,
15940
+ Date.now() + nextDelay,
15941
+ JSON.stringify(updatedMessage)
15324
15942
  );
15325
- await this.redis.hset(queueItemsKey, storedMessage.id, JSON.stringify(updatedMessage));
15326
15943
  if (this.concurrencyManager) {
15327
15944
  await this.concurrencyManager.release(descriptor, storedMessage.id);
15328
15945
  }
@@ -15400,28 +16017,36 @@ var FairQueue = class {
15400
16017
  async #reclaimTimedOutMessages() {
15401
16018
  let totalReclaimed = 0;
15402
16019
  for (let shardId = 0; shardId < this.shardCount; shardId++) {
15403
- const reclaimedMessages = await this.visibilityManager.reclaimTimedOut(shardId, (queueId) => ({
15404
- queueKey: this.keys.queueKey(queueId),
15405
- queueItemsKey: this.keys.queueItemsKey(queueId),
15406
- masterQueueKey: this.keys.masterQueueKey(this.masterQueue.getShardForQueue(queueId))
15407
- }));
15408
- if (this.concurrencyManager && reclaimedMessages.length > 0) {
15409
- try {
15410
- await this.concurrencyManager.releaseBatch(
15411
- reclaimedMessages.map((msg) => ({
15412
- queue: {
15413
- id: msg.queueId,
15414
- tenantId: msg.tenantId,
15415
- metadata: msg.metadata ?? {}
15416
- },
15417
- messageId: msg.messageId
15418
- }))
15419
- );
15420
- } catch (error) {
15421
- this.logger.error("Failed to release concurrency for reclaimed messages", {
15422
- count: reclaimedMessages.length,
15423
- error: error instanceof Error ? error.message : String(error)
15424
- });
16020
+ const reclaimedMessages = await this.visibilityManager.reclaimTimedOut(shardId, (queueId) => {
16021
+ const tenantId = this.keys.extractTenantId(queueId);
16022
+ const dispatchShardId = this.tenantDispatch.getShardForTenant(tenantId);
16023
+ return {
16024
+ queueKey: this.keys.queueKey(queueId),
16025
+ queueItemsKey: this.keys.queueItemsKey(queueId),
16026
+ tenantQueueIndexKey: this.keys.tenantQueueIndexKey(tenantId),
16027
+ dispatchKey: this.keys.dispatchKey(dispatchShardId),
16028
+ tenantId
16029
+ };
16030
+ });
16031
+ if (reclaimedMessages.length > 0) {
16032
+ if (this.concurrencyManager) {
16033
+ try {
16034
+ await this.concurrencyManager.releaseBatch(
16035
+ reclaimedMessages.map((msg) => ({
16036
+ queue: {
16037
+ id: msg.queueId,
16038
+ tenantId: msg.tenantId,
16039
+ metadata: msg.metadata ?? {}
16040
+ },
16041
+ messageId: msg.messageId
16042
+ }))
16043
+ );
16044
+ } catch (error) {
16045
+ this.logger.error("Failed to release concurrency for reclaimed messages", {
16046
+ count: reclaimedMessages.length,
16047
+ error: error instanceof Error ? error.message : String(error)
16048
+ });
16049
+ }
15425
16050
  }
15426
16051
  }
15427
16052
  totalReclaimed += reclaimedMessages.length;
@@ -15483,6 +16108,32 @@ var FairQueue = class {
15483
16108
  // ============================================================================
15484
16109
  // Private - Helpers
15485
16110
  // ============================================================================
16111
+ /**
16112
+ * Update both old master queue and new dispatch indexes after a dequeue/complete.
16113
+ * Both calls are idempotent - ZREM on a non-existent member is a no-op.
16114
+ * This handles the transition period where queues may exist in either or both indexes.
16115
+ */
16116
+ async #updateAllIndexesAfterDequeue(queueId, tenantId) {
16117
+ const queueShardId = this.masterQueue.getShardForQueue(queueId);
16118
+ const dispatchShardId = this.tenantDispatch.getShardForTenant(tenantId);
16119
+ const queueKey = this.keys.queueKey(queueId);
16120
+ const masterQueueKey = this.keys.masterQueueKey(queueShardId);
16121
+ const tenantQueueIndexKey = this.keys.tenantQueueIndexKey(tenantId);
16122
+ const dispatchKey = this.keys.dispatchKey(dispatchShardId);
16123
+ const removedFromMaster = await this.redis.updateMasterQueueIfEmpty(
16124
+ masterQueueKey,
16125
+ queueKey,
16126
+ queueId
16127
+ );
16128
+ const removedFromDispatch = await this.redis.updateDispatchIndexes(
16129
+ queueKey,
16130
+ tenantQueueIndexKey,
16131
+ dispatchKey,
16132
+ queueId,
16133
+ tenantId
16134
+ );
16135
+ return { queueEmpty: removedFromMaster === 1 || removedFromDispatch === 1 };
16136
+ }
15486
16137
  #createSchedulerContext() {
15487
16138
  return {
15488
16139
  getCurrentConcurrency: async (groupName, groupId) => {
@@ -15522,13 +16173,9 @@ local messageId = ARGV[2]
15522
16173
  local timestamp = tonumber(ARGV[3])
15523
16174
  local payload = ARGV[4]
15524
16175
 
15525
- -- Add to sorted set (score = timestamp)
15526
16176
  redis.call('ZADD', queueKey, timestamp, messageId)
15527
-
15528
- -- Store payload in hash
15529
16177
  redis.call('HSET', queueItemsKey, messageId, payload)
15530
16178
 
15531
- -- Update master queue with oldest message timestamp
15532
16179
  local oldest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES')
15533
16180
  if #oldest >= 2 then
15534
16181
  redis.call('ZADD', masterQueueKey, oldest[2], queueId)
@@ -15546,20 +16193,14 @@ local masterQueueKey = KEYS[3]
15546
16193
 
15547
16194
  local queueId = ARGV[1]
15548
16195
 
15549
- -- Args after queueId are triples: [messageId, timestamp, payload, ...]
15550
16196
  for i = 2, #ARGV, 3 do
15551
16197
  local messageId = ARGV[i]
15552
16198
  local timestamp = tonumber(ARGV[i + 1])
15553
16199
  local payload = ARGV[i + 2]
15554
-
15555
- -- Add to sorted set
15556
16200
  redis.call('ZADD', queueKey, timestamp, messageId)
15557
-
15558
- -- Store payload in hash
15559
16201
  redis.call('HSET', queueItemsKey, messageId, payload)
15560
16202
  end
15561
16203
 
15562
- -- Update master queue with oldest message timestamp
15563
16204
  local oldest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES')
15564
16205
  if #oldest >= 2 then
15565
16206
  redis.call('ZADD', masterQueueKey, oldest[2], queueId)
@@ -15579,11 +16220,119 @@ local count = redis.call('ZCARD', queueKey)
15579
16220
  if count == 0 then
15580
16221
  redis.call('ZREM', masterQueueKey, queueId)
15581
16222
  return 1
16223
+ end
16224
+
16225
+ -- Queue still has messages but don't re-add to legacy master queue.
16226
+ -- New enqueues go through the V2 dispatch path, so we only drain here.
16227
+ -- Just remove it so it doesn't linger.
16228
+ redis.call('ZREM', masterQueueKey, queueId)
16229
+ return 0
16230
+ `
16231
+ });
16232
+ this.redis.defineCommand("enqueueMessageAtomicV2", {
16233
+ numberOfKeys: 4,
16234
+ lua: `
16235
+ local queueKey = KEYS[1]
16236
+ local queueItemsKey = KEYS[2]
16237
+ local tenantQueueIndexKey = KEYS[3]
16238
+ local dispatchKey = KEYS[4]
16239
+
16240
+ local queueId = ARGV[1]
16241
+ local messageId = ARGV[2]
16242
+ local timestamp = tonumber(ARGV[3])
16243
+ local payload = ARGV[4]
16244
+ local tenantId = ARGV[5]
16245
+
16246
+ -- Add to per-queue storage (same as before)
16247
+ redis.call('ZADD', queueKey, timestamp, messageId)
16248
+ redis.call('HSET', queueItemsKey, messageId, payload)
16249
+
16250
+ -- Update tenant queue index (Level 2) with queue's oldest message
16251
+ local oldest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES')
16252
+ if #oldest >= 2 then
16253
+ redis.call('ZADD', tenantQueueIndexKey, oldest[2], queueId)
16254
+ end
16255
+
16256
+ -- Update dispatch index (Level 1) with tenant's oldest across all queues
16257
+ local tenantOldest = redis.call('ZRANGE', tenantQueueIndexKey, 0, 0, 'WITHSCORES')
16258
+ if #tenantOldest >= 2 then
16259
+ redis.call('ZADD', dispatchKey, tenantOldest[2], tenantId)
16260
+ end
16261
+
16262
+ return 1
16263
+ `
16264
+ });
16265
+ this.redis.defineCommand("enqueueBatchAtomicV2", {
16266
+ numberOfKeys: 4,
16267
+ lua: `
16268
+ local queueKey = KEYS[1]
16269
+ local queueItemsKey = KEYS[2]
16270
+ local tenantQueueIndexKey = KEYS[3]
16271
+ local dispatchKey = KEYS[4]
16272
+
16273
+ local queueId = ARGV[1]
16274
+ local tenantId = ARGV[2]
16275
+
16276
+ -- Args after queueId and tenantId are triples: [messageId, timestamp, payload, ...]
16277
+ for i = 3, #ARGV, 3 do
16278
+ local messageId = ARGV[i]
16279
+ local timestamp = tonumber(ARGV[i + 1])
16280
+ local payload = ARGV[i + 2]
16281
+ redis.call('ZADD', queueKey, timestamp, messageId)
16282
+ redis.call('HSET', queueItemsKey, messageId, payload)
16283
+ end
16284
+
16285
+ -- Update tenant queue index (Level 2)
16286
+ local oldest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES')
16287
+ if #oldest >= 2 then
16288
+ redis.call('ZADD', tenantQueueIndexKey, oldest[2], queueId)
16289
+ end
16290
+
16291
+ -- Update dispatch index (Level 1)
16292
+ local tenantOldest = redis.call('ZRANGE', tenantQueueIndexKey, 0, 0, 'WITHSCORES')
16293
+ if #tenantOldest >= 2 then
16294
+ redis.call('ZADD', dispatchKey, tenantOldest[2], tenantId)
16295
+ end
16296
+
16297
+ return (#ARGV - 2) / 3
16298
+ `
16299
+ });
16300
+ this.redis.defineCommand("updateDispatchIndexes", {
16301
+ numberOfKeys: 3,
16302
+ lua: `
16303
+ local queueKey = KEYS[1]
16304
+ local tenantQueueIndexKey = KEYS[2]
16305
+ local dispatchKey = KEYS[3]
16306
+ local queueId = ARGV[1]
16307
+ local tenantId = ARGV[2]
16308
+
16309
+ local count = redis.call('ZCARD', queueKey)
16310
+ if count == 0 then
16311
+ -- Queue is empty: remove from tenant queue index
16312
+ redis.call('ZREM', tenantQueueIndexKey, queueId)
16313
+
16314
+ -- Check if tenant has any queues left
16315
+ local tenantQueueCount = redis.call('ZCARD', tenantQueueIndexKey)
16316
+ if tenantQueueCount == 0 then
16317
+ -- No more queues: remove tenant from dispatch
16318
+ redis.call('ZREM', dispatchKey, tenantId)
16319
+ else
16320
+ -- Update dispatch score to tenant's new oldest
16321
+ local tenantOldest = redis.call('ZRANGE', tenantQueueIndexKey, 0, 0, 'WITHSCORES')
16322
+ if #tenantOldest >= 2 then
16323
+ redis.call('ZADD', dispatchKey, tenantOldest[2], tenantId)
16324
+ end
16325
+ end
16326
+ return 1
15582
16327
  else
15583
- -- Update with oldest message timestamp
16328
+ -- Queue still has messages: update scores
15584
16329
  local oldest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES')
15585
16330
  if #oldest >= 2 then
15586
- redis.call('ZADD', masterQueueKey, oldest[2], queueId)
16331
+ redis.call('ZADD', tenantQueueIndexKey, oldest[2], queueId)
16332
+ end
16333
+ local tenantOldest = redis.call('ZRANGE', tenantQueueIndexKey, 0, 0, 'WITHSCORES')
16334
+ if #tenantOldest >= 2 then
16335
+ redis.call('ZADD', dispatchKey, tenantOldest[2], tenantId)
15587
16336
  end
15588
16337
  return 0
15589
16338
  end
@@ -15616,6 +16365,7 @@ exports.NoRetry = NoRetry;
15616
16365
  exports.NoopScheduler = NoopScheduler;
15617
16366
  exports.RoundRobinScheduler = RoundRobinScheduler;
15618
16367
  exports.SimpleQueue = SimpleQueue;
16368
+ exports.TenantDispatch = TenantDispatch;
15619
16369
  exports.VisibilityManager = VisibilityManager;
15620
16370
  exports.WeightedScheduler = WeightedScheduler;
15621
16371
  exports.Worker = Worker;