Skip to content

Commit ec37e4d

Browse files
MariefayTrigger.dev RepoOps
authored andcommitted
feat(webapp,clickhouse): opt-in query limits for event reads on a read replica
Event reads on a separate ClickHouse read service (`EVENTS_READER_CLICKHOUSE_URL`) can now carry their own query limits via the new `EVENTS_READER_CLICKHOUSE_*` env vars. Nothing changes unless they are set. Mono-RevId: e18c23e1a07b8a119b0bb98bad7b9ab7c6c80392
1 parent d17d15c commit ec37e4d

6 files changed

Lines changed: 259 additions & 4 deletions

File tree

‎apps/webapp/app/env.server.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2554,6 +2554,17 @@ const EnvironmentSchema = z
25542554
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
25552555
// Events read replica (traces/spans/logs). No CLICKHOUSE_READER_URL fallback by design: this write-capable client opts in explicitly.
25562556
EVENTS_READER_CLICKHOUSE_URL: z.string().optional(),
2557+
// Reader-only limits for event reads; each is sent only when set.
2558+
EVENTS_READER_CLICKHOUSE_MAX_EXECUTION_TIME: z.coerce.number().int().positive().optional(),
2559+
EVENTS_READER_CLICKHOUSE_EXPORT_MAX_EXECUTION_TIME: z.coerce
2560+
.number()
2561+
.int()
2562+
.positive()
2563+
.optional(),
2564+
EVENTS_READER_CLICKHOUSE_MAX_THREADS: z.coerce.number().int().positive().optional(),
2565+
EVENTS_READER_CLICKHOUSE_MAX_MEMORY_USAGE: z.coerce.number().int().positive().optional(),
2566+
EVENTS_READER_CLICKHOUSE_READONLY: z.enum(["1", "2"]).optional(),
2567+
EVENTS_READER_CLICKHOUSE_REQUEST_TIMEOUT_MS: z.coerce.number().int().positive().optional(),
25572568
EVENTS_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
25582569
EVENTS_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
25592570
EVENTS_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,23 @@ const defaultEventsClickhouseClient = singleton(
442442
initializeEventsClickhouseClient
443443
);
444444

445+
function getEventsReaderClickhouseSettings(): ClickHouseSettings {
446+
return {
447+
...(env.EVENTS_READER_CLICKHOUSE_MAX_EXECUTION_TIME && {
448+
max_execution_time: env.EVENTS_READER_CLICKHOUSE_MAX_EXECUTION_TIME,
449+
}),
450+
...(env.EVENTS_READER_CLICKHOUSE_MAX_THREADS && {
451+
max_threads: env.EVENTS_READER_CLICKHOUSE_MAX_THREADS,
452+
}),
453+
...(env.EVENTS_READER_CLICKHOUSE_MAX_MEMORY_USAGE && {
454+
max_memory_usage: env.EVENTS_READER_CLICKHOUSE_MAX_MEMORY_USAGE.toString(),
455+
}),
456+
...(env.EVENTS_READER_CLICKHOUSE_READONLY && {
457+
readonly: env.EVENTS_READER_CLICKHOUSE_READONLY,
458+
}),
459+
};
460+
}
461+
445462
function initializeEventsClickhouseClient(): ClickHouse {
446463
if (!env.EVENTS_CLICKHOUSE_URL) {
447464
throw new Error("EVENTS_CLICKHOUSE_URL is not set");
@@ -474,6 +491,8 @@ function initializeEventsClickhouseClient(): ClickHouse {
474491
writerUrl: writerUrl.toString(),
475492
readerName: "task-events-reader",
476493
readerUrl: readerUrl.toString(),
494+
readerClickhouseSettings: getEventsReaderClickhouseSettings(),
495+
readerRequestTimeoutMs: env.EVENTS_READER_CLICKHOUSE_REQUEST_TIMEOUT_MS,
477496
});
478497
}
479498
}
@@ -868,6 +887,9 @@ function buildEventRepository(
868887
),
869888
maximumLiveReloadingSetting: env.EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING,
870889
traceChunkSize: env.EVENTS_CLICKHOUSE_TRACE_CHUNK_SIZE,
890+
exportMaxExecutionTime: clickhouse.hasSeparateReader
891+
? env.EVENTS_READER_CLICKHOUSE_EXPORT_MAX_EXECUTION_TIME
892+
: undefined,
871893
maximumTraceViewCount: env.EVENTS_CLICKHOUSE_MAX_TRACE_VIEW_COUNT,
872894
maximumErrorMatches: clampToEmergencySpanCap(DEFAULT_MAX_ERROR_MATCHES),
873895
maximumKeyRows: clampToEmergencySpanCap(DEFAULT_MAX_KEY_ROWS),
@@ -904,6 +926,9 @@ function buildEventRepository(
904926
),
905927
maximumLiveReloadingSetting: env.EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING,
906928
traceChunkSize: env.EVENTS_CLICKHOUSE_TRACE_CHUNK_SIZE,
929+
exportMaxExecutionTime: clickhouse.hasSeparateReader
930+
? env.EVENTS_READER_CLICKHOUSE_EXPORT_MAX_EXECUTION_TIME
931+
: undefined,
907932
maximumTraceViewCount: env.EVENTS_CLICKHOUSE_MAX_TRACE_VIEW_COUNT,
908933
maximumErrorMatches: clampToEmergencySpanCap(DEFAULT_MAX_ERROR_MATCHES),
909934
maximumKeyRows: clampToEmergencySpanCap(DEFAULT_MAX_KEY_ROWS),

