Skip to content

Commit 2743a85

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 33897bb commit 2743a85

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 };
@@ -67,7 +69,7 @@ export type LogsListAppliedFilters = LogsList["filters"];
6769

6870
// Bump when the cursor shape changes so stale cursors are ignored (reset to the first page)
6971
// rather than misparsed.
70-
const LOG_CURSOR_VERSION = 3;
72+
const LOG_CURSOR_VERSION = 4;
7173

7274
// Cursor is a base64 encoded JSON of the pagination keys
7375
type LogCursor = {
@@ -77,6 +79,7 @@ type LogCursor = {
7779
triggeredTimestamp: string; // DateTime64(9) string
7880
traceId: string;
7981
spanId: string;
82+
projectionFingerprint?: string;
8083
};
8184

8285
const LogCursorSchema = z.object({
@@ -86,6 +89,7 @@ const LogCursorSchema = z.object({
8689
triggeredTimestamp: z.string(),
8790
traceId: z.string(),
8891
spanId: z.string(),
92+
projectionFingerprint: z.string().optional(),
8993
});
9094

9195
function encodeCursor(cursor: LogCursor): string {
@@ -223,6 +227,10 @@ export class LogsListPresenter extends BasePresenter {
223227
}
224228

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

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

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

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

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

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

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

367391
// Build next cursor from the last item
368392
let nextCursor: string | undefined;
@@ -375,6 +399,7 @@ export class LogsListPresenter extends BasePresenter {
375399
triggeredTimestamp: lastLog.triggered_timestamp,
376400
traceId: lastLog.trace_id,
377401
spanId: lastLog.span_id,
402+
projectionFingerprint: lastLog.projection_fingerprint_string,
378403
});
379404
}
380405

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)