jfastframework
Documentación English PyPI GitHub

Transacciones

Cuándo se confirman las escrituras de una request, en qué se convierte una escritura que falla, y cómo se evita que dos requests que compiten por la misma fila ganen las dos.

from jfastframework.plugins.builtin.database import DbSession

@router.post("/invoices", status_code=201)
async def create_invoice(payload: InvoiceCreate, session: DbSession) -> InvoiceRead:
    invoice = await InvoiceRepository(session).create(**payload.model_dump())
    return InvoiceRead.model_validate(invoice, from_attributes=True)

Una request, una transacción. El repositorio hace flush; la sesión hace commit cuando el endpoint regresa; una excepción en cualquier punto deshace toda la request.


El commit ocurre antes de la respuesta

DbSession, ReadSession y TenantSession son las dependencias de sesión con scope="function" ya aplicado. El scope decide cuándo corre el commit, y el valor por defecto es la respuesta equivocada.

FastAPI corre el código que sigue al yield de una dependencia después de enviar la respuesta, salvo que la dependencia tenga scope de función. La sesión hace commit en ese código. Hasta 0.1.0a8 eso significaba:

Con scope="function" el commit corre cuando el endpoint regresa, antes de armar la respuesta: un commit fallido es el 500 que debe ser, y un 201 significa que la fila está ahí.

El plugin de base de datos se niega a arrancar mientras alguna ruta llegue a una dependencia de sesión con otro scope, y nombra cada ruta. Cualquiera de estas dos formas sirve:

async def create(session: DbSession): ...
async def create(session = Depends(session_dependency, scope="function")): ...

Una dependencia generadora propia que envuelva una sesión también tiene que tener scope de función: FastAPI rechaza una dependencia con scope de request que dependa de una con scope de función.

Una dependencia de sesión propia: @transactional

A veces un servicio abre su propia sesión -- para poner valores extra de row-level security, o para elegir una base que el framework no conoce -- y hace commit en su propia dependencia con yield. Ese commit corre después de la respuesta igual que correría el del framework, y la revisión de arriba no lo ve: solo conoce las dependencias que trae. Marca la tuya y queda sujeta a la misma regla:

from jfastframework.plugins.builtin.database import transactional

@transactional
async def session_with_companies(request: Request) -> AsyncIterator[AsyncSession]:
    async with maker() as session:
        yield session
        await session.commit()

@router.post("/invoices")
async def create(session = Depends(session_with_companies, scope="function")): ...

Una ruta que dependa de ella sin scope="function" impide que el servicio arranque, y el error nombra la ruta y la dependencia: POST /invoices -> session_with_companies.