‎apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ export type ClickhouseEventRepositoryConfig = {
151151
maximumKeyRows?: number;
152152
maximumLiveReloadingSetting?: number;
153153
traceChunkSize?: number;
154+
/** Server-side `max_execution_time` (seconds) for trace exports; overrides the client-level limit. */
155+
exportMaxExecutionTime?: number;
154156
/**
155157
* Maximum age in milliseconds for start_time. If start_time is older than this threshold,
156158
* it will be clamped to the current time when creating events.
@@ -3316,10 +3318,14 @@ export class ClickhouseEventRepository implements IEventRepository {
33163318
): AsyncIterable<StreamedTraceEvent> {
33173319
const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 1000);
33183320

3321+
const exportSettings = this._config.exportMaxExecutionTime
3322+
? { settings: { max_execution_time: this._config.exportMaxExecutionTime } }
3323+
: undefined;
3324+
33193325
const queryBuilder =
33203326
this._version === "v2"
3321-
? this._clickhouse.taskEventsV2.traceEventsForExportQueryBuilder()
3322-
: this._clickhouse.taskEvents.traceEventsForExportQueryBuilder();
3327+
? this._clickhouse.taskEventsV2.traceEventsForExportQueryBuilder(exportSettings)
3328+
: this._clickhouse.taskEvents.traceEventsForExportQueryBuilder(exportSettings);
33233329

33243330
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
33253331
queryBuilder.where("trace_id = {traceId: String}", { traceId });
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { ClickHouse, type TaskEventV2Input } from "@internal/clickhouse";
2+
import { clickhouseTest } from "@internal/testcontainers";
3+
import { describe, expect, vi } from "vitest";
4+
import { z } from "zod";
5+
import { ClickhouseEventRepository } from "~/v3/eventRepository/clickhouseEventRepository.server";
6+
7+
vi.setConfig({ testTimeout: 60_000 });
8+
9+
const TRACE_ID = "trace_reader_limits";
10+
const ENV_ID = "env_reader_limits";
11+
const BASE = new Date("2026-09-01T10:00:00.000Z");
12+
13+
function clickhouseDate(value: Date) {
14+
return value.toISOString().replace("T", " ").replace("Z", "");
15+
}
16+
17+
const EVENT: TaskEventV2Input = {
18+
environment_id: ENV_ID,
19+
organization_id: "org_reader_limits",
20+
project_id: "project_reader_limits",
21+
task_identifier: "limits-task",
22+
run_id: "run_reader_limits",
23+
start_time: clickhouseDate(BASE),
24+
duration: "1000000",
25+
trace_id: TRACE_ID,
26+
span_id: "root",
27+
parent_span_id: "",
28+
message: "root",
29+
kind: "SPAN",
30+
status: "OK",
31+
attributes: {},
32+
metadata: "{}",
33+
expires_at: clickhouseDate(new Date(Date.now() + 86_400_000)),
34+
};
35+
36+
async function setup(url: string) {
37+
const clickhouse = new ClickHouse({
38+
writerUrl: url,
39+
readerUrl: url,
40+
readerClickhouseSettings: { max_execution_time: 30 },
41+
});
42+
const repository = new ClickhouseEventRepository({
43+
clickhouse,
44+
version: "v2",
45+
exportMaxExecutionTime: 300,
46+
});
47+
48+
const [insertError] = await clickhouse.taskEventsV2.insert([EVENT]);
49+
if (insertError) throw insertError;
50+
51+
return { clickhouse, repository };
52+
}
53+
54+
async function shutdown(clickhouse: ClickHouse, repository: ClickhouseEventRepository) {
55+
await Promise.all([
56+
(repository as any)._flushScheduler.shutdown(),
57+
(repository as any)._llmMetricsFlushScheduler.shutdown(),
58+
(repository as any)._otlpMetricsFlushScheduler.shutdown(),
59+
]);
60+
await clickhouse.close();
61+
}
62+
63+
async function serverNow(url: string): Promise<string> {
64+
const inspector = new ClickHouse({ url, name: "inspector" });
65+
const [error, rows] = await inspector.reader.query({
66+
name: "read-server-now",
67+
query: "SELECT toString(now64(6)) AS now",
68+
schema: z.object({ now: z.string() }),
69+
})({});
70+
await inspector.close();
71+
if (error) throw error;
72+
73+
return rows[0].now;
74+
}
75+
76+
async function eventReadLimits(url: string, since: string): Promise<string[]> {
77+
const inspector = new ClickHouse({ url, name: "inspector" });
78+
const [flushError] = await inspector.writer.command({
79+
name: "flush-logs",
80+
query: "SYSTEM FLUSH LOGS",
81+
})({});
82+
if (flushError) throw flushError;
83+
84+
const [error, rows] = await inspector.reader.query({
85+
name: "read-event-read-limits",
86+
query: `SELECT Settings['max_execution_time'] AS max_execution_time
87+
FROM system.query_log
88+
WHERE type = 'QueryFinish'
89+
AND query_kind = 'Select'
90+
AND has(tables, 'trigger_dev.task_events_v2')
91+
AND query_start_time_microseconds > toDateTime64({since: String}, 6)`,
92+
params: z.object({ since: z.string() }),
93+
schema: z.object({ max_execution_time: z.string() }),
94+
})({ since });
95+
await inspector.close();
96+
if (error) throw error;
97+
98+
return rows.map((row) => row.max_execution_time);
99+
}
100+
101+
describe("event read limits", () => {
102+
clickhouseTest("export reads use the export limit", async ({ clickhouseContainer }) => {
103+
const url = clickhouseContainer.getConnectionUrl();
104+
const { clickhouse, repository } = await setup(url);
105+
const since = await serverNow(url);
106+
107+
const exported: string[] = [];
108+
for await (const event of repository.streamTraceEvents(
109+
"taskEventPartitioned",
110+
ENV_ID,
111+
TRACE_ID,
112+
new Date(BASE.getTime() - 60_000),
113+
new Date(BASE.getTime() + 60_000)
114+
)) {
115+
exported.push(event.spanId);
116+
}
117+
118+
expect(exported).toEqual(["root"]);
119+
expect(await eventReadLimits(url, since)).toEqual(["300"]);
120+
121+
await shutdown(clickhouse, repository);
122+
});
123+
124+
clickhouseTest("interactive reads use the reader limit", async ({ clickhouseContainer }) => {
125+
const url = clickhouseContainer.getConnectionUrl();
126+
const { clickhouse, repository } = await setup(url);
127+
const since = await serverNow(url);
128+
129+
const chunk = await repository.getTraceChunk(
130+
"taskEventPartitioned",
131+
ENV_ID,
132+
TRACE_ID,
133+
new Date(BASE.getTime() - 60_000),
134+
new Date(BASE.getTime() + 60_000),
135+
undefined,
136+
{ includeDebugLogs: true, limit: 100 }
137+
);
138+
139+
expect(chunk?.events.map((event) => event.spanId)).toEqual(["root"]);
140+
expect(new Set(await eventReadLimits(url, since))).toEqual(new Set(["30"]));
141+
142+
await shutdown(clickhouse, repository);
143+
});
144+
});

‎internal-packages/clickhouse/src/index.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ export type ClickHouseConfig =
169169
url?: string;
170170
writerUrl?: never;
171171
readerUrl?: never;
172+
readerClickhouseSettings?: never;
173+
readerRequestTimeoutMs?: never;
172174
} & ClickhouseCommonConfig)
173175
| ({
174176
name?: never;
@@ -177,6 +179,10 @@ export type ClickHouseConfig =
177179
writerUrl: string;
178180
readerName?: string;
179181
readerUrl: string;
182+
/** Applied to the reader only, on top of `clickhouseSettings`. */
183+
readerClickhouseSettings?: ClickHouseSettings;
184+
/** Reader-only override of `requestTimeoutMs`. */
185+
readerRequestTimeoutMs?: number;
180186
} & ClickhouseCommonConfig);
181187

