Improve handling of FASTAPI Asyncio in API#4924
Conversation
…ollection of background tasks.
Unit Test Results682 tests 682 ✅ 8s ⏱️ Results for commit 8a403f8. ♻️ This comment has been updated with latest results. |
There was a problem hiding this comment.
Pull request overview
This pull request improves reliability of long-running background Service Bus workers in the FastAPI API by managing their lifecycle within the application lifespan, aiming to prevent silent task garbage collection and to make shutdown behavior more controlled/observable.
Changes:
- Track background worker tasks in
app.stateto keep strong references scoped to the FastAPI application lifecycle. - Add controlled shutdown logic that cancels tracked tasks, awaits completion, and logs failures.
- Add an unreleased changelog entry describing the fix.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
api_app/main.py |
Refactors FastAPI lifespan to track, name, cancel, and gather background tasks during shutdown. |
CHANGELOG.md |
Adds an Unreleased BUG FIXES entry for the background task lifecycle fix. |
|
/test-extended |
|
🤖 pr-bot 🤖 🏃 Running extended tests: https://github.com/microsoft/AzureTRE/actions/runs/28435506268 (with refid (in response to this comment from @maxmartin-cgi) |
marrobi
left a comment
There was a problem hiding this comment.
From Opus 4.8:
- Potential duplicate logging / misleading error path during shutdown
- File: api_app/main.py
- In
_done_callback, any non-cancelled exception is logged as an error. - During app shutdown, tasks are explicitly cancelled and then awaited with
gather(..., return_exceptions=True), but cancellation timing races can still surface non-cancel exceptions from teardown paths and be logged as runtime failures even when shutdown is intentional. - Risk: noisy or misleading error logs during normal shutdown/reload cycles.
- Suggested fix: gate error logging on an app “is_shutting_down” flag, or distinguish expected teardown exceptions from true runtime failures.
- Missing explicit type annotation / structure for app.state storage
- File: api_app/main.py
app.state.background_tasks = set()is dynamically typed and reused across lifecycle logic without an explicit contract.- Risk: future refactors can accidentally store non-Task objects, causing runtime failures at
.get_name(),.cancel(), or callback handling. - Suggested fix: initialize with an annotated container (e.g.,
set[asyncio.Task[Any]]) via helper function or typed local alias and only expose add/remove through a small utility.
- No timeout on shutdown gather for background workers
- File: api_app/main.py
await asyncio.gather(*tasks, return_exceptions=True)has no timeout.- Risk: if a task ignores cancellation or blocks in an uninterruptible await path, application shutdown can hang indefinitely.
- Suggested fix: wrap gather with
asyncio.wait_for(..., timeout=<bounded>)and log any tasks still pending after timeout.
- PR description and implementation diverge on exception handling details
- File: PR description vs api_app/main.py
- PR text claims exception handling via
results = await asyncio.gather(...)+ per-result logging withexc_info=result, but implementation logs in done-callback and does not process gathered results for failure classification. - Risk: maintainers/reviewers may approve based on behavior that is not actually present; future debugging expectations mismatch.
- Suggested fix: align description to actual code or implement the documented gather-result inspection pattern.
- Version/changelog bump appears bundled with behavioral change but no migration/release note granularity
- Files: api_app/_version.py, CHANGELOG.md
- Version increment and changelog entry are very terse for a lifecycle/shutdown behavior change.
- Risk: downstream operators may not realize shutdown semantics and logging behavior changed (important for ops playbooks).
- Suggested fix: expand changelog entry with explicit operational impact (startup/shutdown task lifecycle, cancellation behavior, and logging changes).
…ved task management during application lifecycle
| async def fail_immediately(): | ||
| raise ValueError("Runtime failure") | ||
|
|
||
| mock_deployment_instance.receive_messages = fail_immediately | ||
| mock_deployment.return_value = mock_deployment_instance | ||
|
|
||
| mock_airlock_instance = MagicMock() | ||
| mock_airlock_instance.init_repos = AsyncMock() | ||
| mock_airlock_instance.receive_messages = AsyncMock() | ||
| mock_airlock.return_value = mock_airlock_instance | ||
|
|
||
| app = FastAPI() | ||
|
|
||
| async with lifespan(app): | ||
| await asyncio.sleep(0.1) |
| async def raise_teardown_exception(): | ||
| try: | ||
| await asyncio.sleep(5) | ||
| except asyncio.CancelledError: | ||
| raise RuntimeError("Database connection closed abruptly") | ||
|
|
||
| mock_deployment_instance.receive_messages = raise_teardown_exception | ||
| mock_deployment.return_value = mock_deployment_instance | ||
|
|
||
| mock_airlock_instance = MagicMock() | ||
| mock_airlock_instance.init_repos = AsyncMock() | ||
| mock_airlock_instance.receive_messages = AsyncMock() | ||
| mock_airlock.return_value = mock_airlock_instance | ||
|
|
||
| app = FastAPI() | ||
|
|
||
| async with lifespan(app): | ||
| await asyncio.sleep(0.1) |
Resolves #4923
What is being addressed
This pull request resolves a reliability issue where background Service Bus message processors (
DeploymentStatusUpdaterandAirlockStatusUpdater) could potentially be silently garbage-collected. It refactors task lifecycle management to use application-scoped state via a type-safeBackgroundTaskManagercontainer, implements safe concurrent task cancellation during shutdown with a bounded timeout, gates error logging to prevent misleading runtime errors during graceful shutdown/reload cycles, and implements gather-result inspection.Problem Description
Previously, background workers were instantiated and launched using:
In Python, the
asyncioevent loop only maintains weak references to tasks. Discarding the return value ofasyncio.create_task()without keeping a strong reference elsewhere left the task instances vulnerable to garbage collection during runtime, leading to silent processing failures.Additionally, this introduced other architectural concerns:
RuntimeError: Set changed size during iteration.asyncio.gatherhad no timeout, risking indefinite hangs if a task ignored cancellation or blocked in an uninterruptible await path.app.state.background_taskswas dynamically typed as a raw set, making it prone to type safety violations during future refactorings.Solution
Refactored
lifespanin api_app/main.py to implement a correct, type-safe, and robust asyncio background worker pattern:1. App-Scoped & Type-Annotated State Tracking
Background tasks are managed via a dedicated
BackgroundTaskManagerregistered in FastAPI'sapp.state.background_tasks:This guarantees task contexts are cleanly isolated per application instance and enforces explicit type constraints.
2. Task Naming and Observability
Tasks are created with human-readable names for improved debuggability and stack tracing:
name="deployment-status-updater"name="airlock-status-updater"3. Safe, Controlled Shutdown with Bounded Timeout
During teardown (after
yield), tasks are copied to a list before cancellation to prevent mutation-during-iteration errors. The gather call is wrapped in a10.0second timeout to prevent indefinite hangs:4. Gated Logging & Gather-Result Inspection
_done_callback, we gate error logging behind the manager'sis_shutting_downflag to avoid noisy and misleading error logs during normal shutdown/reload cycles.asyncio.CancelledError) are caught, classified, and logged as warnings with full tracebacks.Changes
BackgroundTaskManager, and implemented shutdown timeout/gather-result inspection.0.25.26to0.25.27.Testing
pytest.682test cases completed successfully (including the new lifecycle and task management tests) with no regressions.