improvements
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# Async SQLAlchemy Engine
|
||||
|
||||
!!! info "Primary sources"
|
||||
- [Python `functools.cache`](https://docs.python.org/3/library/functools.html#functools.cache)
|
||||
- [SQLAlchemy connections](https://docs.sqlalchemy.org/en/21/core/connections.html)
|
||||
- [SQLAlchemy asyncio extension](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html)
|
||||
- [SQLAlchemy pooling and multiprocessing](https://docs.sqlalchemy.org/en/21/core/pooling.html#pooling-multiprocessing)
|
||||
@@ -10,55 +11,89 @@
|
||||
|
||||
## Engine Ownership Model
|
||||
|
||||
Create one async engine per process per database URL and keep it for the app lifetime.
|
||||
Create one async engine per process per database URL and keep engine construction independent from FastAPI.
|
||||
|
||||
- SQLAlchemy guidance: the engine is intended as a long-lived, concurrent registry over pooled DB connections, not a per-request object.
|
||||
- In FastAPI, app startup and shutdown ownership belongs in lifespan.
|
||||
- Use `FastAPI(lifespan=...)` (not startup/shutdown events) for modern lifecycle wiring.
|
||||
- A cached function provides stable process-local engine identity without making framework state the only way to obtain it.
|
||||
- FastAPI lifespan starts and stops that independently defined resource; it does not contain the construction policy.
|
||||
|
||||
!!! tip "Practical rule"
|
||||
- Exactly one `create_async_engine(...)` call in app bootstrap code.
|
||||
- Exactly one `create_async_engine(...)` call in the cached engine factory.
|
||||
- Zero `create_async_engine(...)` calls in request handlers.
|
||||
- Zero calls to the cached factory from repository code.
|
||||
|
||||
---
|
||||
|
||||
## Canonical Lifespan Pattern (AsyncExitStack)
|
||||
## Cached Engine Factory
|
||||
|
||||
Use `@asynccontextmanager` + `AsyncExitStack` to make teardown deterministic and composable.
|
||||
Use [`functools.cache`](https://docs.python.org/3/library/functools.html#functools.cache) on a synchronous factory. Creating an `AsyncEngine` configures the dialect and pool; it does not need to await a database connection.
|
||||
|
||||
```python
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from functools import cache
|
||||
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
|
||||
|
||||
@cache
|
||||
def get_engine(database_url: str) -> AsyncEngine:
|
||||
return create_async_engine(
|
||||
database_url,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
|
||||
async def dispose_engine(database_url: str) -> None:
|
||||
engine = get_engine(database_url)
|
||||
try:
|
||||
await engine.dispose()
|
||||
finally:
|
||||
get_engine.cache_clear()
|
||||
|
||||
|
||||
async def refresh_engine(database_url: str) -> AsyncEngine:
|
||||
await dispose_engine(database_url)
|
||||
return get_engine(database_url)
|
||||
```
|
||||
|
||||
The database URL is an explicit, hashable cache key. Calls with the same URL return the same engine; a different URL receives a different engine. If engine options vary at runtime, make them explicit hashable arguments too.
|
||||
|
||||
Resolve settings at the composition boundary and call `get_engine(settings.database_url)`. Do not hide settings lookup or engine creation inside feature code.
|
||||
|
||||
## Thin FastAPI Lifespan Wrapper
|
||||
|
||||
The lifespan context manager only connects the cached resource to FastAPI ownership:
|
||||
|
||||
```python
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async with AsyncExitStack() as stack:
|
||||
engine: AsyncEngine = create_async_engine(
|
||||
app.state.settings.database_url,
|
||||
pool_pre_ping=True,
|
||||
# Optional examples:
|
||||
# echo=app.state.settings.sql_echo,
|
||||
# pool_size=10,
|
||||
# max_overflow=20,
|
||||
)
|
||||
app.state.engine = engine
|
||||
|
||||
# Ensure engine disposal always runs at shutdown.
|
||||
stack.push_async_callback(engine.dispose)
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
database_url = app.state.settings.database_url
|
||||
engine = get_engine(database_url)
|
||||
app.state.engine = engine
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await dispose_engine(database_url)
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
```
|
||||
|
||||
Why this pattern:
|
||||
- FastAPI executes code before `yield` at startup and after `yield` at shutdown.
|
||||
- `AsyncExitStack` lets you register multiple async cleanups in one place while preserving order.
|
||||
- Explicit disposal (directly awaited or via `AsyncExitStack` callback) avoids event-loop-closed warnings when objects fall out of scope.
|
||||
`dispose()` closes checked-in connections and replaces the pool, but it does not remove the Python object from `functools.cache`. `dispose_engine()` clears the cache even if driver cleanup raises, preventing a later lifespan run or test from retrieving that engine instance.
|
||||
|
||||
This simple cleanup assumes one configured database URL per process. If a process intentionally owns several cached engines, use a small registry with per-key removal instead of clearing the whole cache. For a fixed engine, `try/finally` is sufficient; use `AsyncExitStack` when lifespan composes multiple conditional or dynamically acquired resources.
|
||||
|
||||
When directly testing engine construction or lifespan behavior:
|
||||
|
||||
- Call `get_engine.cache_clear()` before the test to remove process-local state.
|
||||
- Dispose any engine the test creates.
|
||||
- Clear the cache again during teardown, even when the test fails.
|
||||
|
||||
---
|
||||
|
||||
@@ -118,7 +153,9 @@ This prevents broken socket state and cross-process connection corruption.
|
||||
|
||||
- Create an engine inside every request dependency.
|
||||
- Create/dispose engines inside repository methods.
|
||||
- Call `get_engine()` from repositories instead of injecting their engine or session dependency.
|
||||
- Keep engine creation as a hidden side effect of import-time module globals.
|
||||
- Dispose a cached engine without clearing the cache during final teardown.
|
||||
- Use deprecated FastAPI startup/shutdown events together with lifespan.
|
||||
|
||||
---
|
||||
@@ -126,8 +163,9 @@ This prevents broken socket state and cross-process connection corruption.
|
||||
## Engine Design Checklist
|
||||
|
||||
- One engine per process per DB URL.
|
||||
- Engine created in lifespan startup.
|
||||
- Engine disposed in lifespan shutdown.
|
||||
- Engine created by one cached, framework-independent factory.
|
||||
- Lifespan only retrieves, exposes, disposes, and uncaches the engine.
|
||||
- Async driver URL matches backend (`asyncpg` or `aiosqlite`).
|
||||
- Pooling strategy is explicit for non-default needs.
|
||||
- No request-path engine creation.
|
||||
- Tests dispose engines and clear cached state deterministically.
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
# Async SQLAlchemy Session Management
|
||||
|
||||
!!! info "Primary sources"
|
||||
- [Python `functools.cache`](https://docs.python.org/3/library/functools.html#functools.cache)
|
||||
- [Python `asynccontextmanager`](https://docs.python.org/3/library/contextlib.html#contextlib.asynccontextmanager)
|
||||
- [SQLAlchemy asyncio extension](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html)
|
||||
- [SQLAlchemy session basics](https://docs.sqlalchemy.org/en/21/orm/session_basics.html)
|
||||
- [FastAPI dependencies with yield](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield/)
|
||||
|
||||
??? abstract "Decision metadata"
|
||||
- Status: adopted
|
||||
- Decision level: mandatory
|
||||
- Applies to: api-runtime, workers, tests
|
||||
- Last reviewed: 2026-06-17
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
@@ -32,29 +28,189 @@ Define one canonical session model for FastAPI + SQLAlchemy asyncio:
|
||||
|
||||
## Rules
|
||||
|
||||
- Create `async_sessionmaker` once from app-owned AsyncEngine.
|
||||
- Create one cached `async_sessionmaker` per app-owned AsyncEngine.
|
||||
- Let repositories resolve the cached maker by database URL, with an injectable factory override for tests.
|
||||
- Use a fresh AsyncSession for each request or explicit unit-of-work.
|
||||
- Pass an `AsyncSession` directly to data-access functions.
|
||||
- Borrow a caller-provided session without closing or committing it.
|
||||
- Do not share AsyncSession across `asyncio.gather()` or parallel tasks.
|
||||
- Prefer direct dependency injection over global scoped-session patterns in new code.
|
||||
- Use explicit transaction boundaries (`async with session.begin():`) for writes.
|
||||
|
||||
---
|
||||
|
||||
## Session Factory Mechanics
|
||||
|
||||
An `async_sessionmaker[AsyncSession]` is a reusable configuration object and callable session producer. It stores how sessions should be created, including the engine binding and options such as `expire_on_commit=False`. It is not itself a session, connection, or transaction, and calling it does not make a shared global `AsyncSession`.
|
||||
|
||||
Cache it by the application-owned engine so repeated composition calls return the same maker:
|
||||
|
||||
```python
|
||||
from functools import cache
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from .engine import dispose_engine
|
||||
from .engine import get_engine
|
||||
|
||||
|
||||
@cache
|
||||
def get_session_factory(database_url: str) -> async_sessionmaker[AsyncSession]:
|
||||
return async_sessionmaker(
|
||||
bind=get_engine(database_url),
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def dispose_session_factory(database_url: str) -> None:
|
||||
get_session_factory.cache_clear()
|
||||
await dispose_engine(database_url)
|
||||
```
|
||||
|
||||
`functools.cache` caches by argument equality and requires hashable arguments. The database URL is an explicit string key shared with the cached engine factory. The cache retains the returned maker until `get_session_factory.cache_clear()` runs. Cache the synchronous maker function, never an async function and never a produced `AsyncSession`.
|
||||
|
||||
Each call to `session_factory()` creates a distinct `AsyncSession`. The caller that invokes the factory owns that session lifetime and must close it, normally with `async with`:
|
||||
|
||||
```python
|
||||
async with session_factory() as session:
|
||||
...
|
||||
```
|
||||
|
||||
The factory can be shared across requests and tasks. Sessions produced by it cannot be shared across concurrent tasks.
|
||||
|
||||
An `async_sessionmaker` has no connection pool or async `dispose()` method of its own. `dispose_session_factory()` means "invalidate the cached maker, then dispose its engine." Clearing the maker first ensures no subsequent composition call can retrieve a maker bound to the engine being shut down.
|
||||
|
||||
Use the helper when shutting down or replacing the database resources:
|
||||
|
||||
```python
|
||||
await dispose_session_factory(database_url)
|
||||
```
|
||||
|
||||
Otherwise, a later call can return a maker that still references the old engine object. This matters in lifespan tests, application restarts within one process, and test suites that replace engines.
|
||||
|
||||
---
|
||||
|
||||
## Optional Session Ownership
|
||||
|
||||
A small [`asynccontextmanager`](https://docs.python.org/3/library/contextlib.html#contextlib.asynccontextmanager) can make repository methods composable. It borrows an existing session when supplied; otherwise it creates and closes one from a supplied factory:
|
||||
|
||||
```python
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def session_scope(
|
||||
*,
|
||||
database_url: str,
|
||||
session: AsyncSession | None = None,
|
||||
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
||||
) -> AsyncIterator[AsyncSession]:
|
||||
if session is not None:
|
||||
yield session
|
||||
return
|
||||
|
||||
active_factory = session_factory or get_session_factory(database_url)
|
||||
|
||||
async with active_factory() as owned_session:
|
||||
yield owned_session
|
||||
```
|
||||
|
||||
The branch is intentionally explicit. Python's [`nullcontext`](https://docs.python.org/3/library/contextlib.html#contextlib.nullcontext) can express the same borrow-or-own idea, but the branch keeps ownership and typing obvious.
|
||||
|
||||
This helper manages session lifetime only:
|
||||
|
||||
- It does not close, commit, or roll back a supplied session; the caller owns it.
|
||||
- It closes a session that it creates. Closing releases resources and rolls back an unfinished transaction; it does not commit.
|
||||
- It does not start a transaction. Put `session.begin()` at the use-case boundary.
|
||||
- A supplied session wins; neither the factory override nor cached factory is used.
|
||||
- A supplied factory overrides cached resolution, which keeps tests and specialized wiring explicit.
|
||||
- Otherwise, `database_url` selects the cached factory returned by `get_session_factory()`.
|
||||
|
||||
Do not turn this into an implicit unit-of-work helper that sometimes commits. Whether work joins an existing transaction or creates a new one must remain visible to the caller.
|
||||
|
||||
---
|
||||
|
||||
## Repository and Function Boundaries
|
||||
|
||||
Pass the database URL to repository constructors. The repository stores repeatable database configuration, not mutable session state, and `session_scope()` resolves the cached factory when a standalone operation needs a session. An optional factory override keeps tests independent:
|
||||
|
||||
```python
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
|
||||
async def find_item(session: AsyncSession, item_id: int) -> Item | None:
|
||||
statement = select(Item).where(Item.id == item_id)
|
||||
return await session.scalar(statement)
|
||||
|
||||
|
||||
class ItemRepository:
|
||||
def __init__(
|
||||
self,
|
||||
database_url: str,
|
||||
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
||||
) -> None:
|
||||
self.database_url = database_url
|
||||
self.session_factory = session_factory
|
||||
|
||||
async def find(
|
||||
self,
|
||||
item_id: int,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Item | None:
|
||||
async with session_scope(
|
||||
database_url=self.database_url,
|
||||
session=session,
|
||||
session_factory=self.session_factory,
|
||||
) as active_session:
|
||||
return await find_item(active_session, item_id)
|
||||
```
|
||||
|
||||
This split gives each layer one job:
|
||||
|
||||
- The repository object identifies its database configuration and creates a session only for a standalone call.
|
||||
- Production calls reuse the cached factory; tests can inject a factory override.
|
||||
- A caller can pass a session to join an existing unit of work; the repository borrows it.
|
||||
- The access function owns only the query and requires an existing `AsyncSession`.
|
||||
- Application wiring supplies the production factory.
|
||||
- Tests can supply a factory bound to a test engine or call `find_item()` with a transaction-scoped test session.
|
||||
|
||||
When several repository operations must share one transaction, pass the same session through each call. Put the transaction at the use-case boundary:
|
||||
|
||||
```python
|
||||
async with session_factory() as session:
|
||||
async with session.begin():
|
||||
item = await repository.find(item_id, session=session)
|
||||
await update_item(session, item, changes)
|
||||
```
|
||||
|
||||
This preserves atomicity without making repository objects hold mutable `AsyncSession` instances across calls.
|
||||
|
||||
---
|
||||
|
||||
## Canonical FastAPI Dependency Pattern
|
||||
|
||||
```python
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from fastapi import Depends
|
||||
from fastapi import Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
|
||||
def get_session_factory(request: Request) -> async_sessionmaker[AsyncSession]:
|
||||
return request.app.state.session_factory
|
||||
type SessionFactory = async_sessionmaker[AsyncSession]
|
||||
|
||||
|
||||
def resolve_session_factory(request: Request) -> SessionFactory:
|
||||
return get_session_factory(request.app.state.settings.database_url)
|
||||
|
||||
|
||||
async def get_db_session(
|
||||
session_factory: async_sessionmaker[AsyncSession] = Depends(get_session_factory),
|
||||
session_factory: SessionFactory = Depends(resolve_session_factory),
|
||||
) -> AsyncIterator[AsyncSession]:
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
@@ -66,6 +222,8 @@ Route usage:
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .session import get_db_session
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -81,22 +239,9 @@ async def create_item(session: AsyncSession = Depends(get_db_session)) -> dict:
|
||||
|
||||
## Configuration Guidance
|
||||
|
||||
Typical session factory setup:
|
||||
|
||||
```python
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
session_factory = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `expire_on_commit=False` is commonly preferred in asyncio applications to reduce accidental post-commit reload behavior.
|
||||
- `AsyncSession.refresh()` is preferred over broad expiration patterns when state refresh is needed.
|
||||
- [`async_sessionmaker.begin()`](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.async_sessionmaker.begin) is a concise option when one scope must create a session, begin a transaction, commit on success, roll back on failure, and close. Do not use it when borrowing a caller's session.
|
||||
|
||||
## SQLModel Alignment
|
||||
|
||||
@@ -118,14 +263,21 @@ Notes:
|
||||
|
||||
- A singleton/global AsyncSession reused across requests.
|
||||
- Sharing one AsyncSession across parallel tasks.
|
||||
- Hidden session creation in lower repository helpers with no caller control.
|
||||
- Passing an application-global AsyncSession to a repository constructor.
|
||||
- Caching an `AsyncSession` instead of caching `async_sessionmaker`.
|
||||
- Leaving a cached maker pointing at a disposed or replaced engine.
|
||||
- Calling the session factory inside low-level access functions such as `find_item()`.
|
||||
- Hidden session creation in lower access functions with no caller control.
|
||||
- Closing or committing a session supplied by the caller.
|
||||
- Starting a new transaction inside a helper that may receive a session already in a transaction.
|
||||
- Mixing commit/rollback ownership across layers without a declared boundary.
|
||||
|
||||
---
|
||||
|
||||
## Operational Checks
|
||||
|
||||
- Exactly one `async_sessionmaker` is registered in app lifecycle.
|
||||
- Exactly one cached `async_sessionmaker` exists per application engine.
|
||||
- Session factory caches are cleared before their engines are disposed or replaced.
|
||||
- Request handlers receive sessions from one canonical dependency.
|
||||
- No code path creates AsyncSession in module import side effects.
|
||||
- Background jobs and API handlers each create task-local sessions.
|
||||
@@ -134,7 +286,12 @@ Notes:
|
||||
|
||||
## Testing Checks
|
||||
|
||||
- Dependency override exists for test session factory.
|
||||
- Repository constructors accept a test session factory without FastAPI startup.
|
||||
- Session-taking access functions accept a transaction-scoped test session directly.
|
||||
- Optional-session tests verify that borrowed sessions remain open and created sessions close.
|
||||
- Optional-session tests verify that neither path commits implicitly.
|
||||
- Cache tests clear `get_session_factory` before and after replacing engines.
|
||||
- Dependency override exists for the FastAPI session factory.
|
||||
- Rollback behavior is verified for failed write units.
|
||||
- Parallel-task tests verify no shared AsyncSession instances.
|
||||
- Lifespan tests confirm session factory is initialized and teardown-safe.
|
||||
|
||||
Reference in New Issue
Block a user