182188
export class ClickHouse {
@@ -215,14 +221,14 @@ export class ClickHouse {
215221
this.reader = new ClickhouseClient({
216222
name: config.readerName ?? "clickhouse-reader",
217223
url: config.readerUrl,
218-
clickhouseSettings: config.clickhouseSettings,
224+
clickhouseSettings: { ...config.clickhouseSettings, ...config.readerClickhouseSettings },
219225
logger: this.logger,
220226
meter: config.meter,
221227
logLevel: config.logLevel,
222228
keepAlive: config.keepAlive,
223229
httpAgent: config.httpAgent,
224230
maxOpenConnections: config.maxOpenConnections,
225-
requestTimeoutMs: config.requestTimeoutMs,
231+
requestTimeoutMs: config.readerRequestTimeoutMs ?? config.requestTimeoutMs,
226232
compression: config.compression,
227233
});
228234
this.writer = new ClickhouseClient({
@@ -267,6 +273,11 @@ export class ClickHouse {
267273
});
268274
}
269275

276+
/** True when reads and writes go to separate services. */
277+
get hasSeparateReader(): boolean {
278+
return this._splitClients;
279+
}
280+
270281
async close() {
271282
if (this._splitClients) {
272283
await Promise.all([this.reader.close(), this.writer.close()]);
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { clickhouseTest } from "@internal/testcontainers";
2+
import { describe, expect } from "vitest";
3+
import { z } from "zod";
4+
import type { ClickHouseSettings } from "@clickhouse/client";
5+
import type { ClickhouseClient } from "./client/client.js";
6+
import type { ClickhouseReader } from "./client/types.js";
7+
import { ClickHouse } from "./index.js";
8+
9+
const settingsSchema = z.object({ max_execution_time: z.string(), readonly: z.string() });
10+
11+
async function readSettings(client: ClickhouseReader, settings?: ClickHouseSettings) {
12+
const query = client.query({
13+
name: "read-effective-settings",
14+
query: `SELECT toString(getSetting('max_execution_time')) AS max_execution_time,
15+
toString(getSetting('readonly')) AS readonly`,
16+
schema: settingsSchema,
17+
settings,
18+
});
19+
20+
const [error, rows] = await query({});
21+
if (error) throw error;
22+
return rows[0];
23+
}
24+
25+
describe("reader-only settings", () => {
26+
clickhouseTest("apply to the reader and not the writer", async ({ clickhouseContainer }) => {
27+
const url = clickhouseContainer.getConnectionUrl();
28+
const ch = new ClickHouse({
29+
writerUrl: url,
30+
readerUrl: url,
31+
readerClickhouseSettings: { max_execution_time: 30, readonly: "2" },
32+
});
33+
34+
expect(await readSettings(ch.reader)).toEqual({ max_execution_time: "30", readonly: "2" });
35+
expect(await readSettings(ch.writer as ClickhouseClient)).toEqual({
36+
max_execution_time: "0",
37+
readonly: "0",
38+
});
39+
40+
await ch.close();
41+
});
42+
43+
clickhouseTest("can be overridden per query", async ({ clickhouseContainer }) => {
44+
const url = clickhouseContainer.getConnectionUrl();
45+
const ch = new ClickHouse({
46+
writerUrl: url,
47+
readerUrl: url,
48+
readerClickhouseSettings: { max_execution_time: 30, readonly: "2" },
49+
});
50+
51+
expect(await readSettings(ch.reader, { max_execution_time: 300 })).toEqual({
52+
max_execution_time: "300",
53+
readonly: "2",
54+
});
55+
56+
await ch.close();
57+
});
58+
});

0 commit comments

Comments
 (0)