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:
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
|
||||
+1
-1
@@ -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, {
|
||||
|
||||
+1
-1
@@ -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, {
|
||||
|
||||
+1
-1
@@ -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 });
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user