Skip to content

Cache catalogue figures and index the search columns - #38

Merged
lbesecker195 merged 2 commits into
mainfrom
perf/cache-and-index-catalogue
Sep 19, 2026
Merged

lbesecker195 merged 2 commits into
mainfrom
perf/cache-and-index-catalogue

Conversation

@lbesecker195

Copy link
Copy Markdown
Owner

Closes #37.

Catalogue pages took 1.4–3.4s in production against ~31,000 listings. Three causes; caching only addresses two of them.

Cost Ran on Fix
stats/0 — five full-table aggregates every / and /servers one pass, cached a day
top_tags/1 — shipped ~90k rows to Elixir to tally every / and /servers GROUP BY in SQL, cached a day
Search — ILIKE '%…%' × 5, no usable index every ?q= trigram indexes

Measured on 31,000 rows

Before After
Selective search 47.4 ms 0.4 ms
top_tags uncached 19.8 ms 9.7 ms
stats uncached 6.9 ms 4.2 ms
stats / top_tags cached ~0 ms

Results do not change

Search keeps ILIKE; the index is only a faster path to the same rows, and Postgres rechecks the real condition on every candidate. Full-text search would have been faster still and would have changed results — stemming, stop words, no substring matching — so it is deliberately not used.

Verified by diffing result sets across eight terms (github, database, tool_3, developer-tools, bench, Search, zz, io.github): zero differing rows in either direction.

Two things worth knowing

  1. All five OR branches had to become indexable. Postgres combines OR branches through a BitmapOr only when every branch has an index — one unindexed branch sends the whole predicate back to a sequential scan. The array columns needed an IMMUTABLE mcp_array_to_text/1 wrapper, because Postgres refuses to index an expression containing array_to_string/2. If filter_q/2 and that function ever drift apart, search silently falls back to a scan.
  2. A broad term that matches most rows still seq-scans, correctly. When nearly everything matches, scanning is cheaper than an index walk, and the planner knows it. The win is on selective terms, which is where the seconds were going.

Cache behaviour

A day is a ceiling, not a staleness window: the cache is dropped on every listing write and once at the end of each official-registry sync, so a submission shows up on the next request. It holds a handful of tuples in ETS — deliberately not a dependency and deliberately tiny, given #36.

Indexes are built CONCURRENTLY, so the migration does not take a write lock on the live catalogue. 85 tests pass; migration verified from a clean database.


Pages affected:

🤖 Generated with Claude Code

Logan Besecker and others added 2 commits September 19, 2026 10:20
Catalogue pages took 1.4-3.4s against 31,000 listings. Three causes, only two
of which caching can touch.

- Cache stats/0 and top_tags/1 for a day in a small ETS table, dropped on any
  write and after an official sync, so the TTL is a ceiling rather than a
  staleness guarantee
- Count tags with GROUP BY instead of shipping ninety thousand rows to Elixir
  to tally, and fold five full-table aggregates into one pass
- Add trigram indexes so search stops sequentially scanning the table

Search keeps ILIKE rather than moving to full-text, so matching is unchanged
and the index is only a faster path to the same rows. Verified across eight
terms: zero differing rows. Indexing the array columns needed an IMMUTABLE
mcp_array_to_text/1, because Postgres will not index array_to_string/2, and
one unindexed OR branch would have sent the whole predicate back to a scan.

Measured on 31,000 rows: selective search 47.4ms to 0.4ms, top_tags 19.8ms to
9.7ms uncached and nothing when cached, stats 6.9ms to 4.2ms uncached.

---

Pages affected:

- [MCP Registry](https://ai.mcpharbor.dev/) — landing page, which renders the counters and tag cloud.
- [Browse MCP servers](https://ai.mcpharbor.dev/servers) — the catalogue and its search.
- [JSON API](https://ai.mcpharbor.dev/api/v0/servers) — machine-readable listings, served by the same queries.
- [Submit a server](https://ai.mcpharbor.dev/submit) — publishing drops the cached figures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Migrations run from the service unit's ExecStartPre, so a migration that
raises means the release never starts. pg_trgm is a trusted extension from
PostgreSQL 13 and the database owner can install it, but on an older server or
a restricted role it needs superuser.

- Create mcp_array_to_text/1 unconditionally, since filter_q/2 calls it and a
  missing function would make every search raise
- Wrap the extension and the indexes, logging what to run by hand instead

Losing an index is a slow catalogue. Losing the boot is an outage.

---

Pages affected:

- [MCP Registry](https://ai.mcpharbor.dev/) — landing page served by the same release.
- [Browse MCP servers](https://ai.mcpharbor.dev/servers) — the catalogue and its search.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lbesecker195
lbesecker195 merged commit d7be4f3 into main Sep 19, 2026
1 check passed
@lbesecker195
lbesecker195 deleted the perf/cache-and-index-catalogue branch September 19, 2026 17:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cache catalogue figures and index the search columns

1 participant