22
33import asyncio
44import contextlib
5- from contextlib import asynccontextmanager
5+ from collections .abc import AsyncIterator
6+ from contextlib import AsyncExitStack , asynccontextmanager
67
78import structlog
89from fastapi import APIRouter , FastAPI , Request
910from 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
1115from helprs .core .config import get_settings
1216from helprs .core .database import (
1317 clear_session_factory ,
1721)
1822from helprs .core .exceptions import DomainError , domain_exception_handler
1923from 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
2132logger = 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
2942async 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
8284async 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