refactor(api): route every async dispatch through one off-loop helper - #162
Merged
Conversation
ADR-0007 bounded how long an unreachable Redis can block a dispatch and moved one call site off the event loop, leaving the other 33 bounded but still inline. This finishes that follow-up and makes the property enforceable rather than remembered. Add backend/core/task_dispatch.py with dispatch_task and dispatch_task_best_effort, and route every dispatch reachable from a request handler through it. That covers the 33 remaining sites plus two helpers that were synchronous but only ever called from async code. enqueue_model_preparation is one, and it blocked twice, since it also wrote download progress to Redis inline. _enqueue_push_channel_refresh is the other, and its own docstring said it existed to keep API paths fast, which the blocking publish undermined. The helper uses asyncio.to_thread rather than Starlette's run_in_threadpool, for two reasons beyond taste. calendar_service is imported by the worker, whose image ships no ASGI stack, so a Starlette import there would not resolve. And the loop's default executor is separate from the anyio limiter that serves sync route handlers, so a Redis outage can no longer consume the threads those handlers need. That closes the threadpool residual ADR-0007 recorded rather than merely shrinking it. Best-effort dispatch is now a named function rather than a bare except at each site, so the choice to swallow is visible where it is made instead of being inferred from a try block. Worker-side code still dispatches inline, deliberately, having no event loop to protect. A test walks the tree and fails on any send_task, apply_async or delay inside an async def, naming file and line. Verified by reintroducing one inline dispatch, which it caught at documents.py:123. Align the tests on one seam while here. Four files reached for a module's celery_app re-export, which is the same import-time binding mistake that made the suite slow, and they broke as soon as the import moved. They now patch the shared app object. Two more stubbed enqueue_model_preparation with sync lambdas that are now awaited. Behaviour is unchanged and re-measured against an unreachable broker: 6.03s to fail a dispatch, with 60 concurrent no-op requests at a 0.9ms median and a 0.00s worst case, matching the run before the mechanism swap. 1151 tests pass. Refs: docs/adr/0007-bounded-fail-fast-task-dispatch.md, docs/ARCHITECTURE.md
This was referenced Jul 28, 2026
Valtora
added a commit
that referenced
this pull request
Jul 30, 2026
Bump docs/VERSION to 2.2.0 so the tag validates, and fill in the release-notes template for this range instead of hand-editing the published body afterwards, which is how v2.1.0 was done. A minor bump rather than a patch: #168 adds a capture action that did not exist before, stopping and processing a paused recording. Refs: #162, #163, #165, #167, #168, #169, #170, #171
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.
Pull Request
Description
Completes the follow-up ADR-0007 named, and tidies the test seams so the suite exercises one dispatch path rather than six. No new dependencies. Behaviour is unchanged and re-measured.
v2.1.0 bounded how long an unreachable Redis can block a dispatch, and moved one call site off the event loop. The other 33 were bounded but still inline, so during an outage each stalled its own request for up to ~6s.
What changed
One helper,
backend/core/task_dispatch.py.dispatch_taskfor work whose result the response depends on,dispatch_task_best_effortfor work the caller must not fail on. Every dispatch reachable from a request handler now goes through it.That covers the 33 remaining sites, plus two helpers that were synchronous but only ever called from async code, which the original count missed:
enqueue_model_preparation— blocked twice, because it also wrote download progress to Redis inline._enqueue_push_channel_refresh— its own docstring says it exists so "API paths stay fast", which the blocking publish quietly undermined.asyncio.to_threadrather than Starlette'srun_in_threadpool, for two reasons beyond taste:calendar_serviceis imported by the worker, whose image ships no ASGI stack (there is already an explicittry/except ModuleNotFoundErroraround its FastAPI import). A Starlette import in a shared helper would not resolve there.Best-effort dispatch is now a named function rather than a bare
except Exceptionrepeated at each site, so the choice to swallow is visible where it is made instead of inferred from atryblock.Worker-side code still dispatches inline, deliberately. It has no event loop to protect, and the guard below only inspects
async def.Enforced, not remembered
test_no_api_code_dispatches_celery_work_on_the_event_loopwalks the tree and fails on anysend_task,apply_asyncordelayinside anasync def, naming file and line. That is what stops this regressing the next time someone adds an endpoint.Test alignment
Four test files reached for a module's
celery_appre-export (system.celery_app,routes_chat.celery_app,cli_oauth.celery_app) and broke the moment the import moved. That is the same import-time binding mistake that made the suite slow in #159, in a different costume. They now patch the shared app object, which holds however the caller reached it.test_cli_oauth_api.pyalso had hand-rolled save/restore around those attributes; the references are repointed so the pattern is consistent with the rest.Two more stubbed
enqueue_model_preparationwith sync lambdas that are now awaited.Fixes # (issue)
Type of change
Refactor. No behaviour change intended or measured; the externally visible contract is the one ADR-0007 already set.
Checks run
source .venv/bin/activate && pytestpython scripts/check.py(Ruff lint, format check, mypy, doc and Alembic validators)cd frontend && npm run lintcd frontend && npm run testcd frontend && npm run buildpython3 scripts/validate_docs.pypython3 scripts/validate_alembic.py1151 tests pass and the gate is green end to end. No frontend file is touched.
Migration impact
Documentation impact
No documentation change required.
Updated the relevant guide(s) in the same PR.
ADR-0007 gains a dated
Updatesection recording that the first two residuals are closed. The decision is unchanged, so the original text stands as written rather than being quietly rewritten to claim something it did not say at the time.docs/ARCHITECTURE.md— the API/worker boundary now describes both halves, the off-loop dispatch and the bounded retries.docs/DEVELOPMENT.md— a backend convention bullet, in the same list as the ML-import rule, so the next contributor meets the rule where they would look for it.Security impact
No auth, token, encryption or ownership boundary is touched. As with #160 it slightly reduces a denial-of-service surface, since no request handler can now be blocked by a slow broker.
Manual verification
The guard catches a reintroduced inline dispatch. Reverting one call site to
celery_app.send_taskinside itsasync def:It names the file and line. The violation was reverted after the check and is not in the diff.
The mechanism swap is behaviour-preserving. Re-ran the same uvicorn harness from #160 against a black-holed broker (
10.255.255.1, packets dropped), now going throughasyncio.to_thread:run_in_threadpool)asyncio.to_thread)/ping, worst/ping, medianA static sweep confirms the property holds tree-wide, both directions: no
send_task/apply_async/delayremains inside anyasync def, and noawaitwas introduced into a sync function.The throwaway harness was removed afterwards; nothing from it is in the diff.
Screenshots (if relevant)
Not applicable.