Skip to content

Commit 11e1cd8

Browse files
authored
feat(webapp): isolate the runs list ClickHouse read pool (#4763)
## Summary Improves the performance and reliability of the runs list and the `runs.list` API, especially for large projects and filtered views. ## What changed - **Filtered runs-list queries use `PREWHERE`.** Immutable and additive-only filters (tags, task identifier, version, queue, region, machine, and the rest) are applied in `PREWHERE` on the `task_runs_v2 FINAL` scan, so ClickHouse filters, and uses the tags skip index, before it reconciles versions and materialises the wide columns. Same results, far less memory per query. `status` stays in `WHERE`: it changes across a run's versions, so filtering it before `FINAL` could return stale rows. - **The runs-list ClickHouse pool gets per-query guardrails**, all env-configurable: a `max_execution_time` paired with the client request timeout, a per-query `max_memory_usage`, a `max_threads` cap, and `readonly`. Each bounds a single query to itself, so a heavy query can't affect other queries, and they are safe as pool-level settings only because this pool is read-only. - **Billing and bulk count reads move to the read pool**, off the write pool. Defaults are conservative for self-hosters; production values are set via env.
1 parent 2e87e93 commit 11e1cd8

10 files changed

Lines changed: 346 additions & 43 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Improved the performance and reliability of the runs list and the runs.list API, especially for large projects and filtered views.

apps/webapp/app/env.server.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2246,6 +2246,15 @@ const EnvironmentSchema = z
22462246
.enum(["log", "error", "warn", "info", "debug"])
22472247
.default("info"),
22482248
RUNS_LIST_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
2249+
RUNS_LIST_CLICKHOUSE_REQUEST_TIMEOUT_MS: z.coerce.number().int().positive().default(40_000),
2250+
RUNS_LIST_CLICKHOUSE_MAX_EXECUTION_TIME: z.coerce.number().int().positive().default(35),
2251+
RUNS_LIST_CLICKHOUSE_MAX_THREADS: z.coerce.number().int().positive().default(4),
2252+
RUNS_LIST_CLICKHOUSE_MAX_MEMORY_USAGE: z.coerce
2253+
.number()
2254+
.int()
2255+
.positive()
2256+
.default(1_073_741_824),
2257+
RUNS_LIST_CLICKHOUSE_READONLY: z.enum(["0", "1", "2"]).default("2"),
22492258
/**
22502259
* Dedicated ClickHouse service for queue metrics: the ingestion consumer's inserts and every
22512260
* queue-metrics read (dashboards, queue pages, run inspector, health report) go through it, so

apps/webapp/app/presenters/v3/CreateBulkActionPresenter.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export class CreateBulkActionPresenter extends BasePresenter {
2626

2727
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
2828
organizationId,
29-
"standard"
29+
"runsList"
3030
);
3131
const runsRepository = new RunsRepository({
3232
clickhouse,

apps/webapp/app/services/clickhouse/clickhouseFactory.server.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ClickHouse } from "@internal/clickhouse";
1+
import { ClickHouse, type ClickHouseSettings } from "@internal/clickhouse";
22
import { createHash } from "crypto";
33
import { ClickhouseEventRepository } from "~/v3/eventRepository/clickhouseEventRepository.server";
44
import { env } from "~/env.server";
@@ -292,6 +292,45 @@ function initializeRealtimeClickhouseClient(): ClickHouse {
292292
});
293293
}
294294

295+
/**
296+
* Server-side query protection for the runs-list read pool. Every setting here is PER-QUERY, so a
297+
* pathological query only ever kills itself: a slow one hits `max_execution_time`, a memory-hungry
298+
* one hits `max_memory_usage`, a thread-hungry one hits `max_threads`. Per-USER limits
299+
* (`max_*_for_user`) are deliberately NOT used: everything connects as `default`, so a per-user cap
300+
* would reject whichever query arrives once the shared budget is hit, punishing innocent tenants
301+
* for a noisy one. The node itself is protected by the server-level `max_server_memory_usage`.
302+
* Safe as client-level settings ONLY because this pool is read-only; on a mixed read+write pool a
303+
* client-level `max_execution_time` would also kill slow inserts. `readonly=2` enforces read-only
304+
* while still allowing these settings to apply (`readonly=1` rejects them).
305+
*/
306+
/**
307+
* Client request timeout for the runs-list pool, forced above the server-side `max_execution_time`
308+
* so the server cap is what stops a slow query and the client stays connected to receive that
309+
* error. If the client timed out first, it would abort while ClickHouse kept executing, which is
310+
* the abandoned-query behaviour this pool is trying to prevent.
311+
*/
312+
function getRunsListRequestTimeoutMs() {
313+
return Math.max(
314+
env.RUNS_LIST_CLICKHOUSE_REQUEST_TIMEOUT_MS,
315+
(env.RUNS_LIST_CLICKHOUSE_MAX_EXECUTION_TIME + 5) * 1000
316+
);
317+
}
318+
319+
function getRunsListClickhouseSettings(): ClickHouseSettings {
320+
const settings: ClickHouseSettings = {
321+
max_execution_time: env.RUNS_LIST_CLICKHOUSE_MAX_EXECUTION_TIME,
322+
timeout_before_checking_execution_speed: 0,
323+
max_threads: env.RUNS_LIST_CLICKHOUSE_MAX_THREADS,
324+
max_memory_usage: env.RUNS_LIST_CLICKHOUSE_MAX_MEMORY_USAGE.toString(),
325+
};
326+
327+
if (env.RUNS_LIST_CLICKHOUSE_READONLY !== "0") {
328+
settings.readonly = env.RUNS_LIST_CLICKHOUSE_READONLY;
329+
}
330+
331+
return settings;
332+
}
333+
295334
/** Runs list reads — dashboard + API (`RUNS_LIST_CLICKHOUSE_URL`);
296335
* falls back to the default client if unset. */
297336
const defaultRunsListClickhouseClient = singleton(
@@ -319,6 +358,8 @@ function initializeRunsListClickhouseClient(): ClickHouse {
319358
request: env.RUNS_LIST_CLICKHOUSE_COMPRESSION_REQUEST === "1",
320359
},
321360
maxOpenConnections: env.RUNS_LIST_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
361+
requestTimeoutMs: getRunsListRequestTimeoutMs(),
362+
clickhouseSettings: getRunsListClickhouseSettings(),
322363
});
323364
}
324365

