Skip to content

Commit 97bf4e9

Browse files
committed
feat(webapp): isolate the runs list ClickHouse read pool
Give the runs-list ClickHouse pool server-side query protection (max_execution_time, thread and memory caps, a per-user concurrency breaker, readonly) so one tenant expensive query cannot saturate the shared read service, and cap the runs list created_at lower bound to a bounded window so an unbounded filter cannot scan every partition. Billing and bulk count reads move to the read pool, off the ingestion writer. Count queries are never date-capped so billing keeps counting runs of any age.
1 parent f98e303 commit 97bf4e9

11 files changed

Lines changed: 237 additions & 9 deletions
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+
The runs list and the runs.list API are more resilient: a single expensive query can no longer slow the runs list down for everyone. The list now loads from a bounded recent time window, which keeps it fast at scale.

apps/webapp/app/env.server.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2233,6 +2233,22 @@ const EnvironmentSchema = z
22332233
.enum(["log", "error", "warn", "info", "debug"])
22342234
.default("info"),
22352235
RUNS_LIST_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
2236+
RUNS_LIST_CLICKHOUSE_REQUEST_TIMEOUT_MS: z.coerce.number().int().default(30_000),
2237+
RUNS_LIST_CLICKHOUSE_MAX_EXECUTION_TIME: z.coerce.number().int().default(35),
2238+
RUNS_LIST_CLICKHOUSE_MAX_THREADS: z.coerce.number().int().optional(),
2239+
RUNS_LIST_CLICKHOUSE_MAX_MEMORY_USAGE: z.coerce.number().int().optional(),
2240+
RUNS_LIST_CLICKHOUSE_MAX_MEMORY_USAGE_FOR_USER: z.coerce.number().int().optional(),
2241+
RUNS_LIST_CLICKHOUSE_MAX_CONCURRENT_QUERIES_FOR_USER: z.coerce.number().int().optional(),
2242+
RUNS_LIST_CLICKHOUSE_READONLY: z.enum(["0", "1", "2"]).default("2"),
2243+
/**
2244+
* Hard cap on how far back the runs list / runs.list API `created_at` lower bound may reach,
2245+
* in milliseconds. The display list adds `created_at >= now - this` so an unbounded filter
2246+
* can't scan all partitions. `0` disables the cap. Does not apply to count queries.
2247+
*/
2248+
RUNS_LIST_MAX_CREATED_AT_AGE_MS: z.coerce
2249+
.number()
2250+
.int()
2251+
.default(30 * 24 * 60 * 60 * 1000),
22362252
/**
22372253
* Dedicated ClickHouse service for queue metrics: the ingestion consumer's inserts and every
22382254
* 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/presenters/v3/NextRunListPresenter.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ export class NextRunListPresenter {
256256
const runsRepository = new RunsRepository({
257257
clickhouse: this.clickhouse,
258258
prisma: this.replica as PrismaClient,
259+
maxCreatedAtAgeMs: env.RUNS_LIST_MAX_CREATED_AT_AGE_MS,
259260
readThrough: this.readThroughDeps
260261
? {
261262
newClient: this.readThroughDeps.newClient ?? this.replica,

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

Lines changed: 53 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,40 @@ function initializeRealtimeClickhouseClient(): ClickHouse {
292292
});
293293
}
294294

295+
/**
296+
* Server-side query protection for the runs-list read pool. Safe as client-level settings ONLY
297+
* because this pool is read-only (no inserts); a client-level `max_execution_time` on a mixed
298+
* read+write pool would also kill slow inserts. `readonly=2` enforces read-only while still
299+
* allowing these settings to apply (`readonly=1` rejects them). `max_concurrent_queries_for_user`
300+
* is a per-ClickHouse-user (`default`) fail-fast circuit breaker, not per-tenant isolation.
301+
*/
302+
function getRunsListClickhouseSettings(): ClickHouseSettings {
303+
const settings: ClickHouseSettings = {
304+
max_execution_time: env.RUNS_LIST_CLICKHOUSE_MAX_EXECUTION_TIME,
305+
timeout_before_checking_execution_speed: 0,
306+
};
307+
308+
if (env.RUNS_LIST_CLICKHOUSE_READONLY !== "0") {
309+
settings.readonly = env.RUNS_LIST_CLICKHOUSE_READONLY;
310+
}
311+
if (env.RUNS_LIST_CLICKHOUSE_MAX_THREADS !== undefined) {
312+
settings.max_threads = env.RUNS_LIST_CLICKHOUSE_MAX_THREADS;
313+
}
314+
if (env.RUNS_LIST_CLICKHOUSE_MAX_MEMORY_USAGE !== undefined) {
315+
settings.max_memory_usage = env.RUNS_LIST_CLICKHOUSE_MAX_MEMORY_USAGE.toString();
316+
}
317+
if (env.RUNS_LIST_CLICKHOUSE_MAX_MEMORY_USAGE_FOR_USER !== undefined) {
318+
settings.max_memory_usage_for_user =
319+
env.RUNS_LIST_CLICKHOUSE_MAX_MEMORY_USAGE_FOR_USER.toString();
320+
}
321+
if (env.RUNS_LIST_CLICKHOUSE_MAX_CONCURRENT_QUERIES_FOR_USER !== undefined) {
322+
settings.max_concurrent_queries_for_user =
323+
env.RUNS_LIST_CLICKHOUSE_MAX_CONCURRENT_QUERIES_FOR_USER;
324+
}
325+
326+
return settings;
327+
}
328+
295329
/** Runs list reads — dashboard + API (`RUNS_LIST_CLICKHOUSE_URL`);
296330
* falls back to the default client if unset. */
297331
const defaultRunsListClickhouseClient = singleton(
@@ -319,6 +353,8 @@ function initializeRunsListClickhouseClient(): ClickHouse {
319353
request: env.RUNS_LIST_CLICKHOUSE_COMPRESSION_REQUEST === "1",
320354
},
321355
maxOpenConnections: env.RUNS_LIST_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
356+
requestTimeoutMs: env.RUNS_LIST_CLICKHOUSE_REQUEST_TIMEOUT_MS,
357+
clickhouseSettings: getRunsListClickhouseSettings(),
322358
});
323359
}
324360

