Skip to content

Commit 3c5f83f

Browse files
committed
perf(webapp): bound missing LLM model queries
1 parent c0fbe91 commit 3c5f83f

1 file changed

Lines changed: 69 additions & 21 deletions

File tree

apps/webapp/app/services/admin/missingLlmModels.server.ts

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,66 @@
1+
import { LRUCache } from "lru-cache";
2+
import { trail } from "agentcrumbs"; // @crumbs
13
import { getAdminClickhouse } from "~/services/clickhouse/clickhouseFactory.server";
24
import { llmPricingRegistry } from "~/v3/llmPricingRegistry.server";
35

6+
const crumb = trail("webapp"); // @crumbs
7+
8+
const DEFAULT_LOOKBACK_HOURS = 24;
9+
const MAX_LOOKBACK_HOURS = 30 * 24;
10+
const MISSING_LLM_MODELS_CACHE_TTL_MS = 60_000;
11+
const MISSING_LLM_QUERY_SETTINGS = {
12+
// Stop server-side before the ClickHouse client's default 30-second request timeout.
13+
max_execution_time: 25,
14+
max_memory_usage: String(512 * 1024 * 1024),
15+
max_threads: 2,
16+
};
17+
418
export type MissingLlmModel = {
519
model: string;
620
system: string;
721
count: number;
822
};
923

24+
const missingLlmModelsCache = new LRUCache<number, Promise<MissingLlmModel[]>>({
25+
max: 16,
26+
ttl: MISSING_LLM_MODELS_CACHE_TTL_MS,
27+
});
28+
1029
export async function getMissingLlmModels(
1130
opts: {
1231
lookbackHours?: number;
1332
} = {}
1433
): Promise<MissingLlmModel[]> {
15-
const lookbackHours = opts.lookbackHours ?? 24;
16-
const since = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
34+
const lookbackHours = validateLookbackHours(opts.lookbackHours);
35+
let candidatesPromise = missingLlmModelsCache.get(lookbackHours);
36+
37+
if (candidatesPromise) {
38+
crumb("missing LLM models cache hit", { lookbackHours }); // @crumbs
39+
} else {
40+
crumb("missing LLM models cache miss", { lookbackHours }); // @crumbs
41+
candidatesPromise = queryMissingLlmModels(lookbackHours);
42+
missingLlmModelsCache.set(lookbackHours, candidatesPromise);
43+
}
44+
45+
let candidates: MissingLlmModel[];
46+
try {
47+
candidates = await candidatesPromise;
48+
} catch (error) {
49+
if (missingLlmModelsCache.get(lookbackHours) === candidatesPromise) {
50+
missingLlmModelsCache.delete(lookbackHours);
51+
}
52+
throw error;
53+
}
54+
55+
// Filter out models that now have pricing in the database (added after spans were inserted).
56+
// The registry's match() handles prefix stripping for gateway/openrouter models.
57+
if (!llmPricingRegistry || !llmPricingRegistry.isLoaded) return candidates;
58+
const registry = llmPricingRegistry;
59+
return candidates.filter((c) => !registry.match(c.model));
60+
}
1761

62+
async function queryMissingLlmModels(lookbackHours: number): Promise<MissingLlmModel[]> {
63+
const since = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
1864
const adminClickhouse = getAdminClickhouse();
1965

2066
// queryBuilderFast returns a factory function — call it to get the builder
@@ -36,13 +82,14 @@ export async function getMissingLlmModels(
3682
},
3783
{ name: "cnt", expression: "count()" },
3884
],
85+
settings: MISSING_LLM_QUERY_SETTINGS,
3986
});
4087
const qb = createBuilder();
4188

42-
// Partition pruning on inserted_at (partition key is toDate(inserted_at))
43-
qb.where("inserted_at >= {since: DateTime64(3)}", {
44-
since: formatDateTime(since),
45-
});
89+
// Read narrow filter columns before materializing and parsing attributes_text.
90+
qb.prewhere("inserted_at >= {since: DateTime64(3)}", { since: formatDateTime(since) });
91+
qb.prewhere("kind = {kind: String}", { kind: "SPAN" });
92+
qb.prewhere("status = {status: String}", { status: "OK" });
4693

