Source code for alchemiq.runtime.unit_of_work

"""UnitOfWork: reentrant async context manager that owns one database transaction."""

from __future__ import annotations

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from contextvars import Token
from types import TracebackType

from sqlalchemy.ext.asyncio import AsyncSession

from alchemiq.runtime.engine import require_sessionmaker
from alchemiq.runtime.post_commit import (
    PostCommitCallback,
    discard_region,
    drain_region,
    open_region,
)
from alchemiq.runtime.session import _active_session


[docs] class UnitOfWork: """One ``async with`` = one transaction. Autocommit on clean exit, rollback on error. Reentrant: a nested :class:`.UnitOfWork` joins the active session and its exit is a no-op; only the outermost commits and closes the session. Use :meth:`.savepoint` for partial rollback within a single transaction. E.g.:: # basic usage - commits on clean exit async with UnitOfWork() as uow: uow.session.add(MyRow(id=1, name="a")) # savepoint - inner error rolls back only to the savepoint async with UnitOfWork() as uow: uow.session.add(MyRow(id=3, name="keep")) await uow.session.flush() async with uow.savepoint(): uow.session.add(MyRow(id=4, name="drop")) raise RuntimeError("inner") # only the savepoint is rolled back # reentrant - nested UoW joins the outer session async with UnitOfWork() as outer: async with UnitOfWork() as inner: assert inner.session is outer.session # same session, no-op exit .. seealso:: :meth:`.UnitOfWork.savepoint` - nested savepoint for partial rollback. """ session: AsyncSession def __init__(self) -> None: self._joined: bool = False self._token: Token[AsyncSession | None] | None = None self._pc_token: Token[list[PostCommitCallback] | None] | None = None async def __aenter__(self) -> UnitOfWork: existing = _active_session.get() if existing is not None: self._joined = True self.session = existing return self session = require_sessionmaker()() try: self._token = _active_session.set(session) except Exception: await session.close() raise self.session = session self._pc_token = open_region() return self async def __aexit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, ) -> bool: if self._joined: return False # inner UoW: outermost owns commit/close committed = False try: if exc_type is None: await self.session.commit() committed = True else: await self.session.rollback() finally: await self.session.close() if self._token is not None: _active_session.reset(self._token) if committed: await drain_region(self._pc_token) else: discard_region(self._pc_token) return False
[docs] async def commit(self) -> None: """Flush and commit the current transaction. :raises RuntimeError: if called on a nested (joined) :class:`.UnitOfWork` - only the outermost may commit. """ if self._joined: raise RuntimeError( "cannot commit a joined UnitOfWork; the outermost UnitOfWork owns the transaction" ) await self.session.commit()
[docs] async def rollback(self) -> None: """Abort the current transaction without closing the session. :raises RuntimeError: if called on a nested (joined) :class:`.UnitOfWork`. Raise an exception to abort the outer transaction instead, or use :meth:`.savepoint` for partial rollback. """ if self._joined: raise RuntimeError( "cannot roll back a joined UnitOfWork; raise an exception to abort, " "or use uow.savepoint() for partial rollback" ) await self.session.rollback()
[docs] @asynccontextmanager async def savepoint(self) -> AsyncIterator[None]: """Yield a nested savepoint; rolls back only to this point on error. E.g.:: async with UnitOfWork() as uow: uow.session.add(MyRow(id=1, name="keep")) await uow.session.flush() async with uow.savepoint(): uow.session.add(MyRow(id=2, name="drop")) raise RuntimeError("abort inner") # only savepoint rolled back # id=1 is still pending; commits with the outer transaction """ async with self.session.begin_nested(): yield