docs(architecture): define application resource ownership
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>
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user