4794
// Only spans that have a model set
4895
qb.where("JSONExtractString(attributes_text, 'gen_ai', 'response', 'model') != {empty: String}", {
@@ -55,10 +102,6 @@ export async function getMissingLlmModels(
55102
{}
56103
);
57104

58-
// Only completed spans
59-
qb.where("kind = {kind: String}", { kind: "SPAN" });
60-
qb.where("status = {status: String}", { status: "OK" });
61-
62105
qb.groupBy("model, system");
63106
qb.orderBy("cnt DESC");
64107
qb.limit(100);
@@ -81,13 +124,8 @@ export async function getMissingLlmModels(
81124
count: parseInt(r.cnt, 10),
82125
}));
83126

84-
if (candidates.length === 0) return [];
85-
86-
// Filter out models that now have pricing in the database (added after spans were inserted).
87-
// The registry's match() handles prefix stripping for gateway/openrouter models.
88-
if (!llmPricingRegistry || !llmPricingRegistry.isLoaded) return candidates;
89-
const registry = llmPricingRegistry;
90-
return candidates.filter((c) => !registry.match(c.model));
127+
crumb("missing LLM models query complete", { lookbackHours, candidates: candidates.length }); // @crumbs
128+
return candidates;
91129
}
92130

93131
export type MissingModelSample = {
@@ -104,7 +142,7 @@ export async function getMissingModelSamples(opts: {
104142
lookbackHours?: number;
105143
limit?: number;
106144
}): Promise<MissingModelSample[]> {
107-
const lookbackHours = opts.lookbackHours ?? 24;
145+
const lookbackHours = validateLookbackHours(opts.lookbackHours);
108146
const limit = opts.limit ?? 10;
109147
const since = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
110148

@@ -114,19 +152,21 @@ export async function getMissingModelSamples(opts: {
114152
name: "missingModelSamples",
115153
table: "trigger_dev.task_events_v2",
116154
columns: ["span_id", "run_id", "message", "attributes_text", "duration", "start_time"],
155+
settings: MISSING_LLM_QUERY_SETTINGS,
117156
});
118157
const qb = createBuilder();
119158

120-
qb.where("inserted_at >= {since: DateTime64(3)}", { since: formatDateTime(since) });
159+
// Read narrow filter columns before materializing and parsing attributes_text.
160+
qb.prewhere("inserted_at >= {since: DateTime64(3)}", { since: formatDateTime(since) });
161+
qb.prewhere("kind = {kind: String}", { kind: "SPAN" });
162+
qb.prewhere("status = {status: String}", { status: "OK" });
121163
qb.where("JSONExtractString(attributes_text, 'gen_ai', 'response', 'model') = {model: String}", {
122164
model: opts.model,
123165
});
124166
qb.where(
125167
"JSONExtract(attributes_text, 'trigger', 'llm', 'total_cost', 'Nullable(Float64)') IS NULL",
126168
{}
127169
);
128-
qb.where("kind = {kind: String}", { kind: "SPAN" });
129-
qb.where("status = {status: String}", { status: "OK" });
130170
qb.orderBy("start_time DESC");
131171
qb.limit(limit);
132172

@@ -139,6 +179,14 @@ export async function getMissingModelSamples(opts: {
139179
return rows ?? [];
140180
}
141181

182+
function validateLookbackHours(lookbackHours = DEFAULT_LOOKBACK_HOURS): number {
183+
if (!Number.isInteger(lookbackHours) || lookbackHours < 1 || lookbackHours > MAX_LOOKBACK_HOURS) {
184+
throw new RangeError(`lookbackHours must be between 1 and ${MAX_LOOKBACK_HOURS}`);
185+
}
186+
187+
return lookbackHours;
188+
}
189+
142190
function formatDateTime(date: Date): string {
143191
return date.toISOString().replace("T", " ").replace("Z", "");
144192
}

0 commit comments

Comments
 (0)