Skip to content

perf: serve the AI Bridge sessions list from an indexed anti-join - #28123

Closed
evgeniy-scherbina wants to merge 1 commit into
mainfrom
yevhenii/aibridge-sessions-antijoin
Closed

perf: serve the AI Bridge sessions list from an indexed anti-join#28123
evgeniy-scherbina wants to merge 1 commit into
mainfrom
yevhenii/aibridge-sessions-antijoin

Conversation

@evgeniy-scherbina

@evgeniy-scherbina evgeniy-scherbina commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Related issue: https://linear.app/codercom/issue/AIGOV-580/ai-gateway-sessions-page-takes-5-10-seconds-to-load

Summary

ListAIBridgeSessions and CountAIBridgeSessions aggregate every completed interception before applying LIMIT, so the AI Gateway sessions page scales with the size of aibridge_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 EXISTS probe for a newer interception in the same session. One partial index answers the probe, so the ordered scan stops after LIMIT sessions 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.

Query Before After
ListAIBridgeSessions, page 1 of 25 5,080 ms 126 ms
CountAIBridgeSessions 5,918 ms 490 ms
Keyset paging, 10 pages of 25 n/a 85-134 ms per page
Index build n/a 1.55 s, 181 MB

Output 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

  1. 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_at is 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: SortsByLastActive is now SortsByLatestInterception, and PromptlessSessionSortsByStartedAt is now PromptlessSessionOrdersByLatestInterception. If preserving the old ordering exactly is a requirement, this PR is the wrong shape and the alternative below is better.

  2. Aggregates with a filter applied. started_at, ended_at, threads and last_active_at are now computed over all completed interceptions of the session rather than only the filter-matching ones. That makes them consistent with providers, models and the token totals, which were already session-wide. Pinned by the new AggregatesSpanUnfilteredInterceptions subtest.

  3. Tie-break. Ties in started_at break on interception id instead of session_id.

Bug fixed

Filtered cursor pagination repeated rows. cursor_pos resolved the cursor session's position while ignoring the filters, so the HAVING comparison used filter-restricted values against an unfiltered cursor. On the benchmark dataset, page 2 of a model filtered list repeated 3 of 25 rows from page 1. cursor_pos now 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 with session ... returned on more than one page.

Relationship to the other open PRs

Three PRs now address AIGOV-580. Measured on the same 2M dataset:

Approach List page Ordering Schema footprint
Current main 5,080 ms last_active_at none
#27900, denormalized last_prompt_at 1,376 ms last_active_at, unchanged 1 column, backfill 108 s, insert path change
#27996, materialized aibridge_sessions 100-115 ms last_active_at 1 table, 6 indexes, 3 functions, 2 triggers, backfill 15 s
This PR 126 ms latest interception 1 index, build 1.55 s

These are complementary rather than competing. If #27900 lands, the anti-join here can key on its last_prompt_at column 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

TestAIBridgeListSessions passes 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.
go test ./enterprise/coderd -run TestAIBridgeListSessions -count=1   ok, 29/29 subtests
go test ./coderd/database/dbauthz -run AIBridge -count=1             ok
make pre-commit                                                      ok

Note on generated code

modelqueries.go positional arguments for ListAuthorizedAIBridgeSessions and CountAuthorizedAIBridgeSessions were 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 with CREATE DATABASE ... TEMPLATE before 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_id and assert the union of pages has no repeats and matches the unpaginated list. Timings are wall clock over repeated runs plus EXPLAIN (ANALYZE, BUFFERS) execution time.


This work was generated by Coder Agents.

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.
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 13, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant