Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,10 @@ All notable changes to this project are documented in this file. The format is b
## [Unreleased]

### Added
- Request-scoped database sessions now expose `session.on_commit(callback)` for
synchronous or asynchronous cache refreshes and other derived state. The
framework invokes callbacks only after a successful commit and discards them
on rollback or commit failure.
- Every `smpy new` scaffold now ships Docker assets by default: a multi-stage
`docker/host.Dockerfile` (uv + Node builder that runs `gen-pages` before the
Vite build, slim non-root runtime that applies migrations on start), a
Expand Down
31 changes: 29 additions & 2 deletions docs/database/sessions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,10 +25,10 @@ Usage in endpoints:
```python
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from simple_module_db import RequestSession
from simple_module_db.deps import get_db

SessionDep = Annotated[AsyncSession, Depends(get_db)]
SessionDep = Annotated[RequestSession, Depends(get_db)]


@router.post("")
Expand DownExpand Up@@ -88,6 +88,33 @@ async def create(self, data: OrderCreate) -> OrderOut:

Flushing sends the INSERT but keeps the transaction open. Rollback still works until the dependency commits.

## Refreshing derived state after commit

The request session exposes `on_commit()` for cache invalidation and other derived
in-process state that must never reflect an uncommitted transaction:

```python
async def create(self, data: OrderCreate) -> OrderOut:
order = Order(**data.model_dump())
self.session.add(order)
await self.session.flush()
self.session.on_commit(self.order_cache.refresh)
return OrderOut.model_validate(order)
```

Callbacks take no arguments and may be synchronous or asynchronous. The framework
runs them in registration order after a successful commit and before the response
leaves the server. It discards them when the request rolls back, when commit fails,
or when the request has no writes. A callback should refresh derived state, not
perform more database mutations.

At callback time the original transaction is already durable, so callback errors
cannot roll it back. The framework logs a `db.session.on_commit_failed` error,
continues with remaining callbacks, and preserves the successful response.

`on_commit()` belongs to the framework-managed request lifecycle. For a manually
managed session, perform post-commit work explicitly after the transaction block.

## Manual transactions

If you need finer control — e.g. a background worker that processes many items in its own transactions — use the `DatabaseState.session_factory` directly:
Expand Down
5 changes: 4 additions & 1 deletion docs/framework-conventions.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -193,7 +193,10 @@ Each request opens one session:
- Read-only requests exit via rollback — cheaper, and keeps the session out of write-side profiling.
- Exceptions always rollback.

Service code should not call `session.commit()` directly. Flush for intermediate reads if you need DB-assigned values, then let the framework commit.
Service code should not call `session.commit()` directly. Flush for intermediate reads if you need DB-assigned values, then let the framework commit. The injected
`RequestSession` also provides `session.on_commit(callback)` for synchronous or
asynchronous cache refreshes that must only observe durable state. Callbacks run
after successful commit and are discarded on rollback or commit failure.

The commit lands in `CommitBeforeResponseMiddleware`, which intercepts the ASGI
`http.response.start` message — the last point still inside the request. That is
Expand Down
10 changes: 6 additions & 4 deletions framework/db/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,8 @@ pip install simple_module_db
## What it provides

- `create_module_base("<module_name>")` — a module-scoped declarative `Base` with its own `MetaData`. All modules share the host's single schema, so `__tablename__` should be prefixed with the module name (e.g. `users_user`) to avoid collisions.
- Per-request async session (`get_db`) with an auto-commit-on-flush hook — `after_flush` commits if there are pending writes, rolls back otherwise.
- Per-request `RequestSession` (`get_db`) that commits pending writes before the response and rolls back read-only requests.
- `session.on_commit(callback)` for synchronous or asynchronous cache refreshes that must only run after a successful request commit.
- Mixins in `simple_module_db.mixins`: `AuditMixin` (created_at/updated_at), `SoftDeleteMixin` (auto-filtered unless `stmt.execution_options(include_deleted=True)`), `MultiTenantMixin`, `VersionedMixin`.
- `DatabaseState` container used by the framework to avoid global mutable state.

Expand All@@ -34,12 +35,13 @@ class Order(Base, AuditMixin, SoftDeleteMixin, table=True):
In a service:

```python
from simple_module_db import get_db
from simple_module_db import RequestSession, get_db

