Skip to content

fix(api): cancel abandoned recall & reflect via cooperative cancellation token - #2127

Merged
nicoloboschi merged 2 commits into
mainfrom
fix/recall-cancellation-token
Jun 11, 2026
Merged

nicoloboschi merged 2 commits into
mainfrom
fix/recall-cancellation-token

Conversation

@nicoloboschi

@nicoloboschi nicoloboschi commented Jun 11, 2026 •

Copy link
Copy Markdown
Collaborator

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 toward RECALL_MAX_CONCURRENT until 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 ThreadPoolExecutor via run_in_executor. Cancelling the awaiting asyncio task only unblocks the await; the worker thread keeps burning CPU to completion. So task cancellation (the approach in #2125) returns 499 and frees the recall semaphore while an uncancellable thread still pins a CPU — RECALL_MAX_CONCURRENT stops bounding actual CPU.

Instead, a CancellationToken is threaded through RequestContext (already passed into every engine operation) and checked at stage / iteration boundaries, bailing out before dispatching the next expensive stage.

Changes

  • New hindsight_api/cancellation.py: CancellationToken (one-shot, awaitable, idempotent) + OperationCancelledError.
  • RequestContext gains a cancellation field and a raise_if_cancelled() helper (no-op when no token attached).
  • Recall checkpoints in recall_async / _search_with_retries: pre-retrieval, pre-rerank, and pre-enrichment (chunk/entity/source-fact fetches).
  • Reflect checkpoints: an early check in reflect_async, plus a cancel_check hook in run_reflect_agent invoked between agent iterations (and the nested recall tool already checks at its own boundaries since it shares the RequestContext).
  • Shared HTTP wiring: run_cancellable_on_disconnect() attaches the disconnect-driven token (1 s poll, restores the prior token on exit) and maps OperationCancelledError → 499. Both the recall and reflect handlers call it — no duplicated try/except.
  • Scoped to HTTP recall + reflect: internal callers (consolidation, MCP, mental-model triggers) pass no token, so every checkpoint is a no-op and behavior is unchanged.

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_CONCURRENT keeps 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 on RequestContext.

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

uv run pytest tests/test_recall_cancellation.py -q   # 13 tests, all green
uv run ruff check hindsight_api/ && uv run ty check hindsight_api/api/http.py

Covers the token primitive, RequestContext integration, the disconnect driver (attach/restore/abort), the shared run_cancellable_on_disconnect helper (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_check defaults to None.

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

Copy link
Copy Markdown
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 nicoloboschi changed the title fix(api): cancel abandoned HTTP recall via cooperative cancellation token fix(api): cancel abandoned recall & reflect via cooperative cancellation token Jun 11, 2026
@nicoloboschi
nicoloboschi merged commit 07c85da into main Jun 11, 2026
177 of 178 checks passed
@nicoloboschi
nicoloboschi deleted the fix/recall-cancellation-token branch June 11, 2026 09:24
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
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 computation is not cancelled on client disconnect — abandoned requests accumulate and starve the instance (v0.7.1)

2 participants