@trigger.dev/redis-worker 0.0.0-prerelease-20260810145618 → 0.0.0-prerelease-deployment-base-image-20260814120901

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
@@ -12109,6 +12109,13 @@ function isAbortError(error) {
12109
12109
  //#endregion
12110
12110
  //#region src/fair-queue/concurrency.ts
12111
12111
  /**
12112
+ * Page size for iterating a concurrency set's members (SSCAN COUNT) and cap on how many
12113
+ * members one sweep script invocation checks, bounding both the snapshot reads and the
12114
+ * time the atomic Lua script can hold up Redis when a set has accumulated many leaked
12115
+ * members.
12116
+ */
12117
+ const SWEEP_MEMBER_CHUNK_SIZE = 500;
12118
+ /**
12112
12119
  * ConcurrencyManager handles multi-level concurrency tracking and limiting.
12113
12120
  *
12114
12121
  * Features:
@@ -12122,12 +12129,17 @@ var ConcurrencyManager = class {
12122
12129
  keys;
12123
12130
  groups;
12124
12131
  groupsByName;
12132
+ logger;
12125
12133
  constructor(options) {
12126
12134
  this.options = options;
12127
12135
  this.redis = createRedisClient(options.redis);
12128
12136
  this.keys = options.keys;
12129
12137
  this.groups = options.groups;
12130
12138
  this.groupsByName = new Map(options.groups.map((g) => [g.name, g]));
12139
+ this.logger = options.logger ?? {
12140
+ debug: () => {},
12141
+ error: () => {}
12142
+ };
12131
12143
  this.#registerCommands();
12132
12144
  }
12133
12145
  /**
@@ -12173,7 +12185,17 @@ var ConcurrencyManager = class {
12173
12185
  const key = this.keys.concurrencyKey(group.name, groupId);
12174
12186
  pipeline.srem(key, messageId);
12175
12187
  }
12176
- await pipeline.exec();
12188
+ this.#assertPipelineSucceeded(await pipeline.exec(), 1);
12189
+ }
12190
+ /**
12191
+ * Throw if any command in a released pipeline failed. ioredis resolves `exec()` even when
12192
+ * individual commands error, so an unchecked pipeline reports success while leaving the
12193
+ * slot held, which strands it permanently once the caller drops the in-flight record.
12194
+ */
12195
+ #assertPipelineSucceeded(results, messageCount) {
12196
+ if (results === null) throw new Error(`Concurrency release pipeline for ${messageCount} message(s) was discarded without executing`);
12197
+ const errors = results.map(([error]) => error).filter((error) => Boolean(error));
12198
+ if (errors.length > 0) throw new Error(`Failed to release ${errors.length} of ${results?.length ?? 0} concurrency slot commands across ${messageCount} message(s): ${errors.map((error) => error.message).join("; ")}`);
12177
12199
  }
