Skip to content

Commit 890dd66

Browse files
nicktrnericallam
andauthored
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>
1 parent b902e65 commit 890dd66

8 files changed

Lines changed: 97 additions & 16 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+
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.

apps/webapp/app/env.server.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1718,6 +1718,11 @@ const EnvironmentSchema = z
17181718

17191719
// Clickhouse
17201720
CLICKHOUSE_URL: z.string(),
1721+
// Optional read replica endpoint. Read-only clients (logs, query, admin, runsList,
1722+
// engine, realtime) default to this when their own URL is unset; writes always stay on
1723+
// CLICKHOUSE_URL. Events reads opt in separately via EVENTS_READER_CLICKHOUSE_URL (no
1724+
// fallback here). Must share storage with the CLICKHOUSE_URL warehouse.
1725+
CLICKHOUSE_READER_URL: z.string().optional(),
17211726
CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
17221727
CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
17231728
CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
@@ -1780,13 +1785,13 @@ const EnvironmentSchema = z
17801785
LOGS_CLICKHOUSE_URL: z
17811786
.string()
17821787
.optional()
1783-
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
1788+
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
17841789

17851790
// Query page ClickHouse limits (for TSQL queries)
17861791
QUERY_CLICKHOUSE_URL: z
17871792
.string()
17881793
.optional()
1789-
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
1794+
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
17901795
QUERY_CLICKHOUSE_MAX_EXECUTION_TIME: z.coerce.number().int().default(10),
17911796
QUERY_CLICKHOUSE_MAX_MEMORY_USAGE: z.coerce.number().int().default(1_073_741_824), // 1GB in bytes
17921797
QUERY_CLICKHOUSE_MAX_AST_ELEMENTS: z.coerce.number().int().default(4_000_000),
@@ -1805,12 +1810,14 @@ const EnvironmentSchema = z
18051810
ADMIN_CLICKHOUSE_URL: z
18061811
.string()
18071812
.optional()
1808-
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
1813+
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
18091814

18101815
EVENTS_CLICKHOUSE_URL: z
18111816
.string()
18121817
.optional()
18131818
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
1819+
// Events read replica (traces/spans/logs). No CLICKHOUSE_READER_URL fallback by design: this write-capable client opts in explicitly.
1820+
EVENTS_READER_CLICKHOUSE_URL: z.string().optional(),
18141821
EVENTS_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
18151822
EVENTS_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
18161823
EVENTS_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
@@ -1823,7 +1830,7 @@ const EnvironmentSchema = z
18231830
RUN_ENGINE_CLICKHOUSE_URL: z
18241831
.string()
18251832
.optional()
1826-
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
1833+
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
18271834
RUN_ENGINE_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
18281835
RUN_ENGINE_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
18291836
RUN_ENGINE_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(5),
@@ -1835,7 +1842,7 @@ const EnvironmentSchema = z
18351842
REALTIME_BACKEND_NATIVE_CLICKHOUSE_URL: z
18361843
.string()
18371844
.optional()
1838-
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
1845+
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
18391846
REALTIME_BACKEND_NATIVE_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
18401847
REALTIME_BACKEND_NATIVE_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce
18411848
.number()
@@ -1846,6 +1853,20 @@ const EnvironmentSchema = z
18461853
.enum(["log", "error", "warn", "info", "debug"])
18471854
.default("info"),
18481855
REALTIME_BACKEND_NATIVE_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
1856+
// Dedicated ClickHouse pool for the runs list (dashboard + API). Lets us point
1857+
// the highest-traffic read path at a read replica without moving ingest/replication
1858+
// writes off CLICKHOUSE_URL. Falls back to CLICKHOUSE_URL when unset.
1859+
RUNS_LIST_CLICKHOUSE_URL: z
1860+
.string()
1861+
.optional()
1862+
.transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL),
1863+
RUNS_LIST_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
1864+
RUNS_LIST_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
1865+
RUNS_LIST_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
1866+
RUNS_LIST_CLICKHOUSE_LOG_LEVEL: z
1867+
.enum(["log", "error", "warn", "info", "debug"])
1868+
.default("info"),
1869+
RUNS_LIST_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
18491870
EVENTS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(1000),
18501871
EVENTS_CLICKHOUSE_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
18511872
METRICS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(10000),

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ export class ApiRunListPresenter extends BasePresenter {
290290

291291
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
292292
organizationId,
293-
"standard"
293+
"runsList"
294294
);
295295
const presenter = new NextRunListPresenter(this._replica, clickhouse, this.readThroughDeps);
296296

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ async function getRunsListFromTableState({
193193

194194
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
195195
project.organizationId,
196-
"standard"
196+
"runsList"
197197
);
198198
const runsListPresenter = new NextRunListPresenter($replica, clickhouse);
199199
const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
104104

105105
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
106106
project.organizationId,
107-
"standard"
107+
"runsList"
108108
);
109109
const presenter = new NextRunListPresenter($replica, clickhouse);
110110
const list = presenter.call(project.organizationId, environment.id, {

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.children-statuses.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
6767

6868
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
6969
project.organizationId,
70-
"standard"
70+
"runsList"
7171
);
7272
const runsRepository = new RunsRepository({ clickhouse, prisma: $replica });
7373

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.live.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
3333

3434
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
3535
project.organizationId,
36-
"standard"
36+
"runsList"
3737
);
3838
const runsRepository = new RunsRepository({ clickhouse, prisma: $replica });
3939

