diff --git a/docs/skills/async-fastapi-sqlmodel/references/engine.md b/docs/skills/async-fastapi-sqlmodel/references/engine.md index a0133f1..1d16f1c 100644 --- a/docs/skills/async-fastapi-sqlmodel/references/engine.md +++ b/docs/skills/async-fastapi-sqlmodel/references/engine.md @@ -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. diff --git a/docs/skills/async-fastapi-sqlmodel/references/session.md b/docs/skills/async-fastapi-sqlmodel/references/session.md index 988cb3f..d196e2e 100644 --- a/docs/skills/async-fastapi-sqlmodel/references/session.md +++ b/docs/skills/async-fastapi-sqlmodel/references/session.md @@ -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.