Skip to content

Commit d2bbe1b

Browse files
committed
refactor(webapp,clickhouse): route runs-list filters through PREWHERE instead of a created_at clamp
Replaces the created_at window clamp with PREWHERE routing on the runs-list query. Immutable and additive-only filters (tags, task_identifier, and the rest) move into PREWHERE so ClickHouse filters, and uses the tags skip index, before FINAL reconciles versions and before materialising the wide columns. This bounds the memory a filtered runs-list query uses without dropping any rows, unlike the date clamp which hid older runs from the list and the runs.list API. status stays in WHERE (post-FINAL): it is the one lifecycle-mutable filter, so PREWHERE-ing it would keep a stale version and drop the winning one.
1 parent 97bf4e9 commit d2bbe1b

8 files changed

Lines changed: 147 additions & 130 deletions

File tree

.server-changes/runs-list-read-isolation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ area: webapp
33
type: improvement
44
---
55

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.
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: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2240,15 +2240,6 @@ const EnvironmentSchema = z
22402240
RUNS_LIST_CLICKHOUSE_MAX_MEMORY_USAGE_FOR_USER: z.coerce.number().int().optional(),
22412241
RUNS_LIST_CLICKHOUSE_MAX_CONCURRENT_QUERIES_FOR_USER: z.coerce.number().int().optional(),
22422242
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),
22522243
/**
22532244
* Dedicated ClickHouse service for queue metrics: the ingestion consumer's inserts and every
22542245
* queue-metrics read (dashboards, queue pages, run inspector, health report) go through it, so

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,6 @@ 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,
260259
readThrough: this.readThroughDeps
261260
? {
262261
newClient: this.readThroughDeps.newClient ?? this.replica,

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

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

134133
const forward = options.page.direction === "forward" || !options.page.direction;
@@ -336,12 +335,6 @@ export class ClickHouseRunsRepository implements IRunsRepository {
336335
};
337336
}
338337

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-
*/
345338
async countRuns(options: RunListInputOptions) {
346339
const queryBuilder = this.options.clickhouse.taskRuns.countQueryBuilder();
347340
applyRunFiltersToQueryBuilder(
@@ -419,14 +412,18 @@ export class ClickHouseRunsRepository implements IRunsRepository {
419412
}
420413

421414
/**
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.
415+
* Builds the shared filter clauses for the runs list against `task_runs_v2 FINAL`.
416+
*
417+
* Immutable / additive-only columns go in PREWHERE so ClickHouse filters (and, for `tags`, uses
418+
* the skip index) before FINAL reconciles versions and before materialising the wide columns,
419+
* which is what bounds memory on these scans. `status` is the one lifecycle-mutable filter, so it
420+
* stays in WHERE (post-FINAL): PREWHERE-ing it would keep a stale version and drop the winner. The
421+
* `(organization_id, project_id, environment_id)` primary-key prefix and the `created_at` range
422+
* stay in WHERE so they keep driving primary-key and partition pruning.
425423
*/
426424
function applyRunFiltersToQueryBuilder<T>(
427425
queryBuilder: ClickhouseQueryBuilder<T>,
428-
options: FilterRunsOptions,
429-
maxCreatedAtAgeMs?: number
426+
options: FilterRunsOptions
430427
) {
431428
queryBuilder
432429
.where("organization_id = {organizationId: String}", {
@@ -439,36 +436,10 @@ function applyRunFiltersToQueryBuilder<T>(
439436
environmentId: options.environmentId,
440437
});
441438

442-
if (typeof maxCreatedAtAgeMs === "number" && maxCreatedAtAgeMs > 0) {
443-
queryBuilder.where("created_at >= fromUnixTimestamp64Milli({createdAtFloor: Int64})", {
444-
createdAtFloor: Date.now() - maxCreatedAtAgeMs,
445-
});
446-
}
447-
448-
if (options.tasks && options.tasks.length > 0) {
449-
queryBuilder.where("task_identifier IN {tasks: Array(String)}", { tasks: options.tasks });
450-
}
451-
452-
if (options.versions && options.versions.length > 0) {
453-
queryBuilder.where("task_version IN {versions: Array(String)}", {
454-
versions: options.versions,
455-
});
456-
}
457-
458439
if (options.statuses && options.statuses.length > 0) {
459440
queryBuilder.where("status IN {statuses: Array(String)}", { statuses: options.statuses });
460441
}
461442

462-
if (options.tags && options.tags.length > 0) {
463-
// Both hasAny and hasAll are served by the tags bloom_filter skip index.
464-
const tagsFn = options.tagsMatch === "all" ? "hasAll" : "hasAny";
465-
queryBuilder.where(`${tagsFn}(tags, {tags: Array(String)})`, { tags: options.tags });
466-
}
467-
468-
if (options.scheduleId) {
469-
queryBuilder.where("schedule_id = {scheduleId: String}", { scheduleId: options.scheduleId });
470-
}
471-
472443
// Period is a number of milliseconds duration
473444
if (options.period) {
474445
queryBuilder.where("created_at >= fromUnixTimestamp64Milli({period: Int64})", {
@@ -486,49 +457,71 @@ function applyRunFiltersToQueryBuilder<T>(
486457
queryBuilder.where("created_at <= fromUnixTimestamp64Milli({to: Int64})", { to: options.to });
487458
}
488459

460+
if (options.tasks && options.tasks.length > 0) {
461+
queryBuilder.prewhere("task_identifier IN {tasks: Array(String)}", { tasks: options.tasks });
462+
}
463+
464+
if (options.versions && options.versions.length > 0) {
465+
queryBuilder.prewhere("task_version IN {versions: Array(String)}", {
466+
versions: options.versions,
467+
});
468+
}
469+
470+
if (options.tags && options.tags.length > 0) {
471+
// Both hasAny and hasAll are served by the tags bloom_filter skip index.
472+
const tagsFn = options.tagsMatch === "all" ? "hasAll" : "hasAny";
473+
queryBuilder.prewhere(`${tagsFn}(tags, {tags: Array(String)})`, { tags: options.tags });
474+
}
475+
476+
if (options.scheduleId) {
477+
queryBuilder.prewhere("schedule_id = {scheduleId: String}", {
478+
scheduleId: options.scheduleId,
479+
});
480+
}
481+
489482
if (typeof options.isTest === "boolean") {
490-
queryBuilder.where("is_test = {isTest: Boolean}", { isTest: options.isTest });
483+
queryBuilder.prewhere("is_test = {isTest: Boolean}", { isTest: options.isTest });
491484
}
492485

493486
if (options.rootOnly) {
494-
queryBuilder.where("root_run_id = ''");
487+
queryBuilder.prewhere("root_run_id = ''");
495488
}
496489

497490
if (options.batchId) {
498-
queryBuilder.where("batch_id = {batchId: String}", { batchId: options.batchId });
491+
queryBuilder.prewhere("batch_id = {batchId: String}", { batchId: options.batchId });
499492
}
500493

501494
if (options.bulkId) {
502-
queryBuilder.where("hasAny(bulk_action_group_ids, {bulkActionGroupIds: Array(String)})", {
495+
queryBuilder.prewhere("hasAny(bulk_action_group_ids, {bulkActionGroupIds: Array(String)})", {
503496
bulkActionGroupIds: [options.bulkId],
504497
});
505498
}
506499

507500
if (options.runId && options.runId.length > 0) {
508501
// it's important that in the query it's "runIds", otherwise it clashes with the cursor which is called "runId"
509-
queryBuilder.where("friendly_id IN {runIds: Array(String)}", {
502+
queryBuilder.prewhere("friendly_id IN {runIds: Array(String)}", {
510503
runIds: options.runId.map((runId) => RunId.toFriendlyId(runId)),
511504
});
512505
}
513506

514507
if (options.queues && options.queues.length > 0) {
515-
queryBuilder.where("queue IN {queues: Array(String)}", { queues: options.queues });
508+
queryBuilder.prewhere("queue IN {queues: Array(String)}", { queues: options.queues });
516509
}
517510

518511
if (options.regions && options.regions.length > 0) {
519-
queryBuilder.where("if(region != '', region, worker_queue) IN {regions: Array(String)}", {
512+
queryBuilder.prewhere("if(region != '', region, worker_queue) IN {regions: Array(String)}", {
520513
regions: options.regions,
521514
});
522515
}
523516

524517
if (options.machines && options.machines.length > 0) {
525-
queryBuilder.where("machine_preset IN {machines: Array(String)}", {
518+
queryBuilder.prewhere("machine_preset IN {machines: Array(String)}", {
526519
machines: options.machines,
527520
});
528521
}
529522

530523
if (options.errorId) {
531-
queryBuilder.where("error_fingerprint = {errorFingerprint: String}", {
524+
queryBuilder.prewhere("error_fingerprint = {errorFingerprint: String}", {
532525
errorFingerprint: ErrorId.toId(options.errorId),
533526
});
534527
}
@@ -539,11 +532,11 @@ function applyRunFiltersToQueryBuilder<T>(
539532
const effectiveKinds = includesStandard ? [...options.taskKinds, ""] : options.taskKinds;
540533

541534
if (effectiveKinds.length === 1) {
542-
queryBuilder.where("task_kind = {taskKind: String}", {
535+
queryBuilder.prewhere("task_kind = {taskKind: String}", {
543536
taskKind: effectiveKinds[0]!,
544537
});
545538
} else {
546-
queryBuilder.where("task_kind IN {taskKinds: Array(String)}", {
539+
queryBuilder.prewhere("task_kind IN {taskKinds: Array(String)}", {
547540
taskKinds: effectiveKinds,
548541
});
549542
}

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

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,6 @@ 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;
4436
};
4537

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

apps/webapp/test/runsListCreatedAtClamp.test.ts

Lines changed: 0 additions & 61 deletions
This file was deleted.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { ClickHouse } from "@internal/clickhouse";
2+
import { containerTest } from "@internal/testcontainers";
3+
import { describe, expect, vi } from "vitest";
4+
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
5+
import {
6+
createRun,
7+
insertTaskRunV2Rows,
8+
seedParents,
9+
} from "./helpers/apiRunListPresenterTestHelpers";
10+
11+
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
12+
13+
vi.setConfig({ testTimeout: 90_000 });
14+
15+
const DAY_MS = 24 * 60 * 60 * 1000;
16+
17+
describe("runs list query shape (PREWHERE routing under FINAL)", () => {
18+
containerTest(
19+
"keeps status post-FINAL and returns old pending runs (no date clamp)",
20+
async ({ clickhouseContainer, prisma }) => {
21+
const clickhouse = new ClickHouse({
22+
url: clickhouseContainer.getConnectionUrl(),
23+
name: "query-shape-test",
24+
});
25+
26+
const ctx = await seedParents(prisma, "shape");
27+
28+
const completed = await createRun(prisma, ctx, { friendlyId: "run_completed" });
29+
const pendingRecent = await createRun(prisma, ctx, { friendlyId: "run_pending_recent" });
30+
const pendingOld = await createRun(prisma, ctx, { friendlyId: "run_pending_old" });
31+
32+
const base = {
33+
taskIdentifier: "webhook.deliver",
34+
runTags: ["booking:T"],
35+
createdAt: new Date(Date.now() - 1 * DAY_MS),
36+
};
37+
38+
await insertTaskRunV2Rows(clickhouse, [
39+
{ ...completed, ...base, status: "PENDING", updatedAt: new Date(Date.now() - 2 * DAY_MS) },
40+
{
41+
...completed,
42+
...base,
43+
status: "COMPLETED",
44+
updatedAt: new Date(Date.now() - 1 * DAY_MS),
45+
},
46+
{
47+
...pendingRecent,
48+
...base,
49+
status: "PENDING",
50+
updatedAt: new Date(Date.now() - 1 * DAY_MS),
51+
},
52+
{
53+
...pendingOld,
54+
...base,
55+
status: "PENDING",
56+
createdAt: new Date(Date.now() - 60 * DAY_MS),
57+
updatedAt: new Date(Date.now() - 60 * DAY_MS),
58+
},
59+
]);
60+
61+
const repository = new RunsRepository({ prisma, clickhouse });
62+
63+
const { runIds } = await repository.listRunIds({
64+
page: { size: 10 },
65+
period: "365d",
66+
organizationId: ctx.organizationId,
67+
projectId: ctx.projectId,
68+
environmentId: ctx.environmentId,
69+
tasks: ["webhook.deliver"],
70+
tags: ["booking:T"],
71+
statuses: ["PENDING", "DELAYED"],
72+
});
73+
74+
expect(runIds.sort()).toEqual([pendingOld.id, pendingRecent.id].sort());
75+
}
76+
);
77+
});

0 commit comments

Comments
 (0)