Files
chatbot_v3/docs/adr/0012-application-resource-lifetime-and-dependency-ownership.md
Ali Zarinkolah 0ca698acfa 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>
2026-08-10 11:50:04 +03:30

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.