@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 +308 -84
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +33 -2
- package/dist/index.d.ts +33 -2
- package/dist/index.js +309 -85
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
12288
|
-
|
|
12289
|
-
|
|
12290
|
-
|
|
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
|
-
* @
|
|
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
|
|
13449
|
+
const candidates = [];
|
|
13333
13450
|
for (let i = 0; i < timedOut.length; i += 2) {
|
|
13334
13451
|
const member = timedOut[i];
|
|
13335
|
-
const
|
|
13336
|
-
if (!member || !
|
|
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
|
-
|
|
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:
|
|
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([
|
|
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 =
|
|
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 =
|
|
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;
|