@trigger.dev/redis-worker 4.5.10 → 4.5.12

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.js CHANGED
@@ -9,7 +9,7 @@ import { calculateNextRetryDelay } from "@trigger.dev/core/v3";
9
9
  import { jumpHash, shutdownManager } from "@trigger.dev/core/v3/serverOnly";
10
10
  import { z } from "zod";
11
11
  import cronParser from "cron-parser";
12
- import { setInterval as setInterval$1 } from "node:timers/promises";
12
+ import { setInterval as setInterval$1, setTimeout as setTimeout$1 } from "node:timers/promises";
13
13
  import seedrandom from "seedrandom";
14
14
 
15
15
  //#region \0rolldown/runtime.js
@@ -9636,22 +9636,28 @@ function createRedisClient(options, handlers) {
9636
9636
  }
9637
9637
 
9638
9638
  //#endregion
9639
- //#region ../../node_modules/.pnpm/nanoid@5.1.2/node_modules/nanoid/url-alphabet/index.js
9640
- const urlAlphabet = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
9639
+ //#region ../../node_modules/.pnpm/nanoid@5.1.16/node_modules/nanoid/url-alphabet/index.js
9640
+ let urlAlphabet = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
9641
9641
 
9642
9642
  //#endregion
9643
- //#region ../../node_modules/.pnpm/nanoid@5.1.2/node_modules/nanoid/index.js
9643
+ //#region ../../node_modules/.pnpm/nanoid@5.1.16/node_modules/nanoid/index.js
9644
9644
  const POOL_SIZE_MULTIPLIER = 128;
9645
9645
  let pool;
9646
9646
  let poolOffset;
