Skip to content

Commit 590acec

Browse files
authored
Merge pull request #52 from tma1-ai/greptime-read-time-bound
fix(greptime): pre-filter base tables in subqueries so JOINs don't defeat index pushdown
2 parents 008aca2 + d24d5f9 commit 590acec

8 files changed

Lines changed: 401 additions & 119 deletions

File tree

packages/shared/src/server/repositories/greptime/dashboards.ts

Lines changed: 79 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { type FilterState } from "../../../types";
2+
import { InvalidRequestError } from "../../../errors";
23
import { greptimeQuery } from "../../greptime/client";
34
import { createGreptimeFilterFromFilterState } from "../../greptime/sql/factory";
45
import { USAGE_COST_KNOWN_KEYS } from "../../greptime/sql/fragments";
@@ -111,7 +112,20 @@ export const getScoreAggregateGreptime = async (
111112
dashboardGreptimeColumnDefinitions,
112113
),
113114
);
114-
const restRes = restList.apply();
115+
// This aggregation reads `scores s` (optionally JOIN traces); it has no `observations o` alias.
116+
// Observation-scoped dashboard columns (level / token / cost / tool-name filters) compile to
117+
// predicates correlated to `o`, so they cannot be expressed here -- reject them loudly instead of
118+
// emitting SQL that references a non-existent alias.
119+
if (restList.some((f) => f.table === "observations")) {
120+
throw new InvalidRequestError(
121+
"Observation-scoped filters are not supported for score aggregation on GreptimeDB",
122+
);
123+
}
124+
// Split by physical table so each side pre-filters inside its own subquery: a flat `scores JOIN
125+
// traces` defeats GreptimeDB pushdown of the scores TIME INDEX / trace_id bloom and the traces TIME
126+
// INDEX to the region scans.
127+
const scoreRestRes = restList.filter((f) => f.table !== "traces").apply();
128+
const traceRestRes = restList.filter((f) => f.table === "traces").apply();
115129

