Skip to content

Commit fade75b

Browse files
matt-aitkenTrigger.dev RepoOps
authored andcommitted
perf(run-engine): bound and meter the concurrency-set reconcile in the dequeue scripts
The run queue's self-heal for saturated concurrency sets is now bounded and configurable (page size, lock TTL, passes per dequeue, on/off) and reports its work through OpenTelemetry metrics and span attributes. Defaults preserve existing behaviour apart from capping passes per dequeue script at 2. Mono-RevId: fa05bcd6515f5905f3be52abeebaeeeeb0287617
1 parent 414e5a2 commit fade75b

7 files changed

Lines changed: 976 additions & 35 deletions

File tree

apps/webapp/app/env.server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1444,6 +1444,10 @@ const EnvironmentSchema = z
14441444
.default("info"),
14451445
RUN_ENGINE_TOTAL_CONCURRENCY_LIMITS_ENABLED: z.string().default("1"),
14461446
RUN_ENGINE_QUEUE_GATES_ENABLED: z.string().default("0"),
1447+
RUN_ENGINE_QUEUE_RECONCILE_ENABLED: z.string().default("1"),
1448+
RUN_ENGINE_QUEUE_RECONCILE_SCAN_COUNT: z.coerce.number().int().positive().default(100),
1449+
RUN_ENGINE_QUEUE_RECONCILE_LOCK_TTL_SECONDS: z.coerce.number().int().positive().default(10),
1450+
RUN_ENGINE_QUEUE_RECONCILE_MAX_PASSES_PER_DEQUEUE: z.coerce.number().int().min(0).default(2),
14471451
RUN_ENGINE_TREAT_PRODUCTION_EXECUTION_STALLS_AS_OOM: z.string().default("0"),
14481452
RUN_ENGINE_READ_REPLICA_SNAPSHOTS_SINCE_ENABLED: z.string().default("0"),
14491453
RUN_ENGINE_SNAPSHOTS_SINCE_REPLICA_RETRY_MIN_MS: z.coerce.number().int().default(50),

