Skip to content

Commit 2a22f83

Browse files
authored
refactor(api): rebuild main.py lifespan on AsyncExitStack (#59)
- Extract the 80-line inline lifespan out of create_app() into a module-level _lifespan built on AsyncExitStack: each resource registers its cleanup as it is acquired, so the LIFO teardown order (cancel loops -> drain replay tasks -> stop containers -> clear factory -> dispose engine) holds by construction, including when startup fails midway - Extract named helpers (_reconcile_stale_sessions, _stop_running_containers, _drain_replay_tasks, _cancel_task) in place of the hand-ordered finally block - Hoist function-level imports to module scope (no cycles) and drop narration comments, including a leftover story-spec reference - Behavior preserved: same teardown order, same exception-swallowing semantics; 282/282 tests pass, ruff and mypy clean
1 parent 0090205 commit 2a22f83

2 files changed

Lines changed: 108 additions & 149 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ infra/
4444

4545
## Key Patterns
4646

47-
- **App factory**: `helprs.main:create_app()`lifespan manages DB engine
47+
- **App factory**: `helprs.main:create_app()`module-level `_lifespan` owns the engine and background loops via `AsyncExitStack`: each resource registers its cleanup at acquisition, teardown runs LIFO (cancel loops → drain replay tasks → stop containers → clear factory → dispose engine)
4848
- **Flat modules** (identity, installation, webhook, container): `router.py`, `service.py`, `models.py`, `schemas.py`
4949
- **Container orchestration**: `container` module manages ephemeral Docker lifecycle, credential injection, result relay
5050
- **Skills as agents**: each skill is a self-contained folder with workflow definitions, mounted into containers

apps/api/src/helprs/main.py

Lines changed: 107 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22

33
import asyncio
44
import contextlib
5-
from contextlib import asynccontextmanager
5+
from collections.abc import AsyncIterator
6+
from contextlib import AsyncExitStack, asynccontextmanager
67

78
import structlog
89
from fastapi import APIRouter, FastAPI, Request
910
from fastapi.responses import JSONResponse
11+
from sqlalchemy import text
12+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
1013

14+
from helprs.admin.views import setup_admin
1115
from helprs.core.config import get_settings
1216
from helprs.core.database import (
1317
clear_session_factory,
@@ -17,35 +21,34 @@
1721
)
1822
from helprs.core.exceptions import DomainError, domain_exception_handler
1923
from helprs.core.middleware import configure_logging, configure_sentry, setup_middleware
24+
from helprs.modules.container import service as containers
25+
from helprs.modules.container.router import router as container_router
26+
from helprs.modules.identity.router import router as identity_router
27+
from helprs.modules.installation.router import router as installation_router
28+
from helprs.modules.webhook.repository import get_replayable_events
29+
from helprs.modules.webhook.router import router as webhook_router
30+
from helprs.modules.webhook.tasks import process_webhook_event
2031

2132
logger = structlog.get_logger()
2233

34+
SessionFactory = async_sessionmaker[AsyncSession]
35+
2336
_REPLAY_DISCOVERY_TIMEOUT_SECONDS = 10.0
2437
_REPLAY_CONCURRENCY = 10
2538
_REAPER_INTERVAL_SECONDS = 300
2639
_CONTAINER_CLEANUP_INTERVAL_SECONDS = 300
2740

2841

2942
async def _replay_pending_webhook_events(app: FastAPI) -> None:
30-
"""Re-dispatch any webhook_events that survived a crash / are stuck.
31-
32-
Used by both the lifespan startup path and the periodic reaper loop.
33-
Bounded by ``LIMIT`` inside ``get_replayable_events`` and a
34-
``Semaphore(_REPLAY_CONCURRENCY)`` here so a backlog cannot fan out into
35-
thousands of concurrent tasks and exhaust the DB pool.
43+
"""Re-dispatch webhook_events that survived a crash or got stuck.
3644
37-
Failures in the discovery query never block startup — they are logged
38-
and swallowed. A stuck DB connection is bounded by
39-
``_REPLAY_DISCOVERY_TIMEOUT_SECONDS`` via ``asyncio.wait_for``.
40-
41-
Spawned tasks are tracked on ``app.state.replay_tasks`` so they can be
42-
awaited on lifespan shutdown (no orphaned ``create_task`` references,
43-
no tasks cancelled mid-commit by ``engine.dispose()``).
45+
Runs at startup and on every reaper tick. Discovery is bounded by
46+
``asyncio.wait_for`` and never blocks startup; dispatch is bounded by
47+
``Semaphore(_REPLAY_CONCURRENCY)`` so a backlog cannot exhaust the DB
48+
pool. Spawned tasks are tracked on ``app.state.replay_tasks`` so
49+
shutdown can await them before the engine is disposed.
4450
"""
45-
from helprs.modules.webhook.repository import get_replayable_events
46-
from helprs.modules.webhook.tasks import process_webhook_event
47-
48-
session_factory = app.state.session_factory
51+
session_factory: SessionFactory = app.state.session_factory
4952

5053
async def _discover() -> list:
5154
async with session_factory() as session:
@@ -57,7 +60,6 @@ async def _discover() -> list:
5760
logger.warning("webhook_replay_discovery_timeout", timeout=_REPLAY_DISCOVERY_TIMEOUT_SECONDS)
5861
return
5962
except Exception:
60-
# Never block startup on replay discovery failures.
6163
logger.exception("webhook_replay_discovery_failed")
6264
return
6365

@@ -80,22 +82,17 @@ async def _bounded(event_id) -> None:
8082

8183

8284
async def _run_webhook_reaper(app: FastAPI, *, interval_seconds: int = _REAPER_INTERVAL_SECONDS) -> None:
83-
"""Periodic reaper that re-runs the replay query on a fixed interval.
85+
"""Re-run the replay query on a fixed interval.
8486
85-
Complements the boot-time replay: a blip mid-run (e.g., `mark_processed`
86-
commit failure) that leaves a row in ``processing`` would otherwise only
87-
be recovered on the next restart. With the reaper, recovery happens
88-
within one ``interval_seconds`` window.
89-
90-
Cancelled cleanly by the lifespan teardown via ``task.cancel()``.
87+
Complements the boot-time replay: a row left in ``processing`` by a
88+
mid-run blip would otherwise only be recovered on the next restart.
9189
"""
9290
try:
9391
while True:
9492
await asyncio.sleep(interval_seconds)
9593
try:
9694
await _replay_pending_webhook_events(app)
9795
except Exception:
98-
# Never crash the reaper loop — keep going on the next tick.
9996
logger.exception("webhook_reaper_cycle_failed")
10097
except asyncio.CancelledError:
10198
logger.info("webhook_reaper_stopped")
@@ -107,21 +104,15 @@ async def _run_container_cleanup(
107104
*,
108105
interval_seconds: int = _CONTAINER_CLEANUP_INTERVAL_SECONDS,
109106
) -> None:
110-
"""Periodic cleanup of expired container sessions.
111-
112-
Finds sessions past their TTL and destroys their Docker containers.
113-
Cancelled cleanly by the lifespan teardown via ``task.cancel()``.
114-
"""
115-
from helprs.modules.container.service import AioDockerClient, cleanup_expired
116-
107+
"""Destroy Docker containers of sessions past their TTL, on a fixed interval."""
117108
ttl = get_settings().CONTAINER_TTL_SECONDS
118109
try:
119110
while True:
120111
await asyncio.sleep(interval_seconds)
121112
try:
122-
docker = AioDockerClient()
113+
docker = containers.AioDockerClient()
123114
async with app.state.session_factory() as db:
124-
cleaned = await cleanup_expired(db, docker, ttl_seconds=ttl)
115+
cleaned = await containers.cleanup_expired(db, docker, ttl_seconds=ttl)
125116
await db.commit()
126117
if cleaned:
127118
logger.info("container_cleanup_cycle", cleaned=cleaned)
@@ -132,144 +123,112 @@ async def _run_container_cleanup(
132123
raise
133124

134125

135-
def create_app() -> FastAPI:
136-
settings = get_settings()
126+
async def _reconcile_stale_sessions(session_factory: SessionFactory) -> None:
127+
"""Mark sessions left RUNNING/PENDING by a previous run as FAILED."""
128+
try:
129+
async with session_factory() as db:
130+
await containers.reconcile_stale_sessions(db)
131+
await db.commit()
132+
except Exception:
133+
logger.exception("session_reconciliation_failed")
137134

138-
# Configure observability
139-
configure_logging()
140-
configure_sentry(settings)
141135

142-
@asynccontextmanager
143-
async def lifespan(app: FastAPI):
144-
"""Manage database engine lifecycle, admin setup, and webhook reaper."""
136+
async def _stop_running_containers(session_factory: SessionFactory) -> None:
137+
"""Stop every container still running before the engine goes away."""
138+
try:
139+
docker = containers.AioDockerClient()
140+
async with session_factory() as db:
141+
stopped = await containers.cleanup_all_running(db, docker)
142+
await db.commit()
143+
if stopped:
144+
logger.info("shutdown_containers_stopped", count=stopped)
145+
await docker.close()
146+
except Exception:
147+
logger.exception("shutdown_container_cleanup_failed")
148+
149+
150+
async def _drain_replay_tasks(app: FastAPI) -> None:
151+
"""Await in-flight replay tasks so none writes to a disposed pool."""
152+
if app.state.replay_tasks:
153+
await asyncio.gather(*app.state.replay_tasks, return_exceptions=True)
154+
155+
156+
async def _cancel_task(task: asyncio.Task) -> None:
157+
task.cancel()
158+
with contextlib.suppress(asyncio.CancelledError, Exception):
159+
await task
160+
161+
162+
@asynccontextmanager
163+
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
164+
"""Acquire long-lived resources; ``AsyncExitStack`` releases them in reverse order."""
165+
settings = get_settings()
166+
async with AsyncExitStack() as stack:
145167
engine = create_engine()
146-
reaper_task: asyncio.Task | None = None
147-
cleanup_task: asyncio.Task | None = None
148-
try:
149-
session_factory = create_session_factory(engine)
150-
app.state.engine = engine
151-
app.state.session_factory = session_factory
152-
# Register the factory for ``get_db_context`` (used by
153-
# background tasks that need DB access outside the request
154-
# dependency graph).
155-
set_session_factory(session_factory)
156-
app.state.replay_semaphore = asyncio.Semaphore(_REPLAY_CONCURRENCY)
157-
app.state.replay_tasks = set()
158-
159-
# Admin panel (needs engine)
160-
from helprs.admin.views import setup_admin
161-
162-
setup_admin(app, engine, settings.SECRET_KEY)
163-
164-
# Crash-replay: any webhook_events still in pending/processing
165-
# after a grace period are re-dispatched. Fire-and-forget so the
166-
# server starts serving traffic immediately (AC #2).
167-
await _replay_pending_webhook_events(app)
168-
169-
# Reconcile container sessions left in RUNNING/PENDING by a
170-
# prior crash — mark them FAILED immediately rather than
171-
# waiting for TTL expiry.
172-
try:
173-
from helprs.modules.container.service import reconcile_stale_sessions
168+
stack.push_async_callback(engine.dispose)
174169

175-
async with session_factory() as db:
176-
await reconcile_stale_sessions(db)
177-
await db.commit()
178-
except Exception:
179-
logger.exception("session_reconciliation_failed")
180-
181-
# Periodic reaper: handles rows that get stuck mid-run (e.g.
182-
# mark_processed commit failure) without waiting for the next
183-
# restart.
184-
reaper_task = asyncio.create_task(_run_webhook_reaper(app))
185-
cleanup_task = asyncio.create_task(_run_container_cleanup(app))
186-
187-
yield
188-
finally:
189-
if cleanup_task is not None:
190-
cleanup_task.cancel()
191-
with contextlib.suppress(asyncio.CancelledError, Exception):
192-
await cleanup_task
193-
194-
if reaper_task is not None:
195-
reaper_task.cancel()
196-
with contextlib.suppress(asyncio.CancelledError, Exception):
197-
await reaper_task
198-
199-
# Wait for in-flight replay tasks before disposing the engine so
200-
# they don't end up writing to a closed pool.
201-
tracked: set[asyncio.Task] = getattr(app.state, "replay_tasks", set())
202-
if tracked:
203-
await asyncio.gather(*tracked, return_exceptions=True)
204-
205-
# Stop all running containers before shutting down.
206-
try:
207-
from helprs.modules.container.service import AioDockerClient, cleanup_all_running
170+
session_factory = create_session_factory(engine)
171+
app.state.engine = engine
172+
app.state.session_factory = session_factory
173+
app.state.replay_semaphore = asyncio.Semaphore(_REPLAY_CONCURRENCY)
174+
app.state.replay_tasks = set()
208175

209-
docker = AioDockerClient()
210-
async with session_factory() as db:
211-
stopped = await cleanup_all_running(db, docker)
212-
await db.commit()
213-
if stopped:
214-
logger.info("shutdown_containers_stopped", count=stopped)
215-
await docker.close()
216-
except Exception:
217-
logger.exception("shutdown_container_cleanup_failed")
176+
# get_db_context() (background tasks outside the request dependency
177+
# graph) resolves sessions through this module-level registry.
178+
set_session_factory(session_factory)
179+
stack.callback(clear_session_factory)
180+
181+
setup_admin(app, engine, settings.SECRET_KEY)
182+
183+
await _replay_pending_webhook_events(app)
184+
await _reconcile_stale_sessions(session_factory)
185+
186+
stack.push_async_callback(_stop_running_containers, session_factory)
187+
stack.push_async_callback(_drain_replay_tasks, app)
188+
189+
for loop in (_run_webhook_reaper(app), _run_container_cleanup(app)):
190+
stack.push_async_callback(_cancel_task, asyncio.create_task(loop))
191+
192+
yield
193+
194+
195+
async def _handle_unhandled_exception(request: Request, exc: Exception) -> JSONResponse:
196+
logger.exception("unhandled_exception", path=request.url.path)
197+
return JSONResponse(
198+
status_code=500,
199+
content={"error": "internal_error", "message": "An unexpected error occurred"},
200+
)
218201

219-
clear_session_factory()
220-
await engine.dispose()
202+
203+
def create_app() -> FastAPI:
204+
settings = get_settings()
205+
configure_logging()
206+
configure_sentry(settings)
221207

222208
app = FastAPI(
223209
title="helPRs API",
224210
description="AI-powered pull request review assistant",
225211
version="0.1.0",
226-
lifespan=lifespan,
212+
lifespan=_lifespan,
227213
)
228214

229-
# Exception handlers
230215
app.add_exception_handler(DomainError, domain_exception_handler)
231-
232-
async def _unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
233-
logger.exception("unhandled_exception", path=request.url.path)
234-
return JSONResponse(
235-
status_code=500,
236-
content={"error": "internal_error", "message": "An unexpected error occurred"},
237-
)
238-
239-
app.add_exception_handler(Exception, _unhandled_exception_handler)
240-
241-
# Middleware (CORS, logging, rate limiting)
216+
app.add_exception_handler(Exception, _handle_unhandled_exception)
242217
setup_middleware(app, settings)
243218

244-
# API router
245219
api_router = APIRouter(prefix="/api/v1")
246-
247-
from helprs.modules.container.router import router as container_router
248-
from helprs.modules.identity.router import router as identity_router
249-
from helprs.modules.installation.router import router as installation_router
250-
from helprs.modules.webhook.router import router as webhook_router
251-
252-
api_router.include_router(container_router)
253-
api_router.include_router(identity_router)
254-
api_router.include_router(installation_router)
255-
api_router.include_router(webhook_router)
256-
220+
for router in (container_router, identity_router, installation_router, webhook_router):
221+
api_router.include_router(router)
257222
app.include_router(api_router)
258223

259-
# Health check
260224
@app.get("/health")
261225
async def health_check():
262-
from sqlalchemy import text
263-
264226
try:
265227
async with app.state.session_factory() as db:
266228
await db.execute(text("SELECT 1"))
267229
return {"status": "ok", "db": "ok"}
268230
except Exception:
269-
return JSONResponse(
270-
status_code=503,
271-
content={"status": "degraded", "db": "unreachable"},
272-
)
231+
return JSONResponse(status_code=503, content={"status": "degraded", "db": "unreachable"})
273232

274233
return app
275234

0 commit comments

Comments
 (0)