Skip to content

Cache get_stats() with a TTL to fix uncached COUNT on hot API paths#119

Open
nsheff wants to merge 2 commits into
devfrom
fix-stats-cache
Open

Cache get_stats() with a TTL to fix uncached COUNT on hot API paths#119
nsheff wants to merge 2 commits into
devfrom
fix-stats-cache

Conversation

@nsheff

@nsheff nsheff commented Jul 13, 2026

Copy link
Copy Markdown
Member

Problem

BedBaseAgent.get_stats() runs three uncached COUNT queries against the 662,590-row bed table 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/stats and, via bbconf/modules/bedfiles.py, in get_neighbours() (line 311), the bed-list builder (1386), and search (2354), just to fill a count field. On the single-worker bedhost deployment this serializes the whole API and causes it to wedge under light load (e.g. Googlebot crawling dev.bedbase.org).

An existing TTLCache in bedhost/dependencies.py only wraps get_detailed_stats(), not get_stats() — and it can't be fixed in bedhost because the three callers live in bbconf.

Fix

Cache get_stats() in bbconf itself with a TTLCache(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-cached get_stats()
  • pyproject.toml — declare cachetools>=4.2.4 (was undeclared); version 0.14.12 → 0.14.13
  • docs/changelog.md — 0.14.13 entry

Notes

  • Optional follow-ups left out of scope: N+1 in get_neighbours, avoiding the distinct genome_alias count in the list/search/neighbours count field.
  • Plan: intervals/bbconf_stats_cache_plan_v1.md.

@nsheff

nsheff commented Jul 14, 2026

Copy link
Copy Markdown
Member Author

Added: batch neighbour metadata fetch (commit 1ceb219)

Follow-up to the get_stats() cache: this eliminates an N+1 query in get_neighbours().

Before: Qdrant returns the N nearest-neighbour IDs (no metadata), and the code hydrated each one by calling self.get(id, full=False) in a loop. Every get() opened its own Session and ran SELECT * FROM bed WHERE id = :id. So a limit=10 neighbours request was ~10 separate DB round-trips, each with its own session setup — on top of the (now cached) stats count.

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 limit.

How it helps:

  • Latency: with the stats cache alone, /v1/bed/{id}/neighbours measured ~0.4 s (down from ~2.4 s). Collapsing the N+1 to a single IN query removes most of that residual — the endpoint's DB work is now one query instead of ten.
  • Load: the crawler (Googlebot on dev.bedbase.org) hits neighbours for every bed. Cutting per-request DB round-trips ~10x proportionally lowers the query load on the shared databio-rds instance.
  • Robustness: previously, if any Qdrant point referenced a bed missing from Postgres, get() raised BEDFileNotFoundError and 500'd the entire neighbours request. The batched version skips absent IDs (if id in beds), so a stale Qdrant point degrades to one fewer result instead of a hard failure.

Refactor note: the per-bed model construction was extracted from get() into a _build_metadata(bed_object, full) helper (called inside the open session so full=True lazy relationships behave exactly as before). get()'s external behavior is unchanged — pure extraction, verified against the original inline path.

Together with the stats cache, the two hot crawled endpoints go: /v1/stats ~1.5 s → ~2 ms (cache hit), neighbours ~2.4 s → ~0.1 s.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 uncached COUNT queries.
  • 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 cachetools dependency; 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.

Comment thread docs/changelog.md
Comment on lines +6 to +15
### [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)


Comment thread pyproject.toml
[project]
name = "bbconf"
version = "0.14.12"
version = "0.14.14"
Comment on lines +317 to +329
# 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()
}
Comment on lines +330 to +334
result_list = [
QdrantSearchResult(
id=result.id.replace("-", ""),
payload=result.payload,
score=result.score,
Comment thread bbconf/bbagent.py
Comment on lines +105 to +109
with self._stats_lock:
cached = self._stats_cache.get("stats")
if cached is not None:
return cached

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.

2 participants