memory: bound memU embedding calls so a hung provider cannot stall it - #258
memory: bound memU embedding calls so a hung provider cannot stall it#258oranjeai wants to merge 5 commits into
Conversation
Every memU call that produces an embedding runs on the "embedding" LLM
profile, and that profile was bounded by nothing: not by the bridge's
asyncio.wait_for layer, not by an httpx transport timeout, not by any
caller. _instrument_llm_timeouts iterates ("memorize", "fast", "default")
only, so the embedding client fell through to the bare OpenAI SDK default
(600s read, 2 retries), i.e. up to ~30 minutes per call. Every other
network call in the file is bounded explicitly, and _LLM_CALL_TIMEOUT's
own comment already claims to cover embed.
Reachable carriers: category seeding during agent init (inside
initialize(), whose return engine.py discards), recall() (the query
embedding is the first thing the RAG retrieve pipeline does), and the
runtime category create/update paths reached from the web UI and the
category_update tool.
The bound goes on the client rather than at the call sites, because that
is where this file already puts per-call bounds and because it covers the
16 memU-internal embed() sites nerve does not own. Ordering matters: the
seed site embeds ~185 lines before _instrument_llm_timeouts() runs, so
the client is also instrumented right after the service is constructed,
before availability is published. That call is deliberately uncaught -
_initialize_impl's existing handler turns a failure into an unavailable
bridge rather than one advertising itself as usable with an unbounded
client. The reset-path call stays best-effort so an embedding failure
cannot break chat re-instrumentation.
SDK max_retries is left at its default here, unlike the chat profile: the
chat path can disable retries because _timeout_chat supplies its own
transient ladder, which is chat-only. With retries kept, the outer
wait_for still fires at the bound (measured overshoot +0.00s).
Two adjacent gaps the bound would otherwise have made worse:
- recall() returned [] for a timeout, which renders as "Recalled 0
memories". _is_transient_llm_error keys on HTTP status and matches no
timeout type, so bounding the call would have converted a stall into a
confident wrong answer. recall now reports a timeout as backend-down.
The base already had this hole for SDK-level embed timeouts.
- _reset_llm_clients_impl evicted memorize/fast/default only, so an embed
timeout recycled three unrelated chat clients and kept the offending
transport. Pre-existing; this change makes that path materially more
reachable, so "embedding" is added to the eviction tuple. The
post-reset warmup tuple is deliberately NOT changed - warming an
embedding client means a real billed request on every reset.
Validation: 13 new arms in TestEmbeddingCallTimeout, no arm touching a
real endpoint (hangs await an Event that is never set). Both directions:
11 failed / 2 passed against the unfixed source with the new tests
present, 13 passed with the fix. Full suite 2933 -> 2946 passed with a
byte-identical 7-name pre-existing failure set. 13-mutant matrix, all 13
killed by their predicted arms, unmutated control green at both ends.
T1/T2 awaited the wrapped embed() directly, so a tree without the wait_for (the M1 mutation, or a future round deleting it) hung the arm forever rather than failing it - the matrix could not report a kill. The _bounded() helper escapes with AssertionError, never TimeoutError, so it cannot be mistaken for the bound firing and cannot make either arm pass vacuously. Verified in both directions: with the fix 13 pass; with the wait_for deleted T1/T2 fail in 10.3s on "the call is NOT bounded".
Removes the `_has_embeddings` gate from `_instrument_embedding_timeout`,
because the premise behind it is false. memU's `LLMProfilesConfig.ensure_default`
synthesizes an "embedding" profile from "default" whenever the caller omits one
(`memu/app/settings.py:286`), so the profile exists even with no
`openai_api_key`. Measured on a real no-key `_initialize_impl`:
`_get_llm_base_client("embedding")` returns an `OpenAISDKClient` whose inner
`AsyncOpenAI` carries the SDK default `Timeout(read=600)` and `max_retries=2`,
and `embed` is unwrapped. That path is reachable: memU's update workflow
declares `embed_llm_profile: "embedding"` (`memu/app/crud.py:431`), reached from
nerve via `update_item`, and nothing along it imposes a timeout. So the gate
left a no-provider install able to make a 600s-by-2-retries embedding call,
which is the stall this change exists to remove. With the gate gone, a no-key
startup still returns True, the inner timeout drops to the 120s bound, SDK
retries are untouched, and a hung embed raises at exactly the bound.
Tests. T5 is repurposed rather than deleted: its premise ("a no-provider
install is a no-op") is now false, so it asserts the opposite, that such an
install is still bounded. T14 drives the no-key update-path shape through
memU's real `LLMClientWrapper`. T15 covers the fail-closed contract that no
arm previously observed: edit (c) is deliberately uncaught so a failure leaves
the bridge unavailable, and a call-site `try/except` left the suite fully green.
It runs the real `_initialize_impl` in a fresh interpreter, since only one
`MemoryService` may exist per process, and asserts a control run first so a
later False cannot be an unrelated fixture failure. T9 stopped asserting a
falsehood about production: the reset evicts the embedding client and the
following re-instrumentation re-creates and re-wraps it, so the cache is
repopulated rather than left empty. The old assertion only passed because the
fake getter closed over a stale copy of the client mapping.
Also widens the backend-down tool message to name timeouts, which now route
into that block, and condenses the method docstring from 19 lines to 6 while
keeping the clauses that pinned mutants; the removed prose is above.
The test and mutant figures in the previous commit message are superseded by
this round's, which are re-derived rather than carried.
The rewritten T9 asserted that the post-reset embedding client carries `_nerve_timeout_wrapped`, but its factory handed out a `MagicMock`, which auto-creates that attribute as a truthy child mock. The assertion could therefore never fail. Caught by a mutant that repopulates the cache with a fresh client and never wraps it: it survived, while the same mutant against a real client stand-in fails on exactly that assertion. The factory now returns `_FastEmbedClient`, which has a real `embed`. A `MagicMock` can only carry a "does this attribute exist" assertion when the attribute's absence is asserted; for presence it is always vacuous.
Two arms were green for reasons unrelated to the property they exist to pin, both instances of the same MagicMock rule the previous commit stated. T11 handed out `MagicMock()` clients, so `chat._nerve_timeout_wrapped` existed as a truthy child mock before any instrumentation. That made production's own sentinel guard skip all three chat profiles, and made the arm's assertion read the same truthy child and pass anyway. Measured: deleting `client.chat = _timeout_chat` outright, so chat is never wrapped anywhere, left T11 at 1 passed. The fixture now hands out `_FastEmbedClient`, captures the original bound methods, and asserts first that the fixture does not pre-carry the sentinel - that assertion is what stops a future round regressing to a mock. T14 was T2's body with `has_embeddings=False`: same hanging client, same manually built wrapper, same two assertions. It never entered `update_item` and never touched a memU workflow, so it stayed green if either link in its own docstring broke. It keeps the bound-fires assertion, which is what makes it the no-key regression arm, and adds the two links directly: nerve still forwards the changed content as `memory_content`, and memU's real update workflow still resolves the "embedding" profile for that step. A real in-memory `MemoryService` was rejected as the vehicle - only one may exist per process, which is the same constraint that pushed T8 to source-order assertions and T15 into a subprocess, and a second subprocess arm is disproportionate here. Link 2 therefore builds the declaration with `object.__new__` and runs memU's own resolver over it, asserting the literal profile string rather than truthiness, since the resolver returns None for any other key shape and the call site would fall back to an "embedding" default. The method docstring drops from 9 source lines to 7, keeping the four clauses that pin mutants. No mutant anchors on docstring text, so the coverage cost is zero. The client-layer bound covers the 17 memU-internal `embed()` sites nerve does not own, 14 of them reachable through `MemoryService`'s MRO (`PatchMixin` is not in it). The first commit of this branch said 16. Validation, all re-derived this round and superseding the previous message's figures: 15 arms, 15 passed. 19-mutant matrix, all 19 killed, unmutated control green at both ends, tree restored. M17 (chat never wrapped) and M18 (sentinel guard always skips) are killed by T11; M19 (`update_item` stops forwarding the content) by T14. Link 2's discriminator is memU's own source, which a shipped matrix cannot mutate. Full suite 7 failed / 2948 passed, with the failed-name set byte-identical to the 7 pre-existing names and the pass count unchanged, since both arms were rewritten in place rather than added. `ruff` findings byte-identical between base and head (79 each, over the same 339 tracked files).
Internal second-model review - 3 rounds, 21 findings adjudicated (click to expand)Before opening this PR I ran it through an independent review loop: a cold code review plus a Final round: 0 findings. ❌ Blockers and majors - all fixed
|
| Finding | Why I disagreed |
|---|---|
The Layer 1 transport timeout is never asserted against a real AsyncOpenAI |
The SDK stores self.timeout at construction and reads it per request, so a post-construction mutation is honoured by construction; and the pre-existing chat instrumentation is asserted no better, so requiring it here would be asymmetric. |
docs/memory.md describes the per-call bound as covering .chat() only |
Measured the convention: of the last 20 commits touching this file, one also touched that doc, and it changed user-visible provider behaviour. This adds no config surface. Noted for a later doc pass. |
💡 Noted, not blocking
- One assertion in the reset arm (
chat is not original) is inert: Python creates a fresh
bound method per attribute access, so it is true whether or not anything was replaced. It was
kept because the fix plan named that exact triple, and it cannot cause a false pass - the two
neighbouring assertions are what the mutants die on. Recorded so nobody mistakes it for coverage. - A recall whose transport was closed by a reset still answers empty rather than backend-down:
a closed transport raisesAPIConnectionError, which matches none of the predicates. That gap
pre-dates this change; widening the predicate has its own cost (a misconfigured base URL would
then fail every recall loudly) and the window is narrow - the whole production log has 7 resets.
Filed separately rather than folded in here. - The helper docstring went from 19 lines to 7. The fix plan asked for 6 and its own prescribed
text measures 7 - my arithmetic was wrong, not the implementation. Reflowing to 6 pushed lines
past the file's width convention and silently changed a word, so the reviewed text was kept. - Two other body figures were corrected in the final round after being flagged: the mutant matrix
(16 -> 19 mutants, 10 -> 13 exact kills) and the open-PR census (eight -> nine).
On the figures
Every quantitative claim in the body was re-derived from source in the final round rather than
carried forward, including the ones no round had moved. That is what surfaced the latency defect
above: it had been correctly measured once, disclosed as "carried, not re-derived", and was wrong
by the time it mattered. Three of my own earlier claims were also refuted by re-measurement and
are corrected here rather than left standing.
Pre-PR validation gate (click to expand)
Session id: cron:clickhouse-impl-slot-43:20260803-211900 |
|
|
|
cc @serxa @alex-fedotyev — could you review this? The |
|
Closing per @pufit's directive on #247: memU is being rewritten and sunset, and Nerve fixes |
Description
Every memU call producing an embedding runs on the
"embedding"LLM profile, and thatprofile was bounded by nothing: not by the bridge's
asyncio.wait_forlayer, not by anhttpx transport timeout, not by any caller.
_instrument_llm_timeoutsiterates("memorize", "fast", "default"), so embeddings fell through to the bare OpenAI SDKdefault (600s read, 2 retries) - up to ~30 minutes per call. Every other network call in
the file is bounded explicitly, and
_LLM_CALL_TIMEOUT's comment already claims to coverembed.Affected: category seeding during agent init (inside
initialize(), whose return valueengine.pydiscards),recall()(the query embedding is the RAG pipeline's first step),and the runtime category create/update paths.
The bound goes on the client, where this file already puts per-call bounds, so it also
covers the memU-internal
embed()sites nerve does not own. Because the seed site runslong before
_instrument_llm_timeouts(), the client is also instrumented right after theservice is constructed, before availability is published; that call is deliberately
uncaught, so a failure leaves the bridge unavailable rather than usable while unbounded.
SDK
max_retriesstays at its default, unlike chat, which can disable retries onlybecause it has its own ladder.
Two pre-existing gaps the bound would otherwise have made worse:
recall()returned[]for a timeout, rendering as "Recalled 0 memories", because_is_transient_llm_errormatches no timeout type - so the bound would have turned astall into a wrong answer. Timeouts now report backend-down.
_reset_llm_clients_implevictedmemorize/fast/defaultonly, recycling threeunrelated chat clients while keeping the transport that timed out, so
"embedding"joinsthat tuple. Its warmup neighbour stays unchanged - warming an embedding client means a
real billed request per reset.
A bounded seed is still not a correct seed: a seed-site timeout is caught by
_create_category_impl, which persistsembedding = None(PR #70's territory).Validation
15 new arms in
TestEmbeddingCallTimeout; none touches a real endpoint (hangs await anasyncio.Eventnever set).embed_batch_sizedefaults to 1, so the 120s bound covers a whole batch, not one request -that is why the transport timeout alone is not enough. Re-measured: 54,846 embed calls,
p99 10.0s, 21 over 30s, one at 129.7s that this bound cuts off (memorize retries).
Both directions: 13 failed / 2 passed against the unfixed source with the new tests
present, 15 passed with the fix. Most base-side failures are
AttributeError, so thereal discriminator is a 19-mutant matrix - all 19 killed (13 exactly as predicted, the
rest by a superset), control green at both ends.
Full suite 2933 -> 2948 passed, 7-name pre-existing failure set byte-identical.
ruffunchanged (79 repo-wide, 0 added).Merge coordination: #251 interacts with one assertion
Nine open PRs touch
memu_bridge.py. Graded by hunk range and identifier,#255/#254/#252/#248/#247 have no overlap, #249 inserts just above this change's anchor
(trivial), and #70 is complementary (chat-only Layer 1/2 rework, adds no embed bound).
#251 relocates
self._available = Trueto the end of_initialize_impl- the samegoal as this change's startup ordering, reached differently. The two compose on the merits
(the bound still precedes the seed), but the ordering assertion in
test_t8_startup_call_precedes_availability_and_seedingmust be re-pinned by whichever ofthe two lands second. It fails loudly rather than silently.
Six of those PRs also append a new test class at the same end-of-file slot; no shared
identifiers.