Repository navigation
fix(api): cancel abandoned recall & reflect via cooperative cancellation token - #2127
Merged
Merged
Conversation
…oken Recall ran to completion even after the client disconnected, burning ~2 CPUs for 60-95s per abandoned request and accumulating toward RECALL_MAX_CONCURRENT until the instance starved (issue #2122). Approach: a CancellationToken carried on RequestContext (already threaded into every engine operation) that the engine checks at recall pipeline stage boundaries (pre-retrieval, pre-rerank, pre-enrichment), aborting before dispatching the next expensive stage. The HTTP layer attaches a token that fires when the client disconnects and maps the resulting OperationCancelledError to 499. This is cooperative: it cannot interrupt work already inside a worker thread (the cross-encoder rerank runs via run_in_executor and cannot be cancelled once dispatched), but it stops an abandoned recall from progressing into - or past - that work. The token lives on RequestContext so reflect/consolidation/MCP and a deadline-based driver can adopt the same checkpoints later. Scoped to HTTP recall; internal recalls pass no token so checkpoints are no-ops.
Contributor
|
I agree this is the better implementation. The cooperative cancellation token fits the recall pipeline better than my outer asyncio-task cancellation approach, especially around threadpool reranking. I'll close my PR in favor of this one. |
Reflect has the same abandoned-work problem as recall (agentic LLM loop + nested recalls). Thread the same RequestContext cancellation token through it: the agent loop checks between iterations and the nested recall tool already checks at its stage boundaries, so an abandoned reflect stops instead of running every remaining LLM round-trip (issue #2122). Factor the HTTP wiring into a shared run_cancellable_on_disconnect() helper used by both the recall and reflect handlers: it attaches the disconnect-driven token and maps OperationCancelledError to 499, so neither handler duplicates the try/except. run_reflect_agent gains an optional cancel_check hook (default None -> inert), so internal/non-HTTP reflect callers are unaffected.
nicoloboschi
added a commit
that referenced
this pull request
Jun 11, 2026
…2131) The cancellation merged in #2127 never fired in production. Live testing (curl --max-time against a real server) showed abandoned recall/reflect requests still ran to completion; 0 cancellations under a 4000-request storm. Two root causes, both found by black-box testing + ASGI probes: 1. Request.is_disconnected() is broken behind BaseHTTPMiddleware. This app installs two @app.middleware("http") handlers (BaseHTTPMiddleware), which run the route in a child task behind anyio memory streams, so http.disconnect never reaches the route's Request and is_disconnected() returns False forever. The #2127 watcher therefore never tripped. (Reproduced in isolation: one no-op @app.middleware("http") flips detection from working to broken.) Fix: ClientDisconnectCancellationMiddleware, a pure-ASGI middleware installed OUTSIDE the BaseHTTPMiddleware layer where it owns the real receive channel. It drains receive in a background pump, trips a CancellationToken on http.disconnect, and stashes it on the ASGI scope. Only wraps recall/reflect (small JSON bodies); everything else passes straight through. 2. Even once the token tripped, recall did not cancel: _search_with_retries wraps its whole body in a broad except Exception and re-raises as RuntimeError, burying OperationCancelledError. Fix: re-raise OperationCancelledError ahead of the broad handlers (both the search body and the connection-retry loop). Kept OperationCancelledError as a plain Exception (not BaseException) on purpose: BaseException dodges the broad handlers but also slips past the reflect agent's isinstance(result, Exception) gather handling and crashes it. run_cancellable_on_disconnect now just reads the scope token onto RequestContext; the polling is_disconnected() watcher is gone. Verified live (real server, real corpus): RECALL CANCELLED and REFLECT CANCELLED fire; a 30s socket-closing storm produced 397 recall + 80 reflect cancellations with 40/40 canary recalls served, health all 200, 0 errors, full recovery. Reflect cancellation is best-effort between agent iterations (a disconnect during the final-answer LLM call is not interruptible), analogous to the rerank stage.
nicoloboschi
pushed a commit
that referenced
this pull request
Jun 15, 2026
…d operation (#2185) run_cancellable_on_disconnect (added in #2127/#2131 for #2122) converts the engine's OperationCancelledError into HTTPException(499), which propagates out through record_operation's blanket `except Exception: success = False`, so every abandoned recall/reflect was counted as a failure on hindsight.operation.total. Exclude a client cancellation from the counter entirely (neither success nor failure), detecting it via the exception's __cause__ chain so an unrelated 499 is still recorded as a failure. Adds regression tests.
cdbartholomew
added a commit
that referenced
this pull request
Oct 20, 2026
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.
Summary
Cancel abandoned HTTP recall and reflect work via a cooperative cancellation token carried on
RequestContext, instead of relying on asyncio task cancellation. Alternative to #2125 with an architecture that stops the most expensive stage and generalizes across endpoints.Fixes #2122.
Problem
A
/memories/recall(or/reflect) that outlives its client kept running server-side — ~2 CPUs for 60–95 s per abandoned recall on an 11.6K-unit bank — and abandoned requests accumulated towardRECALL_MAX_CONCURRENTuntil the instance starved (RSS 7.2 GB, CPU pinned at the container cap for hours, from a single retry-looping client). See #2122.Why a token, not asyncio task cancellation
The recall pipeline's heaviest stage — cross-encoder reranking (flashrank) — runs in a
ThreadPoolExecutorviarun_in_executor. Cancelling the awaiting asyncio task only unblocks theawait; the worker thread keeps burning CPU to completion. So task cancellation (the approach in #2125) returns499and frees the recall semaphore while an uncancellable thread still pins a CPU —RECALL_MAX_CONCURRENTstops bounding actual CPU.Instead, a
CancellationTokenis threaded throughRequestContext(already passed into every engine operation) and checked at stage / iteration boundaries, bailing out before dispatching the next expensive stage.Changes
hindsight_api/cancellation.py:CancellationToken(one-shot, awaitable, idempotent) +OperationCancelledError.RequestContextgains acancellationfield and araise_if_cancelled()helper (no-op when no token attached).recall_async/_search_with_retries: pre-retrieval, pre-rerank, and pre-enrichment (chunk/entity/source-fact fetches).reflect_async, plus acancel_checkhook inrun_reflect_agentinvoked between agent iterations (and the nested recall tool already checks at its own boundaries since it shares theRequestContext).run_cancellable_on_disconnect()attaches the disconnect-driven token (1 s poll, restores the prior token on exit) and mapsOperationCancelledError→ 499. Both the recall and reflect handlers call it — no duplicated try/except.Honest limitation
Cooperative: it cannot interrupt a computation already inside a worker thread. If the client disconnects mid-rerank, we finish that one rerank then abort at the next checkpoint. The win is that an abandoned request never starts rerank (if the client left during retrieval), never runs post-rerank enrichment, and a reflect loop stops between iterations instead of spending every remaining LLM round-trip — and
RECALL_MAX_CONCURRENTkeeps bounding real work. Truly bounding mid-rerank would need chunked token checks inside the predict loop or a killable subprocess (out of scope). A deadline-based driver (for the consolidation-contention half of #2122) is a trivial extension since the token lives onRequestContext.Relationship to #2125
Alternative implementation of the same fix. #2125 wraps recall in a disconnect watcher and cancels the asyncio task; this PR uses a checkpoint token. The token approach stops the threadpool rerank stage from starting (which task cancellation can't reach), avoids early-releasing the concurrency slot while a thread runs on, reuses a carrier every engine operation already receives, and covers reflect with the same mechanism. Pick one.
Tests
Covers the token primitive,
RequestContextintegration, the disconnect driver (attach/restore/abort), the sharedrun_cancellable_on_disconnecthelper (result-on-connected, 499-on-disconnect), and an ASGI end-to-end (disconnect → aborted staged work → 499). Existing reflect suites (test_reflect_agent,test_per_operation_llm_config, etc.) remain green —cancel_checkdefaults toNone.