Transactions (commit and savepoint)¶
Every BaseRepository write commits on its own. For a single write that
is right — and for a business rule with two writes it is exactly the
problem:
from src.db.models import OrderItemModel, OrderModel
from tempest_fastapi_sdk import BaseRepository
async def create_order_unsafe(
orders: BaseRepository[OrderModel],
items: BaseRepository[OrderItemModel],
order: OrderModel,
rows: list[OrderItemModel],
) -> None:
"""Persist an order and its items without atomicity — the problem.
Args:
orders (BaseRepository[OrderModel]): The order repository.
items (BaseRepository[OrderItemModel]): The item repository.
order (OrderModel): The order to persist.
rows (list[OrderItemModel]): The order's items.
"""
await orders.add(order) # already durable
await items.add_all(rows) # if this fails, the order is orphaned
This page closes that hole.
The transaction() block¶
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models import OrderItemModel, OrderModel
from tempest_fastapi_sdk import BaseRepository, transaction
async def create_order(
session: AsyncSession,
orders: BaseRepository[OrderModel],
items: BaseRepository[OrderItemModel],
order: OrderModel,
rows: list[OrderItemModel],
) -> OrderModel:
"""Persist an order and its items atomically.
Args:
session (AsyncSession): The session both repositories share.
orders (BaseRepository[OrderModel]): The order repository.
items (BaseRepository[OrderItemModel]): The item repository.
order (OrderModel): The order to persist.
rows (list[OrderItemModel]): The order's items.
Returns:
OrderModel: The persisted order.
"""
async with transaction(session):
await orders.add(order)
await items.add_all(rows)
return order
Inside the block each write flushes instead of committing. A clean exit
produces one COMMIT; any exception produces one ROLLBACK and
the exception keeps propagating.
The counter lives on the session, not on the repository
Notice that items honours the block too, without anyone having
called anything on it. The state is kept in session.info, so
every repository bound to the same AsyncSession joins the same
block. That is what makes a service orchestrating several
repositories work without threading context between them.
The repository exposes the block as sugar:
from src.db.models import OrderItemModel, OrderModel
from tempest_fastapi_sdk import BaseRepository
async def create_order_with_sugar(
orders: BaseRepository[OrderModel],
items: BaseRepository[OrderItemModel],
order: OrderModel,
rows: list[OrderItemModel],
) -> None:
"""Open the block through the repository instead of the session.
Args:
orders (BaseRepository[OrderModel]): The order repository.
items (BaseRepository[OrderItemModel]): The item repository.
order (OrderModel): The order to persist.
rows (list[OrderItemModel]): The order's items.
"""
async with orders.transaction():
await orders.add(order)
await items.add_all(rows) # same session, same block
Explicit commit()¶
Sometimes the durable point is a business decision, and calling
update() purely for its commit side effect is dishonest. Use commit():
from src.db.models import OrderModel
from tempest_fastapi_sdk import BaseRepository
async def persist(
orders: BaseRepository[OrderModel],
order: OrderModel,
) -> None:
"""State the durable point instead of inferring it from an update.
Args:
orders (BaseRepository[OrderModel]): The order repository.
order (OrderModel): The order to persist.
"""
await orders.add(order)
await orders.commit()
commit() inside an open transaction() block flushes instead of
committing, because committing there would break the block's
all-or-nothing guarantee. That is what makes the call safe to leave in
place when someone later wraps the code in a block — a bare
session.commit() has no such property.
flush() (make a row visible to later statements in the same
transaction, without committing) and rollback() are there too.
rollback() inside a block is refused
A rollback there would discard the whole block — including writes
made by other repositories sharing the session — while the caller
believes it is undoing only its own step. The method raises
RuntimeError saying so. To abort the block, let the exception
propagate; for a step you intend to recover from, use savepoint().
An always-explicit repository: autocommit=False¶
When a whole repository belongs to a caller-owned unit of work:
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models import OrderModel
from tempest_fastapi_sdk import BaseRepository
async def persist_explicitly(session: AsyncSession, order: OrderModel) -> None:
"""Build a repository whose commit always belongs to the caller.
Args:
session (AsyncSession): The session to use.
order (OrderModel): The order to persist.
"""
repository: BaseRepository[OrderModel] = BaseRepository(
session, model=OrderModel, autocommit=False
)
await repository.add(order) # flush only
await repository.commit() # you decide when
The flag disables only the implicit commit inside the write methods.
An explicit commit() still commits.
savepoint(): fail without losing the rest¶
A real SAVEPOINT. A failure reverts only the nested part and the
surrounding transaction stays usable:
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models import AccountModel, AuditEntryModel, NicknameModel
from tempest_fastapi_sdk import (
BaseRepository,
ConflictException,
savepoint,
transaction,
)
async def open_account(
session: AsyncSession,
accounts: BaseRepository[AccountModel],
nicknames: BaseRepository[NicknameModel],
audit: BaseRepository[AuditEntryModel],
account: AccountModel,
nickname: NicknameModel,
entry: AuditEntryModel,
) -> None:
"""Open the account even when the nickname is already taken.
Args:
session (AsyncSession): The shared session.
accounts (BaseRepository[AccountModel]): The account repository.
nicknames (BaseRepository[NicknameModel]): The nickname repository.
audit (BaseRepository[AuditEntryModel]): The audit repository.
account (AccountModel): The account to create.
nickname (NicknameModel): The intended nickname.
entry (AuditEntryModel): The audit record.
"""
async with transaction(session):
await accounts.add(account)
try:
async with savepoint(session):
await nicknames.add(nickname)
except ConflictException:
pass # the account survives; only the nickname reverted
await audit.add(entry)
Without the savepoint, catching that ConflictException would leave the
session unusable — SQLAlchemy requires a rollback after a failed flush.
SQLite needs configuration, and the SDK already applies it
The pysqlite driver under aiosqlite opens transactions implicitly
and emits no BEGIN. SQLite therefore sees the SAVEPOINT as the
outermost transaction and its matching RELEASE SAVEPOINT becomes a
commit — a nested block that exits cleanly turns durable even when
the outer block is rolled back afterwards. AsyncDatabaseManager
applies SQLAlchemy's documented remedy to every SQLite engine it
builds. If you build the engine yourself, call
enable_sqlite_savepoints(engine).
Nesting¶
transaction() blocks are re-entrant: an inner block joins the outer one
and does not commit on its own. Only the outermost exit commits. That lets
one service call another without either knowing who opened the block.
Recap¶
transaction(session)groups everything into oneCOMMIT; the counter is on the session, so all of its repositories join.commit()/flush()/rollback()on the repository keep the service away fromsession;commit()degrades to a flush inside a block.autocommit=Falsemakes a whole repository explicit.savepoint()isolates a step you intend to recover from.rollback()inside a block raisesRuntimeError, on purpose.