feat(webapp): route ClickHouse reads to an optional read replica (#4081)

## Summary

Adds optional configuration to send ClickHouse read traffic to a
separate instance (for example a read replica) while writes stay on the
primary `CLICKHOUSE_URL`. This lets operators offload read load (runs
list, traces, logs, queries) from the cluster that handles inserts.
Fully backwards compatible: with nothing new set, every client resolves
to `CLICKHOUSE_URL` exactly as before.

## What it adds

- `CLICKHOUSE_READER_URL` (optional): a single reader endpoint that the
read-only clients fall back to. Read clients resolve `<own URL> ??
CLICKHOUSE_READER_URL ?? CLICKHOUSE_URL`. The task-events client (which
both inserts events and reads traces, spans, and logs) is built as a
reader/writer pair so queries use the reader while inserts stay on
`CLICKHOUSE_URL`.
- `RUNS_LIST_CLICKHOUSE_URL` (optional): a dedicated client for the runs
list (dashboard list, runs list API, live reload, child-status counts),
so the highest-traffic read path can target its own instance.

## Safety

Only read-only clients fall back to the reader: logs, query, admin, runs
list, the pending-version lookup, and the realtime run-id resolver. The
query page is constrained to read-only (the TSQL parser rejects anything
that is not a `SELECT`, and a `readonly` setting is applied). The
task-events client routes inserts to the writer and queries to the
reader per method, so a write can never reach the reader. Pure-write
clients (event inserts, replication) always use `CLICKHOUSE_URL`.

Note: this PR targets a baseline branch rather than `main` so the diff
stays scoped to the read-replica changes. It will be retargeted to
`main` before merge.

---------

Co-authored-by: Eric Allam <eallam@icloud.com>
This commit is contained in:
nicktrn
2026-07-15 14:59:42 +01:00
committed by GitHub
parent b902e65dfb
commit 890dd66eb5
8 changed files with 97 additions and 16 deletions
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---
Optionally route ClickHouse read traffic to a read replica while writes stay on the primary. Set `CLICKHOUSE_READER_URL` to move all reads, or target the busiest paths with `RUNS_LIST_CLICKHOUSE_URL` (runs list) and `EVENTS_READER_CLICKHOUSE_URL` (traces, spans, logs). All optional; unset keeps current behavior.
+26 -5
View File
@@ -1718,6 +1718,11 @@ const EnvironmentSchema = z
// Clickhouse
CLICKHOUSE_URL: z.string(),
// Optional read replica endpoint. Read-only clients (logs, query, admin, runsList,
// engine, realtime) default to this when their own URL is unset; writes always stay on
// CLICKHOUSE_URL. Events reads opt in separately via EVENTS_READER_CLICKHOUSE_URL (no
// fallback here). Must share storage with the CLICKHOUSE_URL warehouse.
CLICKHOUSE_READER_URL: z.string().optional(),
CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
@@ -1780,13 +1785,13 @@ const EnvironmentSchema = z
LOGS_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
// Query page ClickHouse limits (for TSQL queries)
QUERY_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
QUERY_CLICKHOUSE_MAX_EXECUTION_TIME: z.coerce.number().int().default(10),
QUERY_CLICKHOUSE_MAX_MEMORY_USAGE: z.coerce.number().int().default(1_073_741_824), // 1GB in bytes
QUERY_CLICKHOUSE_MAX_AST_ELEMENTS: z.coerce.number().int().default(4_000_000),
@@ -1805,12 +1810,14 @@ const EnvironmentSchema = z
ADMIN_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
EVENTS_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
// Events read replica (traces/spans/logs). No CLICKHOUSE_READER_URL fallback by design: this write-capable client opts in explicitly.
EVENTS_READER_CLICKHOUSE_URL: z.string().optional(),
EVENTS_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
EVENTS_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
EVENTS_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
@@ -1823,7 +1830,7 @@ const EnvironmentSchema = z
RUN_ENGINE_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
RUN_ENGINE_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
RUN_ENGINE_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
RUN_ENGINE_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(5),
@@ -1835,7 +1842,7 @@ const EnvironmentSchema = z
REALTIME_BACKEND_NATIVE_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
REALTIME_BACKEND_NATIVE_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
REALTIME_BACKEND_NATIVE_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce
.number()
@@ -1846,6 +1853,20 @@ const EnvironmentSchema = z
.enum(["log", "error", "warn", "info", "debug"])
.default("info"),
REALTIME_BACKEND_NATIVE_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
// Dedicated ClickHouse pool for the runs list (dashboard + API). Lets us point
// the highest-traffic read path at a read replica without moving ingest/replication
// writes off CLICKHOUSE_URL. Falls back to CLICKHOUSE_URL when unset.
RUNS_LIST_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
RUNS_LIST_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
RUNS_LIST_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
RUNS_LIST_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
RUNS_LIST_CLICKHOUSE_LOG_LEVEL: z
.enum(["log", "error", "warn", "info", "debug"])
.default("info"),
RUNS_LIST_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
EVENTS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(1000),
EVENTS_CLICKHOUSE_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
METRICS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(10000),
@@ -290,7 +290,7 @@ export class ApiRunListPresenter extends BasePresenter {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
organizationId,
"standard"
"runsList"
);
const presenter = new NextRunListPresenter(this._replica, clickhouse, this.readThroughDeps);
@@ -193,7 +193,7 @@ async function getRunsListFromTableState({
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
"runsList"
);
const runsListPresenter = new NextRunListPresenter($replica, clickhouse);
const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
@@ -104,7 +104,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
"runsList"
);
const presenter = new NextRunListPresenter($replica, clickhouse);
const list = presenter.call(project.organizationId, environment.id, {
@@ -67,7 +67,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
"runsList"
);
const runsRepository = new RunsRepository({ clickhouse, prisma: $replica });
@@ -33,7 +33,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
"runsList"
);
const runsRepository = new RunsRepository({ clickhouse, prisma: $replica });
@@ -251,6 +251,36 @@ function initializeRealtimeClickhouseClient(): ClickHouse {
});
}
/** Runs list reads dashboard + API (`RUNS_LIST_CLICKHOUSE_URL`);
* falls back to the default client if unset. */
const defaultRunsListClickhouseClient = singleton(
"runsListClickhouseClient",
initializeRunsListClickhouseClient
);
function initializeRunsListClickhouseClient(): ClickHouse {
if (!env.RUNS_LIST_CLICKHOUSE_URL) {
return defaultClickhouseClient;
}
const url = new URL(env.RUNS_LIST_CLICKHOUSE_URL);
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "runs-list-clickhouse",
keepAlive: {
enabled: env.RUNS_LIST_CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.RUNS_LIST_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.RUNS_LIST_CLICKHOUSE_LOG_LEVEL,
compression: {
request: env.RUNS_LIST_CLICKHOUSE_COMPRESSION_REQUEST === "1",
},
maxOpenConnections: env.RUNS_LIST_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
});
}
/** Task events (`EVENTS_CLICKHOUSE_URL`); not exported — accessed via factory. */
const defaultEventsClickhouseClient = singleton(
"eventsClickhouseClient",
@@ -262,12 +292,10 @@ function initializeEventsClickhouseClient(): ClickHouse {
throw new Error("EVENTS_CLICKHOUSE_URL is not set");
}
const url = new URL(env.EVENTS_CLICKHOUSE_URL);
url.searchParams.delete("secure");
const writerUrl = new URL(env.EVENTS_CLICKHOUSE_URL);
writerUrl.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "task-events",
const commonConfig = {
keepAlive: {
enabled: env.EVENTS_CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.EVENTS_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
@@ -277,6 +305,28 @@ function initializeEventsClickhouseClient(): ClickHouse {
request: env.EVENTS_CLICKHOUSE_COMPRESSION_REQUEST === "1",
},
maxOpenConnections: env.EVENTS_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
};
// Mixed read+write client: split reads to its own EVENTS_READER_CLICKHOUSE_URL (not the global reader) so inserts can never hit the replica.
if (env.EVENTS_READER_CLICKHOUSE_URL) {
const readerUrl = new URL(env.EVENTS_READER_CLICKHOUSE_URL);
readerUrl.searchParams.delete("secure");
if (readerUrl.toString() !== writerUrl.toString()) {
return new ClickHouse({
...commonConfig,
writerName: "task-events-writer",
writerUrl: writerUrl.toString(),
readerName: "task-events-reader",
readerUrl: readerUrl.toString(),
});
}
}
return new ClickHouse({
...commonConfig,
name: "task-events",
url: writerUrl.toString(),
});
}
@@ -298,7 +348,8 @@ export type ClientType =
| "query"
| "admin"
| "engine"
| "realtime";
| "realtime"
| "runsList";
function buildOrgClickhouseClient(url: string, clientType: ClientType): ClickHouse {
const parsed = new URL(url);
@@ -388,6 +439,7 @@ function buildOrgClickhouseClient(url: string, clientType: ClientType): ClickHou
case "standard":
case "query":
case "admin":
case "runsList":
return new ClickHouse({
url: parsed.toString(),
name,
@@ -455,6 +507,8 @@ export class ClickhouseFactory {
return defaultRunEngineClickhouseClient;
case "realtime":
return defaultRealtimeClickhouseClient;
case "runsList":
return defaultRunsListClickhouseClient;
}
}