Skip to content

fix(recall): add timeout/fallback guard to expand_observations entity CTE - #3724

Open
jordigilh wants to merge 2 commits into
vectorize-io:mainfrom
jordigilh:fix/expand-observations-timeout-fallback
Open

fix(recall): add timeout/fallback guard to expand_observations entity CTE#3724
jordigilh wants to merge 2 commits into
vectorize-io:mainfrom
jordigilh:fix/expand-observations-timeout-fallback

Conversation

@jordigilh

Copy link
Copy Markdown
Contributor

Fixes #3723.

Problem

_expand_combined() has an asyncio.wait_for/fallback guard around its entity CTE (added in #911); its sibling _expand_observations() only got that PR's per-entity LATERAL cap, not the timeout guard. On a large/dense bank the entity query inside expand_observations() can still exceed budget, and with no guard the raw TimeoutError fails the whole recall() call instead of degrading to semantic+causal results.

Fix

Wraps just the entity_rows fetch in PostgreSQLOps.expand_observations() with the same asyncio.wait_for(..., timeout=config.link_expansion_timeout) / except asyncio.TimeoutError pattern already used by _expand_combined(), falling back to entity_rows = [] (the semantic+causal query still runs independently, unaffected).

Test plan

  • Added test_expand_observations_falls_back_gracefully_on_timeout, mirroring the existing test_entity_expansion_timeout_fallback technique (forces the real query to exceed an artificially low link_expansion_timeout) for the observation path. Fails with the exact production error (RuntimeError: Failed to search memories (TimeoutError): TimeoutError()) without this fix, passes with it.
  • Kept test_expand_combined_gracefully_falls_back_on_timeout alongside it as a contrast/sanity check that the sibling path's existing behavior is unchanged.
  • uv run pytest tests/test_expand_observations_timeout_gap.py tests/test_link_expansion_retrieval.py tests/test_observation_expansion_scoring.py tests/test_graph_entity_fanout_cap.py all green locally (one unrelated pre-existing error in a test requiring a real LLM API key I don't have configured).
  • ruff check --fix / ruff format clean.

Not touched: Oracle's expand_observations() (ops_oracle.py) has the same shape but I don't have an Oracle test environment to validate a change there — flagging in case a maintainer wants the same guard applied there.

… CTE

_expand_combined() got a per-entity LATERAL cap and an asyncio.wait_for
timeout+fallback in vectorize-io#911, but its sibling _expand_observations() only
got the cap. On a large/dense bank the entity CTE can still exceed the
configured link_expansion_timeout, and with no guard the raw
TimeoutError propagates and fails the whole recall() call instead of
degrading to semantic+causal results.

Wraps just the entity_rows fetch in expand_observations() with the same
asyncio.wait_for/except asyncio.TimeoutError pattern, falling back to
an empty entity result set (the semantic+causal query still runs).
Observed in production: RuntimeError: Failed to search memories
(TimeoutError): TimeoutError() on a bank with ~410K
entity_cooccurrences rows.
@strix-security

strix-security Bot commented Aug 22, 2026

Copy link
Copy Markdown

Strix Security Review

Warning

This pull request has 1 commit after the last Strix review (d5c664e). Strix has not reviewed these changes.
Automatic review on push is off for this repository. To review the latest changes, tag @strix-security in a comment, or turn on re-review on push.

No security issues found.

Updated for d5c664e.


Reviewed by Strix
Re-run review · Configure security review settings

@Sanderhoff-alt Sanderhoff-alt 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.

Two items to address before merge: the new regression test also passes without the change, and the Oracle implementation of the same query is left unguarded, so Oracle deployments keep the failure this fixes.

# Recall succeeds even though entity expansion timed out; the
# seed observation itself is still found via semantic search.
assert result.results is not None
assert len(result.results) > 0

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.

This test passes on main as well, so it does not guard the fix. Before this change link_expansion_timeout is read in exactly one place (hindsight_api/engine/search/link_expansion_retrieval.py:345, inside _expand_combined), and fact_type=["observation"] routes to _expand_observations, which never consulted it — so lowering the timeout cannot raise TimeoutError on the old code path. With the guard in place the assertion here is satisfied by the semantic seed regardless of whether entity expansion ran. Please make the test discriminate, for example assert via caplog that the new [ExpandObservations] Entity expansion timed out warning fired and that no entity-expanded rows came back.

@jordigilh jordigilh Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, confirmed. Added a caplog check for the [ExpandObservations] Entity expansion timed out warning, which only fires once the guard exists and actually runs. Ran the test against main and this branch to confirm: fails on main (no warning), passes here. Pushed in 5403261.


config = get_config()
try:
entity_rows = await asyncio.wait_for(

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.

The guard is added to the PostgreSQL ops class only. hindsight_api/engine/db/ops_oracle.py:717 has the same unguarded entity_rows = await conn.fetch(...) inside its expand_observations, so on Oracle 23ai a slow entity CTE still propagates TimeoutError and fails the entire recall (memory_engine.py:7850). The wrapper is plain Python around the fetch with no dialect-specific SQL, so it can be mirrored in the Oracle implementation without an Oracle environment, or factored into a helper both ops classes call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed, same gap. Mirrored the wait_for/except TimeoutError guard into ops_oracle.py's expand_observations, same log message format as the PG fix. Left the two PG-only optimizations you noted alone, they're already explained inline. Pushed in 5403261.

…criminate

Address review feedback on vectorize-io#3724:
- ops_oracle.py's expand_observations() had the same unguarded entity CTE
  the PG backend was fixed for, so Oracle recalls could still fail on
  timeout instead of degrading to semantic+causal results.
- The new regression test asserted only that recall succeeds, which also
  held true before this fix (the seed observation is found via direct
  semantic match regardless of the guard). It now also asserts via caplog
  that the timeout warning actually fired, which only happens once the
  guard exists and is taken.
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.

recall() fails on TimeoutError in _expand_observations(); sibling _expand_combined() degrades gracefully

2 participants