Skip to content

Commit d314be8

Browse files
committed
perf(webapp,clickhouse): move runs empty-state check to ClickHouse
The runs list empty-state check ("does this environment have any run?") ran a findFirst against the Postgres TaskRun table. Serve it from ClickHouse instead (the same store the runs list already reads), so the check no longer queries TaskRun. Only the runs list issues the check now; it filters the (organization, project, environment) prefix with a configurable created_at lower bound and is SWR-cached (positive results only) so a new environment's first run is picked up immediately.
1 parent a3dca98 commit d314be8

8 files changed

Lines changed: 158 additions & 29 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+
Reduce primary database load on the runs page by serving its empty-state check from ClickHouse instead of Postgres.

apps/webapp/app/env.server.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,13 @@ const EnvironmentSchema = z
330330
.string()
331331
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
332332
TASK_META_CACHE_CURRENT_ENV_TTL_SECONDS: z.coerce.number().default(86400),
333+
334+
// Runs-list empty-state check: how far back the ClickHouse "does this env have any run"
335+
// probe looks. Bounds the prove-absence partition scan. 0 = unbounded ("any run ever").
336+
RUN_LIST_HAS_RUNS_LOOKBACK_DAYS: z.coerce.number().default(30),
337+
// SWR TTLs for the empty-state has-runs cache (memory + Redis).
338+
RUN_LIST_HAS_RUNS_CACHE_FRESH_MS: z.coerce.number().default(86_400_000),
339+
RUN_LIST_HAS_RUNS_CACHE_STALE_MS: z.coerce.number().default(604_800_000),
333340
TASK_META_CACHE_BY_WORKER_TTL_SECONDS: z.coerce.number().default(2592000),
334341

335342
REALTIME_STREAMS_REDIS_HOST: z

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

Lines changed: 67 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,47 @@ import { timeFilters } from "~/components/runs/v3/SharedFilters";
1111
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
1212
import { getTaskIdentifiers } from "~/models/task.server";
1313
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
14+
import { env } from "~/env.server";
15+
import {
16+
createCache,
17+
createLRUMemoryStore,
18+
DefaultStatefulContext,
19+
Namespace,
20+
} from "@internal/cache";
21+
import { RedisCacheStore } from "~/services/unkey/redisCacheStore.server";
22+
import { singleton } from "~/utils/singleton";
1423
import { regionForDisplay } from "~/runEngine/concerns/workerQueueSplit.server";
1524
import { machinePresetFromRun } from "~/v3/machinePresets.server";
1625
import { ServiceValidationError } from "~/v3/services/baseService.server";
1726
import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/v3/taskStatus";
1827

28+
// Positive-only cache: only envs known to have runs are stored (empty envs are re-checked),
29+
// so "has runs" is monotonic and the TTL can be very long. Tiered memory + Redis.
30+
const runsExistCache = singleton("runsExistCache", () => {
31+
const ctx = new DefaultStatefulContext();
32+
const memory = createLRUMemoryStore(5000, "runs-has-runs-cache");
33+
const redis = new RedisCacheStore({
34+
name: "runs-has-runs",
35+
connection: {
36+
keyPrefix: "tr:cache:runs-has-runs",
37+
port: env.CACHE_REDIS_PORT,
38+
host: env.CACHE_REDIS_HOST,
39+
username: env.CACHE_REDIS_USERNAME,
40+
password: env.CACHE_REDIS_PASSWORD,
41+
tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true",
42+
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
43+
},
44+
});
45+
46+
return createCache({
47+
hasRuns: new Namespace<boolean>(ctx, {
48+
stores: [memory, redis],
49+
fresh: env.RUN_LIST_HAS_RUNS_CACHE_FRESH_MS,
50+
stale: env.RUN_LIST_HAS_RUNS_CACHE_STALE_MS,
51+
}),
52+
});
53+
});
54+
1955
export type RunListOptions = {
2056
userId?: string;
2157
projectId: string;
@@ -42,6 +78,8 @@ export type RunListOptions = {
4278
direction?: Direction;
4379
cursor?: string;
4480
pageSize?: number;
81+
// Run the empty-state "has any run ever" probe. Only the runs list consumes it.
82+
includeHasAnyRuns?: boolean;
4583
};
4684

4785
const DEFAULT_PAGE_SIZE = 25;
@@ -65,37 +103,31 @@ export class NextRunListPresenter {
65103
}
66104
) {}
67105

