Skip to content

Commit 1a0198c

Browse files
authored
perf(webapp,clickhouse): move runs empty-state check to ClickHouse (#4202)
## Summary The runs page's empty-state check (whether an environment has ever had a run, which decides between the "getting started" and "no runs match your filters" states) ran a `findFirst` against the Postgres `TaskRun` table. This moves it to ClickHouse, the same store the runs list itself reads from, so the check no longer queries `TaskRun`. ## Design Only the runs list triggers the check now (via an `includeHasAnyRuns` flag); the other presenters that reuse `NextRunListPresenter` (API, schedule detail, waitpoint detail, error group) no longer issue it. When the list is empty it runs `SELECT 1 FROM task_runs_v2 ... LIMIT 1` filtered on the full `(organization_id, project_id, environment_id)` sort-key prefix with a configurable `created_at` lower bound (`RUN_LIST_HAS_RUNS_LOOKBACK_DAYS`, default 30), so it hits the primary index and reads minimal granules. Results are cached in a tiered memory + Redis SWR cache. Only positive ("has runs") results are cached, so an environment with no runs is always re-checked and its first run shows up immediately.
1 parent 0631c83 commit 1a0198c

9 files changed

Lines changed: 160 additions & 239 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",

0 commit comments

Comments
 (0)