perf(agent-sessions): rank the page on the index, then aggregate only that page - #741
Merged
Merged
Conversation
… that page The list still took 5-15s a page after ai_trace_index (#692) and was killed at the 15s ceiling on the 30-day preset: the index fixed detection, but the trace_detail_spans fan-out still ran over every agent trace in the window (~1,800 traces / 96k spans a day, ±1 day pad) and paged with LIMIT/OFFSET afterwards. On object-storage parts that seek is priced by the partitions it probes, not the rows it returns — measured in production, five trace ids across 32 partitions ran past 10s while a page of sessions inside one partition took 1.3-2.8s cold. The list is now two reads, the shape traceListQuery already uses: - aiSessionPageQuery ranks sessions on ai_trace_index alone over the caller's window — session key, first agent span as the start, filters as per-trace existence tests, LIMIT/OFFSET — and returns the page's ids with the bounds of their agent spans. ~0.6s cold over 30 days. - aiSessionListQuery aggregates that page only: TraceId IN over the index filtered to the page's keys, the fan-out bounded by the page's own extent ±1h, and the session key taken from the index through an INNER JOIN so both stages resolve a trace identically. Its index reads are bounded by the page too, so a 30-day request scans the index once. The handler runs them in sequence, short-circuits an empty page, keeps the page's order, and annotates page_size/aggregated so a mismatch is visible. FAN_OUT_PAD_SECONDS drops to an hour (measured: first span leads the first agent span by ≤0.1s, last span trails the last agent span by ≤10min over 4,920 traces); the deep-link window resolve keeps its day under WINDOW_PAD_SECONDS. No HTTP contract change. Measured with the exact compiled SQL against production: a one-day page went from 5.5-15s to 0.6s + 1.8s cold; the 30-day preset from killed to 0.6s + 2.4s.
JeremyFunk
had a problem deploying
to
pr-preview
September 2, 2026 11:24 — with
GitHub Actions
Failure
🍁 Maple PR previewWarning Preview cleanup could not be confirmed. The Alchemy teardown outcome was Final commit |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The Agent Sessions list still took 5–15s per page after
ai_trace_indexlanded (#692), and every scroll waited that long again. Production telemetry (WarehouseQueryService.executeSql,query.context = listAiSessions, 2026-08-30 → 09-02):listAiSessions(post-index, 1-day window)listAiSessions(30-day window, the page's largest preset)aiSessionsFacets(index only)The index fixed detection, but the list still ran the whole
trace_detail_spansfan-out over every agent trace in the window (~1,800 traces / 96k spans a day) and paged withLIMIT/OFFSETafterwards, so each page paid the full aggregation.What that fan-out actually costs was measured on production (
db.duration_ms, cold parts, one org):trace_detail_spanssits on object storage, and aTraceId IN (…)seek there is priced by the partitions it probes, not by the rows it returns. The caller's window — padded by a day on each side — is what the old query pruned by.What
The list is now two reads, the shape
traceListQueryalready uses:aiSessionPageQuery— overai_trace_indexalone, across the caller's whole window: resolves each trace to its session key, ranks sessions by first agent span, applies the vendor/service filters (as per-traceHAVING countIf(...) > 0existence tests) andLIMIT/OFFSET, and returns the page's session ids with the bounds of their agent spans. ~0.6s cold over 30 days.aiSessionListQuery— the existing aggregation, restricted to that page's traces (TraceId INover the index, filtered to the page's keys) and bounded by the page's agent-span extent ± 1h instead of the caller's window ± 1 day. The session key comes from the index via anINNER JOINon the per-trace rows, so both stages resolve a trace identically (a re-derivation from spans could disagree and drop the row from the page it was ranked into).The handler (
ai-sessions.http.ts) runs them in sequence, short-circuits an empty page, derivesfanOutStart/fanOutEndfrom the page, returns rows in the page's order, and annotates the span withmaple.ai.page_size/maple.ai.aggregatedso a page/aggregation mismatch is visible.FAN_OUT_PAD_SECONDSdrops from a day to an hour, measured: across 4,920 agent traces (two days of production), the first span leads the first agent span by ≤0.1s and the last span trails the last agent span by ≤10 min. The deep-link window resolve (aiSessionWindowQuery/aiTraceWindowQuery) keeps its day of padding under a separateWINDOW_PAD_SECONDS— that path resolves one session and the detail page shows the spans themselves, so it is not worth clamping.Stage 2 is bounded by the page alone: its own index reads run over
fanOutStart..fanOutEndtoo (a page trace's index rows all lie inside its session's bounds, so it resolves exactly as the page did), which means a 30-day list request scans the whole index once, not three times. An emptysessionIdsis aQueryBuilderDefectrather thanIN ().Measured end to end against production with the exact compiled SQL (cold day, never touched during testing):
One additive contract change:
ListAiSessionsResponse.ranked(optional) — how many sessions the page ranked.datacan fall short of it:ai_trace_indexandtrace_detail_spansare two materialized views written one after the other from the sametracesinsert, so the newest session can be ranked a moment before its spans are readable (the old single-read shape hid this by paging after the aggregation). The hook now pages onranked(next offset = Σ ranked, last page whenranked < limit) instead ofdata.length, which would have ended the scroll on a short page. Ordering changes from "first span of any kind" to "first agent span" (sub-second difference, documented); the row'sstartTimestill reports the true first span. Vendor/service filters are now per-trace existence tests each (HAVING countIf(...) > 0), matching what the facets count, rather than one row matching both.Known limit
The fan-out window is the page's extent. For an org whose 50 newest sessions spread across many days (a few sessions a day, 30-day preset), the fan-out still probes every partition in that spread. Measured: 5 trace ids across 11 partitions (partly warm) took 0.84s; 5 ids across 32 cold partitions were killed at 10s. That org is no worse off than today (the old shape probed the same partitions and more), but it is not where the 2–3s number applies. The next step for it is either a per-trace rollup so the fan-out reads a narrow table, or chunking stage 2 per day-cluster — not in this PR.
Interaction with open PRs
HAVINGfilters and non-startTimesorts (cost, tokens, duration, errors) toaiSessionListQuery. Those need the full-window fan-out beforeLIMIT— the exact shape this PR removes. Index-level filters (vendor, service, environment, model, agent, tool, id search) slot straight intoaiSessionPageQuery; the session-level ones need either their aggregates materialised intoai_trace_indexso stage 1 can rank on them, or an explicit full-scan fallback. Flagging rather than resolving here.aiSessionSpansQuery/ the spans handler only; merge should be mechanical.Verification
packages/query-engine-integrationsunit tests + SQL baseline regenerated (diff reviewed).apps/apiroute test for the two-stage handler (bounds, session ids, page order, dropped session +ranked, empty page = one read).ai-trace-index-materialization.clickhouse.e2e.test.ts, real migrations, both stages end to end: multi-span and two-trace sessions, an out-of-window trace, sub-second bounds fed back into stage 2) and the SQL-catalog analyzer sweep.rankedcontract, comment corrections, the low-volume limit stated).run_sql(numbers above).Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.