apps/webapp/app/services/clickhouse/clickhouseFactory.server.ts

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,36 @@ function initializeRealtimeClickhouseClient(): ClickHouse {
251251
});
252252
}
253253

254+
/** Runs list reads — dashboard + API (`RUNS_LIST_CLICKHOUSE_URL`);
255+
* falls back to the default client if unset. */
256+
const defaultRunsListClickhouseClient = singleton(
257+
"runsListClickhouseClient",
258+
initializeRunsListClickhouseClient
259+
);
260+
261+
function initializeRunsListClickhouseClient(): ClickHouse {
262+
if (!env.RUNS_LIST_CLICKHOUSE_URL) {
263+
return defaultClickhouseClient;
264+
}
265+
266+
const url = new URL(env.RUNS_LIST_CLICKHOUSE_URL);
267+
url.searchParams.delete("secure");
268+
269+
return new ClickHouse({
270+
url: url.toString(),
271+
name: "runs-list-clickhouse",
272+
keepAlive: {
273+
enabled: env.RUNS_LIST_CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
274+
idleSocketTtl: env.RUNS_LIST_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
275+
},
276+
logLevel: env.RUNS_LIST_CLICKHOUSE_LOG_LEVEL,
277+
compression: {
278+
request: env.RUNS_LIST_CLICKHOUSE_COMPRESSION_REQUEST === "1",
279+
},
280+
maxOpenConnections: env.RUNS_LIST_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
281+
});
282+
}
283+
254284
/** Task events (`EVENTS_CLICKHOUSE_URL`); not exported — accessed via factory. */
255285
const defaultEventsClickhouseClient = singleton(
256286
"eventsClickhouseClient",
@@ -262,12 +292,10 @@ function initializeEventsClickhouseClient(): ClickHouse {
262292
throw new Error("EVENTS_CLICKHOUSE_URL is not set");
263293
}
264294

265-
const url = new URL(env.EVENTS_CLICKHOUSE_URL);
266-
url.searchParams.delete("secure");
295+
const writerUrl = new URL(env.EVENTS_CLICKHOUSE_URL);
296+
writerUrl.searchParams.delete("secure");
267297

268-
return new ClickHouse({
269-
url: url.toString(),
270-
name: "task-events",
298+
const commonConfig = {
271299
keepAlive: {
272300
enabled: env.EVENTS_CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
273301
idleSocketTtl: env.EVENTS_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
@@ -277,6 +305,28 @@ function initializeEventsClickhouseClient(): ClickHouse {
277305
request: env.EVENTS_CLICKHOUSE_COMPRESSION_REQUEST === "1",
278306
},
279307
maxOpenConnections: env.EVENTS_CLICKHOUSE_MAX_OPEN_CONNECTIONS,
308+
};
309+
310+
// Mixed read+write client: split reads to its own EVENTS_READER_CLICKHOUSE_URL (not the global reader) so inserts can never hit the replica.
311+
if (env.EVENTS_READER_CLICKHOUSE_URL) {
312+
const readerUrl = new URL(env.EVENTS_READER_CLICKHOUSE_URL);
313+
readerUrl.searchParams.delete("secure");
314+
315+
if (readerUrl.toString() !== writerUrl.toString()) {
316+
return new ClickHouse({
317+
...commonConfig,
318+
writerName: "task-events-writer",
319+
writerUrl: writerUrl.toString(),
320+
readerName: "task-events-reader",
321+
readerUrl: readerUrl.toString(),
322+
});
323+
}
324+
}
325+
326+
return new ClickHouse({
327+
...commonConfig,
328+
name: "task-events",
329+
url: writerUrl.toString(),
280330
});
281331
}
282332

@@ -298,7 +348,8 @@ export type ClientType =
298348
| "query"
299349
| "admin"
300350
| "engine"
301-
| "realtime";
351+
| "realtime"
352+
| "runsList";
302353

303354
function buildOrgClickhouseClient(url: string, clientType: ClientType): ClickHouse {
304355
const parsed = new URL(url);
@@ -388,6 +439,7 @@ function buildOrgClickhouseClient(url: string, clientType: ClientType): ClickHou
388439
case "standard":
389440
case "query":
390441
case "admin":
442+
case "runsList":
391443
return new ClickHouse({
392444
url: parsed.toString(),
393445
name,
@@ -455,6 +507,8 @@ export class ClickhouseFactory {
455507
return defaultRunEngineClickhouseClient;
456508
case "realtime":
457509
return defaultRealtimeClickhouseClient;
510+
case "runsList":
511+
return defaultRunsListClickhouseClient;
458512
}
459513
}
460514

0 commit comments

Comments
 (0)