Repository navigation
perf(vector-stores): stop paying for debug diagnostics that are discarded - #2261
Conversation
…rded
Python evaluates a logging call's arguments before the level is checked, so
every _logger.debug(await _safe_queryset_info(qs, ...)) in this module ran a
COUNT(*) over the annotation table and then threw the string away. Four of the
sites were _logger.info, so they ran the count AND emitted it in production.
Measured on a 4,679-section authority deployment (18-corpus group, warm caches,
no reranker configured):
_agenerate_query_embedding 0.291s
_build_base_queryset 12.887s <- three COUNT(*) queries
search_by_embedding 2.222s
async_search (whole) 16.548s
78% of a search was diagnostics. The most expensive single count was
unfiltered — Annotation.objects.all().count(), the whole table, once per
search — to log a number that changes only when someone uploads a document.
Guards every _safe_queryset_info call site and every queryset.query
stringification (compiling the SQL is not free either) behind
_diagnostics_enabled(), and demotes the four INFO sites to DEBUG. No behaviour
change when DEBUG logging is on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Review: perf(vector-stores): stop paying for debug diagnostics that are discarded Nice catch, and a well-documented one — the measurement methodology (before/after phase timings, the 6.1x fan-out number) makes the fix easy to trust. A few notes. Correctness - looks solid
Missing: changelog fragment Test coverage gap Suggest adding a small test (e.g. in opencontractserver/tests/) that:
This doesn't need a full integration test with real embeddings - mocking/spying on .count and the query-building path should be enough to pin the behavior. Minor / non-blocking
Overall: the fix is targeted, correctly scoped to the actual expensive call sites, and the measurement work backing it is convincing. Main ask before merge is closing the test-coverage and changelog gaps called out above. |
|
Swept the rest of the codebase for the same primitive rather than only the sites the profile named: Everything else that eagerly evaluates a |
test_no_count_diagnostics_run_when_debug_is_off is the regression test — it fails on main (the COUNT-issuing helper runs with debug logging off) and passes with the guard. The other two are controls against the guard going wrong: diagnostics must still run at DEBUG (deferred, not deleted), and search results must be identical either way. Each asserts the search returned rows first, because all three would pass vacuously against an empty result set — which is how the fixture was actually broken while writing them.
|
Added The regression test: Two controls against the guard going wrong, since the danger of a change like this is silently removing a diagnostic:
All three assert the search returned rows first, because all three would otherwise pass vacuously against an empty result set — which is exactly how the fixture was broken while writing them (a made-up Regression run over Note for anyone running the suite locally: under |
ReviewNice, well-measured fix. The core diagnosis — Python evaluates a logging call's arguments eagerly, so
Test coverage is thorough and does the right thing for a guard like this: one test proves the count-producing helpers are never called (not just not logged) at INFO, one proves they still fire at DEBUG (deferred, not deleted), and one proves search results are identical at both levels. Using Things worth addressing before merge:
Overall: focused, correctly scoped, well-verified perf fix with good regression tests. The changelog fragment is the only thing I would block on; the test-suite verification note is worth a maintainer double-check in CI. |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…ocstring Both Claude reviews flagged the missing changelog.d/ entry for this perf fix and the _diagnostics_enabled() docstring embedding specific benchmark numbers that mirror the PR description and will drift stale. Added the fragment; trimmed the docstring to the durable why (eager argument evaluation in logging calls).
ReviewNice, well-scoped fix. The diagnosis is correct — Python evaluates a logging call's arguments before checking whether the level is enabled, so Verified while reviewing:
Minor, non-blocking:
Process note: the PR body flags that the repo test suite wasn't run locally (a Also noted: the PR explicitly scopes out the non-concurrent Overall: solid, well-measured performance fix with good regression-test coverage for the specific failure mode (guard-that-deletes vs. guard-that-defers). No security or permissioning concerns — this is internal retrieval-path logging, doesn't touch the permission/visibility filtering logic itself (only when it's logged). |
must_have_text, modalities, metadata filters, and both vector-only (sync + async) code paths were never exercised at DEBUG level, so codecov flagged 8 patch lines as uncovered (patch coverage 69.23%, below the required threshold). Add three tests that drive each of those branches and assert the deferred diagnostic actually fires.
ReviewSolid, well-scoped perf fix. The core diagnosis (Python evaluates logging-call arguments before checking the level, so Nice touches:
Minor observations, none blocking:
Behavior-change callout for reviewers/ops: the four (five) sites demoted from No security or IDOR concerns — this is a pure logging/perf change and doesn't touch any permission-filtering logic. |
The bug
Python evaluates a logging call's arguments before it checks whether the
level is enabled. Every diagnostic of this shape in
core_vector_stores.pytherefore runs
_safe_queryset_info— aCOUNT(*)over the annotation table —on every search and then discards the string. Four of the sites were
_logger.info, so they ran the count and emitted it in production.Measurement
On a 4,679-section authority deployment (18-corpus
CorpusGroup, warm caches,no reranker configured), timing one member-corpus search by phase:
_agenerate_query_embedding_build_base_querysetCOUNT(*)search_by_embeddingasync_search(whole)78% of a search was diagnostics. The single most expensive one is
unfiltered — it counts the entire table:
…once per search, to log a number that changes only when someone uploads a
document.
After
search_across_corpora6.1× on the fan-out, and it applies to every corpus search in the product.
The change
Guards every
_safe_queryset_infocall site and everyqueryset.querystringification (compiling the SQL is not free either) behind
_diagnostics_enabled(), and demotes the fourINFOsites toDEBUG. WhenDEBUG logging is on, behaviour is unchanged.
I grepped for every other value reaching the same primitive rather than fixing
only the ones the profile named — that turned up the
queryset.queryf-stringat the metadata-filter site, which the first pass missed.
Verification
test/verify_diag_guard.py(in the ITAR workspace, not this repo) asserts thetwo things that would catch a guard swallowing real work:
i.e. the diagnostics still emit at DEBUG (deferred, not deleted), and the
returned annotation ids and scores are identical with the logger at DEBUG
and at INFO.
The repo suite was not run: under
local.ymlpytest dies atdjango.setup()on a duplicatestoragesapp label, before any of this moduleis imported, and standing up the
test.ymlstack risks the composeproject-name collision while a long measurement was in flight. Worth a CI run.
Not included
While measuring this I also found that the
asyncio.gatherinsearch_across_corporais not concurrent — 45.3s gathered vs 48.8ssequential, a 1.08× speedup, because the paths below it await
sync_to_async(..., thread_sensitive=True). A fix for that was attempted,preserved results on all 18 corpora, delivered only 2.26× rather than the
predicted 18×, and then failed on all 18 when the same wrapper was awaited
sequentially after a gather (
AsyncToSyncfinding a running loop in a workerthread). It was reverted rather than shipped and is not part of this PR.
🤖 Generated with Claude Code