Transactional outbox (reliable events)¶
When a handler writes a row and publishes an event, doing the two as independent operations is fragile: if the process dies after the commit but before the publish, the event is lost; if it dies after the publish but before the commit, a phantom event references a row that never existed. This is the dual-write problem.
The outbox pattern fixes it: write the business row and an
outbox row in the same transaction. Either both commit or neither
does. A separate relay reads the pending rows and publishes them to the
broker, marking each one sent. The broker can be down for minutes — the
events wait, durably, in the table.
Where this fits
It complements the AsyncBrokerManager: the broker
publishes, the outbox guarantees the event exists to be published.
The relay takes any publish callable — so it works with FastStream, a
webhook, whatever.
1. The outbox table¶
BaseOutboxModel is abstract — the project creates the concrete table
(just like BaseUserModel):
from tempest_fastapi_sdk import BaseOutboxModel
class OutboxModel(BaseOutboxModel):
"""The service's pending-events table."""
__tablename__ = "outbox"
It ships topic, payload (JSON), status, attempts, max_attempts,
available_at, sent_at and last_error — on top of BaseModel's four
canonical columns (id / is_active / created_at / updated_at).
Generate the migration with the AlembicHelper like any
other table.
2. Write atomically¶
In the service/repository, use save_with_outbox instead of add: it
inserts the business model and the event in one transaction.
from tempest_fastapi_sdk import BaseRepository
from src.db.models import OrderModel, OutboxModel
async def place_order(repo: BaseRepository[OrderModel], data: dict[str, object]) -> OrderModel:
"""Create the order and queue the event in the same transaction."""
order = OrderModel(**data)
event = OutboxModel.new_event("orders.created", {"order": data})
return await repo.save_with_outbox(order, event)
If the commit fails (e.g. a unique constraint), both rows are rolled
back — no orphan event is ever left behind.
3. Drain and publish (the relay)¶
OutboxRelay reads pending rows and calls your publish callable. It does
not import any specific broker — you pass the function:
import asyncio
from tempest_fastapi_sdk import AsyncDatabaseManager, BaseOutboxModel, OutboxRelay
from tempest_fastapi_sdk.queue import MessageBroker
from src.db.models import OutboxModel
async def run_relay(db: AsyncDatabaseManager, broker: MessageBroker) -> None:
"""Publish pending events continuously."""
async def publish(event: BaseOutboxModel) -> None:
"""Forward one event to the broker."""
await broker.publish(event.topic, event.payload)
relay: OutboxRelay = OutboxRelay(db, model=OutboxModel, publish=publish)
await relay.run(poll_interval=1.0) # loops until the task is cancelled
Run the relay as a separate process/worker (or a task in the lifespan).
Each published event becomes status="sent" with sent_at set.
The relay calls publish(event) with one argument
MessageBroker.publish is publish(channel, message), so it does not
go into OutboxRelay raw — always through the publish(event)
function above. Passing publish=mq.publish directly is refused at
construction:
TypeError: publish=MessageBroker.publish(channel: 'str | QueueSpec', message: 'Any', **options: 'Any') -> 'Any' cannot be called as publish(event): missing a required argument: 'message'. OutboxRelay calls publish(event) with ONE argument, the outbox row. ...
Before that check the relay accepted it, and every call's TypeError
fell into the retry path: each event ended failed without ever being
published.
Failures and retry¶
If publish raises, the relay does not mark the event sent: it
increments attempts, records the error in last_error, and reschedules
the event with exponential backoff (available_at in the future). Once
attempts reaches max_attempts, the row becomes status="failed" and
stays in the table for manual inspection (never auto-retried again).
Multiple workers
On PostgreSQL/MySQL the relay locks the batch with FOR UPDATE SKIP
LOCKED, so several relay workers never take the same row at the
same time. On SQLite (no row locks) it falls back to a plain SELECT —
use a single worker.
Delivery is at-least-once: the consumer must be idempotent
The relay publishes each event of the batch and only writes
status="sent" in the commit at the end of the batch. If the worker
dies in between — a deploy, an OOM, a CancelledError — the broker
already has the event and the row is back to pending; the next drain
publishes it again. Measured in
tests/db/test_outbox.py::TestAtLeastOnce: a drain cancelled on the
second of two events leaves both pending, and the following drain
publishes a again (["a", "b", "a", "b"]). The outbox guarantees
the event is not lost, not that it arrives once. Deduplicate in the
consumer by event.id.
Drain once (tests / cron)¶
For loop-free scenarios (a test, a cron job), call drain_once(), which
returns how many events were published:
import asyncio
from tempest_fastapi_sdk import BaseOutboxModel, OutboxRelay
from src.api.dependencies.resources import db
from src.db.models import OutboxModel
from src.queue import mq
async def publish(event: BaseOutboxModel) -> None:
"""Adapt the broker's `publish(channel, message)` to the relay's `publish(event)`."""
await mq.publish(event.topic, event.payload)
relay = OutboxRelay(db, model=OutboxModel, publish=publish)
async def main() -> None:
"""Run this example."""
published: int = await relay.drain_once()
asyncio.run(main())
Recap¶
BaseOutboxModel→ concreteOutboxModel(__tablename__="outbox").repo.save_with_outbox(model, event)writes business + event atomically.OutboxRelay(db, model=..., publish=...).run()publishes pending rows, with retry/backoff andsent/failedmarking;publishtakes one argument, the event.- Delivery is at-least-once: the consumer deduplicates by
event.id. OutboxModel.new_event(topic, payload)builds the event;drain_once()drains one batch for tests/cron.