@trigger.dev/redis-worker 4.5.11 → 4.5.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +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.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.
|
|
9640
|
-
|
|
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.
|
|
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 (
|
|
9649
|
-
|
|
9650
|
-
|
|
9651
|
-
|
|
9652
|
-
|
|
9653
|
-
|
|
9654
|
-
poolOffset
|
|
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
|
-
|
|
12070
|
-
|
|
12071
|
-
|
|
12072
|
-
|
|
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
|
-
|
|
12278
|
-
|
|
12279
|
-
|
|
12280
|
-
|
|
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
|
-
* @
|
|
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
|
|
13454
|
+
const candidates = [];
|
|
13323
13455
|
for (let i = 0; i < timedOut.length; i += 2) {
|
|
13324
13456
|
const member = timedOut[i];
|
|
13325
|
-
const
|
|
13326
|
-
if (!member || !
|
|
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
|
-
|
|
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:
|
|
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([
|
|
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 =
|
|
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 =
|
|
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;
|