Issue #506: feat: Make logout summarization non-blocking - #1154
Issue #506: feat: Make logout summarization non-blocking#1154dereck-symmetry wants to merge 3 commits into
Conversation
bjagg
left a comment
There was a problem hiding this comment.
Overview
Makes /logout return immediately by moving interaction summarization into a background task, fixing #506 ("kick off background task"). Small and focused, and the error handling is right: _safe_summarize catches broadly but uses logger.exception rather than a bare pass, so failures are still visible. test_logout_succeeds_even_when_summarization_fails is exactly the test I'd want here — it proves the user-facing path is genuinely decoupled from LLM availability.
Two things I'd like changed before merge, and a couple of notes.
1. The task reference is discarded
asyncio.create_task(_safe_summarize(agent, task, username))The return value isn't kept. The event loop holds only a weak reference to a running task, so this can be garbage-collected mid-execution — the CPython docs call this out directly and recommend saving a reference. The failure mode is nasty precisely because it's rare and load-dependent: summaries silently vanish some fraction of the time, with nothing in the logs.
Minimal fix:
_background_tasks: set[asyncio.Task] = set()
task_ref = asyncio.create_task(_safe_summarize(agent, task, username))
_background_tasks.add(task_ref)
task_ref.add_done_callback(_background_tasks.discard)2. FastAPI's BackgroundTasks is the better fit here
FastAPI has a first-class mechanism for "do this after the response is sent":
async def logout(background_tasks: BackgroundTasks, username: str = Depends(get_current_user)):
...
background_tasks.add_task(_safe_summarize, agent, task, username)It solves the reference problem for you, and — the part I care more about — it makes the tests deterministic. TestClient runs background tasks to completion before returning, so the two await asyncio.sleep(0.1) calls disappear.
Those sleeps are the second thing I'd change. A fixed 100ms as a synchronization barrier is timing-dependent and will flake under CI load; grep says these would be the only such sleeps in the whole test suite. If BackgroundTasks isn't wanted for some reason, the alternative is to capture the task and await it in the test rather than sleeping.
Worth noting the repo doesn't use BackgroundTasks anywhere yet, so either way this establishes a pattern — which is an argument for establishing the framework-idiomatic one.
3. Nothing survives shutdown — and this weakens #1118
bases/lif/advisor_restapi/core.py has no on_event("shutdown") or lifespan handler. When an ECS task stops (a deploy, a scale-in, a health-check failure), any in-flight summarization is killed and the interaction summary is lost with no trace beyond an absent log line.
That matters because #1118 already tracks interaction-summary capture being best-effort and logout-only. This change makes the window wider: previously the summary completed before the response returned, so a user who logged out successfully had their summary saved. Now a successful logout no longer implies that.
Not asking you to solve durability here — that's #1118's job. But it's worth a line in the PR description or a Refs #1118, because this trades a latency win for a durability loss and that trade should be visible rather than discovered later.
4. The TODO(#986) marker was dropped
The prompt string moved into _safe_summarize, but the comment went with it:
# TODO(#986): move this hard-coded query prompt to env/config#986 is still open. That comment was its only in-code anchor — worth carrying it over to the new location.
Verdict
Approve once items 1 and 2 are addressed — the unretained task is a real (if intermittent) correctness issue, and the sleeps will cost someone a flaky-CI investigation. Items 3 and 4 are notes, not blockers.
The core change is right and the failure-path test is a good addition.
…ormance # Conflicts: # test/bases/lif/advisor_restapi/test_core.py
…ming sleeps Switch /logout from a bare asyncio.create_task to background_tasks.add_task. That fixes both blocking items in one change. The discarded task reference was a real hazard: the event loop keeps only a weak reference to a bare task, so it can be garbage-collected mid-flight and drop the summary with nothing in the logs -- rare, load-dependent, and silent. Retaining the task in a module-level set would have worked, but BackgroundTasks is the framework's own answer and this base had no existing pattern to match, so it establishes the idiomatic one. It also makes the tests deterministic, which removes the two asyncio.sleep(0.1) synchronization barriers -- the only sleeps in the whole suite, and a standing invitation to a flaky-CI investigation. One correction to the review: these tests use httpx AsyncClient over ASGITransport, not Starlette's TestClient, so the stated reason did not transfer. Verified the conclusion holds anyway -- ASGITransport awaits the full ASGI call and Starlette runs background tasks inside it, so the task has finished before the request returns. Mutation-checked both ways: removing the scheduling fails the two logout tests, and reverting to asyncio.create_task with the sleeps gone fails them too. The second is the one that matters -- it shows the sleeps were doing real synchronization work and BackgroundTasks is what replaces them. Also measured the trade-off before adopting it: BackgroundTasks runs inside the request's ASGI call, so it could in principle hold the keep-alive connection and undo the latency win. Against a real uvicorn server with a 1.5s background task, the next request on the same connection took 1.9ms. No penalty. Restore the TODO(#986) marker that moved out with the prompt. Note main carries two of these -- the load_profile prompt at :266 and this one -- so it was not #986's only anchor, but this branch did drop one of the two. Correct the _safe_summarize docstring: it said "swallows errors" while actually logging a stack trace via logger.exception, which is the behavior worth keeping and describing accurately. Durability is deliberately not addressed here. A successful logout no longer implies the summary was saved, and nothing survives task shutdown -- that is #1118's scope, now referenced from the PR rather than left to be discovered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Addressed in Items 1 and 2 turned out to be one change. Switched to One correction: these tests use httpx Mutation-checked, because "the tests still pass" proves nothing here: removing the scheduling fails both logout tests, and reverting to I nearly didn't take your suggestion. Item 3, documented and deferred as you proposed. The PR now carries Item 4 restored, with one correction for the record: it wasn't #986's only in-code anchor — Also fixed The conflict resolved itself, incidentally: it was the No rush on the re-review. |
Description of Change
Problem.
/logoutblocked on interaction summarization — an LLM round-trip — before returning. #506's note says it plainly: "kick off background task."Solution. Move summarization into a FastAPI background task so
/logoutreturns immediately, with the summarization wrapped in_safe_summarizeso a failing or unavailable LLM cannot fail a logout.Review response
Both blocking items are fixed by the same change, which is why this ended up smaller than the review implied.
1 + 2.
BackgroundTasksinstead of a bareasyncio.create_task.You were right about the discarded reference. The event loop keeps only a weak reference to a bare task, so it can be garbage-collected mid-flight — rare, load-dependent, and silent, which is the worst combination. Retaining it in a module-level set would have worked, but
BackgroundTasksis the framework's own answer, and since this base had no existing pattern, it may as well establish the idiomatic one.It also makes the tests deterministic, so both
asyncio.sleep(0.1)barriers are gone — they were the only sleeps in the entire suite.One correction: these tests use httpx
AsyncClientoverASGITransport(test_core.py:14), not Starlette'sTestClient, so the stated reason didn't transfer. I verified the conclusion holds anyway rather than assuming —ASGITransportawaits the full ASGI call, and Starlette runs background tasks inside it, so the task has finished by the time the request returns:Mutation-checked both ways, so the tests aren't passing by accident:
asyncio.create_task, sleeps still removedThe second is the one that matters: it demonstrates the sleeps were doing real synchronization work, and that
BackgroundTasksis what replaces them rather than just hiding them.A trade-off I checked before adopting it.
BackgroundTasksruns inside the request's ASGI call, so in principle it could hold the keep-alive connection and partly undo the latency win this PR exists for. Measured against a real uvicorn server with a 1.5s background task:No penalty. Recording it because it was a real reason not to adopt
BackgroundTasks, and it turned out not to be.3. Durability — documented and deferred, as you suggested.
You're right that this widens the window, and that the trade should be visible rather than discovered later: previously a successful logout implied the summary was saved; now it doesn't. There is no
on_event("shutdown")or lifespan handler in this base, so an ECS task stop during a deploy or scale-in kills in-flight summarization silently.Not solved here — a shutdown drain means deciding how long to wait, which is a design question, and #1118 already owns best-effort summary capture. Now carried as
Refs #1118rather than left implicit.4.
TODO(#986)restored — carried into_safe_summarizealongside the prompt it annotates.Small correction for the record: it wasn't #986's only in-code anchor.
mainhas two — theload_profileprompt atcore.py:266and this one. This branch dropped one of the two.Also:
_safe_summarize's docstring said it "swallows errors" while actually logging a stack trace vialogger.exception. That behavior is the point — an absent summary stays diagnosable — so the docstring now says what it does.Related Issues
Closes #506
Refs #1131
Refs #1118
Refs #986
Type of Change
Project Area(s) Affected
Checklist
uv run ruff check)uv run ruff format)uv run ty check)Testing
uv run ruff checkuv run ruff format --checkuv run ty check --error-on-warninguv run pytest testuv run pytest test/bases/lif/advisor_restapi/cspell-cli@9.0.1 lint(changed files)Plus the two mutations and the keep-alive measurement above.
Additional Notes
mainmerged in, not rebased (e140f74), with the review round appended asfa40f52. No force-push, so reviewer state is intact. The merge conflict was a single line — this branch addedimport asyncioto the test file,maindidn't — and it dissolved on its own, since that import existed only for the two sleeps.200from/logoutno longer means the interaction summary was saved. It means the summarization was scheduled. SeeRefs #1118.