@trigger.dev/redis-worker 4.4.5 → 4.5.0-rc.0
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 +585 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +151 -1
- package/dist/index.d.ts +151 -1
- package/dist/index.js +579 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -9384,12 +9384,20 @@ var require_built3 = __commonJS({
|
|
|
9384
9384
|
// ../../internal-packages/redis/src/index.ts
|
|
9385
9385
|
var import_ioredis = __toESM(require_built3());
|
|
9386
9386
|
__toESM(require_built3());
|
|
9387
|
+
function defaultReconnectOnError(err) {
|
|
9388
|
+
const msg = err.message ?? "";
|
|
9389
|
+
if (msg.startsWith("READONLY") || msg.startsWith("LOADING") || msg.startsWith("UNBLOCKED")) {
|
|
9390
|
+
return 2;
|
|
9391
|
+
}
|
|
9392
|
+
return false;
|
|
9393
|
+
}
|
|
9387
9394
|
var defaultOptions = {
|
|
9388
9395
|
retryStrategy: (times) => {
|
|
9389
9396
|
const delay = Math.min(times * 50, 1e3);
|
|
9390
9397
|
return delay;
|
|
9391
9398
|
},
|
|
9392
|
-
maxRetriesPerRequest: process.env.GITHUB_ACTIONS ? 50 : process.env.VITEST ? 5 : 20
|
|
9399
|
+
maxRetriesPerRequest: process.env.GITHUB_ACTIONS ? 50 : process.env.VITEST ? 5 : 20,
|
|
9400
|
+
reconnectOnError: defaultReconnectOnError
|
|
9393
9401
|
};
|
|
9394
9402
|
var logger = new logger$1.Logger("Redis", "debug");
|
|
9395
9403
|
function createRedisClient(options, handlers) {
|
|
@@ -16353,9 +16361,581 @@ end
|
|
|
16353
16361
|
}
|
|
16354
16362
|
}
|
|
16355
16363
|
};
|
|
16364
|
+
var BufferEntryStatus = zod.z.enum(["QUEUED", "DRAINING", "FAILED"]);
|
|
16365
|
+
var BufferEntryError = zod.z.object({
|
|
16366
|
+
code: zod.z.string(),
|
|
16367
|
+
message: zod.z.string()
|
|
16368
|
+
});
|
|
16369
|
+
var stringToInt = zod.z.string().transform((v, ctx) => {
|
|
16370
|
+
const n = Number(v);
|
|
16371
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
16372
|
+
ctx.addIssue({ code: zod.z.ZodIssueCode.custom, message: "expected non-negative integer string" });
|
|
16373
|
+
return zod.z.NEVER;
|
|
16374
|
+
}
|
|
16375
|
+
return n;
|
|
16376
|
+
});
|
|
16377
|
+
var stringToDate = zod.z.string().transform((v, ctx) => {
|
|
16378
|
+
const d = new Date(v);
|
|
16379
|
+
if (Number.isNaN(d.getTime())) {
|
|
16380
|
+
ctx.addIssue({ code: zod.z.ZodIssueCode.custom, message: "expected ISO date string" });
|
|
16381
|
+
return zod.z.NEVER;
|
|
16382
|
+
}
|
|
16383
|
+
return d;
|
|
16384
|
+
});
|
|
16385
|
+
var stringToError = zod.z.string().transform((v, ctx) => {
|
|
16386
|
+
try {
|
|
16387
|
+
return BufferEntryError.parse(JSON.parse(v));
|
|
16388
|
+
} catch {
|
|
16389
|
+
ctx.addIssue({ code: zod.z.ZodIssueCode.custom, message: "expected JSON-encoded BufferEntryError" });
|
|
16390
|
+
return zod.z.NEVER;
|
|
16391
|
+
}
|
|
16392
|
+
});
|
|
16393
|
+
var BufferEntrySchema = zod.z.object({
|
|
16394
|
+
runId: zod.z.string().min(1),
|
|
16395
|
+
envId: zod.z.string().min(1),
|
|
16396
|
+
orgId: zod.z.string().min(1),
|
|
16397
|
+
payload: zod.z.string(),
|
|
16398
|
+
status: BufferEntryStatus,
|
|
16399
|
+
attempts: stringToInt,
|
|
16400
|
+
createdAt: stringToDate,
|
|
16401
|
+
lastError: stringToError.optional()
|
|
16402
|
+
});
|
|
16403
|
+
function serialiseSnapshot(snapshot) {
|
|
16404
|
+
return JSON.stringify(snapshot);
|
|
16405
|
+
}
|
|
16406
|
+
function deserialiseSnapshot(serialised) {
|
|
16407
|
+
return JSON.parse(serialised);
|
|
16408
|
+
}
|
|
16409
|
+
|
|
16410
|
+
// src/mollifier/buffer.ts
|
|
16411
|
+
var MollifierBuffer = class {
|
|
16412
|
+
redis;
|
|
16413
|
+
entryTtlSeconds;
|
|
16414
|
+
logger;
|
|
16415
|
+
constructor(options) {
|
|
16416
|
+
this.entryTtlSeconds = options.entryTtlSeconds;
|
|
16417
|
+
this.logger = options.logger ?? new logger$1.Logger("MollifierBuffer", "debug");
|
|
16418
|
+
this.redis = createRedisClient(
|
|
16419
|
+
{
|
|
16420
|
+
...options.redisOptions,
|
|
16421
|
+
retryStrategy(times) {
|
|
16422
|
+
const delay = Math.min(times * 50, 1e3);
|
|
16423
|
+
return delay;
|
|
16424
|
+
},
|
|
16425
|
+
maxRetriesPerRequest: 20
|
|
16426
|
+
},
|
|
16427
|
+
{
|
|
16428
|
+
onError: (error) => {
|
|
16429
|
+
this.logger.error("MollifierBuffer redis client error:", { error });
|
|
16430
|
+
}
|
|
16431
|
+
}
|
|
16432
|
+
);
|
|
16433
|
+
this.#registerCommands();
|
|
16434
|
+
}
|
|
16435
|
+
// Returns true if the entry was newly written; false if a duplicate runId
|
|
16436
|
+
// was already buffered (idempotent no-op). Callers can use the boolean to
|
|
16437
|
+
// record a duplicate-accept metric without affecting buffer state.
|
|
16438
|
+
async accept(input) {
|
|
16439
|
+
const entryKey = `mollifier:entries:${input.runId}`;
|
|
16440
|
+
const queueKey = `mollifier:queue:${input.envId}`;
|
|
16441
|
+
const orgsKey = "mollifier:orgs";
|
|
16442
|
+
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
16443
|
+
const result = await this.redis.acceptMollifierEntry(
|
|
16444
|
+
entryKey,
|
|
16445
|
+
queueKey,
|
|
16446
|
+
orgsKey,
|
|
16447
|
+
input.runId,
|
|
16448
|
+
input.envId,
|
|
16449
|
+
input.orgId,
|
|
16450
|
+
input.payload,
|
|
16451
|
+
createdAt,
|
|
16452
|
+
String(this.entryTtlSeconds),
|
|
16453
|
+
"mollifier:org-envs:"
|
|
16454
|
+
);
|
|
16455
|
+
return result === 1;
|
|
16456
|
+
}
|
|
16457
|
+
async pop(envId) {
|
|
16458
|
+
const queueKey = `mollifier:queue:${envId}`;
|
|
16459
|
+
const orgsKey = "mollifier:orgs";
|
|
16460
|
+
const entryPrefix = "mollifier:entries:";
|
|
16461
|
+
const encoded = await this.redis.popAndMarkDraining(
|
|
16462
|
+
queueKey,
|
|
16463
|
+
orgsKey,
|
|
16464
|
+
entryPrefix,
|
|
16465
|
+
envId,
|
|
16466
|
+
"mollifier:org-envs:"
|
|
16467
|
+
);
|
|
16468
|
+
if (!encoded) return null;
|
|
16469
|
+
let raw;
|
|
16470
|
+
try {
|
|
16471
|
+
raw = JSON.parse(encoded);
|
|
16472
|
+
} catch {
|
|
16473
|
+
this.logger.error("MollifierBuffer.pop: failed to parse script result", { envId });
|
|
16474
|
+
return null;
|
|
16475
|
+
}
|
|
16476
|
+
const parsed = BufferEntrySchema.safeParse(raw);
|
|
16477
|
+
if (!parsed.success) {
|
|
16478
|
+
this.logger.error("MollifierBuffer.pop: invalid entry shape", {
|
|
16479
|
+
envId,
|
|
16480
|
+
errors: parsed.error.flatten()
|
|
16481
|
+
});
|
|
16482
|
+
return null;
|
|
16483
|
+
}
|
|
16484
|
+
return parsed.data;
|
|
16485
|
+
}
|
|
16486
|
+
async getEntry(runId) {
|
|
16487
|
+
const raw = await this.redis.hgetall(`mollifier:entries:${runId}`);
|
|
16488
|
+
if (!raw || Object.keys(raw).length === 0) return null;
|
|
16489
|
+
const parsed = BufferEntrySchema.safeParse(raw);
|
|
16490
|
+
if (!parsed.success) {
|
|
16491
|
+
this.logger.error("MollifierBuffer.getEntry: invalid entry shape", {
|
|
16492
|
+
runId,
|
|
16493
|
+
errors: parsed.error.flatten()
|
|
16494
|
+
});
|
|
16495
|
+
return null;
|
|
16496
|
+
}
|
|
16497
|
+
return parsed.data;
|
|
16498
|
+
}
|
|
16499
|
+
// Drainer walks these two methods to schedule pops with org-level
|
|
16500
|
+
// fairness: one env per org per tick. The Lua scripts maintain both
|
|
16501
|
+
// sets atomically with the per-env queues, so an org/env appears here
|
|
16502
|
+
// exactly when at least one of its envs has a queued entry.
|
|
16503
|
+
async listOrgs() {
|
|
16504
|
+
return this.redis.smembers("mollifier:orgs");
|
|
16505
|
+
}
|
|
16506
|
+
async listEnvsForOrg(orgId) {
|
|
16507
|
+
return this.redis.smembers(`mollifier:org-envs:${orgId}`);
|
|
16508
|
+
}
|
|
16509
|
+
async ack(runId) {
|
|
16510
|
+
await this.redis.del(`mollifier:entries:${runId}`);
|
|
16511
|
+
}
|
|
16512
|
+
async requeue(runId) {
|
|
16513
|
+
await this.redis.requeueMollifierEntry(
|
|
16514
|
+
`mollifier:entries:${runId}`,
|
|
16515
|
+
"mollifier:orgs",
|
|
16516
|
+
"mollifier:queue:",
|
|
16517
|
+
runId,
|
|
16518
|
+
"mollifier:org-envs:"
|
|
16519
|
+
);
|
|
16520
|
+
}
|
|
16521
|
+
// Returns true if the entry transitioned to FAILED; false if the entry no
|
|
16522
|
+
// longer exists (TTL expired between pop and fail). Caller can use the
|
|
16523
|
+
// boolean to skip downstream FAILED handling for ghost entries.
|
|
16524
|
+
async fail(runId, error) {
|
|
16525
|
+
const result = await this.redis.failMollifierEntry(
|
|
16526
|
+
`mollifier:entries:${runId}`,
|
|
16527
|
+
JSON.stringify(error)
|
|
16528
|
+
);
|
|
16529
|
+
return result === 1;
|
|
16530
|
+
}
|
|
16531
|
+
async getEntryTtlSeconds(runId) {
|
|
16532
|
+
return this.redis.ttl(`mollifier:entries:${runId}`);
|
|
16533
|
+
}
|
|
16534
|
+
async evaluateTrip(envId, options) {
|
|
16535
|
+
const rateKey = `mollifier:rate:${envId}`;
|
|
16536
|
+
const trippedKey = `mollifier:tripped:${envId}`;
|
|
16537
|
+
const result = await this.redis.mollifierEvaluateTrip(
|
|
16538
|
+
rateKey,
|
|
16539
|
+
trippedKey,
|
|
16540
|
+
String(options.windowMs),
|
|
16541
|
+
String(options.threshold),
|
|
16542
|
+
String(options.holdMs)
|
|
16543
|
+
);
|
|
16544
|
+
return { count: result[0], tripped: result[1] === 1 };
|
|
16545
|
+
}
|
|
16546
|
+
async close() {
|
|
16547
|
+
await this.redis.quit();
|
|
16548
|
+
}
|
|
16549
|
+
#registerCommands() {
|
|
16550
|
+
this.redis.defineCommand("acceptMollifierEntry", {
|
|
16551
|
+
numberOfKeys: 3,
|
|
16552
|
+
lua: `
|
|
16553
|
+
local entryKey = KEYS[1]
|
|
16554
|
+
local queueKey = KEYS[2]
|
|
16555
|
+
local orgsKey = KEYS[3]
|
|
16556
|
+
local runId = ARGV[1]
|
|
16557
|
+
local envId = ARGV[2]
|
|
16558
|
+
local orgId = ARGV[3]
|
|
16559
|
+
local payload = ARGV[4]
|
|
16560
|
+
local createdAt = ARGV[5]
|
|
16561
|
+
local ttlSeconds = tonumber(ARGV[6])
|
|
16562
|
+
local orgEnvsPrefix = ARGV[7]
|
|
16563
|
+
|
|
16564
|
+
-- Idempotent: refuse if an entry for this runId already exists in any
|
|
16565
|
+
-- state. Caller-side dedup is also enforced via API idempotency keys,
|
|
16566
|
+
-- but the buffer must not double-enqueue if a caller retries.
|
|
16567
|
+
if redis.call('EXISTS', entryKey) == 1 then
|
|
16568
|
+
return 0
|
|
16569
|
+
end
|
|
16570
|
+
|
|
16571
|
+
redis.call('HSET', entryKey,
|
|
16572
|
+
'runId', runId,
|
|
16573
|
+
'envId', envId,
|
|
16574
|
+
'orgId', orgId,
|
|
16575
|
+
'payload', payload,
|
|
16576
|
+
'status', 'QUEUED',
|
|
16577
|
+
'attempts', '0',
|
|
16578
|
+
'createdAt', createdAt)
|
|
16579
|
+
redis.call('EXPIRE', entryKey, ttlSeconds)
|
|
16580
|
+
redis.call('LPUSH', queueKey, runId)
|
|
16581
|
+
-- Org-level membership: maintained atomically with the per-env
|
|
16582
|
+
-- queue so the drainer can walk orgs \u2192 envs-for-org and
|
|
16583
|
+
-- schedule one env per org per tick. SADDs are idempotent if the
|
|
16584
|
+
-- org/env are already tracked.
|
|
16585
|
+
redis.call('SADD', orgsKey, orgId)
|
|
16586
|
+
redis.call('SADD', orgEnvsPrefix .. orgId, envId)
|
|
16587
|
+
return 1
|
|
16588
|
+
`
|
|
16589
|
+
});
|
|
16590
|
+
this.redis.defineCommand("requeueMollifierEntry", {
|
|
16591
|
+
numberOfKeys: 2,
|
|
16592
|
+
lua: `
|
|
16593
|
+
local entryKey = KEYS[1]
|
|
16594
|
+
local orgsKey = KEYS[2]
|
|
16595
|
+
local queuePrefix = ARGV[1]
|
|
16596
|
+
local runId = ARGV[2]
|
|
16597
|
+
local orgEnvsPrefix = ARGV[3]
|
|
16598
|
+
|
|
16599
|
+
local envId = redis.call('HGET', entryKey, 'envId')
|
|
16600
|
+
local orgId = redis.call('HGET', entryKey, 'orgId')
|
|
16601
|
+
if not envId then
|
|
16602
|
+
return 0
|
|
16603
|
+
end
|
|
16604
|
+
|
|
16605
|
+
local currentAttempts = redis.call('HGET', entryKey, 'attempts')
|
|
16606
|
+
local nextAttempts = tonumber(currentAttempts or '0') + 1
|
|
16607
|
+
|
|
16608
|
+
redis.call('HSET', entryKey, 'status', 'QUEUED', 'attempts', tostring(nextAttempts))
|
|
16609
|
+
redis.call('LPUSH', queuePrefix .. envId, runId)
|
|
16610
|
+
-- Re-track the org/env: pop may have SREM'd them when the queue
|
|
16611
|
+
-- last emptied. SADDs are idempotent if the values are still
|
|
16612
|
+
-- present.
|
|
16613
|
+
if orgId then
|
|
16614
|
+
redis.call('SADD', orgsKey, orgId)
|
|
16615
|
+
redis.call('SADD', orgEnvsPrefix .. orgId, envId)
|
|
16616
|
+
end
|
|
16617
|
+
return 1
|
|
16618
|
+
`
|
|
16619
|
+
});
|
|
16620
|
+
this.redis.defineCommand("popAndMarkDraining", {
|
|
16621
|
+
numberOfKeys: 2,
|
|
16622
|
+
lua: `
|
|
16623
|
+
local queueKey = KEYS[1]
|
|
16624
|
+
local orgsKey = KEYS[2]
|
|
16625
|
+
local entryPrefix = ARGV[1]
|
|
16626
|
+
local envId = ARGV[2]
|
|
16627
|
+
local orgEnvsPrefix = ARGV[3]
|
|
16628
|
+
|
|
16629
|
+
-- Helper: prune org-level membership when an env's queue empties.
|
|
16630
|
+
-- Called only from the success branch where we know orgId from the
|
|
16631
|
+
-- popped entry. The no-runId branch below can't reach this because
|
|
16632
|
+
-- it has no entry to read orgId from \u2014 accept any stale org-envs
|
|
16633
|
+
-- entries that result (bounded by env count, recovered next accept).
|
|
16634
|
+
local function pruneOrgMembership(orgId)
|
|
16635
|
+
if not orgId then return end
|
|
16636
|
+
local orgEnvsKey = orgEnvsPrefix .. orgId
|
|
16637
|
+
redis.call('SREM', orgEnvsKey, envId)
|
|
16638
|
+
if redis.call('SCARD', orgEnvsKey) == 0 then
|
|
16639
|
+
redis.call('SREM', orgsKey, orgId)
|
|
16640
|
+
end
|
|
16641
|
+
end
|
|
16642
|
+
|
|
16643
|
+
-- Loop to skip orphan queue references \u2014 runIds whose entry hash has
|
|
16644
|
+
-- expired (TTL hit). HSET on a missing key would CREATE a partial
|
|
16645
|
+
-- hash without a TTL, leaking memory. The loop is bounded by queue
|
|
16646
|
+
-- length; entire Lua script remains atomic.
|
|
16647
|
+
while true do
|
|
16648
|
+
local runId = redis.call('RPOP', queueKey)
|
|
16649
|
+
if not runId then
|
|
16650
|
+
-- Queue is empty AND we have no entry to read orgId from, so
|
|
16651
|
+
-- skip org-level cleanup. Stale org-envs entries are bounded
|
|
16652
|
+
-- by env count and recovered on the next accept.
|
|
16653
|
+
return nil
|
|
16654
|
+
end
|
|
16655
|
+
|
|
16656
|
+
local entryKey = entryPrefix .. runId
|
|
16657
|
+
if redis.call('EXISTS', entryKey) == 1 then
|
|
16658
|
+
redis.call('HSET', entryKey, 'status', 'DRAINING')
|
|
16659
|
+
local raw = redis.call('HGETALL', entryKey)
|
|
16660
|
+
local result = {}
|
|
16661
|
+
for i = 1, #raw, 2 do
|
|
16662
|
+
result[raw[i]] = raw[i + 1]
|
|
16663
|
+
end
|
|
16664
|
+
-- Prune org-level membership if this pop drained the queue.
|
|
16665
|
+
-- Atomic with the RPOP above \u2014 a concurrent accept AFTER this
|
|
16666
|
+
-- script will SADD both back along with its LPUSH.
|
|
16667
|
+
if redis.call('LLEN', queueKey) == 0 then
|
|
16668
|
+
pruneOrgMembership(result['orgId'])
|
|
16669
|
+
end
|
|
16670
|
+
return cjson.encode(result)
|
|
16671
|
+
end
|
|
16672
|
+
-- Orphan queue reference: entry TTL expired while runId was queued.
|
|
16673
|
+
-- Discard the reference and loop to the next.
|
|
16674
|
+
end
|
|
16675
|
+
`
|
|
16676
|
+
});
|
|
16677
|
+
this.redis.defineCommand("failMollifierEntry", {
|
|
16678
|
+
numberOfKeys: 1,
|
|
16679
|
+
lua: `
|
|
16680
|
+
local entryKey = KEYS[1]
|
|
16681
|
+
local errorPayload = ARGV[1]
|
|
16682
|
+
|
|
16683
|
+
-- Guard: never create a partial entry. If the hash expired between
|
|
16684
|
+
-- pop and fail, the run is gone \u2014 nothing to mark FAILED.
|
|
16685
|
+
if redis.call('EXISTS', entryKey) == 0 then
|
|
16686
|
+
return 0
|
|
16687
|
+
end
|
|
16688
|
+
|
|
16689
|
+
redis.call('HSET', entryKey, 'status', 'FAILED', 'lastError', errorPayload)
|
|
16690
|
+
return 1
|
|
16691
|
+
`
|
|
16692
|
+
});
|
|
16693
|
+
this.redis.defineCommand("mollifierEvaluateTrip", {
|
|
16694
|
+
numberOfKeys: 2,
|
|
16695
|
+
lua: `
|
|
16696
|
+
local rateKey = KEYS[1]
|
|
16697
|
+
local trippedKey = KEYS[2]
|
|
16698
|
+
local windowMs = tonumber(ARGV[1])
|
|
16699
|
+
local threshold = tonumber(ARGV[2])
|
|
16700
|
+
local holdMs = tonumber(ARGV[3])
|
|
16701
|
+
|
|
16702
|
+
local count = redis.call('INCR', rateKey)
|
|
16703
|
+
if count == 1 then
|
|
16704
|
+
redis.call('PEXPIRE', rateKey, windowMs)
|
|
16705
|
+
end
|
|
16706
|
+
|
|
16707
|
+
if count > threshold then
|
|
16708
|
+
redis.call('PSETEX', trippedKey, holdMs, '1')
|
|
16709
|
+
end
|
|
16710
|
+
|
|
16711
|
+
local tripped = redis.call('EXISTS', trippedKey)
|
|
16712
|
+
return {count, tripped}
|
|
16713
|
+
`
|
|
16714
|
+
});
|
|
16715
|
+
}
|
|
16716
|
+
};
|
|
16717
|
+
var MollifierDrainer = class {
|
|
16718
|
+
buffer;
|
|
16719
|
+
handler;
|
|
16720
|
+
maxAttempts;
|
|
16721
|
+
isRetryable;
|
|
16722
|
+
pollIntervalMs;
|
|
16723
|
+
maxOrgsPerTick;
|
|
16724
|
+
logger;
|
|
16725
|
+
limit;
|
|
16726
|
+
// Rotation state. `orgCursor` advances through the active-orgs list.
|
|
16727
|
+
// Each org has its own internal cursor in `perOrgEnvCursors` for
|
|
16728
|
+
// cycling through that org's envs. Both reset on `start()`.
|
|
16729
|
+
orgCursor = 0;
|
|
16730
|
+
perOrgEnvCursors = /* @__PURE__ */ new Map();
|
|
16731
|
+
isRunning = false;
|
|
16732
|
+
stopping = false;
|
|
16733
|
+
loopPromise = null;
|
|
16734
|
+
constructor(options) {
|
|
16735
|
+
this.buffer = options.buffer;
|
|
16736
|
+
this.handler = options.handler;
|
|
16737
|
+
this.maxAttempts = options.maxAttempts;
|
|
16738
|
+
this.isRetryable = options.isRetryable;
|
|
16739
|
+
this.pollIntervalMs = options.pollIntervalMs ?? 100;
|
|
16740
|
+
this.maxOrgsPerTick = options.maxOrgsPerTick ?? 500;
|
|
16741
|
+
this.logger = options.logger ?? new logger$1.Logger("MollifierDrainer", "debug");
|
|
16742
|
+
this.limit = pLimit(options.concurrency);
|
|
16743
|
+
}
|
|
16744
|
+
async runOnce() {
|
|
16745
|
+
const orgs = await this.buffer.listOrgs();
|
|
16746
|
+
if (orgs.length === 0) return { drained: 0, failed: 0 };
|
|
16747
|
+
const orgSlice = this.takeOrgSlice(orgs);
|
|
16748
|
+
const envsByOrg = await Promise.all(
|
|
16749
|
+
orgSlice.map((orgId) => this.buffer.listEnvsForOrg(orgId))
|
|
16750
|
+
);
|
|
16751
|
+
const targets = [];
|
|
16752
|
+
for (let i = 0; i < orgSlice.length; i++) {
|
|
16753
|
+
const orgId = orgSlice[i];
|
|
16754
|
+
const envsForOrg = envsByOrg[i];
|
|
16755
|
+
if (envsForOrg.length === 0) continue;
|
|
16756
|
+
const envId = this.pickEnvForOrg(orgId, envsForOrg);
|
|
16757
|
+
targets.push(envId);
|
|
16758
|
+
}
|
|
16759
|
+
const inflight = [];
|
|
16760
|
+
for (const envId of targets) {
|
|
16761
|
+
inflight.push(this.limit(() => this.processOneFromEnv(envId)));
|
|
16762
|
+
}
|
|
16763
|
+
const results = await Promise.all(inflight);
|
|
16764
|
+
return {
|
|
16765
|
+
drained: results.filter((r) => r === "drained").length,
|
|
16766
|
+
failed: results.filter((r) => r === "failed").length
|
|
16767
|
+
};
|
|
16768
|
+
}
|
|
16769
|
+
start() {
|
|
16770
|
+
if (this.isRunning) return;
|
|
16771
|
+
this.isRunning = true;
|
|
16772
|
+
this.stopping = false;
|
|
16773
|
+
this.orgCursor = 0;
|
|
16774
|
+
this.perOrgEnvCursors = /* @__PURE__ */ new Map();
|
|
16775
|
+
this.loopPromise = this.loop();
|
|
16776
|
+
}
|
|
16777
|
+
// Signal the loop to exit (`stopping = true`) and wait for it. With no
|
|
16778
|
+
// timeout, wait indefinitely for the in-flight `runOnce` and its handlers
|
|
16779
|
+
// to settle — same semantic as FairQueue / BatchQueue's `stop()`. With a
|
|
16780
|
+
// timeout, race the loop promise against a deadline so a hung handler
|
|
16781
|
+
// can't wedge the process past its termination grace period.
|
|
16782
|
+
async stop(options = {}) {
|
|
16783
|
+
if (!this.isRunning || !this.loopPromise) return;
|
|
16784
|
+
this.stopping = true;
|
|
16785
|
+
if (options.timeoutMs == null) {
|
|
16786
|
+
await this.loopPromise;
|
|
16787
|
+
return;
|
|
16788
|
+
}
|
|
16789
|
+
const timeoutSentinel = Symbol("mollifier.stop.timeout");
|
|
16790
|
+
let timeoutHandle;
|
|
16791
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
16792
|
+
timeoutHandle = setTimeout(() => resolve(timeoutSentinel), options.timeoutMs);
|
|
16793
|
+
});
|
|
16794
|
+
try {
|
|
16795
|
+
const winner = await Promise.race([
|
|
16796
|
+
this.loopPromise.then(() => "done"),
|
|
16797
|
+
timeoutPromise
|
|
16798
|
+
]);
|
|
16799
|
+
if (winner === timeoutSentinel) {
|
|
16800
|
+
this.logger.warn(
|
|
16801
|
+
"MollifierDrainer.stop: deadline exceeded; returning while loop iteration is in flight",
|
|
16802
|
+
{ timeoutMs: options.timeoutMs }
|
|
16803
|
+
);
|
|
16804
|
+
}
|
|
16805
|
+
} finally {
|
|
16806
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
16807
|
+
}
|
|
16808
|
+
}
|
|
16809
|
+
// Transient Redis errors (e.g. a connection blip in `listOrgs` /
|
|
16810
|
+
// `listEnvsForOrg` / `pop`) must not kill the polling loop permanently.
|
|
16811
|
+
// We log each `runOnce` failure, back off so we don't spin tight on a
|
|
16812
|
+
// sustained outage, and resume. The loop only exits when `stop()` flips
|
|
16813
|
+
// `stopping`.
|
|
16814
|
+
async loop() {
|
|
16815
|
+
try {
|
|
16816
|
+
let consecutiveErrors = 0;
|
|
16817
|
+
while (!this.stopping) {
|
|
16818
|
+
try {
|
|
16819
|
+
const result = await this.runOnce();
|
|
16820
|
+
consecutiveErrors = 0;
|
|
16821
|
+
if (result.drained === 0 && result.failed === 0) {
|
|
16822
|
+
await this.delay(this.pollIntervalMs);
|
|
16823
|
+
}
|
|
16824
|
+
} catch (err) {
|
|
16825
|
+
consecutiveErrors += 1;
|
|
16826
|
+
this.logger.error("MollifierDrainer.runOnce failed; backing off", {
|
|
16827
|
+
err,
|
|
16828
|
+
consecutiveErrors
|
|
16829
|
+
});
|
|
16830
|
+
await this.delay(this.backoffMs(consecutiveErrors));
|
|
16831
|
+
}
|
|
16832
|
+
}
|
|
16833
|
+
} finally {
|
|
16834
|
+
this.isRunning = false;
|
|
16835
|
+
}
|
|
16836
|
+
}
|
|
16837
|
+
// Exponential backoff capped at 5s. Keeps the loop responsive after a
|
|
16838
|
+
// brief blip while preventing a tight retry loop during a long Redis
|
|
16839
|
+
// outage. 1 → 200ms, 2 → 400ms, 3 → 800ms, 4 → 1.6s, 5 → 3.2s, 6+ → 5s.
|
|
16840
|
+
backoffMs(consecutiveErrors) {
|
|
16841
|
+
const base = Math.max(this.pollIntervalMs, 100);
|
|
16842
|
+
const capped = Math.min(base * 2 ** (consecutiveErrors - 1), 5e3);
|
|
16843
|
+
return capped;
|
|
16844
|
+
}
|
|
16845
|
+
delay(ms) {
|
|
16846
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
16847
|
+
}
|
|
16848
|
+
// Take up to `maxOrgsPerTick` orgs starting at the current cursor, with
|
|
16849
|
+
// wrap-around. Cursor advances by 1 each tick so every org reaches
|
|
16850
|
+
// every slot position (0..sliceSize-1) over a full cycle — no
|
|
16851
|
+
// head-of-line bias within the slice. Orgs are sorted before slicing
|
|
16852
|
+
// so rotation is deterministic regardless of Redis SET iteration order.
|
|
16853
|
+
takeOrgSlice(orgs) {
|
|
16854
|
+
const sorted = [...orgs].sort();
|
|
16855
|
+
const n = sorted.length;
|
|
16856
|
+
const sliceSize = Math.min(this.maxOrgsPerTick, n);
|
|
16857
|
+
const start = this.orgCursor % n;
|
|
16858
|
+
this.orgCursor = (this.orgCursor + 1) % Math.max(n, 1);
|
|
16859
|
+
const end = start + sliceSize;
|
|
16860
|
+
if (end <= n) return sorted.slice(start, end);
|
|
16861
|
+
return [...sorted.slice(start), ...sorted.slice(0, end - n)];
|
|
16862
|
+
}
|
|
16863
|
+
// Pick one env from the org's active-envs list, rotating per org via
|
|
16864
|
+
// the per-org cursor. Each org's cursor advances by 1 each visit, so
|
|
16865
|
+
// an org with N envs cycles through them across N visits.
|
|
16866
|
+
pickEnvForOrg(orgId, envsForOrg) {
|
|
16867
|
+
const sorted = [...envsForOrg].sort();
|
|
16868
|
+
const cursor = this.perOrgEnvCursors.get(orgId) ?? 0;
|
|
16869
|
+
const idx = cursor % sorted.length;
|
|
16870
|
+
this.perOrgEnvCursors.set(orgId, (cursor + 1) % sorted.length);
|
|
16871
|
+
return sorted[idx];
|
|
16872
|
+
}
|
|
16873
|
+
// A failure for one env (e.g. a Redis hiccup mid-batch in `pop`, or in
|
|
16874
|
+
// `requeue`/`fail` during error recovery inside `processEntry`) must not
|
|
16875
|
+
// poison the rest of the batch — `Promise.all` would otherwise reject and
|
|
16876
|
+
// bubble all the way to `loop()`. Catch both stages here so the failed env
|
|
16877
|
+
// is just counted as "failed" for this tick and we move on.
|
|
16878
|
+
async processOneFromEnv(envId) {
|
|
16879
|
+
let entry;
|
|
16880
|
+
try {
|
|
16881
|
+
entry = await this.buffer.pop(envId);
|
|
16882
|
+
} catch (err) {
|
|
16883
|
+
this.logger.error("MollifierDrainer.pop failed", { envId, err });
|
|
16884
|
+
return "failed";
|
|
16885
|
+
}
|
|
16886
|
+
if (!entry) return "empty";
|
|
16887
|
+
try {
|
|
16888
|
+
return await this.processEntry(entry);
|
|
16889
|
+
} catch (err) {
|
|
16890
|
+
this.logger.error("MollifierDrainer.processEntry failed", {
|
|
16891
|
+
envId,
|
|
16892
|
+
runId: entry.runId,
|
|
16893
|
+
err
|
|
16894
|
+
});
|
|
16895
|
+
return "failed";
|
|
16896
|
+
}
|
|
16897
|
+
}
|
|
16898
|
+
async processEntry(entry) {
|
|
16899
|
+
try {
|
|
16900
|
+
const payload = deserialiseSnapshot(entry.payload);
|
|
16901
|
+
await this.handler({
|
|
16902
|
+
runId: entry.runId,
|
|
16903
|
+
envId: entry.envId,
|
|
16904
|
+
orgId: entry.orgId,
|
|
16905
|
+
payload,
|
|
16906
|
+
attempts: entry.attempts,
|
|
16907
|
+
createdAt: entry.createdAt
|
|
16908
|
+
});
|
|
16909
|
+
await this.buffer.ack(entry.runId);
|
|
16910
|
+
return "drained";
|
|
16911
|
+
} catch (err) {
|
|
16912
|
+
const nextAttempts = entry.attempts + 1;
|
|
16913
|
+
if (this.isRetryable(err) && nextAttempts < this.maxAttempts) {
|
|
16914
|
+
await this.buffer.requeue(entry.runId);
|
|
16915
|
+
this.logger.warn("MollifierDrainer: retryable error, requeued", {
|
|
16916
|
+
runId: entry.runId,
|
|
16917
|
+
attempts: nextAttempts
|
|
16918
|
+
});
|
|
16919
|
+
return "failed";
|
|
16920
|
+
}
|
|
16921
|
+
const code = err instanceof Error ? err.name : "Unknown";
|
|
16922
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
16923
|
+
await this.buffer.fail(entry.runId, { code, message });
|
|
16924
|
+
this.logger.error("MollifierDrainer: terminal failure", {
|
|
16925
|
+
runId: entry.runId,
|
|
16926
|
+
code,
|
|
16927
|
+
message
|
|
16928
|
+
});
|
|
16929
|
+
return "failed";
|
|
16930
|
+
}
|
|
16931
|
+
}
|
|
16932
|
+
};
|
|
16356
16933
|
|
|
16357
16934
|
exports.BaseScheduler = BaseScheduler;
|
|
16358
16935
|
exports.BatchedSpanManager = BatchedSpanManager;
|
|
16936
|
+
exports.BufferEntryError = BufferEntryError;
|
|
16937
|
+
exports.BufferEntrySchema = BufferEntrySchema;
|
|
16938
|
+
exports.BufferEntryStatus = BufferEntryStatus;
|
|
16359
16939
|
exports.CallbackFairQueueKeyProducer = CallbackFairQueueKeyProducer;
|
|
16360
16940
|
exports.ConcurrencyManager = ConcurrencyManager;
|
|
16361
16941
|
exports.CronSchema = CronSchema;
|
|
@@ -16371,6 +16951,8 @@ exports.ImmediateRetry = ImmediateRetry;
|
|
|
16371
16951
|
exports.LinearBackoffRetry = LinearBackoffRetry;
|
|
16372
16952
|
exports.MasterQueue = MasterQueue;
|
|
16373
16953
|
exports.MessagingAttributes = MessagingAttributes;
|
|
16954
|
+
exports.MollifierBuffer = MollifierBuffer;
|
|
16955
|
+
exports.MollifierDrainer = MollifierDrainer;
|
|
16374
16956
|
exports.NoRetry = NoRetry;
|
|
16375
16957
|
exports.NoopScheduler = NoopScheduler;
|
|
16376
16958
|
exports.RoundRobinScheduler = RoundRobinScheduler;
|
|
@@ -16382,7 +16964,9 @@ exports.Worker = Worker;
|
|
|
16382
16964
|
exports.WorkerQueueManager = WorkerQueueManager;
|
|
16383
16965
|
exports.createDefaultRetryStrategy = createDefaultRetryStrategy;
|
|
16384
16966
|
exports.defaultRetryOptions = defaultRetryOptions;
|
|
16967
|
+
exports.deserialiseSnapshot = deserialiseSnapshot;
|
|
16385
16968
|
exports.isAbortError = isAbortError;
|
|
16386
16969
|
exports.noopTelemetry = noopTelemetry;
|
|
16970
|
+
exports.serialiseSnapshot = serialiseSnapshot;
|
|
16387
16971
|
//# sourceMappingURL=index.cjs.map
|
|
16388
16972
|
//# sourceMappingURL=index.cjs.map
|