Speed up structural search: most-selective-first predicates + statement timeout - #205
Open
skearnes wants to merge 2 commits into
Open
Speed up structural search: most-selective-first predicates + statement timeout#205skearnes wants to merge 2 commits into
skearnes wants to merge 2 commits into
Conversation
…nt timeout Two changes to the shared search engine (queries.py / search.py), addressing the 20-60s+ (or hanging) structural searches analyzed in the ord-logbook entry. 1. Most-selective-first query shape. Replace the INTERSECT of one `SELECT DISTINCT reaction_id` per predicate -- with LIMIT on top, which could not bound the per-branch scans -- with a single `SELECT reaction.reaction_id FROM ord.reaction WHERE <p1> AND <p2> ... LIMIT n`, where each predicate is an EXISTS (or direct) condition correlated to the outer reaction row. The planner can now lead with the most selective predicate as a semi-join and stop once LIMIT rows are found. Measured on the live DB: benzene EXACT + yield>70 ~25s -> ~10s; morphine SIMILAR ~22s -> ~1s. Each ReactionQuery now exposes `where_predicate` instead of `query_and_parameters`. 2. statement_timeout floor. get_cursor sets statement_timeout (default 20s, ORD_INTERFACE_STATEMENT_TIMEOUT_MS) so genuinely pathological queries (e.g. a common-scaffold substructure with no selective co-filter, or an empty-result broad match) are cancelled instead of running away. The resulting psycopg QueryCanceled maps to a 400 with an actionable "too broad" message; the background task path records the error so fetch_query_result reports it rather than polling forever. All existing query/search result-count tests pass unchanged (the rewrite is result-preserving); adds unit tests for the timeout->400 mapping and the background-task error path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment on lines
+287
to
300
| try: | ||
| # NOTE(skearnes): Use reaction IDs to avoid stuffing full protos into the result database. | ||
| result = await run_query(params, return_ids=True) | ||
| except HTTPException as error: | ||
| logger.info(f"Task {task_id} failed: {error.detail}") | ||
| async with get_redis() as client: | ||
| return await client.set( | ||
| f"error:{task_id}", | ||
| json.dumps({"status_code": error.status_code, "detail": error.detail}), | ||
| ex=60 * 60, | ||
| ) | ||
| logger.debug(f"Finished task {task_id}") | ||
| async with get_redis() as client: | ||
| return await client.set(f"result:{task_id}", json.dumps(result), ex=60 * 60) |
There was a problem hiding this comment.
Background task error handling is incomplete
run_task now correctly traps HTTPException (the timeout path) and records it in Redis so fetch_query_result can report it. However, other exceptions that can propagate from run_query — most notably a raw psycopg exception if the database connection drops mid-query — are not caught. In those cases neither result:{task_id} nor error:{task_id} is ever written, so callers polling fetch_query_result will receive 202 indefinitely until query:{task_id} expires after one hour. A broad except Exception fallback that writes a generic error:{task_id} entry would close this gap.
CI runs `ruff format --check`; reformat the new timeout/error-path test params. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Addresses the slow/hanging structural searches analyzed in the ord-logbook entry "Structural search performance in the ORD interface" (
2026-06-26-structural-search-performance.md). Independent of the natural-language work (#203/#204); this touches the sharedqueries.py/search.pyengine used by both/searchand/ask.1. Most-selective-first query shape (
queries.py)Previously each predicate produced a
SELECT DISTINCT reaction_id FROM …branch, combined withINTERSECT, withLIMITon top. Each branch was fully materialized (sort + unique) before the set operation, soLIMITcould not bound the per-branch index scans.Now each
ReactionQueryexposeswhere_predicate— anEXISTS (…)(or direct) condition correlated to a single outerord.reactionrow — andrun_queriesbuilds:The planner can lead with the most selective predicate as a semi-join and stop once
LIMITrows are found. Measured against the live DB:(Similarity ranking is unchanged: candidates are collected, then scored/limited.)
2.
statement_timeoutfloor (search.py)get_cursornow setsstatement_timeout(default 20 s, overrideORD_INTERFACE_STATEMENT_TIMEOUT_MS) so genuinely pathological queries — a common-scaffold substructure with no selective co-filter, or a broad match with an empty result that can't early-terminate — are cancelled rather than running for 30–80 s. The resultingpsycopg.errors.QueryCanceledmaps to a 400 with an actionable "too broad — add constraints" message. The background-task path (run_task/fetch_query_result) records the error so a timed-out async search reports it instead of polling forever. Verified on the live DB: pyridine substructure now returns a 400 at ~20 s instead of hanging.Tests
queries_test.py,search_test.py, sync + async) pass unchanged — the rewrite is result-preserving.QueryCanceled → 400mapping and the background-task error path.Notes
Fix #2 is the safety net; fix #1 is the real speedup where results exist. Remaining ideas for later (selectivity heuristics to reject lone broad substructure searches, SIMILAR threshold tuning, warm-cache/storage) are captured in the logbook entry.
🤖 Generated with Claude Code
Greptile Summary
This PR restructures the reaction search engine to eliminate expensive
SELECT DISTINCT … INTERSECTquery shapes in favour of a singleSELECT … WHERE (EXISTS …) AND (EXISTS …)with an outerLIMIT, enabling the Postgres planner to lead with the most-selective predicate and stop early. Astatement_timeoutfloor (default 20 s, env-overridable) is added to every DB connection, withQueryCanceledmapped to a 400 with an actionable user message; the background-task path (run_task/fetch_query_result) now stores the error in Redis so polling callers get a meaningful response instead of waiting indefinitely.queries.py: Abstract method renamed fromquery_and_parameterstowhere_predicate; each subclass now returns a correlatedEXISTS (…)fragment;run_queriesbuilds oneFROM ord.reaction WHERE (p1) AND (p2) … LIMIT nquery from the collected predicates.search.py:get_cursorsetsstatement_timeout;run_querycatchespsycopg.errors.QueryCanceledand raises a 400;run_taskcatchesHTTPExceptionand writeserror:{task_id}to Redis;fetch_query_resultchecks that key before the result key.search_test.py: Two new async unit tests cover theQueryCanceled → 400path and the background-task error-recording path using monkeypatching.Confidence Score: 5/5
Safe to merge. The SQL rewrite is semantically equivalent to the original — all user values remain bound parameters, and the query shape change is well-reasoned and consistent across all seven query subclasses.
The SQL restructuring from INTERSECT-of-DISTINCT branches to a single correlated-EXISTS WHERE clause is correct across every subclass, existing result-count tests pass unchanged, the new timeout path is covered by two unit tests, and the error-propagation through the background-task pipeline is logically sound. No new defects were identified beyond the items already noted in prior review rounds.
No files require special attention. The
_mols_source/_mols_joinsplit inqueries.pyis worth a quick read if unfamiliar, but the logic is clearly commented and used correctly.Important Files Changed
where_predicatereplacesquery_and_parametersacross all seven subclasses; new_mols_sourceproperty provides a correlated FROM list for EXISTS subqueries while_mols_joinis retained for the top-level similarity-scoring query. SQL assembly is safe — user values remain bound parameters.statement_timeoutto every DB connection and correctly mapsQueryCanceledto HTTP 400. Background-task error path is newly handled:run_taskcatchesHTTPException, storeserror:{task_id}in Redis, and returns early before writing the result key.QueryCanceled → 400path and the background-task error-recording path using monkeypatching. Tests are well-scoped and deterministic.Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[HTTP GET /query or /submit_query] --> B[run_query] B --> C{build queries list} C --> D[get_cursor - statement_timeout=20s] D --> E[run_queries] E --> F{collect where_predicate per ReactionQuery} F --> G[SELECT reaction_id FROM ord.reaction WHERE p1 AND p2 LIMIT n] G -->|success| H{similarity ranking?} H -->|no| I[return reaction_ids] H -->|yes| J[_rank_by_similarity top-k by Tanimoto] J --> I G -->|QueryCanceled| K[raise HTTPException 400] B --> L{background task?} L -->|yes - run_task| M[try run_query] M -->|success| N[Redis SET result:task_id] M -->|HTTPException| O[Redis SET error:task_id] P[GET /fetch_query_result] --> Q{Redis EXISTS query:task_id} Q -->|no| R[404] Q -->|yes| S{error:task_id?} S -->|yes| T[return 400 + detail] S -->|no| U{result:task_id?} U -->|no| V[202 pending] U -->|yes| W[fetch_reactions - return results]%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% flowchart TD A[HTTP GET /query or /submit_query] --> B[run_query] B --> C{build queries list} C --> D[get_cursor - statement_timeout=20s] D --> E[run_queries] E --> F{collect where_predicate per ReactionQuery} F --> G[SELECT reaction_id FROM ord.reaction WHERE p1 AND p2 LIMIT n] G -->|success| H{similarity ranking?} H -->|no| I[return reaction_ids] H -->|yes| J[_rank_by_similarity top-k by Tanimoto] J --> I G -->|QueryCanceled| K[raise HTTPException 400] B --> L{background task?} L -->|yes - run_task| M[try run_query] M -->|success| N[Redis SET result:task_id] M -->|HTTPException| O[Redis SET error:task_id] P[GET /fetch_query_result] --> Q{Redis EXISTS query:task_id} Q -->|no| R[404] Q -->|yes| S{error:task_id?} S -->|yes| T[return 400 + detail] S -->|no| U{result:task_id?} U -->|no| V[202 pending] U -->|yes| W[fetch_reactions - return results]Comments Outside Diff (1)
ord_interface/api/search.py, line 82-104 (link)statement_timeoutapplies to all connections, not just searchget_cursor()is called by several non-search endpoints —get_reaction,get_reactions,get_datasets,get_dataset,fetch_query_result(viafetch_reactions),input_stats, andproduct_stats— that do not wrap their usage intry/except psycopg.errors.QueryCanceled. If any of those queries somehow exceed 20 s (e.g., theinput_statsaggregation under heavy DB load), they will surface as an unhandledQueryCanceledthat FastAPI turns into a 500 Internal Server Error — not the graceful 400 with the user-friendly message. The PR description says "applied to every search connection" but the implementation is broader. In practice this is unlikely to matter, but a separate cursor factory (or a targeted try/except at each non-search site) would keep the intended scope explicit.Reviews (2): Last reviewed commit: "Apply ruff format to search_test.py" | Re-trigger Greptile