Skip to content

Commit ff7f091

Browse files
MariefayTrigger.dev RepoOps
authored andcommitted
perf(webapp): narrow trace reads with sort-key start_time bounds
Run traces and trace APIs read less from ClickHouse for completed runs, most for older runs. Every trace read now also bounds `start_time` in the form of the table's sort key (`toUnixTimestamp(start_time)`), so ClickHouse skips granules outside the run's window even when it doesn't derive that from the exact bounds. Results are unchanged. Mono-RevId: 0776e6cd8797bb3d3777f0a0f1afb1e18e0d07e5
1 parent d9bf563 commit ff7f091

6 files changed

Lines changed: 309 additions & 54 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+
Run traces and trace APIs load faster for older runs.

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

Lines changed: 22 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
buildTraceChunkCursorPredicate,
33
buildTraceChunkKeyPredicate,
4+
buildTraceStartTimeSortKeyPredicate,
45
sliceTraceChunk,
56
toTaskEventSearchV2RowIfEligible,
67
TRACE_CHUNK_ORDER_BY,
@@ -1903,6 +1904,21 @@ export class ClickhouseEventRepository implements IEventRepository {
19031904
return queryBuilder;
19041905
}
19051906

1907+
#whereStartTimeWindow(
1908+
queryBuilder: { where: (clause: string, params?: any) => unknown },
1909+
start: Date,
1910+
end: Date | undefined
1911+
) {
1912+
const startNs = convertDateToNanoseconds(start).toString();
1913+
const endNs = end ? convertDateToNanoseconds(end).toString() : undefined;
1914+
queryBuilder.where("start_time >= {startCreatedAt: String}", { startCreatedAt: startNs });
1915+
if (endNs) {
1916+
queryBuilder.where("start_time <= {endCreatedAt: String}", { endCreatedAt: endNs });
1917+
}
1918+
const sortKey = buildTraceStartTimeSortKeyPredicate({ startNs, endNs });
1919+
queryBuilder.where(sortKey.clause, sortKey.params);
1920+
}
1921+
19061922
#applyTraceScopeWhere(
19071923
queryBuilder: { where: (clause: string, params?: any) => unknown },
19081924
{
@@ -1927,15 +1943,7 @@ export class ClickhouseEventRepository implements IEventRepository {
19271943
? new Date(endCreatedAt.getTime() + 60_000)
19281944
: undefined;
19291945

1930-
queryBuilder.where("start_time >= {startCreatedAt: String}", {
1931-
startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(),
1932-
});
1933-
1934-
if (endCreatedAtWithBuffer) {
1935-
queryBuilder.where("start_time <= {endCreatedAt: String}", {
1936-
endCreatedAt: convertDateToNanoseconds(endCreatedAtWithBuffer).toString(),
1937-
});
1938-
}
1946+
this.#whereStartTimeWindow(queryBuilder, startCreatedAtWithBuffer, endCreatedAtWithBuffer);
19391947

19401948
if (this._version === "v2") {
19411949
queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
@@ -2400,15 +2408,7 @@ export class ClickhouseEventRepository implements IEventRepository {
24002408
? new Date(endCreatedAt.getTime() + 60_000)
24012409
: undefined;
24022410

2403-
queryBuilder.where("start_time >= {startCreatedAt: String}", {
2404-
startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(),
2405-
});
2406-
2407-
if (endCreatedAtWithBuffer) {
2408-
queryBuilder.where("start_time <= {endCreatedAt: String}", {
2409-
endCreatedAt: convertDateToNanoseconds(endCreatedAtWithBuffer).toString(),
2410-
});
2411-
}
2411+
this.#whereStartTimeWindow(queryBuilder, startCreatedAtWithBuffer, endCreatedAtWithBuffer);
24122412

24132413
if (this._version === "v2") {
24142414
queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
@@ -2522,15 +2522,7 @@ export class ClickhouseEventRepository implements IEventRepository {
25222522
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
25232523
queryBuilder.where("trace_id = {traceId: String}", { traceId });
25242524
queryBuilder.where("span_id = {spanId: String}", { spanId });
2525-
queryBuilder.where("start_time >= {startCreatedAt: String}", {
2526-
startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(),
2527-
});
2528-
2529-
if (endCreatedAt) {
2530-
queryBuilder.where("start_time <= {endCreatedAt: String}", {
2531-
endCreatedAt: convertDateToNanoseconds(endCreatedAt).toString(),
2532-
});
2533-
}
2525+
this.#whereStartTimeWindow(queryBuilder, startCreatedAtWithBuffer, endCreatedAt);
25342526

25352527
// For v2, add inserted_at filtering for partition pruning
25362528
if (this._version === "v2") {
@@ -3010,15 +3002,7 @@ export class ClickhouseEventRepository implements IEventRepository {
30103002

30113003
const startCreatedAtWithBuffer = new Date(startCreatedAt.getTime() - 1000);
30123004

3013-
queryBuilder.where("start_time >= {startCreatedAt: String}", {
3014-
startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(),
3015-
});
3016-
3017-
if (endCreatedAt) {
3018-
queryBuilder.where("start_time <= {endCreatedAt: String}", {
3019-
endCreatedAt: convertDateToNanoseconds(endCreatedAt).toString(),
3020-
});
3021-
}
3005+
this.#whereStartTimeWindow(queryBuilder, startCreatedAtWithBuffer, endCreatedAt);
30223006

30233007
if (this._version === "v2") {
30243008
queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
@@ -3267,15 +3251,7 @@ export class ClickhouseEventRepository implements IEventRepository {
32673251

32683252
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
32693253
queryBuilder.where("trace_id = {traceId: String}", { traceId });
3270-
queryBuilder.where("start_time >= {startCreatedAt: String}", {
3271-
startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(),
3272-
});
3273-
3274-
if (endCreatedAt) {
3275-
queryBuilder.where("start_time <= {endCreatedAt: String}", {
3276-
endCreatedAt: convertDateToNanoseconds(endCreatedAt).toString(),
3277-
});
3278-
}
3254+
this.#whereStartTimeWindow(queryBuilder, startCreatedAtWithBuffer, endCreatedAt);
32793255

32803256
if (this._version === "v2") {
32813257
queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
@@ -3442,15 +3418,7 @@ export class ClickhouseEventRepository implements IEventRepository {
34423418
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
34433419
queryBuilder.where("trace_id = {traceId: String}", { traceId });
34443420
queryBuilder.where("run_id = {runId: String}", { runId });
3445-
queryBuilder.where("start_time >= {startCreatedAt: String}", {
3446-
startCreatedAt: convertDateToNanoseconds(startCreatedAtWithBuffer).toString(),
3447-
});
3448-
3449-
if (endCreatedAt) {
3450-
queryBuilder.where("start_time <= {endCreatedAt: String}", {
3451-
endCreatedAt: convertDateToNanoseconds(endCreatedAt).toString(),
3452-
});
3453-
}
3421+
this.#whereStartTimeWindow(queryBuilder, startCreatedAtWithBuffer, endCreatedAt);
34543422

34553423
// For v2, add inserted_at filtering for partition pruning
34563424
if (this._version === "v2") {
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import {
2+
buildTraceStartTimeSortKeyPredicate,
3+
ClickHouse,
4+
type TaskEventV2Input,
5+
} from "@internal/clickhouse";
6+
import { clickhouseTest } from "@internal/testcontainers";
7+
import { describe, expect, vi } from "vitest";
8+
import { z } from "zod";
9+
import {
10+
ClickhouseEventRepository,
11+
convertDateToClickhouseDateTime,
12+
} from "~/v3/eventRepository/clickhouseEventRepository.server";
13+
import { convertDateToNanoseconds } from "~/v3/eventRepository/common.server";
14+
15+
vi.setConfig({ testTimeout: 120_000 });
16+
17+
const TRACE_ID = "trace_start_time_end_test";
18+
const ENV_ID = "env_start_time_end_test";
19+
const START = new Date("2026-09-01T10:00:00.000Z");
20+
// Sub-second end, so a rounding mistake in the sort-key bound would drop rows.
21+
const END = new Date("2026-09-01T10:00:00.500Z");
22+
// The trace scope adds 60s to the end.
23+
const SCOPE_END_SECOND = "2026-09-01 10:01:00";
24+
const NOISE_ROWS = 30_000;
25+
// query_log stores the SQL with params inlined as _CAST(value, 'Int64').
26+
const SORT_KEY_START =
27+
/toUnixTimestamp\(start_time\) >= intDiv\(_CAST\(\d+, 'Int64'\), 1000000000\)/;
28+
const SORT_KEY_END = /toUnixTimestamp\(start_time\) <= intDiv\(_CAST\(\d+, 'Int64'\), 1000000000\)/;
29+
30+
function clickhouseDate(value: Date) {
31+
return value.toISOString().replace("T", " ").replace("Z", "");
32+
}
33+
34+
function row(
35+
traceId: string,
36+
spanId: string,
37+
message: string,
38+
startTime: string,
39+
insertedAt = START
40+
): TaskEventV2Input {
41+
return {
42+
environment_id: ENV_ID,
43+
organization_id: "org_start_time_end_test",
44+
project_id: "project_start_time_end_test",
45+
task_identifier: "start-time-end-task",
46+
run_id: "run_start_time_end_test",
47+
start_time: startTime,
48+
inserted_at: convertDateToClickhouseDateTime(insertedAt),
49+
duration: "1000000",
50+
trace_id: traceId,
51+
span_id: spanId,
52+
parent_span_id: "",
53+
message,
54+
kind: "SPAN",
55+
status: "OK",
56+
attributes: {},
57+
metadata: "{}",
58+
expires_at: clickhouseDate(new Date(Date.now() + 86_400_000)),
59+
};
60+
}
61+
62+
async function shutdownRepository(repository: ClickhouseEventRepository): Promise<void> {
63+
await Promise.all([
64+
(repository as any)._flushScheduler.shutdown(),
65+
(repository as any)._llmMetricsFlushScheduler.shutdown(),
66+
(repository as any)._otlpMetricsFlushScheduler.shutdown(),
67+
]);
68+
}
69+
70+
function setup(url: string) {
71+
const clickhouse = new ClickHouse({ url, name: "test" });
72+
const repository = new ClickhouseEventRepository({
73+
clickhouse,
74+
version: "v2",
75+
insertStrategy: "insert",
76+
});
77+
return { clickhouse, repository };
78+
}
79+
80+
async function readMessages(repository: ClickhouseEventRepository, end: Date | undefined) {
81+
const chunk = await repository.getTraceChunk(
82+
"taskEventPartitioned",
83+
ENV_ID,
84+
TRACE_ID,
85+
START,
86+
end,
87+
undefined,
88+
{}
89+
);
90+
return (chunk?.events ?? []).map((e) => e.message).sort();
91+
}
92+
93+
describe("trace reads with a start_time end", () => {
94+
clickhouseTest("returns the same rows around the end second", async ({ clickhouseContainer }) => {
95+
const { clickhouse, repository } = setup(clickhouseContainer.getConnectionUrl());
96+
97+
try {
98+
const [insertError] = await clickhouse.taskEventsV2.insert([
99+
row(TRACE_ID, "a", "second-start", `${SCOPE_END_SECOND}.000000000`),
100+
row(TRACE_ID, "b", "before-end", `${SCOPE_END_SECOND}.100000000`),
101+
row(TRACE_ID, "c", "at-end", `${SCOPE_END_SECOND}.500000000`),
102+
row(TRACE_ID, "d", "end-plus-1ns", `${SCOPE_END_SECOND}.500000001`),
103+
row(TRACE_ID, "e", "same-second-later", `${SCOPE_END_SECOND}.900000000`),
104+
row(TRACE_ID, "f", "next-second", "2026-09-01 10:01:01.000000000"),
105+
]);
106+
expect(insertError).toBeNull();
107+
108+
expect(await readMessages(repository, END)).toEqual(["at-end", "before-end", "second-start"]);
109+
expect(await readMessages(repository, undefined)).toEqual([
110+
"at-end",
111+
"before-end",
112+
"end-plus-1ns",
113+
"next-second",
114+
"same-second-later",
115+
"second-start",
116+
]);
117+
} finally {
118+
await shutdownRepository(repository);
119+
}
120+
});
121+
122+
clickhouseTest(
123+
"narrows the primary key with the sort-key bounds alone",
124+
async ({ clickhouseContainer }) => {
125+
const { clickhouse, repository } = setup(clickhouseContainer.getConnectionUrl());
126+
127+
try {
128+
// Other traces in the same env, one per second before and after the run, so the
129+
// run's row sits mid-part with granules on both sides.
130+
const noise = Array.from({ length: NOISE_ROWS }, (_, i) =>
131+
row(
132+
`trace_noise_${i}`,
133+
`noise_${i}`,
134+
"noise",
135+
clickhouseDate(new Date(START.getTime() + (i - NOISE_ROWS / 2) * 1000))
136+
)
137+
);
138+
const [insertError] = await clickhouse.taskEventsV2.insert([
139+
row(TRACE_ID, "a", "span", clickhouseDate(START)),
140+
...noise,
141+
]);
142+
expect(insertError).toBeNull();
143+
144+
// Some ClickHouse setups (prod) ignore the exact string bounds when choosing
145+
// granules, so check the sort-key clause without them.
146+
const { clause, params } = buildTraceStartTimeSortKeyPredicate({
147+
startNs: convertDateToNanoseconds(START).toString(),
148+
endNs: convertDateToNanoseconds(END).toString(),
149+
});
150+
const explain = clickhouse.reader.query({
151+
name: "explainSortKeyBounds",
152+
query: `EXPLAIN indexes = 1 SELECT span_id FROM trigger_dev.task_events_v2 WHERE environment_id = {environmentId: String} AND ${clause}`,
153+
params: z.any(),
154+
schema: z.object({ explain: z.string() }),
155+
});
156+
const [explainError, rows] = await explain({ ...params, environmentId: ENV_ID });
157+
expect(explainError).toBeNull();
158+
159+
const lines = (rows ?? []).map((r) => r.explain.trim());
160+
const primaryKey = lines.indexOf("PrimaryKey");
161+
const granules = lines.slice(primaryKey).find((l) => l.startsWith("Granules:"));
162+
const [selected, total] = granules!.replace("Granules:", "").trim().split("/").map(Number);
163+
expect(total).toBeGreaterThan(2);
164+
expect(selected).toBe(1);
165+
} finally {
166+
await shutdownRepository(repository);
167+
}
168+
}
169+
);
170+
171+
clickhouseTest(
172+
"adds the sort-key bounds to trace chunk reads",
173+
async ({ clickhouseContainer }) => {
174+
const { clickhouse, repository } = setup(clickhouseContainer.getConnectionUrl());
175+
176+
try {
177+
const [insertError] = await clickhouse.taskEventsV2.insert([
178+
row(TRACE_ID, "a", "span", clickhouseDate(START)),
179+
]);
180+
expect(insertError).toBeNull();
181+
182+
// The container is shared across tests, so only look at queries from here on.
183+
const serverNow = clickhouse.reader.query({
184+
name: "serverNowMicros",
185+
query: "SELECT toString(toUnixTimestamp64Micro(now64(6))) AS since",
186+
schema: z.object({ since: z.string() }),
187+
});
188+
const [nowError, nowRows] = await serverNow({});
189+
expect(nowError).toBeNull();
190+
const since = nowRows![0].since;
191+
192+
// Completed run first, then live run, so query_log order identifies them.
193+
expect(await readMessages(repository, END)).toEqual(["span"]);
194+
expect(await readMessages(repository, undefined)).toEqual(["span"]);
195+
196+
const flushLogs = clickhouse.writer.command({
197+
name: "flushLogs",
198+
query: "SYSTEM FLUSH LOGS",
199+
});
200+
const [flushError] = await flushLogs({});
201+
expect(flushError).toBeNull();
202+
203+
const loggedQueries = clickhouse.reader.query({
204+
name: "traceChunkQueryLog",
205+
query: `SELECT query FROM system.query_log
206+
WHERE type = 'QueryFinish'
207+
AND toUnixTimestamp64Micro(event_time_microseconds) > {since: Int64}
208+
AND query LIKE 'SELECT%FROM trigger_dev.task_events_v2%'
209+
ORDER BY event_time_microseconds`,
210+
params: z.object({ since: z.string() }),
211+
schema: z.object({ query: z.string() }),
212+
});
213+
const [logError, rows] = await loggedQueries({ since });
214+
expect(logError).toBeNull();
215+
216+
const queries = (rows ?? []).map((r) => r.query);
217+
expect(queries).toHaveLength(2);
218+
const [completed, live] = queries;
219+
220+
expect(completed).toMatch(SORT_KEY_START);
221+
expect(completed).toMatch(SORT_KEY_END);
222+
expect(live).toMatch(SORT_KEY_START);
223+
expect(live).not.toMatch(SORT_KEY_END);
224+
} finally {
225+
await shutdownRepository(repository);
226+
}
227+
}
228+
);
229+
});

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export type * from "./taskEvents.js";
8989
export {
9090
buildTraceChunkCursorPredicate,
9191
buildTraceChunkKeyPredicate,
92+
buildTraceStartTimeSortKeyPredicate,
9293
sliceTraceChunk,
9394
TRACE_CHUNK_ORDER_BY,
9495
} from "./taskEvents.js";

0 commit comments

Comments
 (0)