Transações (commit e savepoint)¶
Todo método de escrita do BaseRepository faz COMMIT sozinho. Para uma
escrita só isso é o certo — e para uma regra de negócio com duas escritas
é exatamente o problema:
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:
"""Grava pedido e itens sem atomicidade — o problema, não a solução.
Args:
orders (BaseRepository[OrderModel]): Repositório de pedidos.
items (BaseRepository[OrderItemModel]): Repositório de itens.
order (OrderModel): O pedido a gravar.
rows (list[OrderItemModel]): Os itens do pedido.
"""
await orders.add(order) # já está no banco
await items.add_all(rows) # se isto falhar, o pedido ficou órfão
Esta página mostra como fechar esse buraco.
O bloco transaction()¶
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:
"""Grava pedido e itens de forma atômica.
Args:
session (AsyncSession): A sessão compartilhada pelos repositórios.
orders (BaseRepository[OrderModel]): Repositório de pedidos.
items (BaseRepository[OrderItemModel]): Repositório de itens.
order (OrderModel): O pedido a gravar.
rows (list[OrderItemModel]): Os itens do pedido.
Returns:
OrderModel: O pedido gravado.
"""
async with transaction(session):
await orders.add(order)
await items.add_all(rows)
return order
Dentro do bloco, cada escrita faz flush em vez de COMMIT. Saída limpa
resulta em um COMMIT; qualquer exceção resulta em um ROLLBACK
e a exceção continua subindo.
O contador vive na sessão, não no repositório
Repare que items também respeita o bloco, mesmo sem ninguém ter
chamado nada nele. O controle é guardado em session.info, então
todo repositório ligado à mesma AsyncSession participa do mesmo
bloco. É isso que faz um serviço orquestrando vários repositórios
funcionar sem passar contexto de um para o outro.
O repositório também expõe o bloco como açúcar:
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:
"""Abre o bloco pelo repositório em vez de pela sessão.
Args:
orders (BaseRepository[OrderModel]): Repositório de pedidos.
items (BaseRepository[OrderItemModel]): Repositório de itens.
order (OrderModel): O pedido a gravar.
rows (list[OrderItemModel]): Os itens do pedido.
"""
async with orders.transaction():
await orders.add(order)
await items.add_all(rows) # mesma sessão, mesmo bloco
commit() explícito¶
Às vezes o ponto durável é uma decisão da regra de negócio, e chamar
update() só pelo efeito colateral do commit é desonesto. Use
commit():
from src.db.models import OrderModel
from tempest_fastapi_sdk import BaseRepository
async def persist(
orders: BaseRepository[OrderModel],
order: OrderModel,
) -> None:
"""Declara o ponto durável em vez de deduzi-lo de um `update`.
Args:
orders (BaseRepository[OrderModel]): Repositório de pedidos.
order (OrderModel): O pedido a gravar.
"""
await orders.add(order)
await orders.commit()
commit() dentro de um bloco transaction() aberto faz flush em vez de
commitar, porque commitar ali quebraria a garantia de tudo-ou-nada do
bloco. Isso é o que torna a chamada segura de deixar no lugar quando
alguém, depois, envolver esse código num bloco — um session.commit()
cru não tem essa propriedade.
Também existem flush() (torna a linha visível para as próximas queries
da mesma transação, sem commitar) e rollback().
rollback() dentro de um bloco é recusado
Um rollback() ali descartaria o bloco inteiro — inclusive escritas
de outros repositórios da mesma sessão — enquanto quem chamou acredita
estar desfazendo só o próprio passo. O método levanta RuntimeError
explicando isso. Para abortar o bloco, deixe a exceção subir; para um
passo do qual você pretende se recuperar, use savepoint().
Repositório sempre explícito: autocommit=False¶
Quando um repositório inteiro pertence a uma unidade de trabalho do chamador:
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:
"""Constrói um repositório cujo commit é sempre do chamador.
Args:
session (AsyncSession): A sessão a usar.
order (OrderModel): O pedido a gravar.
"""
repository: BaseRepository[OrderModel] = BaseRepository(
session, model=OrderModel, autocommit=False
)
await repository.add(order) # só flush
await repository.commit() # você decide quando
O flag desliga apenas o commit implícito de dentro dos métodos de
escrita. Um commit() explícito continua commitando.
savepoint(): falhar sem perder o resto¶
Um SAVEPOINT de verdade. A falha reverte só o trecho aninhado e a
transação em volta continua utilizável:
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:
"""Abre a conta mesmo quando o apelido já está em uso.
Args:
session (AsyncSession): A sessão compartilhada.
accounts (BaseRepository[AccountModel]): Repositório de contas.
nicknames (BaseRepository[NicknameModel]): Repositório de apelidos.
audit (BaseRepository[AuditEntryModel]): Repositório de auditoria.
account (AccountModel): A conta a criar.
nickname (NicknameModel): O apelido pretendido.
entry (AuditEntryModel): O registro de auditoria.
"""
async with transaction(session):
await accounts.add(account)
try:
async with savepoint(session):
await nicknames.add(nickname)
except ConflictException:
pass # a conta sobrevive; só o apelido foi revertido
await audit.add(entry)
Sem o savepoint, o ConflictException capturado deixaria a sessão em
estado inválido — o SQLAlchemy exige um rollback depois de um flush que
falhou.
SQLite precisa de uma configuração, e o SDK já aplica
O driver pysqlite sob o aiosqlite abre transações implicitamente e
não emite BEGIN. O SQLite então enxerga o SAVEPOINT como a
transação mais externa e o RELEASE SAVEPOINT correspondente vira um
commit — um bloco aninhado que sai sem erro fica durável mesmo que
o bloco externo seja revertido depois. O
AsyncDatabaseManager aplica o remédio documentado do SQLAlchemy em
toda engine SQLite que cria. Se você monta a engine na mão, chame
enable_sqlite_savepoints(engine).
Aninhamento¶
Blocos transaction() são reentrantes: o interno entra no externo e não
commita sozinho. Só a saída do mais externo commita. Isso deixa um
serviço chamar outro sem nenhum dos dois saber quem abriu o bloco.
Recapitulando¶
transaction(session)agrupa tudo em umCOMMIT; o contador fica na sessão, então todos os repositórios dela participam.commit()/flush()/rollback()no repositório evitam que o serviço toque emsession;commit()viraflushdentro de um bloco.autocommit=Falsedeixa um repositório inteiro explícito.savepoint()isola um passo do qual você quer se recuperar.rollback()dentro de um bloco levantaRuntimeErrorde propósito.