116130
const hasTraceFilter = restList.some((f) => f.table === "traces");
117131
const timeFilter = restList.find(
@@ -122,7 +136,8 @@ export const getScoreAggregateGreptime = async (
122136
const useLookback = Boolean(timeFilter && hasTraceFilter);
123137
const params: Record<string, unknown> = {
124138
projectId,
125-
...restRes.params,
139+
...scoreRestRes.params,
140+
...traceRestRes.params,
126141
...env.params,
127142
};
128143
if (useLookback && timeFilter) {
@@ -143,12 +158,22 @@ export const getScoreAggregateGreptime = async (
143158
query: `
144159
SELECT s.name AS name, count(*) AS count, avg(s.value) AS avg_value,
145160
s.source AS source, s.data_type AS data_type
146-
FROM scores s
147-
${hasTraceFilter ? "JOIN traces t ON t.id = s.trace_id AND t.project_id = s.project_id AND " + notDeleted("t") : ""}
148-
WHERE s.project_id = :projectId AND ${notDeleted("s")}
149-
${restRes.query ? `AND ${restRes.query}` : ""}
150-
${env.query ? `AND ${env.query}` : ""}
151-
${useLookback ? "AND t.timestamp >= :tracesTimestamp" : ""}
161+
FROM (
162+
SELECT * FROM scores s
163+
WHERE s.project_id = :projectId AND ${notDeleted("s")}
164+
${scoreRestRes.query ? `AND ${scoreRestRes.query}` : ""}
165+
${env.query ? `AND ${env.query}` : ""}
166+
) s
167+
${
168+
hasTraceFilter
169+
? `JOIN (
170+
SELECT * FROM traces t
171+
WHERE t.project_id = :projectId AND ${notDeleted("t")}
172+
${traceRestRes.query ? `AND ${traceRestRes.query}` : ""}
173+
${useLookback ? "AND t.timestamp >= :tracesTimestamp" : ""}
174+
) t ON t.id = s.trace_id AND t.project_id = s.project_id`
175+
: ""
176+
}
152177
GROUP BY s.name, s.source, s.data_type
153178
ORDER BY count(*) DESC`,
154179
params,
@@ -188,7 +213,6 @@ const getObservationDetailByTypeByTime = async (opts: {
188213
dashboardGreptimeColumnDefinitions,
189214
),
190215
);
191-
const restRes = restList.apply();
192216

193217
const hasTraceFilter = restList.some((f) => f.table === "traces");
194218
// CH derived the trace lookback from an observation start_time lower bound, only when a trace
@@ -212,11 +236,20 @@ const getObservationDetailByTypeByTime = async (opts: {
212236
Boolean(env.query) ||
213237
restList.some((f) => f.table === "observations");
214238

239+
// Split predicates by physical table so each base table pre-filters inside its own subquery: a flat
240+
// `uc JOIN observations JOIN traces ... WHERE` defeats GreptimeDB pushdown of the uc/observations TIME
241+
// INDEX window to the region scans. Score-grain predicates correlate to `o`, so they stay obs-side.
242+
const obsRestRes = restList.filter((f) => f.table !== "traces").apply();
243+
const traceRestRes = restList.filter((f) => f.table === "traces").apply();
244+
const obsRestClause = obsRestRes.query ? `AND ${obsRestRes.query}` : "";
245+
const traceRestClause = traceRestRes.query ? `AND ${traceRestRes.query}` : "";
246+
215247
const params: Record<string, unknown> = {
216248
projectId,
217249
winFrom: greptimeTsParam(new Date(fromTime)),
218250
winTo: greptimeTsParam(new Date(toTime)),
219-
...restRes.params,
251+
...obsRestRes.params,
252+
...traceRestRes.params,
220253
...env.params,
221254
};
222255
if (useLookback && obsStartLowerBound) {
@@ -231,20 +264,30 @@ const getObservationDetailByTypeByTime = async (opts: {
231264
const kind = jsonColumn === "cost_details" ? "cost" : "usage";
232265
params.kind = kind;
233266

234-
const traceJoin = hasTraceFilter
235-
? `LEFT JOIN traces t ON o.trace_id = t.id AND o.project_id = t.project_id AND ${notDeleted("t")}`
236-
: "";
237-
// Conditional in Q2 (see needsObservationJoin); Q1 always reads observations directly so it keeps
238-
// its own FROM.
239-
const obsJoin = needsObservationJoin
240-
? `JOIN observations o ON uc.entity_id = o.id AND uc.project_id = o.project_id AND ${notDeleted("o")}`
241-
: "";
242-
const restClause = restRes.query ? `AND ${restRes.query}` : "";
243267
const envClause = env.query ? `AND ${env.query}` : "";
244268
const lookbackClause = useLookback
245269
? "AND t.timestamp >= :traceTimestamp"
246270
: "";
247271

272+
// Pre-filtered traces subquery. INNER join: a trace filter means the row must match a passing trace
273+
// -- the same effect the old `LEFT JOIN traces ... WHERE t.<pred>` produced.
274+
const tracesJoinSql = hasTraceFilter
275+
? `JOIN (
276+
SELECT * FROM traces t
277+
WHERE t.project_id = :projectId AND ${notDeleted("t")} ${traceRestClause} ${lookbackClause}
278+
) t ON o.trace_id = t.id AND o.project_id = t.project_id`
279+
: "";
280+
// Pre-filtered observations subquery for Q2, bounded by the same window (the EAV row's timestamp is
281+
// the observation start_time) so the scan prunes via the TIME INDEX before the EAV join.
282+
const obsJoinSql = needsObservationJoin
283+
? `JOIN (
284+
SELECT * FROM observations o
285+
WHERE o.project_id = :projectId AND ${notDeleted("o")}
286+
AND o.start_time >= :winFrom AND o.start_time < :winTo
287+
${obsRestClause} ${envClause}
288+
) o ON uc.entity_id = o.id AND uc.project_id = o.project_id`
289+
: "";
290+
248291
const byBucket = new Map<number, Record<string, number>>();
249292
const allKeys = new Set<string>();
250293
const setSum = (bucketMsKey: number, key: string, value: number) => {
@@ -269,13 +312,14 @@ const getObservationDetailByTypeByTime = async (opts: {
269312
query: `
270313
SELECT date_bin(INTERVAL '${bucketSizeSeconds}' second, o.start_time) AS bucket,
271314
${knownSums}
272-
FROM observations o
273-
${traceJoin}
274-
WHERE o.project_id = :projectId AND ${notDeleted("o")}
275-
AND o.start_time >= :winFrom AND o.start_time < :winTo
276-
${restClause}
277-
${envClause}
278-
${lookbackClause}
315+
FROM (
316+
SELECT * FROM observations o
317+
WHERE o.project_id = :projectId AND ${notDeleted("o")}
318+
AND o.start_time >= :winFrom AND o.start_time < :winTo
319+
${obsRestClause}
320+
${envClause}
321+
) o
322+
${tracesJoinSql}
279323
GROUP BY bucket
280324
ORDER BY bucket ASC`,
281325
params,
@@ -295,16 +339,15 @@ const getObservationDetailByTypeByTime = async (opts: {
295339
SELECT date_bin(INTERVAL '${bucketSizeSeconds}' second, uc.${quoteIdent("timestamp")}) AS bucket,
296340
uc.${quoteIdent("key")} AS detail_key,
297341
sum(uc.${quoteIdent("value")}) AS sum
298-
FROM observations_usage_cost uc
299-
${obsJoin}
300-
${traceJoin}
301-
WHERE uc.${quoteIdent("kind")} = :kind
302-
AND uc.project_id = :projectId AND ${notDeleted("uc")}
303-
AND uc.${quoteIdent("timestamp")} >= :winFrom AND uc.${quoteIdent("timestamp")} < :winTo
304-
AND uc.${quoteIdent("key")} NOT IN (${KNOWN_DETAIL_KEYS_SQL})
305-
${restClause}
306-
${envClause}
307-
${lookbackClause}
342+
FROM (
343+
SELECT * FROM observations_usage_cost uc
344+
WHERE uc.${quoteIdent("kind")} = :kind
345+
AND uc.project_id = :projectId AND ${notDeleted("uc")}
346+
AND uc.${quoteIdent("timestamp")} >= :winFrom AND uc.${quoteIdent("timestamp")} < :winTo
347+
AND uc.${quoteIdent("key")} NOT IN (${KNOWN_DETAIL_KEYS_SQL})
348+
) uc
349+
${obsJoinSql}
350+
${tracesJoinSql}
308351
GROUP BY bucket, uc.${quoteIdent("key")}
309352
ORDER BY bucket ASC`,
310353
params,

packages/shared/src/server/repositories/greptime/eventsObservations.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import {
1515
buildObservationsTableQuery,
1616
getObservationsTableCountGreptime,
1717
getObservationsTableRowsGreptime,
18+
observationsScopedFrom,
1819
} from "./observationsTable";
19-
import { notDeleted } from "./queryHelpers";
2020

2121
/**
2222
* GreptimeDB obs-from-events collapse (04-read-path.md, P5 Piece D).
@@ -78,11 +78,7 @@ const resolvePositionQualifyingIds = async (
7878
PARTITION BY o.trace_id
7979
ORDER BY o.start_time ${direction}, o.id ${direction}
8080
) AS rn
81-
FROM observations o
82-
${compiled.traceJoin ? "LEFT JOIN traces t ON t.id = o.trace_id AND t.project_id = o.project_id AND " + notDeleted("t") : ""}
83-
WHERE ${compiled.whereSql} AND ${notDeleted("o")}
84-
${compiled.lookback ? "AND t.timestamp >= :obsTraceLookback" : ""}
85-
${search.query}
81+
FROM ${observationsScopedFrom(compiled, search)}
8682
)
8783
SELECT id FROM qualifying WHERE rn = :rank`,
8884
params: {

packages/shared/src/server/repositories/greptime/observationsTable.ts

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,16 @@ export type GreptimeObservationsTableProps = {
7171
searchType?: Parameters<typeof greptimeSearchCondition>[0]["searchType"];
7272
};
7373

74-
type CompiledObs = {
75-
whereSql: string;
74+
export type CompiledObs = {
75+
// observations-scoped predicates (o.* + score-grain EXISTS, which correlate to o.id).
76+
obsWhereSql: string;
77+
// trace-scoped predicates (t.*), applied inside the traces subquery.
78+
traceWhereSql: string;
7679
params: Record<string, unknown>;
7780
traceJoin: boolean;
81+
// A trace FILTER (or the trace lookback) means an observation must match a passing/recent trace, so
82+
// the join must be INNER. A join that exists only to order by a trace column stays LEFT.
83+
innerTraceJoin: boolean;
7884
lookback?: string;
7985
};
8086

@@ -97,8 +103,15 @@ export const buildObservationsTableQuery = (
97103
),
98104
]);
99105

106+
// Split by physical table so each side can be pre-filtered inside its own subquery (a flat
107+
// `observations o LEFT JOIN traces t ... WHERE` defeats GreptimeDB index pushdown on the driving
108+
// observations scan). Score-grain filters carry table "scores" but render as EXISTS correlated to
109+
// `o.id`, so they stay on the observations side.
110+
const obsFilters = filters.filter((f) => f.table !== "traces");
111+
const traceFilters = filters.filter((f) => f.table === "traces");
112+
100113
const traceJoin =
101-
filters.some((f) => f.table === "traces") ||
114+
traceFilters.length() > 0 ||
102115
findUiColumnMapping(observationsTableMapping, orderBy?.column)
103116
?.greptimeTableName === "traces";
104117

@@ -117,15 +130,39 @@ export const buildObservationsTableQuery = (
117130
)
118131
: undefined;
119132

120-
const applied = filters.apply();
133+
const obsRes = obsFilters.apply();
134+
const traceRes = traceFilters.apply();
121135
return {
122-
whereSql: applied.query,
123-
params: applied.params,
136+
obsWhereSql: obsRes.query,
137+
traceWhereSql: traceRes.query,
138+
// `projectId` binds the `t.project_id = :projectId` scope in the traces subquery (the o-side
139+
// project filter above carries its own auto-generated placeholder, not :projectId).
140+
params: { projectId, ...obsRes.params, ...traceRes.params },
124141
traceJoin,
142+
innerTraceJoin: traceFilters.length() > 0 || lookback !== undefined,
125143
lookback,
126144
};
127145
};
128146

147+
// Build the pre-filtered FROM clause (observations subquery [join traces subquery]) so each base table
148+
// scan prunes before the join. `SELECT *` is an intentional intermediate relation (projection pushdown
149+
// drops unreferenced columns; the outer query wraps JSON columns via its explicit select list).
150+
export const observationsScopedFrom = (
151+
compiled: CompiledObs,
152+
search: { query: string },
153+
): string => {
154+
const obsSub = `(SELECT * FROM observations o
155+
WHERE ${compiled.obsWhereSql} AND ${notDeleted("o")} ${search.query}) o`;
156+
if (!compiled.traceJoin) return obsSub;
157+
const traceSub = `(SELECT * FROM traces t
158+
WHERE t.project_id = :projectId AND ${notDeleted("t")}
159+
${compiled.traceWhereSql ? `AND ${compiled.traceWhereSql}` : ""}
160+
${compiled.lookback ? "AND t.timestamp >= :obsTraceLookback" : ""}) t`;
161+
const joinKind = compiled.innerTraceJoin ? "JOIN" : "LEFT JOIN";
162+
return `${obsSub}
163+
${joinKind} ${traceSub} ON t.id = o.trace_id AND t.project_id = o.project_id`;
164+
};
165+
129166
const observationsOrderBy = (orderBy?: OrderByState): string => {
130167
const primary: OrderByState = orderBy ?? {
131168
column: "startTime",
@@ -149,11 +186,7 @@ export const getObservationsTableCountGreptime = async (
149186
const rows = await greptimeQuery<{ count: string | number }>({
150187
query: `
151188
SELECT count(*) AS count
152-
FROM observations o
153-
${compiled.traceJoin ? "LEFT JOIN traces t ON t.id = o.trace_id AND t.project_id = o.project_id AND " + notDeleted("t") : ""}
154-
WHERE ${compiled.whereSql} AND ${notDeleted("o")}
155-
${compiled.lookback ? "AND t.timestamp >= :obsTraceLookback" : ""}
156-
${search.query}`,
189+
FROM ${observationsScopedFrom(compiled, search)}`,
157190
params: {
158191
...compiled.params,
159192
...search.params,
@@ -178,11 +211,7 @@ export const getObservationsTableRowsGreptime = async (
178211
const rows = await greptimeQuery<Record<string, unknown>>({
179212
query: `
180213
SELECT ${greptimeObservationSelect({ prefix: "o", excludeIo: exclude, excludeMetadata: exclude })}
181-
FROM observations o
182-
${compiled.traceJoin ? "LEFT JOIN traces t ON t.id = o.trace_id AND t.project_id = o.project_id AND " + notDeleted("t") : ""}
183-
WHERE ${compiled.whereSql} AND ${notDeleted("o")}
184-
${compiled.lookback ? "AND t.timestamp >= :obsTraceLookback" : ""}
185-
${search.query}
214+
FROM ${observationsScopedFrom(compiled, search)}
186215
${observationsOrderBy(props.orderBy)}
187216
${props.limit !== undefined && props.offset !== undefined ? "LIMIT :limit OFFSET :offset" : ""}`,
188217
params: {

packages/shared/src/server/repositories/greptime/queryHelpers.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { quoteIdent } from "../../greptime/schemaUtils";
22
import { greptimeTimestampLiteral } from "../../greptime/sql/greptime-filter";
3+
import { greptimeQuery } from "../../greptime/client";
34

45
/**
56
* Shared SQL fragments for the GreptimeDB read repositories (04-read-path.md, P1).
@@ -48,6 +49,41 @@ const quoteColumnRef = (ref: string): string => {
4849
/** Bind a Date as a ms-precision GreptimeDB timestamp literal (string -> TIMESTAMP coercion). */
4950
export const greptimeTsParam = (d: Date): string => greptimeTimestampLiteral(d);
5051

52+
/**
53+
* Earliest trace `timestamp` for a finite set of session/user ids, scoped by `project_id` and pruned
54+
* by the bloom skipping index on `scopeColumn` (04-read-path.md, migration 0006). Returns null when the
55+
* set has no live traces.
56+
*
57+
* Why: the all-time metrics reads (`getSessionsWithMetricsGreptime` with an id-only filter,
58+
* `getUserMetrics` with an empty filter) pass no UI timestamp bound. After the join-pushdown fix the
59+
* `traces` side prunes via the `session_id`/`user_id` bloom index, but the `observations` side then has
60+
* no index-eligible predicate. Feeding `min(timestamp) - INTERVAL` as an `observations.start_time` lower
61+
* bound restores TIME-INDEX pruning there.
62+
*
63+
* IMPORTANT — this is a DELIBERATE lookback-bounded narrowing, NOT strict all-time equivalence: it drops
64+
* observations whose `start_time` precedes the group's earliest trace by more than the caller's INTERVAL
65+
* (pathological clock skew > INTERVAL or a back-dated import; for sane data the dropped set is empty).
66+
* It mirrors the exact heuristic the windowed metrics path already applies (`obsLookback = tsFilter -
67+
* INTERVAL`). Callers subtract their own INTERVAL and must document the trade-off.
68+
*/
69+
export const deriveTraceMinTimestamp = async (
70+
projectId: string,
71+
scopeColumn: "session_id" | "user_id",
72+
ids: readonly string[],
73+
): Promise<Date | null> => {
74+
if (ids.length === 0) return null;
75+
const inClause = greptimeInClause(scopeColumn, ids, "scope");
76+
const rows = await greptimeQuery<{ min_ts: Date | string | null }>({
77+
query: `SELECT min(${quoteIdent("timestamp")}) AS min_ts FROM traces
78+
WHERE ${quoteIdent("project_id")} = :projectId AND ${inClause.sql} AND ${notDeleted()}`,
79+
params: { projectId, ...inClause.params },
80+
readOnly: true,
81+
});
82+
const v = rows[0]?.min_ts;
83+
if (v == null) return null;
84+
return v instanceof Date ? v : new Date(v);
85+
};
86+
5187
/** UTC calendar-day bounds [start, end) for a same-day match, as ms-precision literals. */
5288
export const greptimeDayBounds = (d: Date): { start: string; end: string } => {
5389
const start = new Date(

0 commit comments

Comments
 (0)