@@ -550,10 +586,25 @@ function buildOrgClickhouseClient(url: string, clientType: ClientType): ClickHou
550586
},
551587
maxOpenConnections: env.REALTIME_BACKEND_NATIVE_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
552588
});
589+
case "runsList":
590+
return new ClickHouse({
591+
url: parsed.toString(),
592+
name,
593+
keepAlive: {
594+
enabled: env.RUNS_LIST_CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
595+
idleSocketTtl: env.RUNS_LIST_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
596+
},
597+
logLevel: env.RUNS_LIST_CLICKHOUSE_LOG_LEVEL,
598+
compression: {
599+
request: env.RUNS_LIST_CLICKHOUSE_COMPRESSION_REQUEST === "1",
600+
},
601+
maxOpenConnections: env.RUNS_LIST_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
602+
requestTimeoutMs: env.RUNS_LIST_CLICKHOUSE_REQUEST_TIMEOUT_MS,
603+
clickhouseSettings: getRunsListClickhouseSettings(),
604+
});
553605
case "standard":
554606
case "query":
555607
case "admin":
556-
case "runsList":
557608
return new ClickHouse({
558609
url: parsed.toString(),
559610
name,

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

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,8 @@ export class ClickHouseRunsRepository implements IRunsRepository {
127127
options,
128128
this.options.prisma,
129129
this.options.runStore ?? runStore
130-
)
130+
),
131+
this.options.maxCreatedAtAgeMs
131132
);
132133

133134
const forward = options.page.direction === "forward" || !options.page.direction;
@@ -335,6 +336,12 @@ export class ClickHouseRunsRepository implements IRunsRepository {
335336
};
336337
}
337338

339+
/**
340+
* Deliberately NOT passed `maxCreatedAtAgeMs`: the only callers are billing limit checks and
341+
* bulk actions, which must count runs of any age (a queued/delayed run older than the window
342+
* still counts). Clamping here would undercount. Runaway counts are bounded instead by the
343+
* read pool's server-side `max_execution_time`, not by a date cap.
344+
*/
338345
async countRuns(options: RunListInputOptions) {
339346
const queryBuilder = this.options.clickhouse.taskRuns.countQueryBuilder();
340347
applyRunFiltersToQueryBuilder(
@@ -411,9 +418,15 @@ export class ClickHouseRunsRepository implements IRunsRepository {
411418
}
412419
}
413420

421+
/**
422+
* Builds the shared WHERE clauses for the runs list. `maxCreatedAtAgeMs` (when > 0) floors the
423+
* `created_at` lower bound to `now - maxCreatedAtAgeMs`; it is ANDed with any period/from filter,
424+
* so the tighter bound wins, and it keeps an unbounded filter from scanning every partition.
425+
*/
414426
function applyRunFiltersToQueryBuilder<T>(
415427
queryBuilder: ClickhouseQueryBuilder<T>,
416-
options: FilterRunsOptions
428+
options: FilterRunsOptions,
429+
maxCreatedAtAgeMs?: number
417430
) {
418431
queryBuilder
419432
.where("organization_id = {organizationId: String}", {
@@ -426,6 +439,12 @@ function applyRunFiltersToQueryBuilder<T>(
426439
environmentId: options.environmentId,
427440
});
428441

442+
if (typeof maxCreatedAtAgeMs === "number" && maxCreatedAtAgeMs > 0) {
443+
queryBuilder.where("created_at >= fromUnixTimestamp64Milli({createdAtFloor: Int64})", {
444+
createdAtFloor: Date.now() - maxCreatedAtAgeMs,
445+
});
446+
}
447+
429448
if (options.tasks && options.tasks.length > 0) {
430449
queryBuilder.where("task_identifier IN {tasks: Array(String)}", { tasks: options.tasks });
431450
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ export type RunsRepositoryOptions = {
3333
// Resolved boot constant; when false the split branch is never entered.
3434
splitEnabled?: boolean;
3535
};
36+
37+
/**
38+
* Hard cap on how far back the run-listing `created_at` lower bound may reach, in ms. When set
39+
* and > 0, the list queries add `created_at >= now - maxCreatedAtAgeMs` so an unbounded filter
40+
* can't scan every partition. Omitted / 0 => no cap. Applies to `listRuns`/`listRunIds` only,
41+
* never to `countRuns` (billing and bulk counts must count runs of any age).
42+
*/
43+
maxCreatedAtAgeMs?: number;
3644
};
3745

3846
const RunStatus = z.enum(Object.values(TaskRunStatus) as [TaskRunStatus, ...TaskRunStatus[]]);

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)