perf: serve the AI Bridge sessions list from an indexed anti-join - #28123
Closed
evgeniy-scherbina wants to merge 1 commit into
Closed
perf: serve the AI Bridge sessions list from an indexed anti-join#28123evgeniy-scherbina wants to merge 1 commit into
evgeniy-scherbina wants to merge 1 commit into
Conversation
ListAIBridgeSessions and CountAIBridgeSessions aggregated every completed interception before applying LIMIT, so a page load scaled with the size of aibridge_interceptions. On a 2,000,000 interception dataset the list took 5.0 to 5.6 s and the count 5.7 s. Represent a session by its latest interception that matches the filters, found with a NOT EXISTS probe for a newer interception in the same session. A partial index on (session_id, initiator_id, started_at DESC, id DESC) answers the probe, so the ordered scan stops after LIMIT sessions and the expensive aggregation runs only for that page. On the same dataset the list takes 126 ms and the count 490 ms. Pagination is now keyset on the (started_at, id) of that row, and cursor_pos resolves the cursor with the same filters, which fixes filtered pagination repeating sessions across pages. The list is now ordered by the session's latest completed interception rather than by last_active_at. last_active_at is still returned for display, and the two can disagree when interceptions in a session overlap in time.
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.
Related issue: https://linear.app/codercom/issue/AIGOV-580/ai-gateway-sessions-page-takes-5-10-seconds-to-load
Summary
ListAIBridgeSessionsandCountAIBridgeSessionsaggregate every completed interception before applyingLIMIT, so the AI Gateway sessions page scales with the size ofaibridge_interceptions(AIGOV-580). On a 2,000,000 interception dataset the list query takes 5.0-5.6 s and the count 5.7 s.This represents a session by its latest interception that matches the filters, found with a
NOT EXISTSprobe for a newer interception in the same session. One partial index answers the probe, so the ordered scan stops afterLIMITsessions and the expensive aggregation runs only for that page.Result on the same dataset: list 126 ms, count 490 ms. No new table, no triggers, no backfill.
Benchmark, 2M interceptions
Dataset: 2,000,000 interceptions, 318,747 sessions, 4.0M prompts, 3.0M token usages, 6.0M tool usages, 10 GB, 50 users, 5 providers, 12 models. Postgres 16, warm cache.
ListAIBridgeSessions, page 1 of 25CountAIBridgeSessionsOutput was diffed column by column against the pre-change query on that dataset: the unfiltered page is byte-identical. Filtered pages return the same set of sessions, with the ordering difference described below.
Behaviour changes, please review these deliberately
Ordering key. The list is ordered by the session's latest completed interception instead of
last_active_at(the latest prompt, falling back to the earliest interception when a session has no prompts).last_active_atis still returned for display. The two disagree when interceptions within a session overlap in time, or when a prompt is recorded after a later interception has already started. Two existing subtests asserted the old order and were updated with comments explaining the trade:SortsByLastActiveis nowSortsByLatestInterception, andPromptlessSessionSortsByStartedAtis nowPromptlessSessionOrdersByLatestInterception. If preserving the old ordering exactly is a requirement, this PR is the wrong shape and the alternative below is better.Aggregates with a filter applied.
started_at,ended_at,threadsandlast_active_atare now computed over all completed interceptions of the session rather than only the filter-matching ones. That makes them consistent withproviders,modelsand the token totals, which were already session-wide. Pinned by the newAggregatesSpanUnfilteredInterceptionssubtest.Tie-break. Ties in
started_atbreak on interceptionidinstead ofsession_id.Bug fixed
Filtered cursor pagination repeated rows.
cursor_posresolved the cursor session's position while ignoring the filters, so theHAVINGcomparison used filter-restricted values against an unfiltered cursor. On the benchmark dataset, page 2 of amodelfiltered list repeated 3 of 25 rows from page 1.cursor_posnow applies the same filters and the initiator, and pagination is keyset on a single row's(started_at, id), which is unique. Ten pages of 25 return 250 rows and 250 distinct sessions, filtered and unfiltered. Regression test:KeysetPaginationWithModelFilter, which fails against the old query withsession ... returned on more than one page.Relationship to the other open PRs
Three PRs now address AIGOV-580. Measured on the same 2M dataset:
mainlast_active_atlast_prompt_atlast_active_at, unchangedaibridge_sessionslast_active_atThese are complementary rather than competing. If #27900 lands, the anti-join here can key on its
last_prompt_atcolumn and then ordering is preserved with no behaviour change at all. A first attempt at that combination measured 418 ms because the expression needs a second index for the outer ordered scan, so it needs work before it is a viable follow-up. Happy to do that instead if the ordering change in point 1 is not acceptable.Tests
TestAIBridgeListSessionspasses with 29 subtests. Added:KeysetPaginationWithModelFilter, disjoint and complete filtered paging, regression test for the repeated rows.KeysetPaginationUnfiltered, the same for page sizes 1, 2 and 4.OrderedByLatestMatchingInterception, a session spanning two models orders by its latest matching interception.CountMatchesListedRows, count agrees with the unpaginated list, filtered and unfiltered.NewerInflightInterception, a session with a newer in-flight interception is keyed by its latest completed one.AggregatesSpanUnfilteredInterceptions, pins behaviour change 2.Note on generated code
modelqueries.gopositional arguments forListAuthorizedAIBridgeSessionsandCountAuthorizedAIBridgeSessionswere reordered to match the new placeholder order. These hand-written wrappers are not checked by the compiler against the query text, so the ordering is easy to break silently. Worth reviewing carefully.Benchmark methodology
Two dev instances were run against identical data on one host: baseline at the merge-base of #27996 and the branch under test, both against a Postgres 16 container with
shared_buffers=8GB. Data came from a deterministic generator (md5-derived ids, fixed base time, 40-day window so the 60-day AI Gateway retention purge leaves it alone), so the same target row count reproduces the same rows. The second database was cloned withCREATE DATABASE ... TEMPLATEbefore any migration ran, so both sides started byte-identical.Comparison harness, per parameter set: run the old and new queries with the same parameters, dump all columns pipe-separated, and diff. For pagination: page with
after_session_idand assert the union of pages has no repeats and matches the unpaginated list. Timings are wall clock over repeated runs plusEXPLAIN (ANALYZE, BUFFERS)execution time.This work was generated by Coder Agents.