Las dependencias sin marcar no se ignoran. Al arrancar, el plugin también lee el código de cada dependencia generadora con scope de request y nombra las rutas cuya dependencia llama .commit( después de su yield. Es una advertencia, no un rechazo -- una heurística puede equivocarse, el marcador no -- y desaparece cuando marcas la dependencia o le pones el scope.

Si la única razón de tu dependencia propia era un segundo valor para row-level security, puede que ya no la necesites: ve valores extra en cada transacción.


En qué se convierte una escritura que falla

BaseRepository hace flush después de cada escritura, y el flush es donde la base dice que no. Las excepciones del driver se vuelven el error que el cliente debe ver:

Qué pasó Error Status
Se violó una constraint única o de exclusión ConflictError 409
Una FK apunta a nada, o un borrado deja huérfano a un hijo ConflictError 409
Otra request cambió la fila desde que esta la leyó ConflictError 409
update(expected_version=n) y la fila ya pasó de n PreconditionFailedError 412

Después de cualquiera de ellos la transacción terminó -- PostgreSQL rechaza toda sentencia hasta el rollback -- y la sesión de la request hace el rollback.

Revisar-y-luego-insertar es una carrera; la constraint es la regla

if await repository.by_name(payload.name) is not None:
    raise ConflictError(...)
return await repository.create(**payload.model_dump())

Dos requests pueden pasar la revisión. Los modelos generados llevan UniqueConstraint("tenant_id", "name", postgresql_nulls_not_distinct=True), así que solo una pasa el insert y la otra recibe el mismo 409. La revisión se queda por su mensaje. NULLS NOT DISTINCT es lo que hace valer la constraint en filas sin tenant; requiere PostgreSQL 15 o posterior.


Actualizaciones perdidas: VersionedMixin

Dos personas abren la misma factura. Las dos la editan. Sin versión, quien guarda segundo borra en silencio lo del primero. Con versión:

from jfastframework.db import Base, TimestampMixin, VersionedMixin

class Invoice(Base, VersionedMixin, TimestampMixin):   # Versioned primero
    ...

Una sola columna da dos protecciones:

VersionedMixin va antes de TimestampMixin en las bases. Los dos definen __mapper_args__ y gana el primero; este lleva también la configuración del de timestamps. El otro orden perdería el versionado sin decir nada, así que lanza TypeError al importar.

Los módulos layered generados ya lo traen: el modelo está versionado, Read devuelve version y Update la acepta. Si un PATCH no la manda, se conserva el comportamiento anterior: gana la última escritura.


Valores que no caben en un solo UPDATE

Un saldo, un inventario, el siguiente folio: leer, calcular, escribir. Dos requests que leen el mismo valor escriben cada una su resultado, y uno de los dos cambios se pierde. La columna de versión lo convierte en un 409; cuando lo correcto es esperar, se bloquea.

La fila, cuando la hay:

account = await repository.get_for_update(account_id)   # SELECT ... FOR UPDATE
account.balance += amount

Una llave, cuando lo que se protege no es una fila -- "una factura abierta por cliente por mes":

from jfastframework.db import advisory_lock

await advisory_lock(session, f"open-invoice:{customer_id}:{month}")

pg_advisory_xact_lock se libera en el commit o el rollback, así que no hay unlock que olvidar ni bloqueo que sobreviva a una request caída. En SQLite, que ya permite un solo escritor a la vez, no hace nada.


Reintentar una transacción que la base abandonó

40001 (fallo de serialización) y 40P01 (deadlock) significan que la base deshizo la transacción para proteger a otra. El mismo trabajo, corrido otra vez, normalmente funciona. run_in_transaction hace exactamente eso y nada más:

from jfastframework.db import run_in_transaction

async def settle(session):
    ...

await run_in_transaction(sessionmaker, settle, attempts=3)

Para jobs y scripts. Una request ya tiene su transacción, y reintentar dentro de ella repetiría solo la parte posterior al punto de reintento. Deja fuera de la función reintentada los efectos externos a la base -- un correo, una llamada HTTP --: ocurren una vez por intento.


Un mensaje que se confirma con las filas: el outbox

Guardar un pedido y encolar su recibo son dos escrituras. Como dos transacciones, cualquiera puede ocurrir sola: el job se encola y el pedido hace rollback, o el pedido se confirma y el proceso muere antes de que exista el job. El plugin outbox las vuelve una:

from jfastframework.queues import Job

@router.post("/orders", status_code=201)
async def create(payload: OrderIn, request: Request, session: DbSession):
    order = await OrderRepository(session).create(**payload.model_dump())
    outbox = request.app.state.jfast.require("outbox")
    await outbox.enqueue(session, Job(task="send_receipt", payload={"id": order.id}))
    return order

outbox.enqueue y outbox.publish escriben a través de la sesión de la request, así que el mensaje existe si y solo si existe el pedido. Con la cola de PostgreSQL en la misma base, el job entra directo a ella; si no, un relay en cada proceso mueve los mensajes confirmados a la cola o al bus de eventos, con FOR UPDATE SKIP LOCKED para que dos relays nunca envíen el mismo mensaje. Ver Colas y eventos para la mitad del consumidor.

Un POST reintentado: idempotency keys

Un cliente cuya conexión se cae después de enviar POST /payments no sabe si llegó, así que reintenta -- y sin ayuda el servidor cobra dos veces. Con el plugin idempotency, una ruta que pide la llave la registra en la misma transacción que el pago:

from jfastframework.idempotency import IdempotencyKey

@router.post("/payments", status_code=201)
async def pay(payload: PaymentIn, session: DbSession, key: IdempotencyKey): ...
El cliente manda Idempotency-Key: k y Recibe
k es nueva La request corre; su respuesta se registra
la misma request otra vez La respuesta registrada, con Idempotent-Replayed: true
otra request distinta con k 422: una llave nombra una sola operación
la misma request mientras la primera sigue corriendo 409

Si la primera request falla, la llave hace rollback con ella y el reintento corre desde cero. Las llaves son por tenant y vencen tras ttl_hours (24). RequiredIdempotencyKey rechaza una request que no la trae.


Lo que esto no es

No es un circuit breaker. Un breaker deja de llamar a algo que está caído. No hace nada por un trabajo que falló a la mitad; eso lo hacen la frontera de la transacción, el outbox y la idempotency key. El cliente entre servicios con timeouts, reintentos y breaker está en PLAN-NEXT.md.