Skip to content

Commit fc4761f

Browse files
committed
feat: optimize log list query
1 parent af2035c commit fc4761f

2 files changed

Lines changed: 77 additions & 16 deletions

File tree

packages/query-engine/src/ch/queries/logs.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,37 @@ describe("logsListQuery", () => {
197197
const { sql } = compileCH(q, baseParams)
198198
expect(sql).toContain("SeverityNumber >= 9")
199199
})
200+
201+
it("gates the heavy column scan on a cheap cutoff subquery", () => {
202+
const q = logsListQuery({ limit: 100 })
203+
const { sql } = compileCH(q, baseParams)
204+
205+
// Outer query keeps the heavy projection.
206+
expect(sql).toContain("Body AS body")
207+
expect(sql).toContain("toJSONString(LogAttributes) AS logAttributes")
208+
209+
// Cutoff subquery reads only Timestamp — no Body, no toJSONString.
210+
const cutoffMatch = sql.match(/SELECT min\(ts\) FROM \(([\s\S]*?)\)\)/)
211+
expect(cutoffMatch).not.toBeNull()
212+
const inner = cutoffMatch![1]!
213+
expect(inner).toContain("Timestamp AS ts")
214+
expect(inner).not.toContain("Body")
215+
expect(inner).not.toContain("toJSONString")
216+
expect(inner).toContain("ORDER BY ts DESC")
217+
expect(inner).toContain("LIMIT 100")
218+
219+
// Outer query gates on the cutoff.
220+
expect(sql).toContain("Timestamp >= (SELECT min(ts) FROM (")
221+
})
222+
223+
it("applies the same filters to both the cutoff and outer stages", () => {
224+
const q = logsListQuery({ serviceName: "api", severity: "ERROR" })
225+
const { sql } = compileCH(q, baseParams)
226+
// Each filter appears twice — once per stage.
227+
expect(sql.match(/ServiceName = 'api'/g)).toHaveLength(2)
228+
expect(sql.match(/SeverityText = 'ERROR'/g)).toHaveLength(2)
229+
expect(sql.match(/OrgId = 'org_1'/g)).toHaveLength(2)
230+
})
200231
})
201232

202233
// ---------------------------------------------------------------------------

packages/query-engine/src/ch/queries/logs.ts

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// DSL-based query definitions for logs timeseries and breakdown.
55
// ---------------------------------------------------------------------------
66

7+
import { compileCH } from "../compile"
78
import * as CH from "../expr"
89
import { param } from "../param"
910
import { from, type ColumnAccessor } from "../query"
@@ -187,7 +188,50 @@ export interface LogsListOutput {
187188
readonly resourceAttributes: string
188189
}
189190

191+
/**
192+
* Two-stage list query. The `logs` sort key is
193+
* `(OrgId, ServiceName, TimestampTime, Timestamp)` — `ServiceName` sits between
194+
* `OrgId` and the timestamps, so `ORDER BY Timestamp DESC` is not a sort-key
195+
* prefix and ClickHouse cannot read-in-order. A single-stage query therefore
196+
* scans the whole window and materializes the heavy `Body` / attribute-map
197+
* columns for every matching row *before* `LIMIT` discards all but N, which
198+
* OOMs on busy orgs.
199+
*
200+
* Stage 1 reads only `Timestamp` to find the cutoff (the Nth-newest matching
201+
* timestamp). Stage 2 gates on `Timestamp >= cutoff`, so the heavy columns are
202+
* materialized only for the small slice of rows at/after the cutoff. The outer
203+
* `LIMIT` trims any ties at the cutoff timestamp.
204+
*/
190205
export function logsListQuery(opts: LogsListOpts) {
206+
const limit = opts.limit ?? 50
207+
208+
const baseWhere = ($: ColumnAccessor<typeof Logs.columns>): Array<CH.Condition | undefined> => [
209+
$.OrgId.eq(param.string("orgId")),
210+
$.TimestampTime.gte(param.dateTime("startTime")),
211+
$.TimestampTime.lte(param.dateTime("endTime")),
212+
$.Timestamp.gte(param.dateTime("startTime")),
213+
$.Timestamp.lte(param.dateTime("endTime")),
214+
CH.when(opts.serviceName, (v: string) => $.ServiceName.eq(v)),
215+
CH.when(opts.severity, (v: string) => $.SeverityText.eq(v)),
216+
CH.when(opts.minSeverity, (v: number) => $.SeverityNumber.gte(v)),
217+
CH.when(opts.traceId, (v: string) => $.TraceId.eq(v)),
218+
CH.when(opts.spanId, (v: string) => $.SpanId.eq(v)),
219+
CH.when(opts.cursor, (v: string) => $.Timestamp.lt(v)),
220+
CH.when(opts.search, (v: string) => $.Body.ilike(`%${v}%`)),
221+
environmentCondition($, opts),
222+
]
223+
224+
// Stage 1: cheap scan — only `Timestamp` is read. Compiled with placeholders
225+
// intact ({} params) so the outer `CH.compile()` substitutes them once.
226+
const cutoffInner = from(Logs)
227+
.select(($) => ({ ts: $.Timestamp }))
228+
.where(baseWhere)
229+
.orderBy(["ts", "desc"])
230+
.limit(limit)
231+
const cutoffSql = compileCH(cutoffInner, {}, { skipFormat: true }).sql
232+
const cutoff = CH.rawExpr<string>(`(SELECT min(ts) FROM (${cutoffSql}))`)
233+
234+
// Stage 2: heavy columns read only for rows at/after the cutoff timestamp.
191235
return from(Logs)
192236
.select(($) => ({
193237
timestamp: $.Timestamp,
@@ -200,23 +244,9 @@ export function logsListQuery(opts: LogsListOpts) {
200244
logAttributes: CH.toJSONString($.LogAttributes),
201245
resourceAttributes: CH.toJSONString($.ResourceAttributes),
202246
}))
203-
.where(($) => [
204-
$.OrgId.eq(param.string("orgId")),
205-
$.TimestampTime.gte(param.dateTime("startTime")),
206-
$.TimestampTime.lte(param.dateTime("endTime")),
207-
$.Timestamp.gte(param.dateTime("startTime")),
208-
$.Timestamp.lte(param.dateTime("endTime")),
209-
CH.when(opts.serviceName, (v: string) => $.ServiceName.eq(v)),
210-
CH.when(opts.severity, (v: string) => $.SeverityText.eq(v)),
211-
CH.when(opts.minSeverity, (v: number) => $.SeverityNumber.gte(v)),
212-
CH.when(opts.traceId, (v: string) => $.TraceId.eq(v)),
213-
CH.when(opts.spanId, (v: string) => $.SpanId.eq(v)),
214-
CH.when(opts.cursor, (v: string) => $.Timestamp.lt(v)),
215-
CH.when(opts.search, (v: string) => $.Body.ilike(`%${v}%`)),
216-
environmentCondition($, opts),
217-
])
247+
.where(($) => [...baseWhere($), $.Timestamp.gte(cutoff)])
218248
.orderBy(["timestamp", "desc"])
219-
.limit(opts.limit ?? 50)
249+
.limit(limit)
220250
.format("JSON")
221251
}
222252

0 commit comments

Comments
 (0)