9647
9647
  function fillPool(bytes) {
9648
- if (!pool || pool.length < bytes) {
9649
- pool = Buffer.allocUnsafe(bytes * POOL_SIZE_MULTIPLIER);
9650
- webcrypto.getRandomValues(pool);
9651
- poolOffset = 0;
9652
- } else if (poolOffset + bytes > pool.length) {
9653
- webcrypto.getRandomValues(pool);
9654
- poolOffset = 0;
9648
+ if (bytes < 0) throw new RangeError("Wrong ID size");
9649
+ try {
9650
+ if (!pool || pool.length < bytes) {
9651
+ pool = Buffer.allocUnsafe(bytes * POOL_SIZE_MULTIPLIER);
9652
+ webcrypto.getRandomValues(pool);
9653
+ poolOffset = 0;
9654
+ } else if (poolOffset + bytes > pool.length) {
9655
+ webcrypto.getRandomValues(pool);
9656
+ poolOffset = 0;
9657
+ }
9658
+ } catch (e) {
9659
+ pool = void 0;
9660
+ throw e;
9655
9661
  }
9656
9662
  poolOffset += bytes;
9657
9663
  }
@@ -12066,12 +12072,21 @@ var Worker = class Worker {
12066
12072
  }
12067
12073
  this.isShuttingDown = true;
12068
12074
  this.logger.log("Shutting down worker loops...", { signal });
12069
- await Promise.race([Promise.all(this.workerLoops), Worker.delay(this.shutdownTimeoutMs).then(() => {
12070
- this.logger.error("Worker shutdown timed out", {
12071
- signal,
12072
- shutdownTimeoutMs: this.shutdownTimeoutMs
12073
- });
12074
- })]);
12075
+ let shutdownDeadline;
12076
+ const deadlinePromise = new Promise((resolve) => {
12077
+ shutdownDeadline = setTimeout(() => {
12078
+ this.logger.error("Worker shutdown timed out", {
12079
+ signal,
12080
+ shutdownTimeoutMs: this.shutdownTimeoutMs
12081
+ });
12082
+ resolve();
12083
+ }, this.shutdownTimeoutMs);
12084
+ });
12085
+ try {
12086
+ await Promise.race([Promise.all(this.workerLoops), deadlinePromise]);
12087
+ } finally {
12088
+ if (shutdownDeadline) clearTimeout(shutdownDeadline);
12089
+ }
12075
12090
  await this.subscriber?.unsubscribe();
12076
12091
  await this.subscriber?.quit();
12077
12092
  await this.queue.close();
@@ -12099,6 +12114,13 @@ function isAbortError(error) {
12099
12114
  //#endregion
12100
12115
  //#region src/fair-queue/concurrency.ts
12101
12116
  /**
12117
+ * Page size for iterating a concurrency set's members (SSCAN COUNT) and cap on how many
12118
+ * members one sweep script invocation checks, bounding both the snapshot reads and the
12119
+ * time the atomic Lua script can hold up Redis when a set has accumulated many leaked
12120
+ * members.
12121
+ */
12122
+ const SWEEP_MEMBER_CHUNK_SIZE = 500;
12123
+ /**
12102
12124
  * ConcurrencyManager handles multi-level concurrency tracking and limiting.
12103
12125
  *
12104
12126
  * Features:
@@ -12112,12 +12134,17 @@ var ConcurrencyManager = class {
12112
12134
  keys;
12113
12135
  groups;
12114
12136
  groupsByName;
12137
+ logger;
12115
12138
  constructor(options) {
12116
12139
  this.options = options;
12117
12140
  this.redis = createRedisClient(options.redis);
12118
12141
  this.keys = options.keys;
12119
12142
  this.groups = options.groups;
12120
12143
  this.groupsByName = new Map(options.groups.map((g) => [g.name, g]));
12144
+ this.logger = options.logger ?? {
12145
+ debug: () => {},
12146
+ error: () => {}
12147
+ };
12121
12148
  this.#registerCommands();
12122
12149
  }
12123
12150
  /**
@@ -12163,7 +12190,17 @@ var ConcurrencyManager = class {
12163
12190
  const key = this.keys.concurrencyKey(group.name, groupId);
12164
12191
  pipeline.srem(key, messageId);
12165
12192
  }
12166
- await pipeline.exec();
12193
+ this.#assertPipelineSucceeded(await pipeline.exec(), 1);
12194
+ }
12195
+ /**
12196
+ * Throw if any command in a released pipeline failed. ioredis resolves `exec()` even when
12197
+ * individual commands error, so an unchecked pipeline reports success while leaving the
12198
+ * slot held, which strands it permanently once the caller drops the in-flight record.
12199
+ */
12200
+ #assertPipelineSucceeded(results, messageCount) {
12201
+ if (results === null) throw new Error(`Concurrency release pipeline for ${messageCount} message(s) was discarded without executing`);
12202
+ const errors = results.map(([error]) => error).filter((error) => Boolean(error));
12203
+ 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("; ")}`);
12167
12204
  }
12168
12205
  /**
12169
12206
  * Release concurrency slots for multiple messages in a single pipeline.
@@ -12177,7 +12214,69 @@ var ConcurrencyManager = class {
12177
12214
  const key = this.keys.concurrencyKey(group.name, groupId);
12178
12215
  pipeline.srem(key, messageId);
12179
12216
  }
12180
- await pipeline.exec();
12217
+ this.#assertPipelineSucceeded(await pipeline.exec(), messages.length);
12218
+ }
12219
+ /**
12220
+ * Remove concurrency set members that no longer correspond to an in-flight message,
12221
+ * healing slots leaked by failed releases. Scans every set of every group and, per
12222
+ * member, atomically removes it unless the message id appears in one of the given
12223
+ * in-flight data hashes. Sound because a message is registered in-flight before its
12224
+ * slot is reserved, so at the moment of the atomic check a member with no in-flight
12225
+ * record can only be a leak; if the message is about to be re-claimed, reserve simply
12226
+ * re-adds the member.
12227
+ *
12228
+ * @param inflightDataKeys - The in-flight data hash keys for every shard. The sweep
12229
+ * refuses to run when this is empty, since with nowhere to look for running messages
12230
+ * every member would look orphaned and all concurrency accounting would be erased.
12231
+ * @returns The message ids that were removed, and how many sets were checked
12232
+ */
12233
+ async sweepOrphanedSlots(inflightDataKeys) {
12234
+ if (inflightDataKeys.length === 0) {
12235
+ 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");
12236
+ return {
12237
+ scannedSets: 0,
12238
+ removed: []
12239
+ };
12240
+ }
12241
+ const keyPrefix = this.options.redis.keyPrefix ?? "";
12242
+ let scannedSets = 0;
12243
+ const removed = [];
12244
+ for (const group of this.groups) {
12245
+ const pattern = `${keyPrefix}${this.keys.concurrencyKey(group.name, "*")}`;
12246
+ let cursor = "0";
12247
+ do {
12248
+ const [nextCursor, foundKeys] = await this.redis.scan(cursor, "MATCH", pattern, "COUNT", 1e3);
12249
+ cursor = nextCursor;
12250
+ for (const fullKey of foundKeys) {
12251
+ const key = keyPrefix && fullKey.startsWith(keyPrefix) ? fullKey.slice(keyPrefix.length) : fullKey;
12252
+ try {
12253
+ let sawMembers = false;
12254
+ let memberCursor = "0";
12255
+ do {
12256
+ const [nextMemberCursor, page] = await this.redis.sscan(key, memberCursor, "COUNT", SWEEP_MEMBER_CHUNK_SIZE);
12257
+ memberCursor = nextMemberCursor;
12258
+ if (page.length === 0) continue;
12259
+ sawMembers = true;
12260
+ for (let i = 0; i < page.length; i += SWEEP_MEMBER_CHUNK_SIZE) {
12261
+ const chunk = page.slice(i, i + SWEEP_MEMBER_CHUNK_SIZE);
12262
+ const removedIds = await this.redis.removeOrphanedConcurrencySlots(1 + inflightDataKeys.length, [key, ...inflightDataKeys], ...chunk);
12263
+ removed.push(...removedIds);
12264
+ }
12265
+ } while (memberCursor !== "0");
12266
+ if (sawMembers) scannedSets++;
12267
+ } catch (error) {
12268
+ this.logger.error("Failed to sweep concurrency set, skipping it", {
12269
+ key,
12270
+ error: error instanceof Error ? error.message : String(error)
12271
+ });
12272
+ }
12273
+ }
12274
+ } while (cursor !== "0");
12275
+ }
12276
+ return {
12277
+ scannedSets,
12278
+ removed
12279
+ };
12181
12280
  }
12182
12281
  /**
12183
12282
  * Get current concurrency for a specific group.
@@ -12270,14 +12369,20 @@ var ConcurrencyManager = class {
12270
12369
  local numGroups = #KEYS
12271
12370
  local messageId = ARGV[1]
12272
12371
 
12273
- -- Check all groups first
12372
+ -- Check all groups first. A message that is already a member of a group's set passes
12373
+ -- that group's check: re-admitting it does not increase concurrency (SADD is a no-op),
12374
+ -- and counting its own leftover slot against it would let a message whose earlier
12375
+ -- release failed block its own retry forever.
12274
12376
  for i = 1, numGroups do
12275
12377
  local key = KEYS[i]
12276
12378
  local limit = tonumber(ARGV[1 + i]) -- Limits start at ARGV[2]
12277
- local current = redis.call('SCARD', key)
12278
-
12279
- if current >= limit then
12280
- return 0 -- At capacity
12379
+
12380
+ if redis.call('SISMEMBER', key, messageId) == 0 then
12381
+ local current = redis.call('SCARD', key)
12382
+
12383
+ if current >= limit then
12384
+ return 0 -- At capacity
12385
+ end
12281
12386
  end
12282
12387
  end
12283
12388
 
@@ -12288,6 +12393,30 @@ for i = 1, numGroups do
12288
12393
  end
12289
12394
 
12290
12395
  return 1
12396
+ ` });
12397
+ this.redis.defineCommand("removeOrphanedConcurrencySlots", { lua: `
12398
+ local concurrencyKey = KEYS[1]
12399
+ local removedIds = {}
12400
+
12401
+ for i = 1, #ARGV do
12402
+ local messageId = ARGV[i]
12403
+ local inflight = false
12404
+
12405
+ for j = 2, #KEYS do
12406
+ if redis.call('HEXISTS', KEYS[j], messageId) == 1 then
12407
+ inflight = true
12408
+ break
12409
+ end
12410
+ end
12411
+
12412
+ if not inflight then
12413
+ if redis.call('SREM', concurrencyKey, messageId) == 1 then
12414
+ table.insert(removedIds, messageId)
12415
+ end
12416
+ end
12417
+ end
12418
+
12419
+ return removedIds
12291
12420
  ` });