12178
12200
  /**
12179
12201
  * Release concurrency slots for multiple messages in a single pipeline.
@@ -12187,7 +12209,69 @@ var ConcurrencyManager = class {
12187
12209
  const key = this.keys.concurrencyKey(group.name, groupId);
12188
12210
  pipeline.srem(key, messageId);
12189
12211
  }
12190
- await pipeline.exec();
12212
+ this.#assertPipelineSucceeded(await pipeline.exec(), messages.length);
12213
+ }
12214
+ /**
12215
+ * Remove concurrency set members that no longer correspond to an in-flight message,
12216
+ * healing slots leaked by failed releases. Scans every set of every group and, per
12217
+ * member, atomically removes it unless the message id appears in one of the given
12218
+ * in-flight data hashes. Sound because a message is registered in-flight before its
12219
+ * slot is reserved, so at the moment of the atomic check a member with no in-flight
12220
+ * record can only be a leak; if the message is about to be re-claimed, reserve simply
12221
+ * re-adds the member.
12222
+ *
12223
+ * @param inflightDataKeys - The in-flight data hash keys for every shard. The sweep
12224
+ * refuses to run when this is empty, since with nowhere to look for running messages
12225
+ * every member would look orphaned and all concurrency accounting would be erased.
12226
+ * @returns The message ids that were removed, and how many sets were checked
12227
+ */
12228
+ async sweepOrphanedSlots(inflightDataKeys) {
12229
+ if (inflightDataKeys.length === 0) {
12230
+ this.logger.error("Refusing to sweep concurrency slots without any in-flight data keys: every member would look orphaned and all concurrency accounting would be erased");
12231
+ return {
12232
+ scannedSets: 0,
12233
+ removed: []
12234
+ };
12235
+ }
12236
+ const keyPrefix = this.options.redis.keyPrefix ?? "";
12237
+ let scannedSets = 0;
12238
+ const removed = [];
12239
+ for (const group of this.groups) {
12240
+ const pattern = `${keyPrefix}${this.keys.concurrencyKey(group.name, "*")}`;
12241
+ let cursor = "0";
12242
+ do {
12243
+ const [nextCursor, foundKeys] = await this.redis.scan(cursor, "MATCH", pattern, "COUNT", 1e3);
12244
+ cursor = nextCursor;
12245
+ for (const fullKey of foundKeys) {
12246
+ const key = keyPrefix && fullKey.startsWith(keyPrefix) ? fullKey.slice(keyPrefix.length) : fullKey;
12247
+ try {
12248
+ let sawMembers = false;
12249
+ let memberCursor = "0";
12250
+ do {
12251
+ const [nextMemberCursor, page] = await this.redis.sscan(key, memberCursor, "COUNT", SWEEP_MEMBER_CHUNK_SIZE);
12252
+ memberCursor = nextMemberCursor;
12253
+ if (page.length === 0) continue;
12254
+ sawMembers = true;
12255
+ for (let i = 0; i < page.length; i += SWEEP_MEMBER_CHUNK_SIZE) {
12256
+ const chunk = page.slice(i, i + SWEEP_MEMBER_CHUNK_SIZE);
12257
+ const removedIds = await this.redis.removeOrphanedConcurrencySlots(1 + inflightDataKeys.length, [key, ...inflightDataKeys], ...chunk);
12258
+ removed.push(...removedIds);
12259
+ }
12260
+ } while (memberCursor !== "0");
12261
+ if (sawMembers) scannedSets++;
12262
+ } catch (error) {
12263
+ this.logger.error("Failed to sweep concurrency set, skipping it", {
12264
+ key,
12265
+ error: error instanceof Error ? error.message : String(error)
12266
+ });
12267
+ }
12268
+ }
12269
+ } while (cursor !== "0");
12270
+ }
12271
+ return {
12272
+ scannedSets,
12273
+ removed
12274
+ };
12191
12275
  }
12192
12276
  /**
12193
12277
  * Get current concurrency for a specific group.
@@ -12280,14 +12364,20 @@ var ConcurrencyManager = class {
12280
12364
  local numGroups = #KEYS
12281
12365
  local messageId = ARGV[1]
12282
12366
 
12283
- -- Check all groups first
12367
+ -- Check all groups first. A message that is already a member of a group's set passes
12368
+ -- that group's check: re-admitting it does not increase concurrency (SADD is a no-op),
12369
+ -- and counting its own leftover slot against it would let a message whose earlier
12370
+ -- release failed block its own retry forever.
12284
12371
  for i = 1, numGroups do
12285
12372
  local key = KEYS[i]
12286
12373
  local limit = tonumber(ARGV[1 + i]) -- Limits start at ARGV[2]
12287
- local current = redis.call('SCARD', key)
12288
-
12289
- if current >= limit then
12290
- return 0 -- At capacity
12374
+
12375
+ if redis.call('SISMEMBER', key, messageId) == 0 then
12376
+ local current = redis.call('SCARD', key)
12377
+
12378
+ if current >= limit then
12379
+ return 0 -- At capacity
12380
+ end
12291
12381
  end
12292
12382
  end
12293
12383
 
@@ -12298,6 +12388,30 @@ for i = 1, numGroups do
12298
12388
  end
12299
12389
 
12300
12390
  return 1
12391
+ ` });
12392
+ this.redis.defineCommand("removeOrphanedConcurrencySlots", { lua: `
12393
+ local concurrencyKey = KEYS[1]
12394
+ local removedIds = {}
12395
+
12396
+ for i = 1, #ARGV do
12397
+ local messageId = ARGV[i]
12398
+ local inflight = false
12399
+
12400
+ for j = 2, #KEYS do
12401
+ if redis.call('HEXISTS', KEYS[j], messageId) == 1 then
12402
+ inflight = true
12403
+ break
12404
+ end
12405
+ end
12406
+
12407
+ if not inflight then
12408
+ if redis.call('SREM', concurrencyKey, messageId) == 1 then
12409
+ table.insert(removedIds, messageId)
12410
+ end
12411
+ end
12412
+ end
12413
+
12414
+ return removedIds
12301
12415
  ` });
12302
12416
  }
12303
12417
  };
@@ -13322,50 +13436,57 @@ var VisibilityManager = class {
13322
13436
  *
13323
13437
  * @param shardId - The shard to check
13324
13438
  * @param getQueueKeys - Function to get queue keys for a queue ID
13325
- * @returns Array of reclaimed message info for concurrency release
13439
+ * @param onBeforeRequeue - Hook invoked with the batch before any message is requeued,
13440
+ * used to free concurrency slots. A throw aborts the whole batch, leaving the
13441
+ * messages in-flight for the next reclaim tick.
13442
+ * @returns Array of reclaimed message info
13326
13443
  */