async def create_order(session = Depends(get_db), ...):
async def create_order(session: RequestSession = Depends(get_db), ...):
order = Order(customer_id=..., total_cents=...)
session.add(order)
await session.flush() # assigns order.id; auto-commit happens after the request
await session.flush() # assigns order.id; commit happens before the response
session.on_commit(order_cache.refresh)
return order
```

Expand Down
5 changes: 4 additions & 1 deletion framework/db/simple_module_db/__init__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

from simple_module_db.audit import AuditRecord
from simple_module_db.base import create_module_base
from simple_module_db.callbacks import OnCommitCallback
from simple_module_db.deps import get_db
from simple_module_db.listeners import TenantIsolationError, current_tenant_id
from simple_module_db.migrations import (
Expand All@@ -13,7 +14,7 @@
from simple_module_db.mixins import AuditMixin, MultiTenantMixin, SoftDeleteMixin, VersionedMixin
from simple_module_db.provider import DatabaseProvider, detect_provider
from simple_module_db.search import LIKE_ESCAPE_CHAR, like_contains_pattern, like_prefix_pattern
from simple_module_db.session import DatabaseState, init_db
from simple_module_db.session import DatabaseState, RequestSession, init_db
from simple_module_db.transaction import CommitBeforeResponseMiddleware, finalize_session

__all__ = [
Expand All@@ -24,6 +25,8 @@
"DatabaseProvider",
"DatabaseState",
"MultiTenantMixin",
"OnCommitCallback",
"RequestSession",
"SoftDeleteMixin",
"TenantIsolationError",
"VersionedMixin",
Expand Down
47 changes: 47 additions & 0 deletions framework/db/simple_module_db/callbacks.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
"""Post-commit callbacks for framework-managed database sessions."""

from __future__ import annotations

import inspect
import logging
from collections.abc import Awaitable, Callable

from sqlalchemy.ext.asyncio import AsyncSession

type OnCommitCallback = Callable[[], Awaitable[None] | None]

_CALLBACKS_KEY = "sm_db_on_commit_callbacks"
logger = logging.getLogger("simple_module.db")


def register_on_commit(session: AsyncSession, callback: OnCommitCallback) -> None:
"""Queue ``callback`` for this session's next successful finalization."""
if not callable(callback):
raise TypeError("on_commit callback must be callable")
callbacks = session.info.setdefault(_CALLBACKS_KEY, [])
callbacks.append(callback)


def discard_on_commit_callbacks(session: AsyncSession) -> None:
"""Discard callbacks belonging to a transaction that did not commit."""
session.info.pop(_CALLBACKS_KEY, None)


async def run_on_commit_callbacks(session: AsyncSession) -> None:
"""Run and remove queued callbacks after a successful commit.

The transaction is already durable, so callback failures are logged and do
not turn a successful database mutation into a misleading failed response.
Remaining callbacks still run.
"""
callbacks: list[OnCommitCallback] = session.info.pop(_CALLBACKS_KEY, [])
for callback in callbacks:
try:
result = callback()
if inspect.isawaitable(result):
await result
except Exception:
logger.exception(
"db.session.on_commit_failed",
extra={"operation": "on_commit_failed"},
)
6 changes: 3 additions & 3 deletions framework/db/simple_module_db/deps.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,8 +6,8 @@
from collections.abc import AsyncGenerator

from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession

from simple_module_db.session import RequestSession
from simple_module_db.transaction import (
SESSION_START_KEY,
finalize_session,
Expand All@@ -16,7 +16,7 @@
)


async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
async def get_db(request: Request) -> AsyncGenerator[RequestSession, None]:
"""Yield an async database session, auto-closing on exit.

Commits only when the session has pending writes (``new``, ``dirty``,
Expand All@@ -34,7 +34,7 @@ async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
Usage in FastAPI endpoints::

@router.get("/items")
async def list_items(db: AsyncSession = Depends(get_db)):
async def list_items(db: RequestSession = Depends(get_db)):
...
"""
factory = request.app.state.sm.db.session_factory
Expand Down
20 changes: 18 additions & 2 deletions framework/db/simple_module_db/session.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,15 +13,28 @@
)
from sqlalchemy.orm import Session

from simple_module_db.callbacks import OnCommitCallback, register_on_commit
from simple_module_db.provider import DatabaseProvider, detect_provider


class RequestSession(AsyncSession):
"""Async session with hooks for the request-owned transaction."""

def on_commit(self, callback: OnCommitCallback) -> None:
"""Run ``callback`` after this request's next successful commit.

Callbacks may be synchronous or asynchronous. They are discarded when
the request rolls back or has no writes.
"""
register_on_commit(self, callback)


@dataclass
class DatabaseState:
"""Holds all database state for a single application instance."""

engine: AsyncEngine
session_factory: async_sessionmaker[AsyncSession]
session_factory: async_sessionmaker[RequestSession]
sync_session_class: type[Session] = field(repr=False, default=Session)
audit_callback: Callable | None = field(default=None, repr=False)
_listeners_registered: bool = field(default=False, repr=False)
Expand DownExpand Up@@ -67,7 +80,10 @@ def init_db(
# Scoped Session subclass so event listeners only fire for this engine's sessions
scoped_session_class = type("ScopedSession", (Session,), {})
session_factory = async_sessionmaker(
engine, expire_on_commit=False, sync_session_class=scoped_session_class
engine,
class_=RequestSession,
expire_on_commit=False,
sync_session_class=scoped_session_class,
)

return DatabaseState(
Expand Down
8 changes: 8 additions & 0 deletions framework/db/simple_module_db/transaction.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,10 @@

from sqlalchemy.ext.asyncio import AsyncSession

from simple_module_db.callbacks import (
discard_on_commit_callbacks,
run_on_commit_callbacks,
)
from simple_module_db.listeners import SESSION_HAS_WRITES_KEY

logger = logging.getLogger("simple_module.db")
Expand DownExpand Up@@ -97,6 +101,7 @@ async def finalize_session(session: AsyncSession) -> None:
if session.info.get(_FINALIZED_KEY):
return
_settle(session)
discard_on_commit_callbacks(session)
await session.rollback()
logger.debug(
"db.session.read_only",
Expand All@@ -106,6 +111,7 @@ async def finalize_session(session: AsyncSession) -> None:
try:
await session.commit()
except Exception:
discard_on_commit_callbacks(session)
await session.rollback()
raise
finally:
Expand All@@ -116,10 +122,12 @@ async def finalize_session(session: AsyncSession) -> None:
"db.session.commit",
extra={"operation": "commit", "db_duration_ms": _elapsed_ms(session)},
)
await run_on_commit_callbacks(session)


async def rollback_session(session: AsyncSession) -> None:
"""Roll ``session`` back and drop its pending work. Idempotent."""
discard_on_commit_callbacks(session)
if session.info.get(_FINALIZED_KEY) and not _has_pending(session):
return
_settle(session)
Expand Down
3 changes: 2 additions & 1 deletion framework/db/tests/test_session.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,7 @@
from unittest.mock import MagicMock

from simple_module_db.deps import get_db
from simple_module_db.session import DatabaseState, init_db
from simple_module_db.session import DatabaseState, RequestSession, init_db
from sqlalchemy.ext.asyncio import AsyncSession


Expand DownExpand Up@@ -58,6 +58,7 @@ async def test_get_db_yields_session(self):
gen = get_db(mock_request)
session = await gen.__anext__()
assert isinstance(session, AsyncSession)
assert isinstance(session, RequestSession)

with contextlib.suppress(StopAsyncIteration):
await gen.__anext__()
Expand Down
Loading
Loading