Skip to content

Commit e719c68

Browse files
d-csTrigger.dev RepoOps
authored andcommitted
perf(run-engine): scan for concurrency sets with SCAN COUNT 1000
The concurrency sweeper now scans Redis in pages of 1000 keys instead of 10 (configurable with `RUN_ENGINE_CONCURRENCY_SWEEPER_SCAN_COUNT`), processes matched sets ten at a time, and waits for the last page before finishing. Large keyspaces no longer see a long burst of tiny SCAN calls on every sweep. Mono-RevId: 35be77d7aeff9cd4df4c1c305fbb0da62bfcf6a8
1 parent 8794f9b commit e719c68

8 files changed

Lines changed: 423 additions & 11 deletions

File tree

‎apps/webapp/app/env.server.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1359,6 +1359,8 @@ const EnvironmentSchema = z
13591359
RUN_ENGINE_CONCURRENCY_SWEEPER_PROCESS_MARKED_SCHEDULE: z.string().optional(),
13601360
RUN_ENGINE_CONCURRENCY_SWEEPER_SCAN_JITTER_IN_MS: z.coerce.number().int().optional(),
13611361
RUN_ENGINE_CONCURRENCY_SWEEPER_PROCESS_MARKED_JITTER_IN_MS: z.coerce.number().int().optional(),
1362+
// Blank or 0 means the engine default of 1000; clamped to 1..10000 in RunQueue.
1363+
RUN_ENGINE_CONCURRENCY_SWEEPER_SCAN_COUNT: OptionalLimitEnv,
13621364