apps/webapp/app/v3/runEngine.server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ function createRunEngine() {
6464
defaultEnvConcurrencyBurstFactor: env.DEFAULT_ENV_EXECUTION_CONCURRENCY_BURST_FACTOR,
6565
totalConcurrencyEnabled: env.RUN_ENGINE_TOTAL_CONCURRENCY_LIMITS_ENABLED === "1",
6666
gatesEnabled: env.RUN_ENGINE_QUEUE_GATES_ENABLED === "1",
67+
reconcile: {
68+
enabled: env.RUN_ENGINE_QUEUE_RECONCILE_ENABLED === "1",
69+
scanCount: env.RUN_ENGINE_QUEUE_RECONCILE_SCAN_COUNT,
70+
lockTtlSeconds: env.RUN_ENGINE_QUEUE_RECONCILE_LOCK_TTL_SECONDS,
71+
maxPassesPerDequeue: env.RUN_ENGINE_QUEUE_RECONCILE_MAX_PASSES_PER_DEQUEUE,
72+
},
6773
logLevel: env.RUN_ENGINE_RUN_QUEUE_LOG_LEVEL,
6874
redis: {
6975
keyPrefix: "engine:",

internal-packages/run-engine/src/engine/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ export class RunEngine {
227227
defaultEnvConcurrencyBurstFactor: options.queue?.defaultEnvConcurrencyBurstFactor,
228228
totalConcurrencyEnabled: options.queue?.totalConcurrencyEnabled,
229229
gatesEnabled: options.queue?.gatesEnabled,
230+
reconcile: options.queue?.reconcile,
230231
logger: new Logger("RunQueue", options.queue?.logLevel ?? "info"),
231232
redis: { ...options.queue.redis, keyPrefix: `${options.queue.redis.keyPrefix}runqueue:` },
232233
retryOptions: options.queue?.retryOptions,

internal-packages/run-engine/src/engine/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ export type RunEngineOptions = {
9696
totalConcurrencyEnabled?: boolean;
9797
/** Enforce the gates carried in message payloads. See RunQueueOptions.gatesEnabled. */
9898
gatesEnabled?: boolean;
99+
/** Bounds for the saturated-set reconcile. See RunQueueOptions.reconcile. */
100+
reconcile?: {
101+
enabled?: boolean;
102+
scanCount?: number;
103+
lockTtlSeconds?: number;
104+
maxPassesPerDequeue?: number;
105+
};
99106
/** Optional queue-metrics emitter; enables gauge + counter emission from the RunQueue. */
100107
queueMetrics?: RunQueueMetricsEmitter;
101108
queueSelectionStrategyOptions?: Pick<
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
/**
2+
* Latency benchmark for the saturated-set reconcile inside the dequeue Lua script.
3+
*
4+
* Seeds a base queue whose groupConcurrency set is full of LEGITIMATE members (each
5+
* has a message key and sits in its home currentConcurrency set), so every pass pays
6+
* the worst-case per-member cost (GET, cjson.decode, SISMEMBER) and prunes nothing.
7+
* The set therefore stays saturated and every iteration measures the same work.
8+
*
9+
* Reports client-observed dequeue-script latency for: reconcile disabled (the
10+
* saturated dequeue's floor), and reconcile enabled at several SSCAN page sizes. The
11+
* enabled-minus-disabled delta divided by the page size is the per-member cost.
12+
*
13+
* Run on demand with `pnpm run test:bench` (kept out of the default suite).
14+
*
15+
* Results print as a table and land as JSON in `.bench/run-queue-reconcile.json` at the
16+
* repo root.
17+
*
18+
* Knobs, all optional: BENCH_MEMBERS (default 5000), BENCH_ITERATIONS (default 200),
19+
* BENCH_SCAN_COUNTS (comma list, default "20,100,500"), BENCH_OUT_DIR.
20+
*/
21+
import { redisTest } from "@internal/testcontainers";
22+
import { trace } from "@internal/tracing";
23+
import { Decimal } from "@trigger.dev/database";
24+
import { mkdir, writeFile } from "node:fs/promises";
25+
import { join } from "node:path";
26+
import { describe } from "vitest";
27+
import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js";
28+
import { RunQueue, type RunQueueOptions } from "../index.js";
29+
import { RunQueueFullKeyProducer } from "../keyProducer.js";
30+
import type { InputPayload } from "../types.js";
31+
32+
vi.setConfig({ testTimeout: 900_000 });
33+
34+
const MEMBERS = Number(process.env.BENCH_MEMBERS ?? 5000);
35+
const ITERATIONS = Number(process.env.BENCH_ITERATIONS ?? 200);
36+
const SCAN_COUNTS = (process.env.BENCH_SCAN_COUNTS ?? "20,100,500").split(",").map(Number);
37+
const OUT_DIR = process.env.BENCH_OUT_DIR ?? join(process.cwd(), "..", "..", ".bench");
38+
39+
const SHARD_COUNT = 2;
40+
const QUEUE = "task/bench-task";
41+
const KEY_PREFIX = "runqueue:bench:";
42+
43+
const testOptions = {
44+
name: "rq",
45+
tracer: trace.getTracer("rq"),
46+
workers: 1,
47+
defaultEnvConcurrency: 100_000,
48+
retryOptions: {
49+
maxAttempts: 5,
50+
factor: 1.1,
51+
minTimeoutInMs: 100,
52+
maxTimeoutInMs: 1_000,
53+
randomize: true,
54+
},
55+
keys: new RunQueueFullKeyProducer(),
56+
masterQueueConsumersDisabled: true,
57+
};
58+
59+
const env = {
60+
id: "e-bench",
61+
type: "PRODUCTION" as const,
62+
maximumConcurrencyLimit: 100_000,
63+
concurrencyLimitBurstFactor: new Decimal(1.0),
64+
project: { id: "p-bench" },
65+
organization: { id: "o-bench" },
66+
};
67+
68+
function createQueue(redisContainer: any, reconcile: RunQueueOptions["reconcile"]) {
69+
return new RunQueue({
70+
...testOptions,
71+
shardCount: SHARD_COUNT,
72+
totalConcurrencyEnabled: true,
73+
reconcile,
74+
queueSelectionStrategy: new FairQueueSelectionStrategy({
75+
redis: {
76+
keyPrefix: KEY_PREFIX,
77+
host: redisContainer.getHost(),
78+
port: redisContainer.getPort(),
79+
},
80+
keys: testOptions.keys,
81+
}),
82+
redis: {
83+
keyPrefix: KEY_PREFIX,
84+
host: redisContainer.getHost(),
85+
port: redisContainer.getPort(),
86+
},
87+
});
88+
}
89+
90+
function makeMessage(overrides: Partial<InputPayload> = {}): InputPayload {
91+
return {
92+
runId: "r0",
93+
taskIdentifier: QUEUE,
94+
orgId: env.organization.id,
95+
projectId: env.project.id,
96+
environmentId: env.id,
97+
environmentType: env.type,
98+
queue: QUEUE,
99+
timestamp: Date.now(),
100+
attempt: 0,
101+
...overrides,
102+
};
103+
}
104+
105+
function percentile(sorted: number[], q: number): number {
106+
if (sorted.length === 0) return 0;
107+
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * q))]!;
108+
}
109+
110+
function summarize(durations: number[]) {
111+
const sorted = [...durations].sort((a, b) => a - b);
112+
const round = (n: number) => Math.round(n * 1000) / 1000;
113+
return {
114+
p50: round(percentile(sorted, 0.5)),
115+
p95: round(percentile(sorted, 0.95)),
116+
p99: round(percentile(sorted, 0.99)),
117+
mean: round(sorted.reduce((a, b) => a + b, 0) / (sorted.length || 1)),
118+
};
119+
}
120+
121+
/**
122+
* MEMBERS legitimate holders in the group set, each with a payload naming its home
123+
* queue and a matching home-set membership, plus one queued run held at the cap.
124+
*/
125+
async function seedSaturatedGroup(queue: RunQueue) {
126+
const keys = testOptions.keys;
127+
const homeKey = keys.queueCurrentConcurrencyKey(env, QUEUE);
128+
const groupKey = keys.queueGroupConcurrencyKey(env, QUEUE);
129+
const queueKey = keys.queueKey(env, QUEUE);
130+
131+
await queue.updateQueueConcurrencyLimits(env, QUEUE, MEMBERS + 10);
132+
await queue.updateQueueTotalConcurrencyLimits(env, QUEUE, MEMBERS);
133+
134+
const batch = 500;
135+
for (let start = 0; start < MEMBERS; start += batch) {
136+
const ids = Array.from(
137+
{ length: Math.min(batch, MEMBERS - start) },
138+
(_, i) => `m-${start + i}`
139+
);
140+
const pipeline = queue.redis.pipeline();
141+
for (const id of ids) {
142+
const payload = JSON.stringify(
143+
makeMessage({ runId: id, queue: queueKey, timestamp: Date.now() - 60_000 })
144+
);
145+
pipeline.set(keys.messageKey(env.organization.id, id), payload);
146+
}
147+
pipeline.sadd(homeKey, ...ids);
148+
pipeline.sadd(groupKey, ...ids);
149+
await pipeline.exec();
150+
}
151+
152+
await queue.enqueueMessage({
153+
env,
154+
message: makeMessage({ runId: "r0", timestamp: Date.now() - 1000 }),
155+
workerQueue: "main",
156+
});
157+
158+
return { groupKey };
159+
}
160+
161+
async function measure(queue: RunQueue, groupKey: string, iterations: number): Promise<number[]> {
162+
const shard = testOptions.keys.masterQueueShardForEnvironment(env.id, SHARD_COUNT);
163+
const durations: number[] = [];
164+
for (let i = 0; i < iterations; i++) {
165+
await queue.redis.del(`${groupKey}:reconcileLock`);
166+
const startedAt = performance.now();
167+
const admitted = await queue.testDequeueFromMasterQueue(shard, env.id, 10);
168+
durations.push(performance.now() - startedAt);
169+
if (admitted.length > 0) {
170+
throw new Error("benchmark invariant broken: a saturated dequeue admitted a run");
171+
}
172+
}
173+
return durations;
174+
}
175+
176+
describe("run-queue reconcile latency benchmark", () => {
177+
redisTest("saturated dequeue with and without reconcile", async ({ redisContainer }) => {
178+
const seedQueue = createQueue(redisContainer, { enabled: false });
179+
let groupKey: string;
180+
try {
181+
const seedStartedAt = performance.now();
182+
({ groupKey } = await seedSaturatedGroup(seedQueue));
183+
console.log(
184+
`seeded ${MEMBERS} legitimate group members in ${Math.round(performance.now() - seedStartedAt)}ms`
185+
);
186+
expect(await seedQueue.redis.scard(groupKey)).toBe(MEMBERS);
187+
} finally {
188+
await seedQueue.quit();
189+
}
190+
191+
const results: Array<Record<string, number | string>> = [];
192+
193+
const disabledQueue = createQueue(redisContainer, { enabled: false });
194+
let floor: ReturnType<typeof summarize>;
195+
try {
196+
await measure(disabledQueue, groupKey, 20);
197+
floor = summarize(await measure(disabledQueue, groupKey, ITERATIONS));
198+
results.push({ variant: "reconcile disabled", scanCount: 0, ...floor, deltaP50: 0 });
199+
} finally {
200+
await disabledQueue.quit();
201+
}
202+
203+
for (const scanCount of SCAN_COUNTS) {
204+
const queue = createQueue(redisContainer, {
205+
enabled: true,
206+
scanCount,
207+
lockTtlSeconds: 10,
208+
maxPassesPerDequeue: 1,
209+
});
210+
try {
211+
await measure(queue, groupKey, 20);
212+
const stats = summarize(await measure(queue, groupKey, ITERATIONS));
213+
const deltaP50 = Math.round((stats.p50 - floor.p50) * 1000) / 1000;
214+
results.push({
215+
variant: "reconcile enabled",
216+
scanCount,
217+
...stats,
218+
deltaP50,
219+
perMemberUs: Math.round((deltaP50 / scanCount) * 1000),
220+
});
221+
expect(await queue.redis.scard(groupKey)).toBe(MEMBERS);
222+
} finally {
223+
await queue.quit();
224+
}
225+
}
226+
227+
const summary = { members: MEMBERS, iterations: ITERATIONS, results };
228+
await mkdir(OUT_DIR, { recursive: true });
229+
const outPath = join(OUT_DIR, "run-queue-reconcile.json");
230+
await writeFile(outPath, JSON.stringify(summary, null, 2));
231+
console.table(results);
232+
console.log(`wrote ${outPath}`);
233+
});
234+
});

0 commit comments

Comments
 (0)