Cache get_stats() with a TTL to fix uncached COUNT on hot API paths#119
Cache get_stats() with a TTL to fix uncached COUNT on hot API paths#119nsheff wants to merge 2 commits into
Conversation
Added: batch neighbour metadata fetch (commit 1ceb219)Follow-up to the Before: Qdrant returns the N nearest-neighbour IDs (no metadata), and the code hydrated each one by calling After: one batched query hydrates all neighbours at once: beds = {b.id: b for b in session.scalars(
select(Bed).where(Bed.id.in_(ids)).options(selectinload(Bed.annotations))
).all()}~10 round-trips → 1, regardless of How it helps:
Refactor note: the per-bed model construction was extracted from Together with the stats cache, the two hot crawled endpoints go: |
There was a problem hiding this comment.
Pull request overview
This PR reduces load on hot API paths by caching expensive global stats counts and by reducing database round-trips when hydrating neighbour search results. It targets two major sources of avoidable latency: repeated COUNT scans on the large bed table and an N+1 metadata fetch pattern in neighbour retrieval.
Changes:
- Add a TTL cache (with locking) around
BedBaseAgent.get_stats()to avoid repeated uncachedCOUNTqueries. - Refactor
get_neighbours()to batch-fetch neighbour metadata and skip stale Qdrant points that no longer exist in Postgres. - Bump package version and add the missing
cachetoolsdependency; update changelog entries accordingly.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| pyproject.toml | Bumps version and declares cachetools dependency needed for TTL caching. |
| docs/changelog.md | Adds release notes for the performance/behavior changes. |
| bbconf/modules/bedfiles.py | Refactors metadata building and batches neighbour hydration to avoid N+1 queries. |
| bbconf/bbagent.py | Adds TTL caching + locking to get_stats() to eliminate repeated expensive DB counts. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ### [0.14.14] - 2026-07-13 | ||
| ### Fixed: | ||
| - Eliminated an N+1 query in `get_neighbours()` by fetching all neighbour metadata in a single batched query (with annotations eager-loaded) instead of one query per neighbour; stale Qdrant points are now skipped rather than raising | ||
|
|
||
|
|
||
| ### [0.14.13] - 2026-07-13 | ||
| ### Fixed: | ||
| - Cache `get_stats()` with a TTL to avoid running uncached COUNT queries on the bed table on every request to hot API paths (stats, neighbours, list, search) | ||
|
|
||
|
|
| [project] | ||
| name = "bbconf" | ||
| version = "0.14.12" | ||
| version = "0.14.14" |
| # Hydrate all neighbours with a single batched query instead of one | ||
| # SELECT per neighbour (was an N+1). annotations is joined-loaded, | ||
| # but selectinload keeps that explicit for this detached-object path. | ||
| ids = [result.id.replace("-", "") for result in results.points] | ||
| with Session(self._sa_engine) as session: | ||
| beds = { | ||
| bed.id: bed | ||
| for bed in session.scalars( | ||
| select(Bed) | ||
| .where(Bed.id.in_(ids)) | ||
| .options(selectinload(Bed.annotations)) | ||
| ).all() | ||
| } |
| result_list = [ | ||
| QdrantSearchResult( | ||
| id=result.id.replace("-", ""), | ||
| payload=result.payload, | ||
| score=result.score, |
| with self._stats_lock: | ||
| cached = self._stats_cache.get("stats") | ||
| if cached is not None: | ||
| return cached | ||
|
|
Problem
BedBaseAgent.get_stats()runs three uncachedCOUNTqueries against the 662,590-rowbedtable on every call (~1,375 ms on prod:count(bed.id)420 ms,count(bedsets.id)181 ms,count(distinct genome_alias)774 ms). Postgres counts are sequential scans under MVCC, so the cost is paid in full each time.It is called raw on hot paths —
GET /v1/statsand, viabbconf/modules/bedfiles.py, inget_neighbours()(line 311), the bed-list builder (1386), and search (2354), just to fill acountfield. On the single-worker bedhost deployment this serializes the whole API and causes it to wedge under light load (e.g. Googlebot crawlingdev.bedbase.org).An existing
TTLCacheinbedhost/dependencies.pyonly wrapsget_detailed_stats(), notget_stats()— and it can't be fixed in bedhost because the three callers live in bbconf.Fix
Cache
get_stats()in bbconf itself with aTTLCache(maxsize=1, ttl=3600)guarded by a lock (the lock guards only the cache dict, not the DB query). All four call sites now get cached totals → ~1.4 s → ~0 ms on cache hits.Changes
bbconf/bbagent.py— TTL-cachedget_stats()pyproject.toml— declarecachetools>=4.2.4(was undeclared); version 0.14.12 → 0.14.13docs/changelog.md— 0.14.13 entryNotes
get_neighbours, avoiding thedistinct genome_aliascount in the list/search/neighbourscountfield.intervals/bbconf_stats_cache_plan_v1.md.