PRD 0002: Async DB session migration — retire get_db / NullPool sync-in-async#
Source of truth for this epic. Tracker: epic issue. The epic links here; this file is not embedded in the issue body. Seed: issue #175.
Problem#
PRD 0001 hardened DB session/engine
lifecycle across epic #158, but explicitly descoped its root cause as a
human-gated design slice (#166): get_db
(ccat_ops_db_api/dependencies/__init__.py:27-69) yields a sync SQLAlchemy
Session with null_pool=True — a fresh connection per request. This is a
band-aid over the real bug: three async def auth dependencies in
ccat_ops_db_api/auth/unified_auth.py (get_current_user,
get_current_user_optional, get_service_user) take this sync session via
Depends(get_db) and call sync DB-querying helpers directly, blocking the
event loop on every authenticated request. Because FastAPI caches a
dependency’s result per callable per request, auth’s get_db() call and any
endpoint’s own Depends(get_db) resolve to the same session, held for the
whole request — including any awaited async work afterward.
Depends(get_db) appears 269 times across 47 files (ui/ 101/14, obs/
58/9, pipeline/ 38/7, plan/ 37/8, evaluation/ 4/1, top-level routers 27,
unified_auth.py 3, scheduler/ 0). Two overlapping open issues address
adjacent slices of this surface: #77 (convert ~67 broken
async def-with-sync-query endpoints to plain def) and #78 (the full ORM
migration to AsyncSession). This epic fully absorbs both rather than
leaving three overlapping efforts in flight, and retires #166 via the ADR it
produces.
The actual blocking surface is larger than the sync-DB-call-site count
suggests. Starlette decides sync-vs-async dispatch by inspecting the
decorator wrapper, not the original function: require_permissions()
(unified_auth.py:435) wraps every decorated endpoint in an async def that
calls the wrapped function inline (result = func(*args, **kwargs), line
513), so a @require_permissions-decorated plain-def endpoint is
dispatched as async and runs its entire sync DB body on the event loop —
FastAPI’s threadpool protection never applies. An AST scan puts this at
142 of the 222 plain-def router endpoints (out of 290 total router
endpoints: 68 async def, 222 plain def; 155 @require_permissions uses
in routers). The real blast radius is closer to 210 of 290 endpoints, not
just the auth dependencies plus the 68 genuinely async def endpoints.
(require_roles, unified_auth.py:428, has a related but separate bug: an
unconditional await func(...) that would 500 on a plain-def endpoint —
latent today because its 5 current uses are all async def, but worth
fixing in the same pass.)
There is already a parallel async stack to build on: AsyncDatabaseResources
/ build_async_database_resources() (dependencies/__init__.py:277-301),
lifespan-owned, async_sessionmaker-based, built for the transaction-buffering
subsystem (#162). A get_async_db() dependency exists in the same file but is
wired into zero routers — dead scaffolding this epic promotes into the
canonical request-scoped async dependency. The working async query idiom
already lives in ccat_ops_db_api/transaction_buffering/
(transaction_executor.py, smart_query_manager.py, lsn_tracker.py):
select(...) + await session.execute(...) against async_sessionmaker
factories directly. That subsystem is fully async and decoupled from
get_db() already — it needs no changes.
Solution#
A full migration of all sync DB session usage in the request path to
AsyncSession, landed as an epic branch collecting several sub-issue PRs
(mirroring #158’s pattern), with the final epic-branch → develop PR held and
tested against a local checkout before merging:
Landing strategy — decided: epic-branch-hold. One long-lived epic branch collects every slice, held and tested locally, single final PR to
develop— mirroring #158. A reviewer counter-proposal argued for shipping each domain slice as an ordinary PR straight todeveloponce Slices 0+1 land, to avoid branch drift and get per-slice staging validation. Rejected: that proposal’s core worry — a long-lived branch drifting againstdevelopover the epic’s duration — assumes human-paced sequential implementation. With LLM-orchestrated implementation across the slices, this isn’t the months-long-branch problem #158’s pattern was designed around; the branch’s lifetime is short enough that drift risk doesn’t dominate the decision. No partial-migration state reachesdevelopat any point during the epic.
Slice 0 — foundation, no endpoints touched. Promote
get_async_dbto the canonical request-scoped session; fix secondary-siteautoflush/ read-only parity on the async local session (see Implementation decisions); establish theselectinloadeager-loading convention; stand up a real-DB async integration test harness (sqlite+aiosqliteor a Postgres testcontainer) — the current suite’s mocked DB sessions (tests/conftest.py) cannot surface the failure modes this migration introduces.Slice 1 — auth. Convert
unified_auth.py,token_manager.py,admin.py,github_auth.py, andapi_tokens.pyto async withselectinload(User.roles)at the query sites. Fixrequire_permissions()/require_roles()dispatch in the same pass:await run_in_threadpool(func, ...)for a wrapped plain-defendpoint,await func(...)for a wrappedasync defone. This single change restores FastAPI’s threadpool protection for every not-yet-migrated decorated router immediately — it converts the epic from “the API stays partially broken until each domain’s slice lands” into “the API is loop-safe from Slice 1 onward, and each later domain slice is a scalability upgrade rather than an urgent bugfix.” (Longer-term, convertingrequire_permissions/require_rolesfrom decorators toDepends-based guards would make this bug class structurally impossible — worth doing opportunistically per domain during the later slices, not as a separate effort.) This slice is independently implementable and testable as its own PR — the actual root-cause fix, becauseget_dbandget_async_dbare separate cached FastAPI dependencies, so auth can go async while business-logic endpoints are still migrating. It still lands on the epic branch like every other slice and does not ship todevelopahead of the rest: per the epic-branch-hold workflow above, nothing merges until the full migration is tested together on a local checkout. (Shipping auth’s fix todevelopearly would require an explicit exception to the hold-until-complete workflow — not assumed here.) Implemented as sub-issue #178; the async-auth conversion and the decorator-dispatch fix are recorded in ADR 0005 (decision 5). As built, the low-frequency token-management CRUD (token_manager.py, theapi_tokens.pytoken endpoints) stays on the syncget_dbsession — it is not the per-request auth hot path and its authenticated-user.rolesreads are already safe via eager loading — so the async conversion in this slice covers the auth dependency chain andgithub_auth.py’s login/role-mutation path rather than the token CRUD layer.Slices 2..N — one router/schema domain per PR (
obs→plan→ui/*→evaluation→pipeline), converting endpoints and CRUD toawait session.execute(select(...)), auditing every existingjoinedloadcollection use for the.unique()requirement (see below), and auditing eachresponse_model’s serialized relationships for eager-load coverage.scheduler/has zeroDepends(get_db)call sites (Redis-only) and needs no slice.Residual top-level routers slice —
auth.py(2 sites),user_preferences.py(5),admin_diagnose.py(2), andadmin_live_check.py(1) are not covered by Slice 1’s auth-module scope and need their own pass before the final slice can deleteget_db(every remaining call site must be gone first).Special slice —
routers/ui/transfer.py’s WebSocket handler (lines ~65-133), which drives blocking sync DB calls inside an async loop. It cannot use plaindef(WebSocket routes can’t be sync) and needs a bespoke fix — genuine async DB access orrun_in_threadpool.Final slice — delete
get_db()and the sync request-path engine once zeroDepends(get_db)call sites remain; set a short, explicit fail-fastpool_timeouton the async engine (_build_async_engine(), which currently has none and defaults to SQLAlchemy’s 30s) pluspool_pre_ping=True— this replaces the originally proposed NullPool→QueuePool revert entirely, since there is no surviving sync pool to revert. The ADR must write down the per-worker connection arithmetic —workers × (pool_size + max_overflow)per engine (main and local at the secondary site) — against Postgresmax_connectionsheadroom (~60 free of 100 today, per the connection budget above), since this is the number that changes if uvicorn workers are ever increased from today’s 1-per-process deployment.
Implementation decisions#
No surviving sync connection pool in the request path.
get_db()and the sync engine are deleted outright once the migration completes; there is no boundedQueuePoolfallback. The one legitimate sync residual is the dev-token-seed at FastAPI lifespan startup (main.py:~124-158) — one-shot, dev-only, outside the request path, calling the upstreamccat_ops_dbpackage’s sync-onlyinit_ccat_ops_db().ccat_ops_dbhas no async engine or session helpers — nocreate_async_engine,AsyncSession, orasync_sessionmakeranywhere in the package (a vestigialasync_driverURL flag exists but is unreachable —get_engine()never passes it) — so adding async support there is out of scope; the dev-seed keeps its existing sync call.#77is closed as superseded, not implemented. A migrated endpoint correctly staysasync defonce it gets real async DB access — #77’s “convert to plaindef” is the opposite direction and never becomes the right answer for any endpoint in this epic’s scope. #77 could not have fixed the root cause even on its own terms: auth’s event-loop blocking comes fromasync defdependencies calling sync DB helpers while also awaiting other async work (verify_api_token,buffer_transaction) — the fix has to be auth’s session, not the endpoint’sdef/async defchoice. #77’s only real appeal — a cheap win shipped todevelopwhile the full migration is still in progress — doesn’t apply here either, since the epic-branch-hold workflow means nothing in this epic reachesdevelopbefore the whole migration is tested together. It was also never fully implementable as written: roughly 20 endpoints mix genuineawaitwork (the httpx OAuth exchange ingithub_auth.py, Redis pub/sub broadcasts inui/transfer.py’s CRUD path,SmartQueryManagerbuffered reads inobs/raw_data_files_obs.py) with sync DB calls in the same function body — those could never have cleanly converted to plaindefin the first place.ccat-apiCLI is out of scope. Confirmed to be Redis/site-config only (ccat_ops_db_api/cli/) with zero DB access — no async migration surface there.Commit/rollback contract for
get_async_db. The promoted dependency wraps its yield intry/except Exception: await session.rollback(); raise/finally: await session.close(). Write endpoints callawait session.commit()explicitly; the dependency guarantees cleanup but never commits on an endpoint’s behalf. This applies uniformly across every migrated write endpoint in Slices 1 through N, not just auth.selectinload(User.roles), notjoinedload, at the auth query sites (verify_jwt_token,verify_api_token— which is also the queryget_service_userrelies on for its user fetch).User.rolesis a many-to-many collection (ccat_ops_db/models.py:1120, defaultlazy="select");joinedloadon a collection would force.unique()handling on theResultfor no benefit. This one change covers all ~16 downstream lazy-read sites (unified_auth.py421/522/591/596/605/613/647,dependencies/admin.py:29,routers/api_tokens.py108/131/434) for the life of the request.github_auth.py’s role-mutation path becomes async.assign_roles_from_teams/get_or_create_user(currently sync) mutateuser.rolesvia.append()/.remove()(lines 198/249/285); the collection must be eager-loaded before mutation, and the call chain becomesasync defwith an explicitawait session.commit().Secondary-site read-only enforcement is new hardening, not restored parity — and it’s a hard requirement, not an optimization.
get_db()’sread_onlyflag only gates DDL:init_ccat_ops_dbskipscreate_schemawhenread_only=Trueand does nothing else. The only runtime guardget_db()gives the secondary today isautoflush=False(dependencies/__init__.py:65), which does not stop an explicitadd()/commit()from succeeding. The async factories (build_async_database_resources) set neitherautoflush=Falsenor any connection-level guard. This epic must add both to the async local session (autoflush=Falseplus an enforced guard, e.g. a connect-event runningSET default_transaction_read_only) — this is genuinely new protection, and turning it on is a deliberate behavior change: it can surface secondary-site direct writes that currently succeed silently. Land it deliberately, with a clear rollout note, not as a drop-in “restore what was there.” This is the mechanism that keeps transaction-buffering’s outage fail-and-recover behavior intact going forward — the buffering subsystem’s own factories (transaction_executor,smart_query_manager,lsn_tracker) are otherwise untouched by this epic.expire_on_commit=False(already set on the existing async factories) must be preserved. Flipping it reintroducesMissingGreenleton post-commit attribute reads (e.g.github_auth.py’s post-commituser.usernameread).Audit
User.api_tokens’s cascade.cascade="all, delete-orphan"meanssession.delete(user)needsapi_tokenseager-loaded or the cascade lazy-load faults under async. Bulkdelete()/update()bypass ORM cascades entirely and need their own review where used.Connection budget confirmed comfortable. Primary Postgres runs ~40/100 configured worst-case connections today across all known consumers (ops-db-api, data-transfer, workflow-manager); removing the sync engine only reduces risk further, since
NullPoolwas already uncapped and load-dependent rather than a fixed baseline.data-transfer’s Celery workers running with no-cconcurrency flag (worst case scales with host CPU count) is a separate, pre-existing risk unrelated to this epic — noted for awareness, not addressed here. During the migration window, a request whose auth has migrated but whose endpoint hasn’t will transiently open both an async session (auth) and a syncNullPoolconnection (the endpoint) — expected, and immaterial at ~40/100, but named here since this bullet would otherwise read as airtight.This epic produces the first ADR touching sessions/pooling/async (
docs/adr/currently has none relevant) — it also formally retires #166.
Testing decisions#
The current suite’s mocked DB sessions (tests/conftest.py, 100% MagicMock)
cannot surface the failure modes this migration introduces — MissingGreenlet
from an unloaded lazy relationship, a missing .unique() on a converted
joinedload collection (181 joinedload uses vs. 3 selectinloads in
routers today — this really is the highest-frequency mechanical trap), or a
gap in the new secondary-site read-only guard. A real async integration-test
harness is a Slice 0 deliverable, not an afterthought — split across two
backends, not either/or: sqlite+aiosqlite for the broad per-router
migration tests (ops-db’s models are already sqlite-compatible — JSONB is
wrapped as JSON().with_variant(JSONB(), "postgresql"), UUID(as_uuid=True)
emulates cleanly, no ARRAY type is used), and a small real-Postgres job
(testcontainer or CI service container) for the two things sqlite cannot
validate: the secondary-site read-only guard (SET default_transaction_read_only is a Postgres-only connect-event) and any
JSONB-specific query behavior. Don’t let the convenient sqlite harness
silently become the only one. At minimum, tests should:
exercise the auth path end-to-end against a real async engine, confirming
User.rolesloads withoutMissingGreenletand downstreamrequire_roles/require_permissionschecks work;cover at least one representative eager-load +
response_modelendpoint per migrated domain, confirming serialization doesn’t trigger a lazy load;attach a
do_orm_executeevent applyingraiseload("*")to every ORM query on the test session factory only (do not change modellazy=defaults globally — sync consumers likedata-transferandworkflow-managerlegitimately rely on lazy loading). This turns any endpoint whose response serialization would lazy-load in production into a deterministic local test failure instead of a productionMissingGreenlet— the cheapest available guard against the migration’s dominant failure mode;assert the secondary-site async local session is read-only (a write attempt against it fails) and has
autoflush=False— this is new protection this epic adds (see Implementation decisions), not a restored guarantee, so write the test and its review as a behavior-change check, not a regression check;cover the
github_auth.pyrole-mutation path (append/remove) under an async session;regression-test the
.unique()requirement on anyjoinedloadcollection converted in each domain slice.
Out of scope#
Adding async support to
ccat_ops_db(the upstream ORM package) — it has no async engine/session helpers at all; the dev-token-seed’s one-shot sync call at startup is left as-is.PgBouncer / connection-proxy deployment (already out of scope for #158 too).
data-transfer’s unbounded Celery worker connection consumption — a separate, pre-existing risk, flagged in this PRD for awareness only.Re-litigating the session/engine-lifecycle work already landed in #158.