13327
- async reclaimTimedOut(shardId, getQueueKeys) {
13444
+ async reclaimTimedOut(shardId, getQueueKeys, onBeforeRequeue) {
13328
13445
  const inflightKey = this.keys.inflightKey(shardId);
13329
13446
  const inflightDataKey = this.keys.inflightDataKey(shardId);
13330
13447
  const now = Date.now();
13331
13448
  const timedOut = await this.redis.zrangebyscore(inflightKey, "-inf", now, "WITHSCORES", "LIMIT", 0, 100);
13332
- const reclaimedMessages = [];
13449
+ const candidates = [];
13333
13450
  for (let i = 0; i < timedOut.length; i += 2) {
13334
13451
  const member = timedOut[i];
13335
- const _deadlineScore = timedOut[i + 1];
13336
- if (!member || !_deadlineScore) continue;
13452
+ const deadlineScore = timedOut[i + 1];
13453
+ if (!member || !deadlineScore) continue;
13337
13454
  const { messageId, queueId } = this.#parseMember(member);
13455
+ const dataJson = await this.redis.hget(inflightDataKey, messageId);
13456
+ let storedMessage = null;
13457
+ if (dataJson) try {
13458
+ storedMessage = JSON.parse(dataJson);
13459
+ } catch {}
13460
+ if (!storedMessage) this.logger.error("Missing or corrupted message data during reclaim, using fallback", {
13461
+ messageId,
13462
+ queueId
13463
+ });
13464
+ candidates.push({
13465
+ member,
13466
+ deadlineScore,
13467
+ storedMessage,
13468
+ info: {
13469
+ messageId,
13470
+ queueId,
13471
+ tenantId: storedMessage?.tenantId ?? this.keys.extractTenantId(queueId),
13472
+ metadata: storedMessage?.metadata ?? {}
13473
+ }
13474
+ });
13475
+ }
13476
+ if (candidates.length === 0) return [];
13477
+ if (onBeforeRequeue) await onBeforeRequeue(candidates.map((candidate) => candidate.info));
13478
+ const reclaimedMessages = [];
13479
+ for (const { member, deadlineScore, storedMessage, info } of candidates) {
13480
+ const { messageId, queueId } = info;
13338
13481
  const { queueKey, queueItemsKey, tenantQueueIndexKey, dispatchKey, tenantId } = getQueueKeys(queueId);
13339
13482
  try {
13340
- const dataJson = await this.redis.hget(inflightDataKey, messageId);
13341
- let storedMessage = null;
13342
- if (dataJson) try {
13343
- storedMessage = JSON.parse(dataJson);
13344
- } catch {}
13345
13483
  const score = storedMessage?.timestamp ?? now;
13346
13484
  await this.redis.releaseMessage(inflightKey, inflightDataKey, queueKey, queueItemsKey, tenantQueueIndexKey, dispatchKey, member, messageId, score.toString(), queueId, "", tenantId);
13347
- if (storedMessage) reclaimedMessages.push({
13348
- messageId,
13349
- queueId,
13350
- tenantId: storedMessage.tenantId,
13351
- metadata: storedMessage.metadata
13352
- });
13353
- else {
13354
- this.logger.error("Missing or corrupted message data during reclaim, using fallback", {
13355
- messageId,
13356
- queueId
13357
- });
13358
- reclaimedMessages.push({
13359
- messageId,
13360
- queueId,
13361
- tenantId: this.keys.extractTenantId(queueId),
13362
- metadata: {}
13363
- });
13364
- }
13485
+ reclaimedMessages.push(info);
13365
13486
  this.logger.debug("Reclaimed timed-out message", {
13366
13487
  messageId,
13367
13488
  queueId,
13368
- deadline: _deadlineScore
13489
+ deadline: deadlineScore
13369
13490
  });
13370
13491
  } catch (error) {
13371
13492
  this.logger.error("Failed to reclaim message", {
@@ -13558,7 +13679,9 @@ local tenantId = ARGV[6]
13558
13679
  -- Get message data from in-flight
13559
13680
  local payload = redis.call('HGET', inflightDataKey, messageId)
13560
13681
  if not payload then
13561
- -- Message not in in-flight or already released
13682
+ -- Message not in in-flight or already released. Drop the dangling in-flight
13683
+ -- entry so it stops consuming a slot in every future reclaim scan.
13684
+ redis.call('ZREM', inflightKey, member)
13562
13685
  return 0
13563
13686
  end
13564
13687
 
@@ -13568,14 +13691,15 @@ if updatedData and updatedData ~= "" then
13568
13691
  payload = updatedData
13569
13692
  end
13570
13693
 
13694
+ -- Add back to queue before dropping the in-flight record. Lua does not roll back on
13695
+ -- error, so removing first would lose the message outright if the queue write failed.
13696
+ redis.call('ZADD', queueKey, score, messageId)
13697
+ redis.call('HSET', queueItemsKey, messageId, payload)
13698
+
13571
13699
  -- Remove from in-flight
13572
13700
  redis.call('ZREM', inflightKey, member)
13573
13701
  redis.call('HDEL', inflightDataKey, messageId)
13574
13702
 
13575
- -- Add back to queue
13576
- redis.call('ZADD', queueKey, score, messageId)
13577
- redis.call('HSET', queueItemsKey, messageId, payload)
13578
-
13579
13703
  -- Update tenant queue index (Level 2) with queue's oldest message
13580
13704
  local oldest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES')
13581
13705
  if #oldest >= 2 then
@@ -13620,15 +13744,20 @@ for i = 0, numMessages - 1 do
13620
13744
  -- Get message data from in-flight
13621
13745
  local payload = redis.call('HGET', inflightDataKey, messageId)
13622
13746
  if payload then
13747
+ -- Add back to queue before dropping the in-flight record, so a failed queue write
13748
+ -- cannot lose the message: Lua does not roll back already-applied commands.
13749
+ redis.call('ZADD', queueKey, score, messageId)
13750
+ redis.call('HSET', queueItemsKey, messageId, payload)
13751
+
13623
13752
  -- Remove from in-flight
13624
13753
  redis.call('ZREM', inflightKey, member)
13625
13754
  redis.call('HDEL', inflightDataKey, messageId)
13626
13755
 
13627
- -- Add back to queue
13628
- redis.call('ZADD', queueKey, score, messageId)
13629
- redis.call('HSET', queueItemsKey, messageId, payload)
13630
-
13631
13756
  releasedCount = releasedCount + 1
13757
+ else
13758
+ -- Dangling in-flight entry with no payload: drop it so it stops consuming
13759
+ -- a slot in every future reclaim scan.
13760
+ redis.call('ZREM', inflightKey, member)
13632
13761
  end
13633
13762
  end
13634
13763
 
@@ -14871,6 +15000,7 @@ var FairQueue = class {
14871
15000
  visibilityTimeoutMs;
14872
15001
  heartbeatIntervalMs;
14873
15002
  reclaimIntervalMs;
15003
+ reconcileIntervalMs;
14874
15004
  workerQueueResolver;
14875
15005
  batchClaimSize;
14876
15006
  cooloffEnabled;
@@ -14887,6 +15017,7 @@ var FairQueue = class {
14887
15017
  abortController;
14888
15018
  masterQueueConsumerLoops = [];
14889
15019
  reclaimLoop;
15020
+ reconcileLoop;
14890
15021
  queueDescriptorCache = /* @__PURE__ */ new Map();
14891
15022
  tenantDispatch;
14892
15023
  constructor(options) {
@@ -14906,6 +15037,7 @@ var FairQueue = class {
14906
15037
  this.visibilityTimeoutMs = options.visibilityTimeoutMs ?? 3e4;
14907
15038
  this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? this.visibilityTimeoutMs / 3;
14908
15039
  this.reclaimIntervalMs = options.reclaimIntervalMs ?? 5e3;
15040
+ this.reconcileIntervalMs = options.reconcileIntervalMs ?? 6e4;
14909
15041
  this.workerQueueResolver = options.workerQueue.resolveWorkerQueue;
14910
15042
  this.batchClaimSize = options.batchClaimSize ?? 10;
14911
15043
  this.cooloffEnabled = options.cooloff?.enabled ?? true;
@@ -14944,7 +15076,11 @@ var FairQueue = class {
14944
15076
  if (options.concurrencyGroups && options.concurrencyGroups.length > 0) this.concurrencyManager = new ConcurrencyManager({
14945
15077
  redis: options.redis,
14946
15078
  keys: options.keys,
14947
- groups: options.concurrencyGroups
15079
+ groups: options.concurrencyGroups,
15080
+ logger: {
15081
+ debug: (msg, ctx) => this.logger.debug(msg, ctx),
15082
+ error: (msg, ctx) => this.logger.error(msg, ctx)
15083
+ }
14948
15084
  });
14949
15085
  this.visibilityManager = new VisibilityManager({
14950
15086
  redis: options.redis,
@@ -15254,6 +15390,7 @@ var FairQueue = class {
15254
15390
  this.masterQueueConsumerLoops.push(loop);
15255
15391
  }
15256
15392
  this.reclaimLoop = this.#runReclaimLoop();
15393
+ if (this.concurrencyManager && this.reconcileIntervalMs > 0) this.reconcileLoop = this.#runReconcileLoop();
15257
15394
  this.logger.info("FairQueue started", {
15258
15395
  consumerCount: this.consumerCount,
15259
15396
  shardCount: this.shardCount,
@@ -15267,9 +15404,14 @@ var FairQueue = class {
15267
15404
  if (!this.isRunning) return;
15268
15405
  this.isRunning = false;
15269
15406
  this.abortController.abort();
15270
- await Promise.allSettled([...this.masterQueueConsumerLoops, this.reclaimLoop]);
15407
+ await Promise.allSettled([
15408
+ ...this.masterQueueConsumerLoops,
15409
+ this.reclaimLoop,
15410
+ this.reconcileLoop
15411
+ ]);
15271
15412
  this.masterQueueConsumerLoops = [];
15272
15413
  this.reclaimLoop = void 0;
15414
+ this.reconcileLoop = void 0;
15273
15415
  this.logger.info("FairQueue stopped");
15274
15416
  }
15275
15417
  /**
@@ -15603,17 +15745,13 @@ var FairQueue = class {
15603
15745
  if (dataJson) try {
15604
15746
  storedMessage = JSON.parse(dataJson);
15605
15747
  } catch {}
15606
- const descriptor = storedMessage ? this.queueDescriptorCache.get(queueId) ?? {
15607
- id: queueId,
15608
- tenantId: storedMessage.tenantId,
15609
- metadata: storedMessage.metadata ?? {}
15610
- } : {
15748
+ const descriptor = this.queueDescriptorCache.get(queueId) ?? {
15611
15749
  id: queueId,
15612
- tenantId: this.keys.extractTenantId(queueId),
15613
- metadata: {}
15750
+ tenantId: storedMessage?.tenantId ?? this.keys.extractTenantId(queueId),
15751
+ metadata: storedMessage?.metadata ?? {}
15614
15752
  };
15753
+ await this.#releaseConcurrencySafely(descriptor, messageId, "completeMessage");
15615
15754
  await this.visibilityManager.complete(messageId, queueId);
15616
- if (this.concurrencyManager && storedMessage) await this.concurrencyManager.release(descriptor, messageId);
15617
15755
  const { queueEmpty } = await this.#updateAllIndexesAfterDequeue(queueId, descriptor.tenantId);
15618
15756
  if (queueEmpty) {
15619
15757
  this.queueDescriptorCache.delete(queueId);
@@ -15642,26 +15780,59 @@ var FairQueue = class {
15642
15780
  if (dataJson) try {
15643
15781
  storedMessage = JSON.parse(dataJson);
15644
15782
  } catch {}
15645
- const descriptor = storedMessage ? this.queueDescriptorCache.get(queueId) ?? {
15646
- id: queueId,
15647
- tenantId: storedMessage.tenantId,
15648
- metadata: storedMessage.metadata ?? {}
15649
- } : {
15783
+ const descriptor = this.queueDescriptorCache.get(queueId) ?? {
15650
15784
  id: queueId,
15651
- tenantId: this.keys.extractTenantId(queueId),
15652
- metadata: {}
15785
+ tenantId: storedMessage?.tenantId ?? this.keys.extractTenantId(queueId),
15786
+ metadata: storedMessage?.metadata ?? {}
15653
15787
  };
15788
+ await this.#releaseConcurrencySafely(descriptor, messageId, "releaseMessage");
15654
15789
  const dispatchShardId = this.tenantDispatch.getShardForTenant(descriptor.tenantId);
15655
15790
  const tenantQueueIndexKey = this.keys.tenantQueueIndexKey(descriptor.tenantId);
15656
15791
  const dispatchKey = this.keys.dispatchKey(dispatchShardId);
15657
15792
  await this.visibilityManager.release(messageId, queueId, queueKey, queueItemsKey, tenantQueueIndexKey, dispatchKey, descriptor.tenantId, Date.now());
15658
- if (this.concurrencyManager && storedMessage) await this.concurrencyManager.release(descriptor, messageId);
15659
15793
  this.logger.debug("Message released", {
15660
15794
  messageId,
15661
15795
  queueId
15662
15796
  });
15663
15797
  }
15664
15798
  /**
15799
+ * Release a message's concurrency slots without letting a failure escape. Release is
15800
+ * best-effort cleanup: the primary state transition (complete, retry, DLQ, requeue)
15801
+ * must proceed even when the slot cannot be freed, because aborting the transition
15802
+ * re-delivers or strands the message, while a leaked slot is recoverable — reserve()
15803
+ * re-admits a message that already holds its own slot, and the reconcile loop removes
15804
+ * members that are no longer in flight.
15805
+ */
15806
+ async #releaseConcurrencySafely(descriptor, messageId, context) {
15807
+ if (!this.concurrencyManager) return;
15808
+ try {
15809
+ await this.concurrencyManager.release(descriptor, messageId);
15810
+ } catch (error) {
15811
+ this.logger.error("Failed to release concurrency slot, leaving it for the reconciler", {
15812
+ context,
15813
+ messageId,
15814
+ queueId: descriptor.id,
15815
+ tenantId: descriptor.tenantId,
15816
+ error: error instanceof Error ? error.message : String(error)
15817
+ });
15818
+ }
15819
+ }
15820
+ /**
15821
+ * Release a concurrency slot for a message we can no longer describe, deriving the
15822
+ * group from the queue id alone. Used on paths that bail out before the stored
15823
+ * message is available, where the slot would otherwise be held with nothing left
15824
+ * to reclaim it.
15825
+ */
15826
+ async #releaseOrphanedConcurrency(messageId, queueId) {
15827
+ if (!this.concurrencyManager) return;
15828
+ const descriptor = this.queueDescriptorCache.get(queueId) ?? {
15829
+ id: queueId,
15830
+ tenantId: this.keys.extractTenantId(queueId),
15831
+ metadata: {}
15832
+ };
15833
+ await this.#releaseConcurrencySafely(descriptor, messageId, "failMessage:missing-record");
15834
+ }
15835
+ /**
15665
15836
  * Mark a message as failed. This will trigger retry logic if configured,
15666
15837
  * or move the message to the dead letter queue.
15667
15838
  *
@@ -15680,6 +15851,7 @@ var FairQueue = class {
15680
15851
  messageId,
15681
15852
  queueId
15682
15853
  });
15854
+ await this.#releaseOrphanedConcurrency(messageId, queueId);
15683
15855
  return;
15684
15856
  }
15685
15857
  let storedMessage;
@@ -15690,6 +15862,7 @@ var FairQueue = class {
15690
15862
  messageId,
15691
15863
  queueId
15692
15864
  });
15865
+ await this.#releaseOrphanedConcurrency(messageId, queueId);
15693
15866
  return;
15694
15867
  }
15695
15868
  const descriptor = this.queueDescriptorCache.get(queueId) ?? {
@@ -15709,10 +15882,10 @@ var FairQueue = class {
15709
15882
  ...storedMessage,
15710
15883
  attempt: storedMessage.attempt + 1
15711
15884
  };
15885
+ await this.#releaseConcurrencySafely(descriptor, storedMessage.id, "retry");
15712
15886
  const tenantQueueIndexKey = this.keys.tenantQueueIndexKey(descriptor.tenantId);
15713
15887
  const dispatchKey = this.keys.dispatchKey(dispatchShardId);
15714
15888
  await this.visibilityManager.release(storedMessage.id, queueId, queueKey, queueItemsKey, tenantQueueIndexKey, dispatchKey, descriptor.tenantId, Date.now() + nextDelay, JSON.stringify(updatedMessage));
15715
- if (this.concurrencyManager) await this.concurrencyManager.release(descriptor, storedMessage.id);
15716
15889
  this.telemetry.recordRetry();
15717
15890
  this.logger.debug("Message scheduled for retry", {
15718
15891
  messageId: storedMessage.id,
@@ -15723,8 +15896,8 @@ var FairQueue = class {
15723
15896
  return;
15724
15897
  }
15725
15898
  }
15899
+ await this.#releaseConcurrencySafely(descriptor, storedMessage.id, "deadLetter");
15726
15900
  await this.#moveToDeadLetterQueue(storedMessage, error?.message);
15727
- if (this.concurrencyManager) await this.concurrencyManager.release(descriptor, storedMessage.id);
15728
15901
  }
15729
15902
  async #moveToDeadLetterQueue(storedMessage, errorMessage) {
15730
15903
  if (!this.deadLetterQueueEnabled) {
@@ -15773,9 +15946,37 @@ var FairQueue = class {
15773
15946
  throw error;
15774
15947
  }
15775
15948
  }
15949
+ /**
15950
+ * Free every timed-out message's concurrency slot in one pipeline before the batch is
15951
+ * requeued. A failed release never blocks the requeue: holding the message in-flight
15952
+ * would stall it behind a persistent release failure, while the leaked slot self-heals
15953
+ * when the message is re-admitted (reserve is idempotent for a message that already
15954
+ * holds its own slot) or via the reconcile loop.
15955
+ */
15956
+ async #releaseReclaimedConcurrency(messages) {
15957
+ if (!this.concurrencyManager || messages.length === 0) return;
15958
+ const descriptorFor = (message) => ({
15959
+ id: message.queueId,
15960
+ tenantId: message.tenantId,
15961
+ metadata: message.metadata ?? {}
15962
+ });
15963
+ try {
15964
+ await this.concurrencyManager.releaseBatch(messages.map((message) => ({
15965
+ queue: descriptorFor(message),
15966
+ messageId: message.messageId
15967
+ })));
15968
+ return;
15969
+ } catch (error) {
15970
+ this.logger.error("Batch concurrency release failed, retrying message by message", {
15971
+ count: messages.length,
15972
+ error: error instanceof Error ? error.message : String(error)
15973
+ });
15974
+ }
15975
+ for (const message of messages) await this.#releaseConcurrencySafely(descriptorFor(message), message.messageId, "reclaim");
15976
+ }
15776
15977
  async #reclaimTimedOutMessages() {
15777
15978
  let totalReclaimed = 0;
15778
- for (let shardId = 0; shardId < this.shardCount; shardId++) {
15979
+ for (let shardId = 0; shardId < this.shardCount; shardId++) try {
15779
15980
  const reclaimedMessages = await this.visibilityManager.reclaimTimedOut(shardId, (queueId) => {
15780
15981
  const tenantId = this.keys.extractTenantId(queueId);
15781
15982
  const dispatchShardId = this.tenantDispatch.getShardForTenant(tenantId);
@@ -15786,28 +15987,51 @@ var FairQueue = class {
15786
15987
  dispatchKey: this.keys.dispatchKey(dispatchShardId),
15787
15988
  tenantId
15788
15989
  };
15789
- });
15790
- if (reclaimedMessages.length > 0) {
15791
- if (this.concurrencyManager) try {
15792
- await this.concurrencyManager.releaseBatch(reclaimedMessages.map((msg) => ({
15793
- queue: {
15794
- id: msg.queueId,
15795
- tenantId: msg.tenantId,
15796
- metadata: msg.metadata ?? {}
15797
- },
15798
- messageId: msg.messageId
15799
- })));
15800
- } catch (error) {
15801
- this.logger.error("Failed to release concurrency for reclaimed messages", {
15802
- count: reclaimedMessages.length,
15803
- error: error instanceof Error ? error.message : String(error)
15804
- });
15805
- }
15806
- }
15990
+ }, this.#releaseReclaimedConcurrency.bind(this));
15807
15991
  totalReclaimed += reclaimedMessages.length;
15992
+ } catch (error) {
15993
+ this.logger.error("Failed to reclaim shard, leaving messages in-flight for the next tick", {
15994
+ shardId,
15995
+ error: error instanceof Error ? error.message : String(error)
15996
+ });
15808
15997
  }
15809
15998
  if (totalReclaimed > 0) this.logger.info("Reclaimed timed-out messages", { count: totalReclaimed });
15810
15999
  }
16000
+ /**
16001
+ * The initial delay is jittered so instances sharing a Redis do not all sweep at once.
16002
+ */
16003
+ async #runReconcileLoop() {
16004
+ try {
16005
+ await (0, node_timers_promises.setTimeout)(Math.random() * this.reconcileIntervalMs, null, { signal: this.abortController.signal });
16006
+ for await (const _ of (0, node_timers_promises.setInterval)(this.reconcileIntervalMs, null, { signal: this.abortController.signal })) try {
16007
+ await this.#reconcileConcurrency();
16008
+ } catch (error) {
16009
+ this.logger.error("Concurrency reconcile loop error", { error: error instanceof Error ? error.message : String(error) });
16010
+ }
16011
+ } catch (error) {
16012
+ if (isAbortError(error)) {
16013
+ this.logger.debug("Concurrency reconcile loop aborted");
16014
+ return;
16015
+ }
16016
+ throw error;
16017
+ }
16018
+ }
16019
+ /**
16020
+ * Heal orphaned concurrency slots: remove any concurrency set member whose message is
16021
+ * not registered in-flight on any shard. This is the recovery path for slots leaked by
16022
+ * failed releases, which otherwise count against a tenant's limit forever and can
16023
+ * wedge every queue the tenant owns.
16024
+ */
16025
+ async #reconcileConcurrency() {
16026
+ if (!this.concurrencyManager) return;
16027
+ const inflightDataKeys = Array.from({ length: this.shardCount }, (_, shardId) => this.keys.inflightDataKey(shardId));
16028
+ const { scannedSets, removed } = await this.concurrencyManager.sweepOrphanedSlots(inflightDataKeys);
16029
+ if (removed.length > 0) this.logger.info("Reconciled orphaned concurrency slots", {
16030
+ count: removed.length,
16031
+ scannedSets,
16032
+ messageIds: removed.slice(0, 50)
16033
+ });
16034
+ }
15811
16035
  #isInCooloff(queueId) {
15812
16036
  const state = this.queueCooloffStates.get(queueId);
15813
16037
  if (!state) return false;