13631365
// TTL System settings for automatic run expiration
13641366
RUN_ENGINE_TTL_SYSTEM_DISABLED: BoolEnv.default(false),

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ function createRunEngine() {
112112
processMarkedSchedule: env.RUN_ENGINE_CONCURRENCY_SWEEPER_PROCESS_MARKED_SCHEDULE,
113113
scanJitterInMs: env.RUN_ENGINE_CONCURRENCY_SWEEPER_SCAN_JITTER_IN_MS,
114114
processMarkedJitterInMs: env.RUN_ENGINE_CONCURRENCY_SWEEPER_PROCESS_MARKED_JITTER_IN_MS,
115+
scanCount: env.RUN_ENGINE_CONCURRENCY_SWEEPER_SCAN_COUNT,
115116
},
116117
ttlSystem: {
117118
disabled: env.RUN_ENGINE_TTL_SYSTEM_DISABLED,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ export class RunEngine {
251251
processMarkedSchedule: options.queue?.concurrencySweeper?.processMarkedSchedule,
252252
scanJitterInMs: options.queue?.concurrencySweeper?.scanJitterInMs,
253253
processMarkedJitterInMs: options.queue?.concurrencySweeper?.processMarkedJitterInMs,
254+
scanCount: options.queue?.concurrencySweeper?.scanCount,
254255
callback: this.#concurrencySweeperCallback.bind(this),
255256
},
256257
shardCount: options.queue?.shardCount,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export type RunEngineOptions = {
146146
processMarkedSchedule?: string;
147147
scanJitterInMs?: number;
148148
processMarkedJitterInMs?: number;
149+
scanCount?: number;
149150
};
150151
/** TTL system options for automatic run expiration */
151152
ttlSystem?: {
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* Cost of one concurrency-sweeper pass (`scanConcurrencySets`) at different SCAN COUNTs.
3+
*
4+
* Seeds a testcontainers Redis with BENCH_SETS queue currentConcurrency sets plus
5+
* BENCH_UNRELATED_PER_SET non-matching keys per set (default 4,000 sets x (1 set + 99
6+
* unrelated) = 400k keys, 1% matching), then runs one full sweep per entry in
7+
* BENCH_SCAN_COUNTS and reports SCAN calls, SSCAN calls, total commands, server usec for
8+
* each, Redis process CPU, wall time, peak RSS during the sweep, and whether the callback
9+
* saw exactly the seeded run ids.
10+
*
11+
* Run on demand: `pnpm run test:bench src/run-queue/bench/concurrencySweeperScan --silent=false`.
12+
* Knobs: BENCH_SETS, BENCH_UNRELATED_PER_SET, BENCH_SCAN_COUNTS (default "10,1000").
13+
*/
14+
import { redisTest } from "@internal/testcontainers";
15+
import { trace } from "@internal/tracing";
16+
import { Logger } from "@trigger.dev/core/logger";
17+
import { describe } from "vitest";
18+
import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js";
19+
import { RunQueue } from "../index.js";
20+
import { RunQueueFullKeyProducer } from "../keyProducer.js";
21+
import { seedSweeperKeyspace } from "../tests/sweeperKeyspace.js";
22+
23+
vi.setConfig({ testTimeout: 900_000 });
24+
25+
const SETS = Number(process.env.BENCH_SETS ?? 4_000);
26+
const UNRELATED_PER_SET = Number(process.env.BENCH_UNRELATED_PER_SET ?? 99);
27+
const SCAN_COUNTS = (process.env.BENCH_SCAN_COUNTS ?? "10,1000").split(",").map(Number);
28+
const KEY_PREFIX = "engine:runqueue:";
29+
30+
function createQueue(redisContainer: any, scanCount: number, seen: Set<string>) {
31+
const redis = {
32+
keyPrefix: KEY_PREFIX,
33+
host: redisContainer.getHost(),
34+
port: redisContainer.getPort(),
35+
};
36+
return new RunQueue({
37+
name: "rq",
38+
tracer: trace.getTracer("rq"),
39+
logger: new Logger("RunQueue", "warn"),
40+
defaultEnvConcurrency: 10,
41+
keys: new RunQueueFullKeyProducer(),
42+
masterQueueConsumersDisabled: true,
43+
workerOptions: { disabled: true },
44+
queueSelectionStrategy: new FairQueueSelectionStrategy({
45+
redis,
46+
keys: new RunQueueFullKeyProducer(),
47+
}),
48+
redis,
49+
concurrencySweeper: {
50+
scanCount,
51+
callback: async (runIds) => {
52+
runIds.forEach((id) => seen.add(id));
53+
return [];
54+
},
55+
},
56+
});
57+
}
58+
59+
async function stats(queue: RunQueue) {
60+
const [commandstats, info, cpu] = await Promise.all([
61+
queue.redis.info("commandstats"),
62+
queue.redis.info("stats"),
63+
queue.redis.info("cpu"),
64+
]);
65+
const cpuSeconds = (name: string) => Number(cpu.match(new RegExp(`${name}:([\\d.]+)`))![1]);
66+
const cmd = (name: string) => {
67+
const m = commandstats.match(new RegExp(`cmdstat_${name}:calls=(\\d+),usec=(\\d+)`));
68+
return { calls: m ? Number(m[1]) : 0, usec: m ? Number(m[2]) : 0 };
69+
};
70+
return {
71+
scan: cmd("scan"),
72+
sscan: cmd("sscan"),
73+
total: Number(info.match(/total_commands_processed:(\d+)/)![1]),
74+
// Whole-process Redis CPU, including the per-call network/parse cost commandstats leaves out.
75+
cpuMs: (cpuSeconds("used_cpu_user") + cpuSeconds("used_cpu_sys")) * 1000,
76+
};
77+
}
78+
79+
describe("concurrency sweeper SCAN COUNT benchmark", () => {
80+
redisTest("one full sweep per SCAN COUNT", async ({ redisContainer }) => {
81+
const seedQueue = createQueue(redisContainer, 1000, new Set());
82+
let expected: Set<string>;
83+
let dbsize: number;
84+
try {
85+
const startedAt = performance.now();
86+
expected = await seedSweeperKeyspace(seedQueue.redis, {
87+
concurrencySets: SETS,
88+
unrelatedPerSet: UNRELATED_PER_SET,
89+
});
90+
dbsize = await seedQueue.redis.dbsize();
91+
console.log(
92+
`seeded ${dbsize} keys (${SETS} matching sets, ${expected.size} run ids) in ${Math.round(
93+
performance.now() - startedAt
94+
)}ms`
95+
);
96+
} finally {
97+
await seedQueue.quit();
98+
}
99+
100+
const results: Array<Record<string, number | string | boolean>> = [];
101+
for (const scanCount of SCAN_COUNTS) {
102+
const seen = new Set<string>();
103+
const queue = createQueue(redisContainer, scanCount, seen);
104+
try {
105+
const rssBefore = process.memoryUsage.rss();
106+
let rssPeak = rssBefore;
107+
const sampler = setInterval(() => {
108+
rssPeak = Math.max(rssPeak, process.memoryUsage.rss());
109+
}, 5);
110+
111+
const before = await stats(queue);
112+
const startedAt = performance.now();
113+
const sweep = await queue["scanConcurrencySets"]();
114+
const wallMs = performance.now() - startedAt;
115+
const after = await stats(queue);
116+
clearInterval(sampler);
117+
rssPeak = Math.max(rssPeak, process.memoryUsage.rss());
118+
119+
const identical = seen.size === expected.size && [...seen].every((id) => expected.has(id));
120+
results.push({
121+
scanCount,
122+
scanCalls: after.scan.calls - before.scan.calls,
123+
scanServerMs: Math.round((after.scan.usec - before.scan.usec) / 1000),
124+
sscanCalls: after.sscan.calls - before.sscan.calls,
125+
sscanServerMs: Math.round((after.sscan.usec - before.sscan.usec) / 1000),
126+
totalCommands: after.total - before.total,
127+
redisCpuMs: Math.round(after.cpuMs - before.cpuMs),
128+
wallMs: Math.round(wallMs),
129+
rssBeforeMB: Math.round(rssBefore / 2 ** 20),
130+
rssPeakMB: Math.round(rssPeak / 2 ** 20),
131+
keysProcessed: sweep?.processedKeys ?? 0,
132+
runIdsSeen: seen.size,
133+
identical,
134+
});
135+
expect(identical).toBe(true);
136+
} finally {
137+
await queue.quit();
138+
}
139+
}
140+
141+
console.log(`dbsize=${dbsize}`);
142+
console.table(results);
143+
});
144+
});

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

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ const SemanticAttributes = {
8888
* active or observed gate never re-anchors; the Lua helper inlines the same value. */
8989
const GATE_QUEUED_COUNTER_TTL_SECONDS = 86400;
9090

91+
/** Concurrency sets the sweeper processes at once from one SCAN page (the old COUNT 10 page width). */
92+
export const SWEEPER_SET_CONCURRENCY = 10;
93+
9194
/** Counters a dequeue script tallies for its reconcile work; positional on the reply's third slot. */
9295
type ReconcileCounters = {
9396
passesGroup: number;
@@ -1324,6 +1327,8 @@ export type RunQueueOptions = {
13241327
scanJitterInMs?: number;
13251328
processMarkedSchedule?: string;
13261329
processMarkedJitterInMs?: number;
1330+
/** SCAN COUNT hint per call while looking for currentConcurrency sets */
1331+
scanCount?: number;
13271332
callback: ConcurrencySweeperCallback;
13281333
};
13291334
/** TTL system for automatic run expiration */
@@ -2867,7 +2872,7 @@ export class RunQueue {
28672872
* Create a scan stream for queue current concurrency keys
28682873
*/
28692874
public currentConcurrencyScanStream(
2870-
count: number = 10,
2875+
count: number,
28712876
onEnd?: () => void,
28722877
onError?: (error: Error) => void
28732878
): { stream: Readable; redis: Redis } {
@@ -4960,13 +4965,17 @@ export class RunQueue {
49604965
};
49614966

49624967
const { promise, resolve, reject } = promiseWithResolvers<typeof stats>();
4968+
// "end" can fire while the last page is still being processed; finish it first.
4969+
let processing: Promise<void> = Promise.resolve();
49634970

49644971
const { stream, redis } = this.currentConcurrencyScanStream(
4965-
10,
4972+
boundedInt(this.options.concurrencySweeper?.scanCount, 1000, 1, 10_000),
49664973
() => {
4967-
this.logger.debug("Concurrency scan stream closed", { stats });
4974+
void processing.then(() => {
4975+
this.logger.debug("Concurrency scan stream closed", { stats });
49684976

4969-
resolve(stats);
4977+
resolve(stats);
4978+
});
49704979
},
49714980
(error) => {
49724981
this.logger.error("Concurrency scan stream error", {
@@ -5011,11 +5020,21 @@ export class RunQueue {
50115020

50125021
stats.processedKeys += uniqueKeys.length;
50135022

5014-
await Promise.allSettled(uniqueKeys.map((key) => this.processConcurrencySet(key))).finally(
5015-
() => {
5016-
stream.resume();
5023+
// A full SCAN page can hold hundreds of matches; cap the fan-out so the per-set callback
5024+
// (a DB query in the engine) doesn't burst.
5025+
processing = (async () => {
5026+
for (let i = 0; i < uniqueKeys.length; i += SWEEPER_SET_CONCURRENCY) {
5027+
await Promise.allSettled(
5028+
uniqueKeys
5029+
.slice(i, i + SWEEPER_SET_CONCURRENCY)
5030+
.map((key) => this.processConcurrencySet(key))
5031+
);
50175032
}
5018-
);
5033+
})().finally(() => {
5034+
stream.resume();
5035+
});
5036+
5037+
await processing;
50195038
});
50205039

50215040
return promise;
@@ -5027,9 +5046,11 @@ export class RunQueue {
50275046
});
50285047

50295048
const { promise, resolve, reject } = promiseWithResolvers<void>();
5049+
// Same as the SCAN page level: "end" can fire while the last SSCAN page is in flight.
5050+
let processing: Promise<void> = Promise.resolve();
50305051

50315052
stream.on("end", () => {
5032-
resolve();
5053+
void processing.then(() => resolve());
50335054
});
50345055

50355056
stream.on("error", (error) => {
@@ -5041,7 +5062,7 @@ export class RunQueue {
50415062
reject(error);
50425063
});
50435064

5044-
stream.on("data", async (runIds: string[]) => {
5065+
const processPage = async (runIds: string[]) => {
50455066
stream.pause();
50465067

50475068
if (this.abortController.signal.aborted) {
@@ -5069,6 +5090,10 @@ export class RunQueue {
50695090
}
50705091

50715092
stream.resume();
5093+
};
5094+
5095+
stream.on("data", (runIds: string[]) => {
5096+
processing = processPage(runIds);
50725097
});
50735098

50745099
return promise;

0 commit comments

Comments
 (0)