@@ -550,10 +591,25 @@ function buildOrgClickhouseClient(url: string, clientType: ClientType): ClickHou
550591
},
551592
maxOpenConnections: env.REALTIME_BACKEND_NATIVE_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
552593
});
594+
case "runsList":
595+
return new ClickHouse({
596+
url: parsed.toString(),
597+
name,
598+
keepAlive: {
599+
enabled: env.RUNS_LIST_CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
600+
idleSocketTtl: env.RUNS_LIST_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
601+
},
602+
logLevel: env.RUNS_LIST_CLICKHOUSE_LOG_LEVEL,
603+
compression: {
604+
request: env.RUNS_LIST_CLICKHOUSE_COMPRESSION_REQUEST === "1",
605+
},
606+
maxOpenConnections: env.RUNS_LIST_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
607+
requestTimeoutMs: getRunsListRequestTimeoutMs(),
608+
clickhouseSettings: getRunsListClickhouseSettings(),
609+
});
553610
case "standard":
554611
case "query":
555612
case "admin":
556-
case "runsList":
557613
return new ClickHouse({
558614
url: parsed.toString(),
559615
name,

apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts

Lines changed: 52 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,22 @@ export class ClickHouseRunsRepository implements IRunsRepository {
411411
}
412412
}
413413

414+
/**
415+
* Builds the shared filter clauses for the runs list against `task_runs_v2 FINAL`.
416+
*
417+
* A filter may go in PREWHERE only if its truth value can never flip true->false across a run's
418+
* versions, because PREWHERE is evaluated before FINAL reconciles versions and would otherwise keep
419+
* a stale matching version and drop the winning one. That holds for columns that are only ever set
420+
* once and never change: trigger-time identity columns (task_identifier, task_version, schedule_id,
421+
* is_test, root_run_id, batch_id, friendly_id, queue, task_kind), append-only arrays under
422+
* `hasAny`/`hasAll` (tags, bulk_action_group_ids), `region` (set once at dequeue, `''` -> value,
423+
* never changes), and `error_fingerprint` (empty until a terminal error status, then fixed). Those
424+
* go in PREWHERE to filter (and, for tags, use the skip index) before FINAL and before materialising
425+
* the wide columns, which is what bounds memory on these scans. Columns that change as a run runs
426+
* stay in WHERE (post-FINAL): `status` (lifecycle) and `machine_preset` (escalates on OOM retry).
427+
* The `(organization_id, project_id, environment_id)` primary-key prefix and the `created_at` range
428+
* also stay in WHERE so they keep driving primary-key and partition pruning.
429+
*/
414430
function applyRunFiltersToQueryBuilder<T>(
415431
queryBuilder: ClickhouseQueryBuilder<T>,
416432
options: FilterRunsOptions
@@ -426,28 +442,14 @@ function applyRunFiltersToQueryBuilder<T>(
426442
environmentId: options.environmentId,
427443
});
428444

429-
if (options.tasks && options.tasks.length > 0) {
430-
queryBuilder.where("task_identifier IN {tasks: Array(String)}", { tasks: options.tasks });
431-
}
432-
433-
if (options.versions && options.versions.length > 0) {
434-
queryBuilder.where("task_version IN {versions: Array(String)}", {
435-
versions: options.versions,
436-
});
437-
}
438-
439445
if (options.statuses && options.statuses.length > 0) {
440446
queryBuilder.where("status IN {statuses: Array(String)}", { statuses: options.statuses });
441447
}
442448

443-
if (options.tags && options.tags.length > 0) {
444-
// Both hasAny and hasAll are served by the tags bloom_filter skip index.
445-
const tagsFn = options.tagsMatch === "all" ? "hasAll" : "hasAny";
446-
queryBuilder.where(`${tagsFn}(tags, {tags: Array(String)})`, { tags: options.tags });
447-
}
448-
449-
if (options.scheduleId) {
450-
queryBuilder.where("schedule_id = {scheduleId: String}", { scheduleId: options.scheduleId });
449+
if (options.machines && options.machines.length > 0) {
450+
queryBuilder.where("machine_preset IN {machines: Array(String)}", {
451+
machines: options.machines,
452+
});
451453
}
452454

453455
// Period is a number of milliseconds duration
@@ -467,49 +469,63 @@ function applyRunFiltersToQueryBuilder<T>(
467469
queryBuilder.where("created_at <= fromUnixTimestamp64Milli({to: Int64})", { to: options.to });
468470
}
469471

472+
if (options.tasks && options.tasks.length > 0) {
473+
queryBuilder.prewhere("task_identifier IN {tasks: Array(String)}", { tasks: options.tasks });
474+
}
475+
476+
if (options.versions && options.versions.length > 0) {
477+
queryBuilder.prewhere("task_version IN {versions: Array(String)}", {
478+
versions: options.versions,
479+
});
480+
}
481+
482+
if (options.tags && options.tags.length > 0) {
483+
// Both hasAny and hasAll are served by the tags bloom_filter skip index.
484+
const tagsFn = options.tagsMatch === "all" ? "hasAll" : "hasAny";
485+
queryBuilder.prewhere(`${tagsFn}(tags, {tags: Array(String)})`, { tags: options.tags });
486+
}
487+
488+
if (options.scheduleId) {
489+
queryBuilder.prewhere("schedule_id = {scheduleId: String}", {
490+
scheduleId: options.scheduleId,
491+
});
492+
}
493+
470494
if (typeof options.isTest === "boolean") {
471-
queryBuilder.where("is_test = {isTest: Boolean}", { isTest: options.isTest });
495+
queryBuilder.prewhere("is_test = {isTest: Boolean}", { isTest: options.isTest });
472496
}
473497

474498
if (options.rootOnly) {
475-
queryBuilder.where("root_run_id = ''");
499+
queryBuilder.prewhere("root_run_id = ''");
476500
}
477501

478502
if (options.batchId) {
479-
queryBuilder.where("batch_id = {batchId: String}", { batchId: options.batchId });
503+
queryBuilder.prewhere("batch_id = {batchId: String}", { batchId: options.batchId });
480504
}
481505

482506
if (options.bulkId) {
483-
queryBuilder.where("hasAny(bulk_action_group_ids, {bulkActionGroupIds: Array(String)})", {
507+
queryBuilder.prewhere("hasAny(bulk_action_group_ids, {bulkActionGroupIds: Array(String)})", {
484508
bulkActionGroupIds: [options.bulkId],
485509
});
486510
}
487511

488512
if (options.runId && options.runId.length > 0) {
489513
// it's important that in the query it's "runIds", otherwise it clashes with the cursor which is called "runId"
490-
queryBuilder.where("friendly_id IN {runIds: Array(String)}", {
514+
queryBuilder.prewhere("friendly_id IN {runIds: Array(String)}", {
491515
runIds: options.runId.map((runId) => RunId.toFriendlyId(runId)),
492516
});
493517
}
494518

495519
if (options.queues && options.queues.length > 0) {
496-
queryBuilder.where("queue IN {queues: Array(String)}", { queues: options.queues });
520+
queryBuilder.prewhere("queue IN {queues: Array(String)}", { queues: options.queues });
497521
}
498522

499523
if (options.regions && options.regions.length > 0) {
500-
queryBuilder.where("if(region != '', region, worker_queue) IN {regions: Array(String)}", {
501-
regions: options.regions,
502-
});
503-
}
504-
505-
if (options.machines && options.machines.length > 0) {
506-
queryBuilder.where("machine_preset IN {machines: Array(String)}", {
507-
machines: options.machines,
508-
});
524+
queryBuilder.prewhere("region IN {regions: Array(String)}", { regions: options.regions });
509525
}
510526

511527
if (options.errorId) {
512-
queryBuilder.where("error_fingerprint = {errorFingerprint: String}", {
528+
queryBuilder.prewhere("error_fingerprint = {errorFingerprint: String}", {
513529
errorFingerprint: ErrorId.toId(options.errorId),
514530
});
515531
}
@@ -520,11 +536,11 @@ function applyRunFiltersToQueryBuilder<T>(
520536
const effectiveKinds = includesStandard ? [...options.taskKinds, ""] : options.taskKinds;
521537

522538
if (effectiveKinds.length === 1) {
523-
queryBuilder.where("task_kind = {taskKind: String}", {
539+
queryBuilder.prewhere("task_kind = {taskKind: String}", {
524540
taskKind: effectiveKinds[0]!,
525541
});
526542
} else {
527-
queryBuilder.where("task_kind IN {taskKinds: Array(String)}", {
543+
queryBuilder.prewhere("task_kind IN {taskKinds: Array(String)}", {
528544
taskKinds: effectiveKinds,
529545
});
530546
}

apps/webapp/app/v3/services/billingLimit/billingLimitQueuedRuns.server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export async function getBillableEnvironmentsForBillingLimit(
3939
export async function createBillingLimitRunsRepository(organizationId: string) {
4040
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
4141
organizationId,
42-
"standard"
42+
"runsList"
4343
);
4444

4545
return new RunsRepository({
@@ -95,7 +95,7 @@ export async function countBillableQueuedRunsForOrganization(
9595
): Promise<number> {
9696
const client =
9797
clickhouse ??
98-
(await clickhouseFactory.getClickhouseForOrganization(organizationId, "standard"));
98+
(await clickhouseFactory.getClickhouseForOrganization(organizationId, "runsList"));
9999

100100
const queryBuilder = client.taskRuns.countQueryBuilder({
101101
settings: { max_execution_time: BILLING_LIMIT_QUEUED_COUNT_MAX_EXECUTION_S },

apps/webapp/app/v3/services/bulk/BulkActionV2.server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export class BulkActionService extends BaseService {
115115
// Count the runs that will be affected by the bulk action
116116
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
117117
organizationId,
118-
"standard"
118+
"runsList"
119119
);
120120
const runsRepository = new RunsRepository({
121121
clickhouse,
@@ -275,7 +275,7 @@ export class BulkActionService extends BaseService {
275275

276276
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
277277
group.project.organizationId,
278-
"standard"
278+
"runsList"
279279
);
280280
const runsRepository = new RunsRepository({
281281
clickhouse,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { ClickHouse } from "@internal/clickhouse";
2+
import { clickhouseTest } from "@internal/testcontainers";
3+
import { describe, expect, vi } from "vitest";
4+
import { z } from "zod";
5+
6+
vi.setConfig({ testTimeout: 60_000 });
7+
8+
describe("runs-list ClickHouse protection settings", () => {
9+
clickhouseTest(
10+
"server-side max_execution_time kills a slow read, and readonly=2 does not block the caps",
11+
async ({ clickhouseContainer }) => {
12+
const clickhouse = new ClickHouse({
13+
url: clickhouseContainer.getConnectionUrl(),
14+
name: "runs-list-settings-test",
15+
requestTimeoutMs: 30_000,
16+
clickhouseSettings: {
17+
max_execution_time: 1,
18+
timeout_before_checking_execution_speed: 0,
19+
max_threads: 2,
20+
readonly: "2",
21+
},
22+
});
23+
24+
const slow = clickhouse.reader.query({
25+
name: "slow-read",
26+
query: "SELECT sum(number) AS total FROM numbers(1000000000000)",
27+
schema: z.object({ total: z.number() }),
28+
});
29+
const [slowError] = await slow({});
30+
31+
expect(slowError).not.toBeNull();
32+
expect(slowError?.message.toLowerCase()).toMatch(/timeout|exceeded/);
33+
34+
const fast = clickhouse.reader.query({
35+
name: "fast-read",
36+
query: "SELECT 1 AS one",
37+
schema: z.object({ one: z.number() }),
38+
});
39+
const [fastError, rows] = await fast({});
40+
41+
expect(fastError).toBeNull();
42+
expect(rows).toEqual([{ one: 1 }]);
43+
}
44+
);
45+
46+
clickhouseTest(
47+
"readonly=2 rejects writes while permitting reads",
48+
async ({ clickhouseContainer }) => {
49+
const clickhouse = new ClickHouse({
50+
url: clickhouseContainer.getConnectionUrl(),
51+
name: "runs-list-readonly-test",
52+
clickhouseSettings: { readonly: "2" },
53+
});
54+
55+
const write = clickhouse.reader.query({
56+
name: "write-under-readonly",
57+
query: "CREATE TABLE trigger_dev.runs_list_readonly_probe (id UInt8) ENGINE = Memory",
58+
schema: z.object({}),
59+
});
60+
const [writeError] = await write({});
61+
62+
expect(writeError).not.toBeNull();
63+
expect(writeError?.message.toLowerCase()).toMatch(/readonly|read-only|read only/);
64+
}
65+
);
66+
});

0 commit comments

Comments
 (0)