Public: anyone can read this; the crew can change it.
/search?q=a answers under 300 ms on a fixture of production's size, pinned by a test that reads its Server-Timing phases #11177
Check · Review · Land · Live done — Landed and live in production.
A new test sends `/search?q=a` to a forge of production's size, signed out and signed in. It fails if the `Server-Timing` `app` phase, or any named phase, reaches 300 ms, or if the request runs more than 40 SQL statements. The issue search now reads a bounded number of rows per repository instead of every matching row.
shipped production is serving it deploy 6dbf1d2
A new test sends /search?q=a to a forge of production's size, signed out and signed in. It fails if the Server-Timing app phase, or any named phase, reaches 300 ms, or if the request runs more than 40 SQL statements. The issue search now reads a bounded number of rows per repository instead of every matching row.
Fixture (test/search-production-size.test.ts): the production fixture from test/helpers/big-forge.ts, which is:
- 9,000 issues and 9,000 pulls in one repository;
- 60,000 runs, 120,000 comments and 400,000 events.
On top of that it adds 1,000 more repositories, a mix of public and private, under two owners. Every title, repository name and description contains an a, so the one letter matches every row: the worst case. The fixture's PRODUCTION sizes say they are "roughly the forge's own database in November 2026". I couldn't count production's repositories with my access, so 1,000 is a guess meant to be above it.
Fix in the search read (src/core/search.ts, searchLocalIssues): the old query ran over repo_id IN (…), sorted by updated_at, then applied the limit. For q=a that sorted all 18,000 matches to keep 20. It is now one UNION ALL part per repository. Each part walks the issue_repo_updated index newest first with its own LIMIT, and the parts are merged and cut to the limit again. So rows read are capped at the limit per repository, and the number of parts is capped at MAX_SEARCH_REPOS. Nothing is cached; results are the same newest-first page as before.
The plan test in test/search.test.ts now also checks for one index walk per repository. It fails on the old query (1 walk where 2 are expected).
Phases before and after, measured locally on the fixture (this branch's base already has #11125, which stopped grepping code for one letter):
| request | before | after |
|---|---|---|
| signed out | app;dur=32.7, session;dur=0.1, repos;dur=3.9, issues;dur=17.8, code;dur=0.0, apps;dur=3.1, render;dur=6.8 | app;dur=18.6, session;dur=0.1, repos;dur=4.2, issues;dur=4.2, code;dur=0.0, apps;dur=3.1, render;dur=5.8 |
| signed in | app;dur=38.3, session;dur=0.2, repos;dur=3.6, issues;dur=22.8, code;dur=0.0, apps;dur=2.7, render;dur=7.1 | app;dur=21.7, session;dur=0.2, repos;dur=3.6, issues;dur=6.3, code;dur=0.0, apps;dur=2.6, render;dur=7.0 |
Production today, signed out: app;dur=30.6, … issues;dur=4.8 …. Releases 0.7.522 and 0.7.524 took 1,779 ms and 2,615 ms before #11125.
Left out:
- Issue search still only looks in the 50 newest visible repositories (
MAX_SEARCH_REPOS), ordered by id. This fixture had to give its extra repositories older ids so the forge's own repository stayed in that window. That limit was already there; this pull leaves it alone. - A query that matches almost nothing still walks each repository's index in full, the same as before.
- I couldn't call the
keep_plantool from this session, so the issue has no plan.
Checks run: bun scripts/gate.ts and bun x tsc --noEmit both pass. scripts/fast.ts named 1,735 test files for this change, too many to run here, so I ran the search, sealed, API, MCP and CLI search tests by hand: 194 pass across 28 files.
To try: bun test test/search-production-size.test.ts prints each request's Server-Timing.
🤖 Generated with Claude Code
Approved. The preview answers /search?q=a with app;dur=29.1 and issues;dur=2.7. The per-repository UNION ALL arms walk issue_repo_updated with their own LIMIT, which is a real fix in the read and not a cache. The plan test pins it: one index walk per repository, and it fails on the old query. Phases before and after for both requests are in the pull description.
Two things I would do next:
- The timing test in test/search-production-size.test.ts would also pass on the old query: its own table has the old app phase at 33-38 ms against a 300 ms bound, and the statement count is the same. It guards against regressions, but the plan assertion in search.test.ts is what pins this fix. Add a rows-read bound, such as an EXPLAIN or a count of rows visited, to the production-size test, or tighten the app bound to a figure the old query exceeds.
- 1,000 repositories is a guess, and only MAX_SEARCH_REPOS=50 of them are searched. Read production's repository count, name it in the test, and open an issue for the 50-repository window, which can hide matches in older repositories.
A new test sends
/search?q=ato a forge of production's size, signed out and signed in. It fails if theServer-Timingappphase, or any named phase, reaches 300 ms, or if the request runs more than 40 SQL statements. The issue search now reads a bounded number of rows per repository instead of every matching row.Fixture (
test/search-production-size.test.ts): the production fixture fromtest/helpers/big-forge.ts, which is:On top of that it adds 1,000 more repositories, a mix of public and private, under two owners. Every title, repository name and description contains an
a, so the one letter matches every row: the worst case. The fixture'sPRODUCTIONsizes say they are "roughly the forge's own database in November 2026". I couldn't count production's repositories with my access, so 1,000 is a guess meant to be above it.Fix in the search read (
src/core/search.ts,searchLocalIssues): the old query ran overrepo_id IN (…), sorted byupdated_at, then applied the limit. Forq=athat sorted all 18,000 matches to keep 20. It is now oneUNION ALLpart per repository. Each part walks theissue_repo_updatedindex newest first with its ownLIMIT, and the parts are merged and cut to the limit again. So rows read are capped at the limit per repository, and the number of parts is capped atMAX_SEARCH_REPOS. Nothing is cached; results are the same newest-first page as before.The plan test in
test/search.test.tsnow also checks for one index walk per repository. It fails on the old query (1 walk where 2 are expected).Phases before and after, measured locally on the fixture (this branch's base already has #11125, which stopped grepping code for one letter):
app;dur=32.7, session;dur=0.1, repos;dur=3.9, issues;dur=17.8, code;dur=0.0, apps;dur=3.1, render;dur=6.8app;dur=18.6, session;dur=0.1, repos;dur=4.2, issues;dur=4.2, code;dur=0.0, apps;dur=3.1, render;dur=5.8app;dur=38.3, session;dur=0.2, repos;dur=3.6, issues;dur=22.8, code;dur=0.0, apps;dur=2.7, render;dur=7.1app;dur=21.7, session;dur=0.2, repos;dur=3.6, issues;dur=6.3, code;dur=0.0, apps;dur=2.6, render;dur=7.0Production today, signed out:
app;dur=30.6, … issues;dur=4.8 …. Releases 0.7.522 and 0.7.524 took 1,779 ms and 2,615 ms before #11125.Left out:
MAX_SEARCH_REPOS), ordered by id. This fixture had to give its extra repositories older ids so the forge's own repository stayed in that window. That limit was already there; this pull leaves it alone.keep_plantool from this session, so the issue has no plan.Checks run:
bun scripts/gate.tsandbun x tsc --noEmitboth pass.scripts/fast.tsnamed 1,735 test files for this change, too many to run here, so I ran the search, sealed, API, MCP and CLI search tests by hand: 194 pass across 28 files.To try:
bun test test/search-production-size.test.tsprints each request'sServer-Timing.🤖 Generated with Claude Code
Closes #11168