Why: - Establish explicit ownership and lifetime rules for application, request/job, and operation-scoped resources. Changes: - Define FastAPI lifespan ownership for engines, clients, graphs, and pools. - Define dependency-managed sessions and request context. - Require explicit transaction boundaries and dependency passing. - Prohibit shared global SQLAlchemy sessions and import-time network clients. Impact: - Application resources are created and closed by their process owner. - Request and job resources must not be shared across concurrent units of work. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
271 lines
9.7 KiB
Markdown
271 lines
9.7 KiB
Markdown
# 0012. Application resource lifetime and dependency ownership
|
|
|
|
## Status
|
|
|
|
Proposed
|
|
|
|
## Context
|
|
|
|
ADR-0008 defines the FastAPI REST boundary and says the app lifespan owns
|
|
long-lived resources. ADR-0009 chooses SQLAlchemy 2.x, Alembic migrations, and
|
|
async sessions for the Postgres layer. We need a project-wide rule for how those
|
|
resources, and future resources with similar lifetimes, are created, shared, and
|
|
closed.
|
|
|
|
The service will need objects with different lifetimes:
|
|
|
|
- application-lifetime objects such as SQLAlchemy engines, session factories,
|
|
Qdrant clients, LangGraph checkpointers/stores, compiled graphs, HTTP clients,
|
|
observability clients, model clients, Redis clients, or other connection pools;
|
|
- request/job-lifetime objects such as SQLAlchemy sessions, transactions,
|
|
authentication context, tenant context, request ids, and unit-of-work state;
|
|
- operation-lifetime objects such as temporary files, one-off streams, locks, or
|
|
short-lived connections checked out for a single block.
|
|
|
|
Using module-level mutable resource instances for everything would make startup,
|
|
shutdown, testing, concurrency, and transaction boundaries hard to reason about.
|
|
The most dangerous example is a global SQLAlchemy `Session`: sessions track
|
|
identity state and transaction state, and are not safe to share across concurrent
|
|
requests.
|
|
|
|
At the same time, creating expensive clients or connection pools for every
|
|
request would waste resources and lose pooling benefits. We need explicit
|
|
ownership: the component that creates a resource is responsible for closing it.
|
|
|
|
## Decision
|
|
|
|
### Use FastAPI lifespan for application-lifetime resources
|
|
|
|
Create long-lived shared resources in the FastAPI lifespan function and close
|
|
them after the lifespan `yield`.
|
|
|
|
Application-lifetime resources include:
|
|
|
|
- SQLAlchemy async `Engine`;
|
|
- SQLAlchemy `async_sessionmaker` bound to that engine;
|
|
- Qdrant client;
|
|
- LangGraph checkpointer/store and compiled graph;
|
|
- shared `httpx.AsyncClient` instances;
|
|
- Redis or cache clients;
|
|
- Langfuse/observability clients;
|
|
- embedding, reranking, LLM, or other expensive model clients.
|
|
|
|
The lifespan function is the owner of these resources. It is responsible for
|
|
calling cleanup methods such as `dispose()`, `aclose()`, `close()`, or equivalent
|
|
shutdown hooks.
|
|
|
|
Indicative shape:
|
|
|
|
```python
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
|
|
import httpx
|
|
from fastapi import FastAPI
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
app.state.database = Database(settings.database_url)
|
|
app.state.http_client = httpx.AsyncClient()
|
|
|
|
try:
|
|
yield
|
|
finally:
|
|
await app.state.http_client.aclose()
|
|
await app.state.database.close()
|
|
|
|
|
|
app = FastAPI(lifespan=lifespan)
|
|
```
|
|
|
|
Avoid opening external resource clients as import-time side effects. Module-level
|
|
constants, settings objects, pure functions, type aliases, and stateless helpers
|
|
are acceptable. Mutable network/database clients should be constructed by the
|
|
application owner, not by importing a module.
|
|
|
|
### Treat SQLAlchemy engine and session as different lifetimes
|
|
|
|
A SQLAlchemy `Engine` is an application-lifetime pool manager, not a single
|
|
request's transaction. It may keep database connections open in a pool and reuse
|
|
them across requests.
|
|
|
|
A SQLAlchemy `AsyncSession` is a request/job-lifetime unit of work. It tracks ORM
|
|
identity state and transaction state and may check out a database connection from
|
|
the engine when needed.
|
|
|
|
Therefore:
|
|
|
|
- create one engine/session factory per FastAPI process during lifespan;
|
|
- create one `AsyncSession` per request or background-job unit of work;
|
|
- never share one `AsyncSession` globally across concurrent requests;
|
|
- never run DDL such as `create_all()` at FastAPI startup; use Alembic as decided
|
|
in ADR-0009.
|
|
|
|
### Use dependencies for request-lifetime resources
|
|
|
|
Use FastAPI dependencies, usually `yield` dependencies, for objects that should
|
|
exist for one request and then be cleaned up.
|
|
|
|
Indicative database session dependency:
|
|
|
|
```python
|
|
from collections.abc import AsyncIterator
|
|
|
|
from fastapi import Request
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
async def get_db_session(request: Request) -> AsyncIterator[AsyncSession]:
|
|
database: Database = request.app.state.database
|
|
|
|
async with database.session_factory() as session:
|
|
try:
|
|
yield session
|
|
except Exception:
|
|
await session.rollback()
|
|
raise
|
|
```
|
|
|
|
The dependency owns the session lifetime. Route handlers, services, and
|
|
repositories must not close a session they did not create.
|
|
|
|
Authentication and tenant context follow the same rule: resolve them through
|
|
request dependencies, then pass the trusted context into code that needs it.
|
|
|
|
### Keep transaction boundaries explicit
|
|
|
|
Routes or application-service functions own transaction boundaries. Repository
|
|
and CRUD functions receive a session and perform database work, but they should
|
|
not secretly commit, rollback, or close the session unless their contract
|
|
explicitly says they own a complete unit of work.
|
|
|
|
Preferred shape:
|
|
|
|
```python
|
|
async def create_user(session: AsyncSession, data: CreateUserRequest) -> User:
|
|
user = User(email=data.email)
|
|
session.add(user)
|
|
return user
|
|
|
|
|
|
@router.post("/users")
|
|
async def create_user_endpoint(
|
|
body: CreateUserRequest,
|
|
session: SessionDep,
|
|
) -> UserResponse:
|
|
user = await create_user(session, body)
|
|
await session.commit()
|
|
return UserResponse.model_validate(user)
|
|
```
|
|
|
|
Avoid hidden global access:
|
|
|
|
```python
|
|
# Do not do this.
|
|
session = SessionLocal()
|
|
|
|
async def create_user(data: CreateUserRequest) -> User:
|
|
session.add(User(email=data.email))
|
|
await session.commit()
|
|
```
|
|
|
|
This keeps unit-of-work ownership visible, makes multi-step transactions easier,
|
|
and lets tests pass in their own sessions.
|
|
|
|
### Use context managers for operation-lifetime resources
|
|
|
|
Objects needed only inside one function or block should use `with` or
|
|
`async with` rather than app state or module globals.
|
|
|
|
Examples include file handles, temporary streams, one-off SQLAlchemy connections,
|
|
locks, and response streams.
|
|
|
|
```python
|
|
async with http_client.stream("GET", url) as response:
|
|
...
|
|
```
|
|
|
|
or:
|
|
|
|
```python
|
|
async with engine.connect() as connection:
|
|
...
|
|
```
|
|
|
|
The context manager owns and releases the resource at block exit.
|
|
|
|
### Pass dependencies explicitly through service boundaries
|
|
|
|
Routers adapt HTTP requests into typed application calls. Services and
|
|
repositories should receive the resources they use as explicit parameters:
|
|
|
|
```python
|
|
async def get_point(
|
|
session: AsyncSession,
|
|
qdrant: QdrantClient,
|
|
auth: AuthContext,
|
|
point_id: str,
|
|
) -> PointResponse:
|
|
...
|
|
```
|
|
|
|
Do not have lower layers import mutable resource singletons. Explicit parameters
|
|
make ownership clear and allow FastAPI dependency overrides, test fixtures, and
|
|
background workers to supply equivalent resources.
|
|
|
|
### Account for worker process multiplicity
|
|
|
|
FastAPI lifespan runs once per worker process. If the service runs with multiple
|
|
Uvicorn/Gunicorn workers, each worker has its own app instance, engine, and
|
|
connection pool. Database pool sizes and external client limits must be chosen
|
|
with worker count in mind.
|
|
|
|
## Consequences
|
|
|
|
### Positive
|
|
- Resource ownership and cleanup become explicit: lifespan closes app resources,
|
|
dependencies close request resources, and context managers close block-scoped
|
|
resources.
|
|
- SQLAlchemy sessions are not shared across concurrent requests, avoiding unsafe
|
|
identity-map and transaction-state reuse.
|
|
- Connection pooling is preserved because expensive clients and engines are
|
|
created once per process instead of once per request.
|
|
- Transaction boundaries are easier to reason about because routes or
|
|
application services decide when to commit or rollback.
|
|
- Services and repositories are easier to test because sessions, clients, and
|
|
auth contexts can be injected directly.
|
|
- The same policy can be reused for future Redis, Qdrant, Langfuse, HTTP, model,
|
|
and worker resources.
|
|
|
|
### Negative
|
|
- This adds boilerplate: lifespan setup, app state typing/conventions,
|
|
dependencies, and explicit parameters must be maintained.
|
|
- Developers must distinguish engine/session, application/request, and
|
|
owner/borrower lifetimes instead of importing a convenient global.
|
|
- App state access needs discipline and typing helpers so resources do not become
|
|
an unstructured service locator.
|
|
- Multi-worker deployments require explicit pool sizing because each process owns
|
|
its own pools and clients.
|
|
|
|
## Alternatives Considered
|
|
|
|
- **Module-level resource singletons**: rejected as the default for mutable
|
|
external resources. Import-time construction makes startup order, tests,
|
|
reloads, and shutdown cleanup harder to control. Module-level constants and
|
|
stateless helpers remain acceptable.
|
|
- **One global SQLAlchemy session**: rejected. A session is a unit of work with
|
|
identity and transaction state, not an application-wide pool. Sharing it across
|
|
requests is unsafe and makes rollback/error handling ambiguous.
|
|
- **Create every client/session inside each CRUD function**: rejected. It hides
|
|
ownership and transaction boundaries, prevents multi-step units of work, and
|
|
wastes pooling benefits for expensive clients.
|
|
- **Automatically commit every successful request in the session dependency**:
|
|
deferred as a default. It reduces route boilerplate, but it can make write
|
|
boundaries too implicit and does not fit every read/write or multi-transaction
|
|
flow. Routes or application services should own commits unless a future ADR
|
|
adopts a unit-of-work abstraction.
|
|
- **Use a generic global service locator**: rejected for now. It can reduce
|
|
parameter lists, but it obscures dependencies and ownership. FastAPI
|
|
dependencies plus explicit function parameters are clearer at this stage.
|