@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.cjs +340 -101
- 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 +341 -102
- package/dist/index.js.map +1 -1
- package/package.json +3 -5
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.
|
|
9650
|
-
|
|
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.
|
|
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 (
|
|
9659
|
-
|
|
9660
|
-
|
|
9661
|
-
|
|
9662
|
-
|
|
9663
|
-
|
|
9664
|
-
poolOffset
|
|
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
|
-
|
|
12080
|
-
|
|
12081
|
-
|
|
12082
|
-
|
|
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
|
-
|
|
12288
|
-
|
|
12289
|
-
|
|
12290
|
-
|
|
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
|
-
* @
|
|
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
|
|
13464
|
+
const candidates = [];
|
|
13333
13465
|
for (let i = 0; i < timedOut.length; i += 2) {
|
|
13334
13466
|
const member = timedOut[i];
|
|
13335
|
-
const
|
|
13336
|
-
if (!member || !
|
|
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
|
-
|
|
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:
|
|
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([
|
|
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 =
|
|
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 =
|
|
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;
|