Skip to content

Commit 51666e4

Browse files
committed
perf(webapp,clickhouse): preserve early exit when hiding log retries
Fetch bounded extra rows and remove duplicate projection identities in the application. Keep exact keyset pagination while background merges collapse physical copies.
1 parent 5bc48eb commit 51666e4

8 files changed

Lines changed: 134 additions & 51 deletions

File tree

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

Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ import { ServiceValidationError } from "~/v3/services/baseService.server";
1818
import {
1919
escapeClickHouseLike,
2020
hasMinimumLogsSearchLength,
21+
LOGS_SEARCH_RETRY_OVERFETCH_FACTOR,
2122
MIN_LOGS_SEARCH_LENGTH,
2223
normalizeLogsSearchTerm,
24+
prepareLogsSearchPage,
2325
} from "~/utils/logSearch";
2426

2527
export type { LogLevel };
@@ -66,7 +68,7 @@ export type LogEntry = LogsList["logs"][0];
6668

6769
// Bump when the cursor shape changes so stale cursors are ignored (reset to the first page)
6870
// rather than misparsed.
69-
const LOG_CURSOR_VERSION = 3;
71+
const LOG_CURSOR_VERSION = 4;
7072

7173
// Cursor is a base64 encoded JSON of the pagination keys
7274
type LogCursor = {
@@ -76,6 +78,7 @@ type LogCursor = {
7678
triggeredTimestamp: string; // DateTime64(9) string
7779
traceId: string;
7880
spanId: string;
81+
projectionFingerprint?: string;
7982
};
8083

8184
const LogCursorSchema = z.object({
@@ -85,6 +88,7 @@ const LogCursorSchema = z.object({
8588
triggeredTimestamp: z.string(),
8689
traceId: z.string(),
8790
spanId: z.string(),
91+
projectionFingerprint: z.string().optional(),
8892
});
8993

9094
function encodeCursor(cursor: LogCursor): string {
@@ -222,6 +226,10 @@ export class LogsListPresenter extends BasePresenter {
222226
}
223227

224228
const effectivePageSize = Math.min(pageSize, env.LOGS_LIST_MAX_PAGE_SIZE);
229+
const usesV2Search = env.LOGS_SEARCH_TABLE_VERSION === "v2";
230+
const queryLimit = usesV2Search
231+
? (effectivePageSize + 1) * LOGS_SEARCH_RETRY_OVERFETCH_FACTOR
232+
: effectivePageSize + 1;
225233

226234
// Only honor a cursor scoped to this org+env; one copied from another scope would shift the
227235
// pagination anchor instead of resetting to the first page.
@@ -238,10 +246,9 @@ export class LogsListPresenter extends BasePresenter {
238246
const clampedTo = effectiveTo !== undefined ? (effectiveTo > now ? now : effectiveTo) : now;
239247

240248
const rawSearchTerm = search?.trim() ?? "";
241-
const normalizedSearchTerm =
242-
env.LOGS_SEARCH_TABLE_VERSION === "v2"
243-
? normalizeLogsSearchTerm(rawSearchTerm)
244-
: rawSearchTerm.toLocaleLowerCase();
249+
const normalizedSearchTerm = usesV2Search
250+
? normalizeLogsSearchTerm(rawSearchTerm)
251+
: rawSearchTerm.toLocaleLowerCase();
245252
if (rawSearchTerm !== "" && !hasMinimumLogsSearchLength(normalizedSearchTerm)) {
246253
throw new ServiceValidationError(
247254
`Log searches must be at least ${MIN_LOGS_SEARCH_LENGTH} characters.`
@@ -286,7 +293,7 @@ export class LogsListPresenter extends BasePresenter {
286293
}
287294

288295
if (searchTerm !== undefined) {
289-
if (env.LOGS_SEARCH_TABLE_VERSION === "v2") {
296+
if (usesV2Search) {
290297
// One predicate lets the text index answer substring searches without an OR across
291298
// independently indexed columns.
292299
queryBuilder.where("search_text LIKE {searchPattern: String}", {
@@ -327,26 +334,37 @@ export class LogsListPresenter extends BasePresenter {
327334
queryBuilder.whereOr(conditions);
328335
}
329336

330-
// Keyset pagination over the full sort key. ORDER BY is DESC, so the next page is the rows
331-
// that sort after the cursor (strictly less-than). (triggered_timestamp, trace_id) is not
332-
// unique because spans of a trace share both, so span_id is the final tiebreaker; without
333-
// it rows at a tie boundary could be skipped or duplicated across pages.
337+
// Keyset pagination over the sort key. ORDER BY is DESC, so the next page is the rows
338+
// that sort after the cursor (strictly less-than). V2 adds the projection identity as the
339+
// final tiebreaker so retry copies and distinct rows at a span boundary paginate safely.
334340
if (decodedCursor) {
341+
const cursorParams = {
342+
cursorTriggeredTimestamp: decodedCursor.triggeredTimestamp,
343+
cursorTraceId: decodedCursor.traceId,
344+
cursorSpanId: decodedCursor.spanId,
345+
...(usesV2Search && decodedCursor.projectionFingerprint
346+
? { cursorProjectionFingerprint: decodedCursor.projectionFingerprint }
347+
: {}),
348+
};
335349
queryBuilder.where(
336-
`(triggered_timestamp < {cursorTriggeredTimestamp: String}
337-
OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id < {cursorTraceId: String})
338-
OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id = {cursorTraceId: String} AND span_id < {cursorSpanId: String}))`,
339-
{
340-
cursorTriggeredTimestamp: decodedCursor.triggeredTimestamp,
341-
cursorTraceId: decodedCursor.traceId,
342-
cursorSpanId: decodedCursor.spanId,
343-
}
350+
usesV2Search && decodedCursor.projectionFingerprint
351+
? `(triggered_timestamp < {cursorTriggeredTimestamp: String}
352+
OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id < {cursorTraceId: String})
353+
OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id = {cursorTraceId: String} AND span_id < {cursorSpanId: String})
354+
OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id = {cursorTraceId: String} AND span_id = {cursorSpanId: String} AND projection_fingerprint < {cursorProjectionFingerprint: UInt128}))`
355+
: `(triggered_timestamp < {cursorTriggeredTimestamp: String}
356+
OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id < {cursorTraceId: String})
357+
OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id = {cursorTraceId: String} AND span_id < {cursorSpanId: String}))`,
358+
cursorParams
344359
);
345360
}
346361

347-
queryBuilder.orderBy("triggered_timestamp DESC, trace_id DESC, span_id DESC");
348-
// Limit + 1 to check if there are more results
349-
queryBuilder.limit(effectivePageSize + 1);
362+
queryBuilder.orderBy(
363+
usesV2Search
364+
? "triggered_timestamp DESC, trace_id DESC, span_id DESC, projection_fingerprint DESC"
365+
: "triggered_timestamp DESC, trace_id DESC, span_id DESC"
366+
);
367+
queryBuilder.limit(queryLimit);
350368

351369
return queryBuilder.execute();
352370
};
@@ -360,8 +378,14 @@ export class LogsListPresenter extends BasePresenter {
360378
// marker. Keep the default throw behavior so the product never presents truncated results as
361379
// complete.
362380
const results = queryResult ?? [];
363-
const hasMore = results.length > effectivePageSize;
364-
const logs = results.slice(0, effectivePageSize);
381+
const page = usesV2Search
382+
? prepareLogsSearchPage(results, effectivePageSize, queryLimit)
383+
: {
384+
rows: results.slice(0, effectivePageSize),
385+
hasMore: results.length > effectivePageSize,
386+
};
387+
const hasMore = page.hasMore;
388+
const logs = page.rows;
365389

366390
// Build next cursor from the last item
367391
let nextCursor: string | undefined;
@@ -374,6 +398,7 @@ export class LogsListPresenter extends BasePresenter {
374398
triggeredTimestamp: lastLog.triggered_timestamp,
375399
traceId: lastLog.trace_id,
376400
spanId: lastLog.span_id,
401+
projectionFingerprint: lastLog.projection_fingerprint_string,
377402
});
378403
}
379404

apps/webapp/app/utils/logSearch.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
escapeClickHouseLike,
44
hasMinimumLogsSearchLength,
55
normalizeLogsSearchTerm,
6+
prepareLogsSearchPage,
67
} from "./logSearch";
78

89
describe("log search normalization", () => {
@@ -22,4 +23,33 @@ describe("log search normalization", () => {
2223
expect(hasMinimumLogsSearchLength("abc")).toBe(true);
2324
expect(hasMinimumLogsSearchLength("日本語")).toBe(true);
2425
});
26+
27+
it("removes projector retry copies after bounded overfetch", () => {
28+
const row = (fingerprint: string) => ({
29+
projection_fingerprint_string: fingerprint,
30+
trace_id: `trace_${fingerprint}`,
31+
span_id: `span_${fingerprint}`,
32+
run_id: `run_${fingerprint}`,
33+
start_time: "2026-08-14 12:00:00.000000000",
34+
});
35+
const page = prepareLogsSearchPage([row("a"), row("a"), row("b"), row("c"), row("d")], 2, 5);
36+
37+
expect(page.rows.map((item) => item.projection_fingerprint_string)).toEqual(["a", "b"]);
38+
expect(page.hasMore).toBe(true);
39+
});
40+
41+
it("keeps pagination open when retries fill the overfetch bound", () => {
42+
const duplicate = {
43+
projection_fingerprint_string: "same",
44+
trace_id: "trace",
45+
span_id: "span",
46+
run_id: "run",
47+
start_time: "2026-08-14 12:00:00.000000000",
48+
};
49+
50+
expect(prepareLogsSearchPage([duplicate, duplicate, duplicate, duplicate], 2, 4)).toEqual({
51+
rows: [duplicate],
52+
hasMore: true,
53+
});
54+
});
2555
});

apps/webapp/app/utils/logSearch.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,34 @@
11
export const MIN_LOGS_SEARCH_LENGTH = 3;
2+
export const LOGS_SEARCH_RETRY_OVERFETCH_FACTOR = 4;
3+
4+
type ProjectedLogIdentity = {
5+
projection_fingerprint_string?: string;
6+
trace_id: string;
7+
span_id: string;
8+
run_id: string;
9+
start_time: string;
10+
};
11+
12+
export function prepareLogsSearchPage<T extends ProjectedLogIdentity>(
13+
rows: T[],
14+
pageSize: number,
15+
queryLimit: number
16+
): { rows: T[]; hasMore: boolean } {
17+
const seen = new Set<string>();
18+
const uniqueRows = rows.filter((row) => {
19+
const identity =
20+
row.projection_fingerprint_string ??
21+
JSON.stringify([row.trace_id, row.span_id, row.run_id, row.start_time]);
22+
if (seen.has(identity)) return false;
23+
seen.add(identity);
24+
return true;
25+
});
26+
27+
return {
28+
rows: uniqueRows.slice(0, pageSize),
29+
hasMore: uniqueRows.length > pageSize || rows.length === queryLimit,
30+
};
31+
}
232

333
export function hasMinimumLogsSearchLength(value: string): boolean {
434
return [...value.trim()].length >= MIN_LOGS_SEARCH_LENGTH;

internal-packages/clickhouse/schema/039_schedule_task_events_search_v2.sql

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,8 @@ CREATE TABLE trigger_dev.task_events_search_v2_projector
2727
status LowCardinality(String) CODEC(ZSTD(1)),
2828
duration UInt64 CODEC(ZSTD(1)),
2929
parent_span_id String CODEC(ZSTD(1)),
30-
projection_fingerprint FixedString(16) DEFAULT sipHash128(
31-
trace_id,
32-
span_id,
33-
run_id,
34-
start_time
30+
projection_fingerprint UInt128 DEFAULT reinterpretAsUInt128(
31+
sipHash128(trace_id, span_id, run_id, start_time)
3532
),
3633

3734
INDEX idx_run_id run_id TYPE bloom_filter(0.001) GRANULARITY 1,

internal-packages/clickhouse/src/client/queryBuilder.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,6 @@ export class ClickhouseQueryFastBuilder<TOutput extends Record<string, any>> {
148148
private params: QueryParams = {};
149149
private orderByClause: string | null = null;
150150
private limitClause: string | null = null;
151-
private limitByClause: string | null = null;
152151
private groupByClause: string | null = null;
153152

154153
constructor(
@@ -243,11 +242,6 @@ export class ClickhouseQueryFastBuilder<TOutput extends Record<string, any>> {
243242
return this;
244243
}
245244

246-
limitBy(limit: number, expression: string): this {
247-
this.limitByClause = `LIMIT ${limit} BY ${expression}`;
248-
return this;
249-
}
250-
251245
execute(): ReturnType<ClickhouseQueryFunction<void, TOutput>> {
252246
const { query, params } = this.build();
253247

@@ -296,9 +290,6 @@ export class ClickhouseQueryFastBuilder<TOutput extends Record<string, any>> {
296290
if (this.orderByClause) {
297291
query += ` ORDER BY ${this.orderByClause}`;
298292
}
299-
if (this.limitByClause) {
300-
query += ` ${this.limitByClause}`;
301-
}
302293
if (this.limitClause) {
303294
query += ` ${this.limitClause}`;
304295
}

internal-packages/clickhouse/src/taskEvents.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ export const LogsSearchListResult = z.object({
299299
status: z.string(),
300300
duration: z.number().or(z.string()),
301301
triggered_timestamp: z.string(),
302+
projection_fingerprint_string: z.string().optional(),
302303
});
303304

304305
export type LogsSearchListResult = z.output<typeof LogsSearchListResult>;
@@ -335,17 +336,21 @@ export function getLogsSearchListQueryBuilder(
335336
"status",
336337
"duration",
337338
"triggered_timestamp",
339+
...(version === "v2"
340+
? [
341+
{
342+
name: "projection_fingerprint_string",
343+
expression: "toString(projection_fingerprint)",
344+
},
345+
]
346+
: []),
338347
],
339348
settings: {
340349
use_query_condition_cache: 1,
341350
},
342351
});
343352

344-
return (options?: Parameters<typeof createBuilder>[0]) => {
345-
const builder = createBuilder(options);
346-
if (version === "v2") builder.limitBy(1, "projection_fingerprint");
347-
return builder;
348-
};
353+
return createBuilder;
349354
}
350355

351356
// Single log detail query builder (for side panel)

internal-packages/clickhouse/src/taskEventsSearch.test.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ async function project(ch: ClickHouse, start: Date, end: Date) {
5656
function searchRows(ch: ClickHouse) {
5757
const builder = ch.taskEventsSearch.logsListQueryBuilder("v2");
5858
builder.where("organization_id = {organizationId: String}", { organizationId: ORG });
59-
builder.orderBy("triggered_timestamp DESC, trace_id DESC, span_id DESC");
59+
builder.orderBy(
60+
"triggered_timestamp DESC, trace_id DESC, span_id DESC, projection_fingerprint DESC"
61+
);
6062
builder.limit(50);
6163
return builder.execute();
6264
}
@@ -111,9 +113,9 @@ describe("task events search v2", () => {
111113
expect(Number(firstProjection.summary?.written_rows)).toBe(1);
112114
expect(Number(retryProjection.summary?.written_rows)).toBe(1);
113115

114-
const [readError, rows] = await searchRows(ch);
115-
expect(readError).toBeNull();
116-
expect(rows).toHaveLength(1);
116+
const [preMergeReadError, preMergeRows] = await searchRows(ch);
117+
expect(preMergeReadError).toBeNull();
118+
expect([1, 2]).toContain(preMergeRows?.length);
117119
const rawQuery = ch.reader.query({
118120
name: "count-raw-search-v2-fixture",
119121
query: `SELECT count() AS count FROM trigger_dev.task_events_search_v2
@@ -123,7 +125,7 @@ describe("task events search v2", () => {
123125
});
124126
let [rawError, rawRows] = await rawQuery({ organizationId: ORG });
125127
expect(rawError).toBeNull();
126-
expect(rawRows?.[0].count).toBe(2);
128+
expect([1, 2]).toContain(rawRows?.[0].count);
127129

128130
const optimize = ch.writer.command({
129131
name: "merge-search-v2-retry-fixture",
@@ -134,6 +136,9 @@ describe("task events search v2", () => {
134136
[rawError, rawRows] = await rawQuery({ organizationId: ORG });
135137
expect(rawError).toBeNull();
136138
expect(rawRows?.[0].count).toBe(1);
139+
const [readError, rows] = await searchRows(ch);
140+
expect(readError).toBeNull();
141+
expect(rows).toHaveLength(1);
137142

138143
expect(rows?.[0].message.toLowerCase()).toContain(
139144
"typeerror: zahlungsübersicht failed, retrying /api/orders/42"
@@ -145,7 +150,7 @@ describe("task events search v2", () => {
145150
query: `SELECT search_text, error_message
146151
FROM trigger_dev.task_events_search_v2
147152
WHERE organization_id = {organizationId: String}
148-
LIMIT 1 BY projection_fingerprint`,
153+
LIMIT 1`,
149154
params: z.object({ organizationId: z.string() }),
150155
schema: z.object({ search_text: z.string(), error_message: z.string() }),
151156
});
@@ -189,7 +194,7 @@ describe("task events search v2", () => {
189194
query: `SELECT length(search_text) AS search_length
190195
FROM trigger_dev.task_events_search_v2
191196
WHERE organization_id = {organizationId: String}
192-
LIMIT 1 BY projection_fingerprint`,
197+
LIMIT 1`,
193198
params: z.object({ organizationId: z.string() }),
194199
schema: z.object({ search_length: z.number() }),
195200
});

internal-packages/clickhouse/src/taskEventsSearchProjector.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,12 @@ const projectedColumns = `
4444
duration,
4545
parent_span_id`;
4646

47-
const projectionFingerprint = (alias: string) => `sipHash128(
47+
const projectionFingerprint = (alias: string) => `reinterpretAsUInt128(sipHash128(
4848
${alias}.trace_id,
4949
${alias}.span_id,
5050
${alias}.run_id,
5151
${alias}.start_time
52-
)`;
52+
))`;
5353

5454
const projectionSql = `
5555
INSERT INTO trigger_dev.task_events_search_v2

0 commit comments

Comments
 (0)