Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/runs-empty-state-clickhouse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---

Reduce primary database load on the runs page by serving its empty-state check from ClickHouse instead of Postgres.
7 changes: 7 additions & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,13 @@ const EnvironmentSchema = z
.string()
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
TASK_META_CACHE_CURRENT_ENV_TTL_SECONDS: z.coerce.number().default(86400),

// Runs-list empty-state check: how far back the ClickHouse "does this env have any run"
// probe looks. Bounds the prove-absence partition scan. 0 = unbounded ("any run ever").
RUN_LIST_HAS_RUNS_LOOKBACK_DAYS: z.coerce.number().default(30),
// SWR TTLs for the empty-state has-runs cache (memory + Redis).
RUN_LIST_HAS_RUNS_CACHE_FRESH_MS: z.coerce.number().default(86_400_000),
RUN_LIST_HAS_RUNS_CACHE_STALE_MS: z.coerce.number().default(604_800_000),
TASK_META_CACHE_BY_WORKER_TTL_SECONDS: z.coerce.number().default(2592000),

REALTIME_STREAMS_REDIS_HOST: z
Expand Down
96 changes: 67 additions & 29 deletions apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,47 @@ import { timeFilters } from "~/components/runs/v3/SharedFilters";
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
import { getTaskIdentifiers } from "~/models/task.server";
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { env } from "~/env.server";
import {
createCache,
createLRUMemoryStore,
DefaultStatefulContext,
Namespace,
} from "@internal/cache";
import { RedisCacheStore } from "~/services/unkey/redisCacheStore.server";
import { singleton } from "~/utils/singleton";
import { regionForDisplay } from "~/runEngine/concerns/workerQueueSplit.server";
import { machinePresetFromRun } from "~/v3/machinePresets.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/v3/taskStatus";

// Positive-only cache: only envs known to have runs are stored (empty envs are re-checked),
// so "has runs" is monotonic and the TTL can be very long. Tiered memory + Redis.
const runsExistCache = singleton("runsExistCache", () => {
const ctx = new DefaultStatefulContext();
const memory = createLRUMemoryStore(5000, "runs-has-runs-cache");
const redis = new RedisCacheStore({
name: "runs-has-runs",
connection: {
keyPrefix: "tr:cache:runs-has-runs",
port: env.CACHE_REDIS_PORT,
host: env.CACHE_REDIS_HOST,
username: env.CACHE_REDIS_USERNAME,
password: env.CACHE_REDIS_PASSWORD,
tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true",
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
},
});

return createCache({
hasRuns: new Namespace<boolean>(ctx, {
stores: [memory, redis],
fresh: env.RUN_LIST_HAS_RUNS_CACHE_FRESH_MS,
stale: env.RUN_LIST_HAS_RUNS_CACHE_STALE_MS,
}),
});
});

export type RunListOptions = {
userId?: string;
projectId: string;
Expand All @@ -42,6 +78,8 @@ export type RunListOptions = {
direction?: Direction;
cursor?: string;
pageSize?: number;
// Run the empty-state "has any run ever" probe. Only the runs list consumes it.
includeHasAnyRuns?: boolean;
};

const DEFAULT_PAGE_SIZE = 25;
Expand All @@ -65,37 +103,31 @@ export class NextRunListPresenter {
}
) {}

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