12292
12421
  }
12293
12422
  };
@@ -13312,50 +13441,57 @@ var VisibilityManager = class {
13312
13441
  *
13313
13442
  * @param shardId - The shard to check
13314
13443
  * @param getQueueKeys - Function to get queue keys for a queue ID
13315
- * @returns Array of reclaimed message info for concurrency release
13444
+ * @param onBeforeRequeue - Hook invoked with the batch before any message is requeued,
13445
+ * used to free concurrency slots. A throw aborts the whole batch, leaving the
13446
+ * messages in-flight for the next reclaim tick.
13447
+ * @returns Array of reclaimed message info
13316
13448
  */
13317
- async reclaimTimedOut(shardId, getQueueKeys) {
13449
+ async reclaimTimedOut(shardId, getQueueKeys, onBeforeRequeue) {
13318
13450
  const inflightKey = this.keys.inflightKey(shardId);
13319
13451
  const inflightDataKey = this.keys.inflightDataKey(shardId);
13320
13452
  const now = Date.now();
13321
13453
  const timedOut = await this.redis.zrangebyscore(inflightKey, "-inf", now, "WITHSCORES", "LIMIT", 0, 100);
13322
- const reclaimedMessages = [];
13454
+ const candidates = [];
13323
13455
  for (let i = 0; i < timedOut.length; i += 2) {
13324
13456
  const member = timedOut[i];
13325
- const _deadlineScore = timedOut[i + 1];
13326
- if (!member || !_deadlineScore) continue;
13457
+ const deadlineScore = timedOut[i + 1];
13458
+ if (!member || !deadlineScore) continue;
13327
13459
  const { messageId, queueId } = this.#parseMember(member);
13460
+ const dataJson = await this.redis.hget(inflightDataKey, messageId);
13461
+ let storedMessage = null;
13462
+ if (dataJson) try {
13463
+ storedMessage = JSON.parse(dataJson);
13464
+ } catch {}
13465
+ if (!storedMessage) this.logger.error("Missing or corrupted message data during reclaim, using fallback", {
13466
+ messageId,
13467
+ queueId
13468
+ });
13469
+ candidates.push({
13470
+ member,
13471
+ deadlineScore,
13472
+ storedMessage,
13473
+ info: {
13474
+ messageId,
13475
+ queueId,
13476
+ tenantId: storedMessage?.tenantId ?? this.keys.extractTenantId(queueId),
13477
+ metadata: storedMessage?.metadata ?? {}
13478
+ }
13479
+ });
13480
+ }
13481
+ if (candidates.length === 0) return [];
13482
+ if (onBeforeRequeue) await onBeforeRequeue(candidates.map((candidate) => candidate.info));
13483
+ const reclaimedMessages = [];
13484
+ for (const { member, deadlineScore, storedMessage, info } of candidates) {
13485
+ const { messageId, queueId } = info;
13328
13486
  const { queueKey, queueItemsKey, tenantQueueIndexKey, dispatchKey, tenantId } = getQueueKeys(queueId);
13329
13487
  try {
13330
- const dataJson = await this.redis.hget(inflightDataKey, messageId);
13331
- let storedMessage = null;
13332
- if (dataJson) try {
13333
- storedMessage = JSON.parse(dataJson);
13334
- } catch {}
13335
13488
  const score = storedMessage?.timestamp ?? now;
13336
13489
  await this.redis.releaseMessage(inflightKey, inflightDataKey, queueKey, queueItemsKey, tenantQueueIndexKey, dispatchKey, member, messageId, score.toString(), queueId, "", tenantId);
13337
- if (storedMessage) reclaimedMessages.push({
13338
- messageId,
13339
- queueId,
13340
- tenantId: storedMessage.tenantId,
13341
- metadata: storedMessage.metadata
13342
- });
13343
- else {
13344
- this.logger.error("Missing or corrupted message data during reclaim, using fallback", {
13345
- messageId,
13346
- queueId
13347
- });
13348
- reclaimedMessages.push({
13349
- messageId,
13350
- queueId,
13351
- tenantId: this.keys.extractTenantId(queueId),
13352
- metadata: {}
13353
- });
13354
- }
13490
+ reclaimedMessages.push(info);
13355
13491
  this.logger.debug("Reclaimed timed-out message", {
13356
13492
  messageId,
13357
13493
  queueId,
13358
- deadline: _deadlineScore
13494
+ deadline: deadlineScore
13359
13495
  });
13360
13496
  } catch (error) {
13361
13497
  this.logger.error("Failed to reclaim message", {
@@ -13548,7 +13684,9 @@ local tenantId = ARGV[6]
13548
13684
  -- Get message data from in-flight
13549
13685
  local payload = redis.call('HGET', inflightDataKey, messageId)
13550
13686
  if not payload then
13551
- -- Message not in in-flight or already released
13687
+ -- Message not in in-flight or already released. Drop the dangling in-flight
13688
+ -- entry so it stops consuming a slot in every future reclaim scan.
13689
+ redis.call('ZREM', inflightKey, member)
13552
13690
  return 0
13553
13691
  end
13554
13692
 
@@ -13558,14 +13696,15 @@ if updatedData and updatedData ~= "" then
13558
13696
  payload = updatedData
13559
13697
  end
13560
13698
 
13699
+ -- Add back to queue before dropping the in-flight record. Lua does not roll back on
13700
+ -- error, so removing first would lose the message outright if the queue write failed.
13701
+ redis.call('ZADD', queueKey, score, messageId)
13702
+ redis.call('HSET', queueItemsKey, messageId, payload)
13703
+
13561
13704
  -- Remove from in-flight
13562
13705
  redis.call('ZREM', inflightKey, member)
13563
13706
  redis.call('HDEL', inflightDataKey, messageId)
13564
13707
 
13565
- -- Add back to queue
13566
- redis.call('ZADD', queueKey, score, messageId)
13567
- redis.call('HSET', queueItemsKey, messageId, payload)
13568
-
13569
13708
  -- Update tenant queue index (Level 2) with queue's oldest message
13570
13709
  local oldest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES')
13571
13710
  if #oldest >= 2 then
@@ -13610,15 +13749,20 @@ for i = 0, numMessages - 1 do
13610
13749
  -- Get message data from in-flight
13611
13750
  local payload = redis.call('HGET', inflightDataKey, messageId)
13612
13751
  if payload then
13752
+ -- Add back to queue before dropping the in-flight record, so a failed queue write
13753
+ -- cannot lose the message: Lua does not roll back already-applied commands.
13754
+ redis.call('ZADD', queueKey, score, messageId)
13755
+ redis.call('HSET', queueItemsKey, messageId, payload)
13756
+
13613
13757
  -- Remove from in-flight
13614
13758
  redis.call('ZREM', inflightKey, member)
13615
13759
  redis.call('HDEL', inflightDataKey, messageId)
13616
13760
 
13617
- -- Add back to queue
13618
- redis.call('ZADD', queueKey, score, messageId)
13619
- redis.call('HSET', queueItemsKey, messageId, payload)
13620
-
13621
13761
  releasedCount = releasedCount + 1
13762
+ else
13763
+ -- Dangling in-flight entry with no payload: drop it so it stops consuming
13764
+ -- a slot in every future reclaim scan.
13765
+ redis.call('ZREM', inflightKey, member)
13622
13766
  end
13623
13767
  end
13624
13768
 
@@ -13760,7 +13904,7 @@ var WorkerQueueManager = class {
13760
13904
  throw error;
13761
13905
  } finally {
13762
13906
  if (cleanup && signal) signal.removeEventListener("abort", cleanup);
13763
- await blockingClient.quit().catch(() => {});
13907
+ if (blockingClient.status !== "end") await blockingClient.quit().catch(() => {});
13764
13908
  }
13765
13909
  }
13766
13910
  /**
@@ -14861,6 +15005,7 @@ var FairQueue = class {
14861
15005
  visibilityTimeoutMs;
14862
15006
  heartbeatIntervalMs;
14863
15007
  reclaimIntervalMs;
15008
+ reconcileIntervalMs;
14864
15009
  workerQueueResolver;
14865
15010
  batchClaimSize;
14866
15011
  cooloffEnabled;
@@ -14877,6 +15022,7 @@ var FairQueue = class {
14877
15022
  abortController;
14878
15023
  masterQueueConsumerLoops = [];
14879
15024
  reclaimLoop;
15025
+ reconcileLoop;
14880
15026
  queueDescriptorCache = /* @__PURE__ */ new Map();
14881
15027
  tenantDispatch;
14882
15028
  constructor(options) {
@@ -14896,6 +15042,7 @@ var FairQueue = class {
14896
15042
  this.visibilityTimeoutMs = options.visibilityTimeoutMs ?? 3e4;
14897
15043
  this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? this.visibilityTimeoutMs / 3;
14898
15044
  this.reclaimIntervalMs = options.reclaimIntervalMs ?? 5e3;
15045
+ this.reconcileIntervalMs = options.reconcileIntervalMs ?? 6e4;
14899
15046
  this.workerQueueResolver = options.workerQueue.resolveWorkerQueue;
14900
15047
  this.batchClaimSize = options.batchClaimSize ?? 10;
14901
15048
  this.cooloffEnabled = options.cooloff?.enabled ?? true;
@@ -14934,7 +15081,11 @@ var FairQueue = class {
14934
15081
  if (options.concurrencyGroups && options.concurrencyGroups.length > 0) this.concurrencyManager = new ConcurrencyManager({
14935
15082
  redis: options.redis,
14936
15083
  keys: options.keys,
14937
- groups: options.concurrencyGroups
15084
+ groups: options.concurrencyGroups,
15085
+ logger: {
15086
+ debug: (msg, ctx) => this.logger.debug(msg, ctx),
15087
+ error: (msg, ctx) => this.logger.error(msg, ctx)
15088
+ }
14938
15089
  });
14939
15090
  this.visibilityManager = new VisibilityManager({
14940
15091
  redis: options.redis,
@@ -15244,6 +15395,7 @@ var FairQueue = class {
15244
15395
  this.masterQueueConsumerLoops.push(loop);
15245
15396
  }
15246
15397
  this.reclaimLoop = this.#runReclaimLoop();
15398
+ if (this.concurrencyManager && this.reconcileIntervalMs > 0) this.reconcileLoop = this.#runReconcileLoop();
15247
15399
  this.logger.info("FairQueue started", {
15248
15400
  consumerCount: this.consumerCount,
15249
15401
  shardCount: this.shardCount,
@@ -15257,9 +15409,14 @@ var FairQueue = class {
15257
15409
  if (!this.isRunning) return;
15258
15410
  this.isRunning = false;
15259
15411
  this.abortController.abort();
15260
- await Promise.allSettled([...this.masterQueueConsumerLoops, this.reclaimLoop]);
15412
+ await Promise.allSettled([
15413
+ ...this.masterQueueConsumerLoops,
15414
+ this.reclaimLoop,
15415
+ this.reconcileLoop
15416
+ ]);
15261
15417
  this.masterQueueConsumerLoops = [];
15262
15418
  this.reclaimLoop = void 0;
15419
+ this.reconcileLoop = void 0;
15263
15420
  this.logger.info("FairQueue stopped");
15264
15421
  }
15265
15422
  /**
@@ -15593,17 +15750,13 @@ var FairQueue = class {
15593
15750
  if (dataJson) try {
15594
15751
  storedMessage = JSON.parse(dataJson);
15595
15752
  } catch {}
15596
- const descriptor = storedMessage ? this.queueDescriptorCache.get(queueId) ?? {
15597
- id: queueId,
15598
- tenantId: storedMessage.tenantId,
15599
- metadata: storedMessage.metadata ?? {}
15600
- } : {
15753
+ const descriptor = this.queueDescriptorCache.get(queueId) ?? {
15601
15754
  id: queueId,
15602
- tenantId: this.keys.extractTenantId(queueId),
15603
- metadata: {}
15755
+ tenantId: storedMessage?.tenantId ?? this.keys.extractTenantId(queueId),
15756
+ metadata: storedMessage?.metadata ?? {}
15604
15757
  };
15758
+ await this.#releaseConcurrencySafely(descriptor, messageId, "completeMessage");
15605
15759
  await this.visibilityManager.complete(messageId, queueId);
15606
- if (this.concurrencyManager && storedMessage) await this.concurrencyManager.release(descriptor, messageId);
15607
15760
  const { queueEmpty } = await this.#updateAllIndexesAfterDequeue(queueId, descriptor.tenantId);
15608
15761
  if (queueEmpty) {
15609
15762
  this.queueDescriptorCache.delete(queueId);
@@ -15632,26 +15785,59 @@ var FairQueue = class {
15632
15785
  if (dataJson) try {
15633
15786
  storedMessage = JSON.parse(dataJson);
15634
15787
  } catch {}
15635
- const descriptor = storedMessage ? this.queueDescriptorCache.get(queueId) ?? {
15636
- id: queueId,
15637
- tenantId: storedMessage.tenantId,
15638
- metadata: storedMessage.metadata ?? {}
15639
- } : {
15788
+ const descriptor = this.queueDescriptorCache.get(queueId) ?? {
15640
15789
  id: queueId,
15641
- tenantId: this.keys.extractTenantId(queueId),
15642
- metadata: {}
15790
+ tenantId: storedMessage?.tenantId ?? this.keys.extractTenantId(queueId),
15791
+ metadata: storedMessage?.metadata ?? {}
15643
15792
  };
15793
+ await this.#releaseConcurrencySafely(descriptor, messageId, "releaseMessage");
15644
15794
  const dispatchShardId = this.tenantDispatch.getShardForTenant(descriptor.tenantId);
15645
15795
  const tenantQueueIndexKey = this.keys.tenantQueueIndexKey(descriptor.tenantId);
15646
15796
  const dispatchKey = this.keys.dispatchKey(dispatchShardId);
15647
15797
  await this.visibilityManager.release(messageId, queueId, queueKey, queueItemsKey, tenantQueueIndexKey, dispatchKey, descriptor.tenantId, Date.now());
15648
- if (this.concurrencyManager && storedMessage) await this.concurrencyManager.release(descriptor, messageId);
15649
15798
  this.logger.debug("Message released", {
15650
15799
  messageId,
15651
15800
  queueId
15652
15801
  });
15653
15802
  }
15654
15803
  /**
15804
+ * Release a message's concurrency slots without letting a failure escape. Release is
15805
+ * best-effort cleanup: the primary state transition (complete, retry, DLQ, requeue)
15806
+ * must proceed even when the slot cannot be freed, because aborting the transition
15807
+ * re-delivers or strands the message, while a leaked slot is recoverable — reserve()
15808
+ * re-admits a message that already holds its own slot, and the reconcile loop removes
15809
+ * members that are no longer in flight.
15810
+ */
15811
+ async #releaseConcurrencySafely(descriptor, messageId, context) {
15812
+ if (!this.concurrencyManager) return;
15813
+ try {
15814
+ await this.concurrencyManager.release(descriptor, messageId);
15815
+ } catch (error) {
15816
+ this.logger.error("Failed to release concurrency slot, leaving it for the reconciler", {
15817
+ context,
15818
+ messageId,
15819
+ queueId: descriptor.id,
15820
+ tenantId: descriptor.tenantId,
15821
+ error: error instanceof Error ? error.message : String(error)
15822
+ });
15823
+ }
15824
+ }
15825
+ /**
15826
+ * Release a concurrency slot for a message we can no longer describe, deriving the
15827
+ * group from the queue id alone. Used on paths that bail out before the stored
15828
+ * message is available, where the slot would otherwise be held with nothing left
15829
+ * to reclaim it.
15830
+ */
15831
+ async #releaseOrphanedConcurrency(messageId, queueId) {
15832
+ if (!this.concurrencyManager) return;
15833
+ const descriptor = this.queueDescriptorCache.get(queueId) ?? {
15834
+ id: queueId,
15835
+ tenantId: this.keys.extractTenantId(queueId),
15836
+ metadata: {}
15837
+ };
15838
+ await this.#releaseConcurrencySafely(descriptor, messageId, "failMessage:missing-record");
15839
+ }
15840
+ /**
15655
15841
  * Mark a message as failed. This will trigger retry logic if configured,
15656
15842
  * or move the message to the dead letter queue.
15657
15843
  *
@@ -15670,6 +15856,7 @@ var FairQueue = class {
15670
15856
  messageId,
15671
15857
  queueId
15672
15858
  });
15859
+ await this.#releaseOrphanedConcurrency(messageId, queueId);
15673
15860
  return;
15674
15861
  }
15675
15862
  let storedMessage;
@@ -15680,6 +15867,7 @@ var FairQueue = class {
15680
15867
  messageId,
15681
15868
  queueId
15682
15869
  });
15870
+ await this.#releaseOrphanedConcurrency(messageId, queueId);
15683
15871
  return;
15684
15872
  }
15685
15873
  const descriptor = this.queueDescriptorCache.get(queueId) ?? {
@@ -15699,10 +15887,10 @@ var FairQueue = class {
15699
15887
  ...storedMessage,
15700
15888
  attempt: storedMessage.attempt + 1
15701
15889
  };
15890
+ await this.#releaseConcurrencySafely(descriptor, storedMessage.id, "retry");
15702
15891
  const tenantQueueIndexKey = this.keys.tenantQueueIndexKey(descriptor.tenantId);
15703
15892
  const dispatchKey = this.keys.dispatchKey(dispatchShardId);
15704
15893
  await this.visibilityManager.release(storedMessage.id, queueId, queueKey, queueItemsKey, tenantQueueIndexKey, dispatchKey, descriptor.tenantId, Date.now() + nextDelay, JSON.stringify(updatedMessage));
15705
- if (this.concurrencyManager) await this.concurrencyManager.release(descriptor, storedMessage.id);
15706
15894
  this.telemetry.recordRetry();
15707
15895
  this.logger.debug("Message scheduled for retry", {
15708
15896
  messageId: storedMessage.id,
@@ -15713,8 +15901,8 @@ var FairQueue = class {
15713
15901
  return;
15714
15902
  }
15715
15903
  }
15904
+ await this.#releaseConcurrencySafely(descriptor, storedMessage.id, "deadLetter");
15716
15905
  await this.#moveToDeadLetterQueue(storedMessage, error?.message);
15717
- if (this.concurrencyManager) await this.concurrencyManager.release(descriptor, storedMessage.id);
15718
15906
  }
15719
15907
  async #moveToDeadLetterQueue(storedMessage, errorMessage) {
15720
15908
  if (!this.deadLetterQueueEnabled) {
@@ -15763,9 +15951,37 @@ var FairQueue = class {
15763
15951
  throw error;
15764
15952
  }
15765
15953
  }
15954
+ /**
15955
+ * Free every timed-out message's concurrency slot in one pipeline before the batch is
15956
+ * requeued. A failed release never blocks the requeue: holding the message in-flight
15957
+ * would stall it behind a persistent release failure, while the leaked slot self-heals
15958
+ * when the message is re-admitted (reserve is idempotent for a message that already
15959
+ * holds its own slot) or via the reconcile loop.
15960
+ */
15961
+ async #releaseReclaimedConcurrency(messages) {
15962
+ if (!this.concurrencyManager || messages.length === 0) return;
15963
+ const descriptorFor = (message) => ({
15964
+ id: message.queueId,
15965
+ tenantId: message.tenantId,
15966
+ metadata: message.metadata ?? {}
15967
+ });
15968
+ try {
15969
+ await this.concurrencyManager.releaseBatch(messages.map((message) => ({
15970
+ queue: descriptorFor(message),
15971
+ messageId: message.messageId
15972
+ })));
15973
+ return;
15974
+ } catch (error) {
15975
+ this.logger.error("Batch concurrency release failed, retrying message by message", {
15976
+ count: messages.length,
15977
+ error: error instanceof Error ? error.message : String(error)
15978
+ });
15979
+ }
15980
+ for (const message of messages) await this.#releaseConcurrencySafely(descriptorFor(message), message.messageId, "reclaim");
15981
+ }
15766
15982
  async #reclaimTimedOutMessages() {
15767
15983
  let totalReclaimed = 0;
15768
- for (let shardId = 0; shardId < this.shardCount; shardId++) {
15984
+ for (let shardId = 0; shardId < this.shardCount; shardId++) try {
15769
15985
  const reclaimedMessages = await this.visibilityManager.reclaimTimedOut(shardId, (queueId) => {
15770
15986
  const tenantId = this.keys.extractTenantId(queueId);
15771
15987
  const dispatchShardId = this.tenantDispatch.getShardForTenant(tenantId);
@@ -15776,28 +15992,51 @@ var FairQueue = class {
15776
15992
  dispatchKey: this.keys.dispatchKey(dispatchShardId),
15777
15993
  tenantId
15778
15994
  };
15779
- });
15780
- if (reclaimedMessages.length > 0) {
15781
- if (this.concurrencyManager) try {
15782
- await this.concurrencyManager.releaseBatch(reclaimedMessages.map((msg) => ({
15783
- queue: {
15784
- id: msg.queueId,
15785
- tenantId: msg.tenantId,
15786
- metadata: msg.metadata ?? {}
15787
- },
15788
- messageId: msg.messageId
15789
- })));
15790
- } catch (error) {
15791
- this.logger.error("Failed to release concurrency for reclaimed messages", {
15792
- count: reclaimedMessages.length,
15793
- error: error instanceof Error ? error.message : String(error)
15794
- });
15795
- }
15796
- }
15995
+ }, this.#releaseReclaimedConcurrency.bind(this));
15797
15996
  totalReclaimed += reclaimedMessages.length;
15997
+ } catch (error) {
15998
+ this.logger.error("Failed to reclaim shard, leaving messages in-flight for the next tick", {
15999
+ shardId,
16000
+ error: error instanceof Error ? error.message : String(error)
16001
+ });
15798
16002
  }
15799
16003
  if (totalReclaimed > 0) this.logger.info("Reclaimed timed-out messages", { count: totalReclaimed });
15800
16004
  }
16005
+ /**
16006
+ * The initial delay is jittered so instances sharing a Redis do not all sweep at once.
16007
+ */
16008
+ async #runReconcileLoop() {
16009
+ try {
16010
+ await setTimeout$1(Math.random() * this.reconcileIntervalMs, null, { signal: this.abortController.signal });
16011
+ for await (const _ of setInterval$1(this.reconcileIntervalMs, null, { signal: this.abortController.signal })) try {
16012
+ await this.#reconcileConcurrency();
16013
+ } catch (error) {
16014
+ this.logger.error("Concurrency reconcile loop error", { error: error instanceof Error ? error.message : String(error) });
16015
+ }
16016
+ } catch (error) {
16017
+ if (isAbortError(error)) {
16018
+ this.logger.debug("Concurrency reconcile loop aborted");
16019
+ return;
16020
+ }
16021
+ throw error;
16022
+ }
16023
+ }
16024
+ /**
16025
+ * Heal orphaned concurrency slots: remove any concurrency set member whose message is
16026
+ * not registered in-flight on any shard. This is the recovery path for slots leaked by
16027
+ * failed releases, which otherwise count against a tenant's limit forever and can
16028
+ * wedge every queue the tenant owns.
16029
+ */
16030
+ async #reconcileConcurrency() {
16031
+ if (!this.concurrencyManager) return;
16032
+ const inflightDataKeys = Array.from({ length: this.shardCount }, (_, shardId) => this.keys.inflightDataKey(shardId));
16033
+ const { scannedSets, removed } = await this.concurrencyManager.sweepOrphanedSlots(inflightDataKeys);
16034
+ if (removed.length > 0) this.logger.info("Reconciled orphaned concurrency slots", {
16035
+ count: removed.length,
16036
+ scannedSets,
16037
+ messageIds: removed.slice(0, 50)
16038
+ });
16039
+ }
15801
16040
  #isInCooloff(queueId) {
15802
16041
  const state = this.queueCooloffStates.get(queueId);
15803
16042
  if (!state) return false;