68-
// Existence probe for the empty-state. run-ops (TaskRun) read.
69-
// Split off / single-DB: one plain findFirst on `replica` (passthrough).
70-
// Split on: true if a row exists in the NEW run-ops DB OR the LEGACY run-ops
71-
// read replica only (never the legacy writer). New first to avoid touching
72-
// legacy whenever the new DB already answers.
73-
async #anyRunExistsInEnv(environmentId: string): Promise<boolean> {
74-
const splitEnabled = this.readThroughDeps?.splitEnabled ?? false;
106+
// Empty-state existence probe, served from ClickHouse (same connection as the runs
107+
// list) so it no longer scans TaskRun in Postgres. SWR-cached to spare ClickHouse;
108+
// RUN_LIST_HAS_RUNS_LOOKBACK_DAYS bounds the prove-absence partition scan.
109+
async #anyRunExistsInEnv(
110+
runsRepository: RunsRepository,
111+
organizationId: string,
112+
projectId: string,
113+
environmentId: string
114+
): Promise<boolean> {
115+
const lookbackDays = env.RUN_LIST_HAS_RUNS_LOOKBACK_DAYS;
116+
const createdAtLowerBoundMs =
117+
lookbackDays > 0 ? Date.now() - lookbackDays * 24 * 60 * 60 * 1000 : undefined;
75118

76-
if (!splitEnabled) {
77-
const firstRun = await this.replica.taskRun.findFirst({
78-
where: { runtimeEnvironmentId: environmentId },
79-
select: { id: true },
119+
const result = await runsExistCache.hasRuns.swr(environmentId, async () => {
120+
const exists = await runsRepository.runExistsInEnvironment({
121+
organizationId,
122+
projectId,
123+
environmentId,
124+
createdAtLowerBoundMs,
80125
});
81-
return firstRun !== null;
82-
}
83-
84-
const newClient = this.readThroughDeps?.newClient ?? this.replica;
85-
const newRun = await newClient.taskRun.findFirst({
86-
where: { runtimeEnvironmentId: environmentId },
87-
select: { id: true },
126+
// undefined (not false) so swr does NOT cache the empty result — re-check until a run exists.
127+
return exists ? true : undefined;
88128
});
89-
if (newRun) {
90-
return true;
91-
}
92129

93-
const legacyReplica = this.readThroughDeps?.legacyReplica ?? this.replica;
94-
const legacyRun = await legacyReplica.taskRun.findFirst({
95-
where: { runtimeEnvironmentId: environmentId },
96-
select: { id: true },
97-
});
98-
return legacyRun !== null;
130+
return result.val ?? false;
99131
}
100132

101133
public async call(
@@ -125,6 +157,7 @@ export class NextRunListPresenter {
125157
direction = "forward",
126158
cursor,
127159
pageSize = DEFAULT_PAGE_SIZE,
160+
includeHasAnyRuns = false,
128161
}: RunListOptions
129162
) {
130163
//get the time values from the raw values (including a default period)
@@ -252,8 +285,13 @@ export class NextRunListPresenter {
252285

253286
let hasAnyRuns = runs.length > 0;
254287

255-
if (!hasAnyRuns) {
256-
hasAnyRuns = await this.#anyRunExistsInEnv(environmentId);
288+
if (!hasAnyRuns && includeHasAnyRuns) {
289+
hasAnyRuns = await this.#anyRunExistsInEnv(
290+
runsRepository,
291+
organizationId,
292+
projectId,
293+
environmentId
294+
);
257295
}
258296

259297
return {

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
111111
userId,
112112
projectId: project.id,
113113
...filters,
114+
includeHasAnyRuns: true,
114115
});
115116

116117
// Only persist rootOnly when no tasks are filtered. While a task filter is active,

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,38 @@ export class ClickHouseRunsRepository implements IRunsRepository {
3535
return "clickhouse";
3636
}
3737

38+
async runExistsInEnvironment(options: {
39+
organizationId: string;
40+
projectId: string;
41+
environmentId: string;
42+
createdAtLowerBoundMs?: number;
43+
}): Promise<boolean> {
44+
const queryBuilder = this.options.clickhouse.taskRuns.existsQueryBuilder();
45+
46+
queryBuilder
47+
.where("organization_id = {organizationId: String}", {
48+
organizationId: options.organizationId,
49+
})
50+
.where("project_id = {projectId: String}", { projectId: options.projectId })
51+
.where("environment_id = {environmentId: String}", { environmentId: options.environmentId });
52+
53+
if (typeof options.createdAtLowerBoundMs === "number") {
54+
queryBuilder.where("created_at >= fromUnixTimestamp64Milli({createdAtLowerBound: Int64})", {
55+
createdAtLowerBound: options.createdAtLowerBoundMs,
56+
});
57+
}
58+
59+
queryBuilder.limit(1);
60+
61+
const [queryError, result] = await queryBuilder.execute();
62+
63+
if (queryError) {
64+
throw queryError;
65+
}
66+
67+
return (result?.length ?? 0) > 0;
68+
}
69+
3870
/**
3971
* Runs the keyset-paginated query and returns `{ runId, createdAt }` rows
4072
* (one extra beyond `page.size` to signal "has more"). The ordering is always

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,12 @@ export interface IRunsRepository {
176176
}>;
177177
countRuns(options: RunListInputOptions): Promise<number>;
178178
listTags(options: TagListOptions): Promise<TagList>;
179+
runExistsInEnvironment(options: {
180+
organizationId: string;
181+
projectId: string;
182+
environmentId: string;
183+
createdAtLowerBoundMs?: number;
184+
}): Promise<boolean>;
179185
}
180186

181187
export class RunsRepository implements IRunsRepository {
@@ -189,6 +195,26 @@ export class RunsRepository implements IRunsRepository {
189195
return "runsRepository";
190196
}
191197

198+
async runExistsInEnvironment(options: {
199+
organizationId: string;
200+
projectId: string;
201+
environmentId: string;
202+
createdAtLowerBoundMs?: number;
203+
}): Promise<boolean> {
204+
return startActiveSpan(
205+
"runsRepository.runExistsInEnvironment",
206+
async () => this.clickHouseRunsRepository.runExistsInEnvironment(options),
207+
{
208+
attributes: {
209+
"repository.name": "clickhouse",
210+
organizationId: options.organizationId,
211+
projectId: options.projectId,
212+
environmentId: options.environmentId,
213+
},
214+
}
215+
);
216+
}
217+
192218
async listRunIds(options: ListRunsOptions): Promise<RunIdsPage> {
193219
return startActiveSpan(
194220
"runsRepository.listRunIds",

internal-packages/clickhouse/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
getTaskRunsCountQueryBuilder,
1616
getTaskRunTagsQueryBuilder,
1717
getPendingVersionIdsQueryBuilder,
18+
getTaskRunExistsQueryBuilder,
1819
} from "./taskRuns.js";
1920
import {
2021
getSpanDetailsQueryBuilder,
@@ -228,6 +229,7 @@ export class ClickHouse {
228229
insertPayloadsCompactArrays: insertRawTaskRunPayloadsCompactArrays(this.writer),
229230
queryBuilder: getTaskRunsQueryBuilder(this.reader),
230231
countQueryBuilder: getTaskRunsCountQueryBuilder(this.reader),
232+
existsQueryBuilder: getTaskRunExistsQueryBuilder(this.reader, { max_execution_time: 10 }),
231233
tagQueryBuilder: getTaskRunTagsQueryBuilder(this.reader),
232234
pendingVersionIdsQueryBuilder: getPendingVersionIdsQueryBuilder(this.reader),
233235
getTaskActivity: getTaskActivityQueryBuilder(this.reader),

internal-packages/clickhouse/src/taskRuns.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,23 @@ export function getTaskRunsCountQueryBuilder(ch: ClickhouseReader, settings?: Cl
461461
});
462462
}
463463

464+
export const TaskRunExistsQueryResult = z.object({
465+
run_exists: z.number().int(),
466+
});
467+
468+
export type TaskRunExistsQueryResult = z.infer<typeof TaskRunExistsQueryResult>;
469+
470+
// Empty-state existence probe. No FINAL (a stale/dup row still answers "a run exists").
471+
// Callers must filter organization_id + project_id + environment_id (the sort-key prefix).
472+
export function getTaskRunExistsQueryBuilder(ch: ClickhouseReader, settings?: ClickHouseSettings) {
473+
return ch.queryBuilder({
474+
name: "getTaskRunExists",
475+
baseQuery: "SELECT 1 AS run_exists FROM trigger_dev.task_runs_v2",
476+
schema: TaskRunExistsQueryResult,
477+
settings,
478+
});
479+
}
480+
464481
export const TaskRunTagsQueryResult = z.object({
465482
tag: z.string(),
466483
});

0 commit comments

Comments
 (0)