if (!splitEnabled) {
const firstRun = await this.replica.taskRun.findFirst({
where: { runtimeEnvironmentId: environmentId },
select: { id: true },
const result = await runsExistCache.hasRuns.swr(environmentId, async () => {
const exists = await runsRepository.runExistsInEnvironment({
organizationId,
projectId,
environmentId,
createdAtLowerBoundMs,
});
return firstRun !== null;
}

const newClient = this.readThroughDeps?.newClient ?? this.replica;
const newRun = await newClient.taskRun.findFirst({
where: { runtimeEnvironmentId: environmentId },
select: { id: true },
// undefined (not false) so swr does NOT cache the empty result — re-check until a run exists.
return exists ? true : undefined;
});
if (newRun) {
return true;
}

const legacyReplica = this.readThroughDeps?.legacyReplica ?? this.replica;
const legacyRun = await legacyReplica.taskRun.findFirst({
where: { runtimeEnvironmentId: environmentId },
select: { id: true },
});
return legacyRun !== null;
return result.val ?? false;
}
Comment thread
ericallam marked this conversation as resolved.

public async call(
Expand Down Expand Up @@ -125,6 +157,7 @@ export class NextRunListPresenter {
direction = "forward",
cursor,
pageSize = DEFAULT_PAGE_SIZE,
includeHasAnyRuns = false,
}: RunListOptions
) {
//get the time values from the raw values (including a default period)
Expand Down Expand Up @@ -252,8 +285,13 @@ export class NextRunListPresenter {

let hasAnyRuns = runs.length > 0;

if (!hasAnyRuns) {
hasAnyRuns = await this.#anyRunExistsInEnv(environmentId);
if (!hasAnyRuns && includeHasAnyRuns) {
hasAnyRuns = await this.#anyRunExistsInEnv(
runsRepository,
organizationId,
projectId,
environmentId
);
}

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
userId,
projectId: project.id,
...filters,
includeHasAnyRuns: true,
});

// Only persist rootOnly when no tasks are filtered. While a task filter is active,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,38 @@ export class ClickHouseRunsRepository implements IRunsRepository {
return "clickhouse";
}

async runExistsInEnvironment(options: {
organizationId: string;
projectId: string;
environmentId: string;
createdAtLowerBoundMs?: number;
}): Promise<boolean> {
const queryBuilder = this.options.clickhouse.taskRuns.existsQueryBuilder();

queryBuilder
.where("organization_id = {organizationId: String}", {
organizationId: options.organizationId,
})
.where("project_id = {projectId: String}", { projectId: options.projectId })
.where("environment_id = {environmentId: String}", { environmentId: options.environmentId });

if (typeof options.createdAtLowerBoundMs === "number") {
queryBuilder.where("created_at >= fromUnixTimestamp64Milli({createdAtLowerBound: Int64})", {
createdAtLowerBound: options.createdAtLowerBoundMs,
});
}

queryBuilder.limit(1);

const [queryError, result] = await queryBuilder.execute();

if (queryError) {
throw queryError;
}

return (result?.length ?? 0) > 0;
}

/**
* Runs the keyset-paginated query and returns `{ runId, createdAt }` rows
* (one extra beyond `page.size` to signal "has more"). The ordering is always
Expand Down
26 changes: 26 additions & 0 deletions apps/webapp/app/services/runsRepository/runsRepository.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,12 @@ export interface IRunsRepository {
}>;
countRuns(options: RunListInputOptions): Promise<number>;
listTags(options: TagListOptions): Promise<TagList>;
runExistsInEnvironment(options: {
organizationId: string;
projectId: string;
environmentId: string;
createdAtLowerBoundMs?: number;
}): Promise<boolean>;
}

export class RunsRepository implements IRunsRepository {
Expand All @@ -189,6 +195,26 @@ export class RunsRepository implements IRunsRepository {
return "runsRepository";
}

async runExistsInEnvironment(options: {
organizationId: string;
projectId: string;
environmentId: string;
createdAtLowerBoundMs?: number;
}): Promise<boolean> {
return startActiveSpan(
"runsRepository.runExistsInEnvironment",
async () => this.clickHouseRunsRepository.runExistsInEnvironment(options),
{
attributes: {
"repository.name": "clickhouse",
organizationId: options.organizationId,
projectId: options.projectId,
environmentId: options.environmentId,
},
}
);
}

async listRunIds(options: ListRunsOptions): Promise<RunIdsPage> {
return startActiveSpan(
"runsRepository.listRunIds",
Expand Down
Loading