@trigger.dev/redis-worker 4.5.11 → 4.5.13

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
@@ -9646,22 +9646,28 @@ function createRedisClient(options, handlers) {
9646
9646
  }
9647
9647
 
9648
9648
  //#endregion
9649
- //#region ../../node_modules/.pnpm/nanoid@5.1.2/node_modules/nanoid/url-alphabet/index.js
9650
- const urlAlphabet = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
9649
+ //#region ../../node_modules/.pnpm/nanoid@5.1.16/node_modules/nanoid/url-alphabet/index.js
9650
+ let urlAlphabet = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
9651
9651
 
9652
9652
  //#endregion
9653
- //#region ../../node_modules/.pnpm/nanoid@5.1.2/node_modules/nanoid/index.js
9653
+ //#region ../../node_modules/.pnpm/nanoid@5.1.16/node_modules/nanoid/index.js
9654
9654
  const POOL_SIZE_MULTIPLIER = 128;
9655
9655
  let pool;
9656
9656
  let poolOffset;
9657
9657
  function fillPool(bytes) {
9658
- if (!pool || pool.length < bytes) {
9659
- pool = Buffer.allocUnsafe(bytes * POOL_SIZE_MULTIPLIER);
9660
- node_crypto.webcrypto.getRandomValues(pool);
9661
- poolOffset = 0;
9662
- } else if (poolOffset + bytes > pool.length) {
9663
- node_crypto.webcrypto.getRandomValues(pool);
9664
- poolOffset = 0;
9658
+ if (bytes < 0) throw new RangeError("Wrong ID size");
9659
+ try {
9660
+ if (!pool || pool.length < bytes) {
9661
+ pool = Buffer.allocUnsafe(bytes * POOL_SIZE_MULTIPLIER);
9662
+ node_crypto.webcrypto.getRandomValues(pool);
9663
+ poolOffset = 0;
9664
+ } else if (poolOffset + bytes > pool.length) {
9665
+ node_crypto.webcrypto.getRandomValues(pool);
9666
+ poolOffset = 0;
9667
+ }
9668
+ } catch (e) {
9669
+ pool = void 0;
9670
+ throw e;
9665
9671
  }
9666
9672
  poolOffset += bytes;
9667
9673
  }
@@ -12076,12 +12082,21 @@ var Worker = class Worker {
12076
12082
  }
12077
12083
  this.isShuttingDown = true;
12078
12084
  this.logger.log("Shutting down worker loops...", { signal });
12079
- await Promise.race([Promise.all(this.workerLoops), Worker.delay(this.shutdownTimeoutMs).then(() => {
12080
- this.logger.error("Worker shutdown timed out", {
12081
- signal,
12082
- shutdownTimeoutMs: this.shutdownTimeoutMs
12083
- });
12084
- })]);
12085
+ let shutdownDeadline;
12086
+ const deadlinePromise = new Promise((resolve) => {
12087
+ shutdownDeadline = setTimeout(() => {
12088
+ this.logger.error("Worker shutdown timed out", {
12089
+ signal,
12090
+ shutdownTimeoutMs: this.shutdownTimeoutMs
12091
+ });
12092
+ resolve();
12093
+ }, this.shutdownTimeoutMs);
12094
+ });
12095
+ try {
12096
+ await Promise.race([Promise.all(this.workerLoops), deadlinePromise]);
12097
+ } finally {
12098
+ if (shutdownDeadline) clearTimeout(shutdownDeadline);
12099
+ }
12085
12100
  await this.subscriber?.unsubscribe();
12086
12101
  await this.subscriber?.quit();
12087
12102
  await this.queue.close();
@@ -12109,6 +12124,13 @@ function isAbortError(error) {
12109
12124
  //#endregion
12110
12125
  //#region src/fair-queue/concurrency.ts
12111
12126
  /**
12127
+ * Page size for iterating a concurrency set's members (SSCAN COUNT) and cap on how many
12128
+ * members one sweep script invocation checks, bounding both the snapshot reads and the
12129
+ * time the atomic Lua script can hold up Redis when a set has accumulated many leaked
12130
+ * members.
12131
+ */
12132
+ const SWEEP_MEMBER_CHUNK_SIZE = 500;
12133
+ /**
12112
12134
  * ConcurrencyManager handles multi-level concurrency tracking and limiting.
12113
12135
  *
12114
12136
  * Features:
@@ -12122,12 +12144,17 @@ var ConcurrencyManager = class {
12122
12144
  keys;
12123
12145
  groups;
12124
12146
  groupsByName;
12147
+ logger;
12125
12148
  constructor(options) {
12126
12149
  this.options = options;
12127
12150
  this.redis = createRedisClient(options.redis);
12128
12151
  this.keys = options.keys;
12129
12152
  this.groups = options.groups;
12130
12153
  this.groupsByName = new Map(options.groups.map((g) => [g.name, g]));
12154
+ this.logger = options.logger ?? {
12155
+ debug: () => {},
12156
+ error: () => {}
12157
+ };
12131
12158
  this.#registerCommands();
12132
12159
  }
12133
12160
  /**
@@ -12173,7 +12200,17 @@ var ConcurrencyManager = class {
12173
12200
  const key = this.keys.concurrencyKey(group.name, groupId);
12174
12201
  pipeline.srem(key, messageId);
12175
12202
  }
12176
- await pipeline.exec();
12203
+ this.#assertPipelineSucceeded(await pipeline.exec(), 1);
12204
+ }
12205
+ /**
12206
+ * Throw if any command in a released pipeline failed. ioredis resolves `exec()` even when
12207
+ * individual commands error, so an unchecked pipeline reports success while leaving the
12208
+ * slot held, which strands it permanently once the caller drops the in-flight record.
12209
+ */
12210
+ #assertPipelineSucceeded(results, messageCount) {
12211
+ if (results === null) throw new Error(`Concurrency release pipeline for ${messageCount} message(s) was discarded without executing`);
12212
+ const errors = results.map(([error]) => error).filter((error) => Boolean(error));
12213
+ 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
12214
  }
12178
12215
  /**
12179
12216
  * Release concurrency slots for multiple messages in a single pipeline.
@@ -12187,7 +12224,69 @@ var ConcurrencyManager = class {
12187
12224
  const key = this.keys.concurrencyKey(group.name, groupId);
12188
12225
  pipeline.srem(key, messageId);
12189
12226
  }
12190
- await pipeline.exec();
12227
+ this.#assertPipelineSucceeded(await pipeline.exec(), messages.length);
12228
+ }
12229
+ /**
12230
+ * Remove concurrency set members that no longer correspond to an in-flight message,
12231
+ * healing slots leaked by failed releases. Scans every set of every group and, per
12232
+ * member, atomically removes it unless the message id appears in one of the given
12233
+ * in-flight data hashes. Sound because a message is registered in-flight before its
12234
+ * slot is reserved, so at the moment of the atomic check a member with no in-flight
12235
+ * record can only be a leak; if the message is about to be re-claimed, reserve simply
12236
+ * re-adds the member.
12237
+ *
12238
+ * @param inflightDataKeys - The in-flight data hash keys for every shard. The sweep
12239
+ * refuses to run when this is empty, since with nowhere to look for running messages
12240
+ * every member would look orphaned and all concurrency accounting would be erased.
12241
+ * @returns The message ids that were removed, and how many sets were checked
12242
+ */
12243
+ async sweepOrphanedSlots(inflightDataKeys) {
12244
+ if (inflightDataKeys.length === 0) {
12245
+ 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");
12246
+ return {
12247
+ scannedSets: 0,
12248
+ removed: []
12249
+ };
12250
+ }
12251
+ const keyPrefix = this.options.redis.keyPrefix ?? "";
12252
+ let scannedSets = 0;
12253
+ const removed = [];
12254
+ for (const group of this.groups) {
12255
+ const pattern = `${keyPrefix}${this.keys.concurrencyKey(group.name, "*")}`;
12256
+ let cursor = "0";
12257
+ do {
12258
+ const [nextCursor, foundKeys] = await this.redis.scan(cursor, "MATCH", pattern, "COUNT", 1e3);
12259
+ cursor = nextCursor;
12260
+ for (const fullKey of foundKeys) {
12261
+ const key = keyPrefix && fullKey.startsWith(keyPrefix) ? fullKey.slice(keyPrefix.length) : fullKey;
12262
+ try {
12263
+ let sawMembers = false;
12264
+ let memberCursor = "0";
12265
+ do {
12266
+ const [nextMemberCursor, page] = await this.redis.sscan(key, memberCursor, "COUNT", SWEEP_MEMBER_CHUNK_SIZE);
12267
+ memberCursor = nextMemberCursor;
12268
+ if (page.length === 0) continue;
12269
+ sawMembers = true;
12270
+ for (let i = 0; i < page.length; i += SWEEP_MEMBER_CHUNK_SIZE) {
12271
+ const chunk = page.slice(i, i + SWEEP_MEMBER_CHUNK_SIZE);
12272
+ const removedIds = await this.redis.removeOrphanedConcurrencySlots(1 + inflightDataKeys.length, [key, ...inflightDataKeys], ...chunk);
12273
+ removed.push(...removedIds);
12274
+ }
12275
+ } while (memberCursor !== "0");
12276
+ if (sawMembers) scannedSets++;
12277
+ } catch (error) {
12278
+ this.logger.error("Failed to sweep concurrency set, skipping it", {
12279
+ key,
12280
+ error: error instanceof Error ? error.message : String(error)
12281
+ });
12282
+ }
12283
+ }
12284
+ } while (cursor !== "0");
12285
+ }
12286
+ return {
12287
+ scannedSets,
12288
+ removed
12289
+ };
12191
12290
  }
12192
12291
  /**
12193
12292
  * Get current concurrency for a specific group.
@@ -12280,14 +12379,20 @@ var ConcurrencyManager = class {
12280
12379
  local numGroups = #KEYS
12281
12380
  local messageId = ARGV[1]
12282
12381
 
12283
- -- Check all groups first
12382
+ -- Check all groups first. A message that is already a member of a group's set passes
12383
+ -- that group's check: re-admitting it does not increase concurrency (SADD is a no-op),
12384
+ -- and counting its own leftover slot against it would let a message whose earlier
12385
+ -- release failed block its own retry forever.
12284
12386
  for i = 1, numGroups do
12285
12387
  local key = KEYS[i]
12286
12388
  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
12389
+
12390
+ if redis.call('SISMEMBER', key, messageId) == 0 then
12391
+ local current = redis.call('SCARD', key)
12392
+
12393
+ if current >= limit then
12394
+ return 0 -- At capacity
12395
+ end
12291
12396
  end
12292
12397
  end
12293
12398
 
@@ -12298,6 +12403,30 @@ for i = 1, numGroups do
12298
12403
  end
12299
12404
 
12300
12405
  return 1
12406
+ ` });
12407
+ this.redis.defineCommand("removeOrphanedConcurrencySlots", { lua: `
12408
+ local concurrencyKey = KEYS[1]
12409
+ local removedIds = {}
12410
+
12411
+ for i = 1, #ARGV do
12412
+ local messageId = ARGV[i]
12413
+ local inflight = false
12414
+
12415
+ for j = 2, #KEYS do
12416
+ if redis.call('HEXISTS', KEYS[j], messageId) == 1 then
12417
+ inflight = true
12418
+ break
12419
+ end
12420
+ end
12421
+
12422
+ if not inflight then
12423
+ if redis.call('SREM', concurrencyKey, messageId) == 1 then
12424
+ table.insert(removedIds, messageId)
12425
+ end
12426
+ end
12427
+ end
12428
+
12429
+ return removedIds
12301
12430
  ` });
12302
12431
  }
12303
12432
  };
@@ -13322,50 +13451,57 @@ var VisibilityManager = class {
13322
13451
  *
13323
13452
  * @param shardId - The shard to check
13324
13453
  * @param getQueueKeys - Function to get queue keys for a queue ID
13325
- * @returns Array of reclaimed message info for concurrency release
13454
+ * @param onBeforeRequeue - Hook invoked with the batch before any message is requeued,
13455
+ * used to free concurrency slots. A throw aborts the whole batch, leaving the
13456
+ * messages in-flight for the next reclaim tick.
13457
+ * @returns Array of reclaimed message info
13326
13458
  */
13327
- async reclaimTimedOut(shardId, getQueueKeys) {
13459
+ async reclaimTimedOut(shardId, getQueueKeys, onBeforeRequeue) {
13328
13460
  const inflightKey = this.keys.inflightKey(shardId);
13329
13461
  const inflightDataKey = this.keys.inflightDataKey(shardId);
13330
13462
  const now = Date.now();
13331
13463
  const timedOut = await this.redis.zrangebyscore(inflightKey, "-inf", now, "WITHSCORES", "LIMIT", 0, 100);
13332
- const reclaimedMessages = [];
13464
+ const candidates = [];
13333
13465
  for (let i = 0; i < timedOut.length; i += 2) {
13334
13466
  const member = timedOut[i];
13335
- const _deadlineScore = timedOut[i + 1];
13336
- if (!member || !_deadlineScore) continue;
13467
+ const deadlineScore = timedOut[i + 1];
13468
+ if (!member || !deadlineScore) continue;
13337
13469
  const { messageId, queueId } = this.#parseMember(member);
13470
+ const dataJson = await this.redis.hget(inflightDataKey, messageId);
13471
+ let storedMessage = null;
13472
+ if (dataJson) try {
13473
+ storedMessage = JSON.parse(dataJson);
13474
+ } catch {}
13475
+ if (!storedMessage) this.logger.error("Missing or corrupted message data during reclaim, using fallback", {
13476
+ messageId,
13477
+ queueId
13478
+ });
13479
+ candidates.push({
13480
+ member,
13481
+ deadlineScore,
13482
+ storedMessage,
13483
+ info: {
13484
+ messageId,
13485
+ queueId,
13486
+ tenantId: storedMessage?.tenantId ?? this.keys.extractTenantId(queueId),
13487
+ metadata: storedMessage?.metadata ?? {}
13488
+ }
13489
+ });
13490
+ }
13491
+ if (candidates.length === 0) return [];
13492
+ if (onBeforeRequeue) await onBeforeRequeue(candidates.map((candidate) => candidate.info));
13493
+ const reclaimedMessages = [];
13494
+ for (const { member, deadlineScore, storedMessage, info } of candidates) {
13495
+ const { messageId, queueId } = info;
13338
13496
  const { queueKey, queueItemsKey, tenantQueueIndexKey, dispatchKey, tenantId } = getQueueKeys(queueId);
13339
13497
  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
13498
  const score = storedMessage?.timestamp ?? now;
13346
13499
  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
- }
13500
+ reclaimedMessages.push(info);
13365
13501
  this.logger.debug("Reclaimed timed-out message", {
13366
13502
  messageId,
13367
13503
  queueId,
13368
- deadline: _deadlineScore
13504
+ deadline: deadlineScore
13369
13505
  });
13370
13506
  } catch (error) {
13371
13507
  this.logger.error("Failed to reclaim message", {
@@ -13558,7 +13694,9 @@ local tenantId = ARGV[6]
13558
13694
  -- Get message data from in-flight
13559
13695
  local payload = redis.call('HGET', inflightDataKey, messageId)
13560
13696
  if not payload then
13561
- -- Message not in in-flight or already released
13697
+ -- Message not in in-flight or already released. Drop the dangling in-flight
13698
+ -- entry so it stops consuming a slot in every future reclaim scan.
13699
+ redis.call('ZREM', inflightKey, member)
13562
13700
  return 0
13563
13701
  end
13564
13702
 
@@ -13568,14 +13706,15 @@ if updatedData and updatedData ~= "" then
13568
13706
  payload = updatedData
13569
13707
  end
13570
13708
 
13709
+ -- Add back to queue before dropping the in-flight record. Lua does not roll back on
13710
+ -- error, so removing first would lose the message outright if the queue write failed.
13711
+ redis.call('ZADD', queueKey, score, messageId)
13712
+ redis.call('HSET', queueItemsKey, messageId, payload)
13713
+
13571
13714
  -- Remove from in-flight
13572
13715
  redis.call('ZREM', inflightKey, member)
13573
13716
  redis.call('HDEL', inflightDataKey, messageId)
13574
13717
 
13575
- -- Add back to queue
13576
- redis.call('ZADD', queueKey, score, messageId)
13577
- redis.call('HSET', queueItemsKey, messageId, payload)
13578
-
13579
13718
  -- Update tenant queue index (Level 2) with queue's oldest message
13580
13719
  local oldest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES')
13581
13720
  if #oldest >= 2 then
@@ -13620,15 +13759,20 @@ for i = 0, numMessages - 1 do
13620
13759
  -- Get message data from in-flight
13621
13760
  local payload = redis.call('HGET', inflightDataKey, messageId)
13622
13761
  if payload then
13762
+ -- Add back to queue before dropping the in-flight record, so a failed queue write
13763
+ -- cannot lose the message: Lua does not roll back already-applied commands.
13764
+ redis.call('ZADD', queueKey, score, messageId)
13765
+ redis.call('HSET', queueItemsKey, messageId, payload)
13766
+
13623
13767
  -- Remove from in-flight
13624
13768
  redis.call('ZREM', inflightKey, member)
13625
13769
  redis.call('HDEL', inflightDataKey, messageId)
13626
13770
 
13627
- -- Add back to queue
13628
- redis.call('ZADD', queueKey, score, messageId)
13629
- redis.call('HSET', queueItemsKey, messageId, payload)
13630
-
13631
13771
  releasedCount = releasedCount + 1
13772
+ else
13773
+ -- Dangling in-flight entry with no payload: drop it so it stops consuming
13774
+ -- a slot in every future reclaim scan.
13775
+ redis.call('ZREM', inflightKey, member)
13632
13776
  end
13633
13777
  end
13634
13778
 
@@ -13770,7 +13914,7 @@ var WorkerQueueManager = class {
13770
13914
  throw error;
13771
13915
  } finally {
13772
13916
  if (cleanup && signal) signal.removeEventListener("abort", cleanup);
13773
- await blockingClient.quit().catch(() => {});
13917
+ if (blockingClient.status !== "end") await blockingClient.quit().catch(() => {});
13774
13918
  }
13775
13919
  }
13776
13920
  /**
@@ -14871,6 +15015,7 @@ var FairQueue = class {
14871
15015
  visibilityTimeoutMs;
14872
15016
  heartbeatIntervalMs;
14873
15017
  reclaimIntervalMs;
15018
+ reconcileIntervalMs;
14874
15019
  workerQueueResolver;
14875
15020
  batchClaimSize;
14876
15021
  cooloffEnabled;
@@ -14887,6 +15032,7 @@ var FairQueue = class {
14887
15032
  abortController;
14888
15033
  masterQueueConsumerLoops = [];
14889
15034
  reclaimLoop;
15035
+ reconcileLoop;
14890
15036
  queueDescriptorCache = /* @__PURE__ */ new Map();
14891
15037
  tenantDispatch;
14892
15038
  constructor(options) {
@@ -14906,6 +15052,7 @@ var FairQueue = class {
14906
15052
  this.visibilityTimeoutMs = options.visibilityTimeoutMs ?? 3e4;
14907
15053
  this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? this.visibilityTimeoutMs / 3;
14908
15054
  this.reclaimIntervalMs = options.reclaimIntervalMs ?? 5e3;
15055
+ this.reconcileIntervalMs = options.reconcileIntervalMs ?? 6e4;
14909
15056
  this.workerQueueResolver = options.workerQueue.resolveWorkerQueue;
14910
15057
  this.batchClaimSize = options.batchClaimSize ?? 10;
14911
15058
  this.cooloffEnabled = options.cooloff?.enabled ?? true;
@@ -14944,7 +15091,11 @@ var FairQueue = class {
14944
15091
  if (options.concurrencyGroups && options.concurrencyGroups.length > 0) this.concurrencyManager = new ConcurrencyManager({
14945
15092
  redis: options.redis,
14946
15093
  keys: options.keys,
14947
- groups: options.concurrencyGroups
15094
+ groups: options.concurrencyGroups,
15095
+ logger: {
15096
+ debug: (msg, ctx) => this.logger.debug(msg, ctx),
15097
+ error: (msg, ctx) => this.logger.error(msg, ctx)
15098
+ }
14948
15099
  });
14949
15100
  this.visibilityManager = new VisibilityManager({
14950
15101
  redis: options.redis,
@@ -15254,6 +15405,7 @@ var FairQueue = class {
15254
15405
  this.masterQueueConsumerLoops.push(loop);
15255
15406
  }
15256
15407
  this.reclaimLoop = this.#runReclaimLoop();
15408
+ if (this.concurrencyManager && this.reconcileIntervalMs > 0) this.reconcileLoop = this.#runReconcileLoop();
15257
15409
  this.logger.info("FairQueue started", {
15258
15410
  consumerCount: this.consumerCount,
15259
15411
  shardCount: this.shardCount,
@@ -15267,9 +15419,14 @@ var FairQueue = class {
15267
15419
  if (!this.isRunning) return;
15268
15420
  this.isRunning = false;
15269
15421
  this.abortController.abort();
15270
- await Promise.allSettled([...this.masterQueueConsumerLoops, this.reclaimLoop]);
15422
+ await Promise.allSettled([
15423
+ ...this.masterQueueConsumerLoops,
15424
+ this.reclaimLoop,
15425
+ this.reconcileLoop
15426
+ ]);
15271
15427
  this.masterQueueConsumerLoops = [];
15272
15428
  this.reclaimLoop = void 0;
15429
+ this.reconcileLoop = void 0;
15273
15430
  this.logger.info("FairQueue stopped");
15274
15431
  }
15275
15432
  /**
@@ -15603,17 +15760,13 @@ var FairQueue = class {
15603
15760
  if (dataJson) try {
15604
15761
  storedMessage = JSON.parse(dataJson);
15605
15762
  } catch {}
15606
- const descriptor = storedMessage ? this.queueDescriptorCache.get(queueId) ?? {
15607
- id: queueId,
15608
- tenantId: storedMessage.tenantId,
15609
- metadata: storedMessage.metadata ?? {}
15610
- } : {
15763
+ const descriptor = this.queueDescriptorCache.get(queueId) ?? {
15611
15764
  id: queueId,
15612
- tenantId: this.keys.extractTenantId(queueId),
15613
- metadata: {}
15765
+ tenantId: storedMessage?.tenantId ?? this.keys.extractTenantId(queueId),
15766
+ metadata: storedMessage?.metadata ?? {}
15614
15767
  };
15768
+ await this.#releaseConcurrencySafely(descriptor, messageId, "completeMessage");
15615
15769
  await this.visibilityManager.complete(messageId, queueId);
15616
- if (this.concurrencyManager && storedMessage) await this.concurrencyManager.release(descriptor, messageId);
15617
15770
  const { queueEmpty } = await this.#updateAllIndexesAfterDequeue(queueId, descriptor.tenantId);
15618
15771
  if (queueEmpty) {
15619
15772
  this.queueDescriptorCache.delete(queueId);
@@ -15642,26 +15795,59 @@ var FairQueue = class {
15642
15795
  if (dataJson) try {
15643
15796
  storedMessage = JSON.parse(dataJson);
15644
15797
  } catch {}
15645
- const descriptor = storedMessage ? this.queueDescriptorCache.get(queueId) ?? {
15646
- id: queueId,
15647
- tenantId: storedMessage.tenantId,
15648
- metadata: storedMessage.metadata ?? {}
15649
- } : {
15798
+ const descriptor = this.queueDescriptorCache.get(queueId) ?? {
15650
15799
  id: queueId,
15651
- tenantId: this.keys.extractTenantId(queueId),
15652
- metadata: {}
15800
+ tenantId: storedMessage?.tenantId ?? this.keys.extractTenantId(queueId),
15801
+ metadata: storedMessage?.metadata ?? {}
15653
15802
  };
15803
+ await this.#releaseConcurrencySafely(descriptor, messageId, "releaseMessage");
15654
15804
  const dispatchShardId = this.tenantDispatch.getShardForTenant(descriptor.tenantId);
15655
15805
  const tenantQueueIndexKey = this.keys.tenantQueueIndexKey(descriptor.tenantId);
15656
15806
  const dispatchKey = this.keys.dispatchKey(dispatchShardId);
15657
15807
  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
15808
  this.logger.debug("Message released", {
15660
15809
  messageId,
15661
15810
  queueId
15662
15811
  });
15663
15812
  }
15664
15813
  /**
15814
+ * Release a message's concurrency slots without letting a failure escape. Release is
15815
+ * best-effort cleanup: the primary state transition (complete, retry, DLQ, requeue)
15816
+ * must proceed even when the slot cannot be freed, because aborting the transition
15817
+ * re-delivers or strands the message, while a leaked slot is recoverable — reserve()
15818
+ * re-admits a message that already holds its own slot, and the reconcile loop removes
15819
+ * members that are no longer in flight.
15820
+ */
15821
+ async #releaseConcurrencySafely(descriptor, messageId, context) {
15822
+ if (!this.concurrencyManager) return;
15823
+ try {
15824
+ await this.concurrencyManager.release(descriptor, messageId);
15825
+ } catch (error) {
15826
+ this.logger.error("Failed to release concurrency slot, leaving it for the reconciler", {
15827
+ context,
15828
+ messageId,
15829
+ queueId: descriptor.id,
15830
+ tenantId: descriptor.tenantId,
15831
+ error: error instanceof Error ? error.message : String(error)
15832
+ });
15833
+ }
15834
+ }
15835
+ /**
15836
+ * Release a concurrency slot for a message we can no longer describe, deriving the
15837
+ * group from the queue id alone. Used on paths that bail out before the stored
15838
+ * message is available, where the slot would otherwise be held with nothing left
15839
+ * to reclaim it.
15840
+ */
15841
+ async #releaseOrphanedConcurrency(messageId, queueId) {
15842
+ if (!this.concurrencyManager) return;
15843
+ const descriptor = this.queueDescriptorCache.get(queueId) ?? {
15844
+ id: queueId,
15845
+ tenantId: this.keys.extractTenantId(queueId),
15846
+ metadata: {}
15847
+ };
15848
+ await this.#releaseConcurrencySafely(descriptor, messageId, "failMessage:missing-record");
15849
+ }
15850
+ /**
15665
15851
  * Mark a message as failed. This will trigger retry logic if configured,
15666
15852
  * or move the message to the dead letter queue.
15667
15853
  *
@@ -15680,6 +15866,7 @@ var FairQueue = class {
15680
15866
  messageId,
15681
15867
  queueId
15682
15868
  });
15869
+ await this.#releaseOrphanedConcurrency(messageId, queueId);
15683
15870
  return;
15684
15871
  }
15685
15872
  let storedMessage;
@@ -15690,6 +15877,7 @@ var FairQueue = class {
15690
15877
  messageId,
15691
15878
  queueId
15692
15879
  });
15880
+ await this.#releaseOrphanedConcurrency(messageId, queueId);
15693
15881
  return;
15694
15882
  }
15695
15883
  const descriptor = this.queueDescriptorCache.get(queueId) ?? {
@@ -15709,10 +15897,10 @@ var FairQueue = class {
15709
15897
  ...storedMessage,
15710
15898
  attempt: storedMessage.attempt + 1
15711
15899
  };
15900
+ await this.#releaseConcurrencySafely(descriptor, storedMessage.id, "retry");
15712
15901
  const tenantQueueIndexKey = this.keys.tenantQueueIndexKey(descriptor.tenantId);
15713
15902
  const dispatchKey = this.keys.dispatchKey(dispatchShardId);
15714
15903
  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
15904
  this.telemetry.recordRetry();
15717
15905
  this.logger.debug("Message scheduled for retry", {
15718
15906
  messageId: storedMessage.id,
@@ -15723,8 +15911,8 @@ var FairQueue = class {
15723
15911
  return;
15724
15912
  }
15725
15913
  }
15914
+ await this.#releaseConcurrencySafely(descriptor, storedMessage.id, "deadLetter");
15726
15915
  await this.#moveToDeadLetterQueue(storedMessage, error?.message);
15727
- if (this.concurrencyManager) await this.concurrencyManager.release(descriptor, storedMessage.id);
15728
15916
  }
15729
15917
  async #moveToDeadLetterQueue(storedMessage, errorMessage) {
15730
15918
  if (!this.deadLetterQueueEnabled) {
@@ -15773,9 +15961,37 @@ var FairQueue = class {
15773
15961
  throw error;
15774
15962
  }
15775
15963
  }
15964
+ /**
15965
+ * Free every timed-out message's concurrency slot in one pipeline before the batch is
15966
+ * requeued. A failed release never blocks the requeue: holding the message in-flight
15967
+ * would stall it behind a persistent release failure, while the leaked slot self-heals
15968
+ * when the message is re-admitted (reserve is idempotent for a message that already
15969
+ * holds its own slot) or via the reconcile loop.
15970
+ */
15971
+ async #releaseReclaimedConcurrency(messages) {
15972
+ if (!this.concurrencyManager || messages.length === 0) return;
15973
+ const descriptorFor = (message) => ({
15974
+ id: message.queueId,
15975
+ tenantId: message.tenantId,
15976
+ metadata: message.metadata ?? {}
15977
+ });
15978
+ try {
15979
+ await this.concurrencyManager.releaseBatch(messages.map((message) => ({
15980
+ queue: descriptorFor(message),
15981
+ messageId: message.messageId
15982
+ })));
15983
+ return;
15984
+ } catch (error) {
15985
+ this.logger.error("Batch concurrency release failed, retrying message by message", {
15986
+ count: messages.length,
15987
+ error: error instanceof Error ? error.message : String(error)
15988
+ });
15989
+ }
15990
+ for (const message of messages) await this.#releaseConcurrencySafely(descriptorFor(message), message.messageId, "reclaim");
15991
+ }
15776
15992
  async #reclaimTimedOutMessages() {
15777
15993
  let totalReclaimed = 0;
15778
- for (let shardId = 0; shardId < this.shardCount; shardId++) {
15994
+ for (let shardId = 0; shardId < this.shardCount; shardId++) try {
15779
15995
  const reclaimedMessages = await this.visibilityManager.reclaimTimedOut(shardId, (queueId) => {
15780
15996
  const tenantId = this.keys.extractTenantId(queueId);
15781
15997
  const dispatchShardId = this.tenantDispatch.getShardForTenant(tenantId);
@@ -15786,28 +16002,51 @@ var FairQueue = class {
15786
16002
  dispatchKey: this.keys.dispatchKey(dispatchShardId),
15787
16003
  tenantId
15788
16004
  };
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
- }
16005
+ }, this.#releaseReclaimedConcurrency.bind(this));
15807
16006
  totalReclaimed += reclaimedMessages.length;
16007
+ } catch (error) {
16008
+ this.logger.error("Failed to reclaim shard, leaving messages in-flight for the next tick", {
16009
+ shardId,
16010
+ error: error instanceof Error ? error.message : String(error)
16011
+ });
15808
16012
  }
15809
16013
  if (totalReclaimed > 0) this.logger.info("Reclaimed timed-out messages", { count: totalReclaimed });
15810
16014
  }
16015
+ /**
16016
+ * The initial delay is jittered so instances sharing a Redis do not all sweep at once.
16017
+ */
16018
+ async #runReconcileLoop() {
16019
+ try {
16020
+ await (0, node_timers_promises.setTimeout)(Math.random() * this.reconcileIntervalMs, null, { signal: this.abortController.signal });
16021
+ for await (const _ of (0, node_timers_promises.setInterval)(this.reconcileIntervalMs, null, { signal: this.abortController.signal })) try {
16022
+ await this.#reconcileConcurrency();
16023
+ } catch (error) {
16024
+ this.logger.error("Concurrency reconcile loop error", { error: error instanceof Error ? error.message : String(error) });
16025
+ }
16026
+ } catch (error) {
16027
+ if (isAbortError(error)) {
16028
+ this.logger.debug("Concurrency reconcile loop aborted");
16029
+ return;
16030
+ }
16031
+ throw error;
16032
+ }
16033
+ }
16034
+ /**
16035
+ * Heal orphaned concurrency slots: remove any concurrency set member whose message is
16036
+ * not registered in-flight on any shard. This is the recovery path for slots leaked by
16037
+ * failed releases, which otherwise count against a tenant's limit forever and can
16038
+ * wedge every queue the tenant owns.
16039
+ */
16040
+ async #reconcileConcurrency() {
16041
+ if (!this.concurrencyManager) return;
16042
+ const inflightDataKeys = Array.from({ length: this.shardCount }, (_, shardId) => this.keys.inflightDataKey(shardId));
16043
+ const { scannedSets, removed } = await this.concurrencyManager.sweepOrphanedSlots(inflightDataKeys);
16044
+ if (removed.length > 0) this.logger.info("Reconciled orphaned concurrency slots", {
16045
+ count: removed.length,
16046
+ scannedSets,
16047
+ messageIds: removed.slice(0, 50)
16048
+ });
16049
+ }
15811
16050
  #isInCooloff(queueId) {
15812
16051
  const state = this.queueCooloffStates.get(queueId);
15813
16052
  if (!state) return false;