Outbox
The outbox module runs an async handler exactly after your database transaction commits, at least once. Use it to turn a side effect into a durable part of your write: send an email, call an external API, or publish an event without ever losing it and without ever running it for a transaction that rolled back.
This is the transactional outbox pattern. It removes the dual-write problem: the moment you write to the database and "also send an email" as two separate steps, a crash between them either loses the email or sends it for a change that never committed. Staging the message in the same transaction makes that impossible.
- publish: stage a message inside your own transaction, so the business row and the message commit together or not at all.
- @handler: register an async function that the relay runs for each staged message.
- relay: a background worker, started with the app, that delivers staged messages with retries and dead-lettering.
Quick start
Define a payload, stage it inside your transaction, and register a handler. The relay does the rest:
from pydantic import BaseModel, EmailStr
from grelmicro import Grelmicro
from grelmicro.outbox import Message, Outbox
from grelmicro.providers.postgres import PostgresProvider
postgres = PostgresProvider("postgresql://localhost:5432/app")
outbox = Outbox(postgres)
micro = Grelmicro(uses=[outbox])
class WelcomeEmail(BaseModel):
to: EmailStr
user_id: int
@outbox.handler(WelcomeEmail)
async def send_welcome(message: Message[WelcomeEmail]) -> None:
await mailer.send(to=message.data.to, idempotency_key=message.id)
async with postgres.client.acquire() as conn, conn.transaction():
user_id = await conn.fetchval(
"INSERT INTO users (email) VALUES ($1) RETURNING id", email
)
await outbox.publish(conn, WelcomeEmail(to=email, user_id=user_id))
One COMMIT makes the user row and the message durable together. async with micro: starts the relay, which picks up the message and calls send_welcome. If the email API is down, the relay retries with backoff. If it stays down, the message lands in the dead-letter state where you can inspect and redrive it.
Backend
The outbox is technology-agnostic and delegates storage to a backend. Wire the backend into a Grelmicro app via the Outbox component. Pass a provider directly to Outbox(...).
Install
The Postgres backend needs the postgres extra: pip install "grelmicro[postgres]". See the installation guide for uv and poetry.
from grelmicro import Grelmicro
from grelmicro.outbox import Outbox
from grelmicro.providers.postgres import PostgresProvider
postgres = PostgresProvider("postgresql://localhost:5432/app")
micro = Grelmicro(uses=[Outbox(postgres)])
async with micro: opens the provider, creates the table, and starts the relay together.
| Postgres | SQLite (planned) | MySQL (planned) | |
|---|---|---|---|
| Use case | Production | Single-host with restart durability | Production (when MySQL is already deployed) |
| Multi-node relay | Yes | No (single file) | Yes |
| Claim | FOR UPDATE SKIP LOCKED |
single-writer | FOR UPDATE SKIP LOCKED |
The outbox is built backend-first. Adding SQLite or MySQL later is one adapter file plus a provider.outbox() factory, the same shape the cache and coordination components already use for their backends. The producer and consumer API never changes when you switch backends.
The Postgres adapter stores messages in a single grelmicro_outbox table. The relay claims a batch with UPDATE ... WHERE id IN (SELECT ... FOR UPDATE SKIP LOCKED), so every replica claims a disjoint set with no leader and no coordination. The table is created on first connect: pass auto_migrate=False when your own migration tool owns the schema, and run the DDL yourself (see Managing the schema with Alembic).
Producer
publish stages a message. It takes the connection or session you are already writing on, so the message joins your open transaction. It never opens or commits a transaction of its own. That is the whole guarantee: your write and your message share one commit.
Typed payloads
Pass a Pydantic model. The topic is derived from the model name and the payload is validated at publish time:
class OrderPlaced(BaseModel):
order_id: int
total: Decimal
await outbox.publish(conn, OrderPlaced(order_id=42, total=Decimal("9.99")))
The handler receives the validated model back as message.data:
@outbox.handler(OrderPlaced)
async def on_order(message: Message[OrderPlaced]) -> None:
await fulfillment.start(message.data.order_id)
For a quick call or a message with no model, pass a topic string and a dict instead. The handler reads message.payload:
await outbox.publish(conn, "email.welcome", {"to": email})
@outbox.handler("email.welcome")
async def send_welcome(message: Message) -> None:
await mailer.send(to=message.payload["to"], idempotency_key=message.id)
The transaction rule
publish must receive a handle that is already inside an open transaction. This is enforced, because a handle with no transaction would commit the message on its own and quietly break the atomicity that is the point of the pattern:
- An asyncpg connection must be inside
conn.transaction(). - A SQLAlchemy
AsyncSessionmust be insidesession.begin(). The message is inserted immediately into the session's transaction, so it commits with the unit of work and never depends on flush ordering. SQLModel'sAsyncSessionis a subclass, so it works the same way. - Passing a pool or an engine raises. A pool hands out a different connection, so the message would land in a separate transaction.
SQLModel and SQLAlchemy
Add the message to the same session.begin() block as your ORM writes. They commit together:
from sqlmodel import Field, SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
class Hero(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
async def create_hero(session: AsyncSession, name: str) -> None:
async with session.begin():
session.add(Hero(name=name))
await outbox.publish(session, "hero.created", {"name": name})
The Hero row and the hero.created message share one commit. A rollback drops both. This is identical with a plain SQLAlchemy AsyncSession, since SQLModel's session subclasses it.
FastAPI
Use a per-request session or connection dependency and hand it to publish. The message joins the request's transaction with no extra wiring:
from sqlmodel.ext.asyncio.session import AsyncSession
async def get_session() -> AsyncIterator[AsyncSession]:
async with AsyncSession(engine) as session, session.begin():
yield session
@app.post("/signup")
async def signup(
body: SignUp, session: AsyncSession = Depends(get_session)
) -> None:
session.add(User(email=body.email))
await outbox.publish(session, WelcomeEmail(to=body.email))
async def get_conn() -> AsyncIterator[asyncpg.Connection]:
async with postgres.client.acquire() as conn, conn.transaction():
yield conn
@app.post("/signup")
async def signup(
body: SignUp, conn: asyncpg.Connection = Depends(get_conn)
) -> None:
user_id = await conn.fetchval(
"INSERT INTO users (email) VALUES ($1) RETURNING id", body.email
)
await outbox.publish(conn, WelcomeEmail(to=body.email, user_id=user_id))
The session dependency opens one transaction per request, so the User row and the WelcomeEmail message commit together or roll back together.
Resolving the outbox
A producer that only publishes does not need to hold the constructed instance or a config-bound module singleton. Outbox.current() returns the app-registered outbox, so a request handler in another module publishes without importing it:
from grelmicro.outbox import Outbox
@app.post("/signup")
async def signup(body: SignUp, session: AsyncSession = Depends(get_session)) -> None:
session.add(User(email=body.email))
await Outbox.current().publish(session, WelcomeEmail(to=body.email))
Outbox.current(name=...) selects a named instance. It resolves inside async with micro: or after micro.install(app), and raises OutOfContextError otherwise. Handlers still register on the instance at wiring time.
Delay and deduplication
delay holds a message back until a future time. dedup_key drops a duplicate before it is stored, using an insert that does nothing on conflict, so a producer retry is safe and never raises:
await outbox.publish(conn, ReminderDue(...), delay=timedelta(hours=1))
await outbox.publish(conn, OrderPlaced(...), dedup_key=f"order:{order_id}")
When delivered messages are deleted (the default), the deduplication window lasts only until delivery. Keep delivered messages with keep_delivered=True to extend it, or keep_delivered=timedelta(days=30) to extend it for a fixed window. A retention window caps it: once a delivered row is purged its dedup_key frees up.
Consumer
A handler is an async function bound to a payload model or a topic string. Register as many as you like:
@outbox.handler(WelcomeEmail)
async def send_welcome(message: Message[WelcomeEmail]) -> None:
await mailer.send(to=message.data.to, idempotency_key=message.id)
Message carries everything the relay knows:
| field | meaning |
|---|---|
id |
stable message id, use it as the idempotency key for the side effect |
topic |
routing topic |
key |
ordering or partition key, None by default |
data |
the validated payload model, for typed handlers |
payload |
the raw payload dict, for topic handlers |
headers |
metadata and trace context |
attempts |
how many times delivery has been tried, starting at 1 |
Controlling retries from the handler
Any exception retries the message with backoff. Raise Retry to reschedule on your own terms, or Cancel to dead-letter it now without burning the remaining attempts:
from grelmicro.outbox import Cancel, Retry
@outbox.handler(ChargeCard)
async def charge(message: Message[ChargeCard]) -> None:
result = await payments.charge(message.data, idempotency_key=message.id)
if result.rate_limited:
raise Retry(delay=timedelta(seconds=30))
if result.card_declined:
raise Cancel(reason="card declined")
Relay
The relay is a background worker started with async with micro:. It is resource-efficient and asyncio-native.
- Every replica runs a relay. They claim disjoint batches with
FOR UPDATE SKIP LOCKED, so the outbox scales out with your app and needs no leader election. - A relay claims only topics it has a handler for. During a rolling deploy an old replica leaves a new topic alone instead of dead-lettering it, and the message waits for a replica that knows it. A message whose topic has no handler anywhere stays pending until one is registered.
- A single dedicated connection listens for
NOTIFY.publishsends a wake inside your transaction, so the relay reacts within milliseconds of a commit. Polling stays the source of truth on a short interval, because a notification is lost if the listener reconnects and does not survive a connection pooler in transaction mode. - Handlers run outside any database transaction. The relay claims a batch, commits, releases the connection, then runs the handlers. No connection is held across a handler's network call.
- A lease makes a crash self-healing. A claimed message is invisible until its lease expires. If a relay dies mid-handler, the lease lapses and another relay reclaims the message. Keep handlers under
lease_duration(30 seconds by default). - Concurrency is bounded. The relay claims only as many messages as it has free handler slots, so a lease covers real work and never expires while a message waits in a local queue.
Connection poolers
LISTEN/NOTIFY does not work through PgBouncer in transaction pooling mode. Set notify=False there and rely on polling. Lower poll_interval if you want tighter latency.
Bounded failure
Build the PostgresProvider with command_timeout so a frozen or unreachable Postgres surfaces as a TimeoutError in bounded time. publish then fails loudly and your business transaction rolls back, instead of hanging until the OS TCP timeout. See Providers.
For the design rationale behind the claim protocol, the lease, and the delivery guarantees, see Outbox Internals.
Scaling the relay
Every replica runs a relay by default, and FOR UPDATE SKIP LOCKED keeps that safe at any number: relays claim disjoint messages, never the same one, so the count only affects resources, never correctness.
The cost of each relay is one dedicated LISTEN connection plus a share of the wake-ups. At a high replica count those connections add up against max_connections, and many relays waking on the same NOTIFY race to claim and mostly find nothing.
Run the relay only where you want it with relay=False. The common shape is many web replicas that publish and a small worker deployment that relays:
# web pods: publish only, no relay
micro = Grelmicro(uses=[Outbox(postgres, relay=False)])
# worker pods: run the relay
micro = Grelmicro(uses=[Outbox(postgres, relay=True)])
relay=True is the default, so a single deployment works out of the box. concurrency bounds the handlers running at once inside each relay.
Ordering
The outbox does not guarantee ordering in this version. Messages are delivered at least once and concurrently, so two messages, even with the same key, may be delivered out of order, most visibly when the first one is retried after a failure. Design handlers to tolerate reordering. Strict per-key ordering is a planned feature with explicit head-of-line semantics.
Delivery semantics
Delivery is at least once. The relay runs the handler, then marks the message done. A crash in between runs the handler again. Handlers must therefore be idempotent, and every message carries a stable id to use as the idempotency key.
To make the side effect itself exactly-once, pass message.id as the idempotency key to an external API that accepts one, or wrap the handler with the idempotency primitive keyed on message.id. The idempotency store rides the cache backend, so add a Cache component alongside the outbox:
from grelmicro.idempotency import Idempotency, idempotent
charges = Idempotency("charges")
@outbox.handler(ChargeCard)
@idempotent(charges, key=lambda message: message.id)
async def charge(message: Message[ChargeCard]) -> None:
await payments.charge(message.data)
Retries and dead-letter
A failed delivery is retried with capped exponential backoff and full jitter. After max_attempts the message moves to the dead state with its last error recorded. It stops blocking the queue and is left for you to inspect. Redrive dead messages back to pending once the cause is fixed:
await outbox.redrive(topic="email.welcome")
Alert on the oldest pending age and on any message entering the dead state.
Retention and cleanup
Delivered rows are deleted on success by default (keep_delivered=False), so the table stays small on its own.
Set keep_delivered to a timedelta to keep delivered rows for a window and let the relay purge them once they age out:
from datetime import timedelta
micro = Grelmicro(uses=[Outbox(postgres, keep_delivered=timedelta(days=30))])
The window is measured from delivery time, not publish time, so a delayed or heavily retried message still gets its full retention. The relay purges expired delivered rows in the background, so retention needs no scheduled job. The purge runs on the relay, so a relay=False replica never purges. Set keep_delivered=True to keep delivered rows for good with no auto-purge.
Auto-purge only removes delivered rows. A dead-letter is a failure to inspect and redrive, so dead rows are never deleted automatically. Trim them yourself with purge, which deletes both delivered and dead rows, optionally only those past a window:
from datetime import timedelta
await outbox.purge() # all delivered and dead rows
await outbox.purge(older_than=timedelta(days=7)) # only those older than 7 days
Pending and in-flight messages are never touched. Run purge from a scheduled task when you keep delivered rows for good and still want dead rows trimmed.
Observability
With the trace component configured, publish writes the current trace context into the message headers, and the relay opens a consumer span for each delivery parented on it. A request that stages a message links to the delivery that runs later, even across replicas. The span follows the messaging semantic conventions (messaging.system, messaging.destination.name, messaging.operation, messaging.message.id).
With the metrics component configured, the relay emits:
| Metric | Type | Meaning |
|---|---|---|
grelmicro.outbox.published |
counter | messages staged by publish |
grelmicro.outbox.delivered |
counter | successful deliveries |
grelmicro.outbox.retried |
counter | deliveries rescheduled after a failure |
grelmicro.outbox.dead_lettered |
counter | messages moved to the dead state |
grelmicro.outbox.handler_duration |
histogram | handler run time in seconds |
Each carries a topic attribute. Both integrations are no-ops when the components are absent, so there is no cost when you do not use them.
The relay also logs each retry at warning level and each dead-letter at error level, with the message id, topic, attempt count, and last error. Alert on the dead-lettered count and on any message entering the dead state.
You can also set your own headers on publish and read them in the handler for correlation ids or routing metadata.
Testing
The MemoryOutboxAdapter runs the whole outbox in the process with no database. It needs no transaction, so publish takes None as the handle. Use it in tests and single-process apps:
from grelmicro.outbox import Message, Outbox
from grelmicro.outbox.memory import MemoryOutboxAdapter
outbox = Outbox(MemoryOutboxAdapter())
@outbox.handler("email.welcome")
async def send_welcome(message: Message) -> None:
print(f"welcome {message.payload['to']}")
async def main() -> None:
# The in-memory backend needs no transaction, so the handle is None.
async with outbox:
await outbox.publish(None, "email.welcome", {"to": "alice@example.com"})
Messages live in the process and are lost on restart, and each process keeps its own, so the memory backend does not share an outbox across nodes.
Schema
The Postgres adapter uses one table. Column names follow the common outbox convention, so change-data-capture tooling can read it directly:
| column | type | purpose |
|---|---|---|
id |
uuid primary key |
stable message id and idempotency key, time-ordered UUIDv7 |
topic |
text |
routes to the handler |
key |
text null |
ordering or partition key |
payload |
jsonb |
the message body |
headers |
jsonb |
metadata and trace context |
dedup_key |
text null |
producer-side deduplication |
attempts |
int |
delivery attempt counter |
available_at |
timestamptz |
when the message is next actionable, for delay, retry, and lease |
state |
text |
pending, processing, delivered, or dead |
last_error |
text null |
the last handler error, for dead messages |
delivered_at |
timestamptz null |
delivery time, set on success, anchors the retention window |
created_at |
timestamptz |
staged time |
Ids are time-ordered UUIDv7, so the claim orders by (available_at, id) for stable, index-friendly delivery. A partial index on available_at for non-terminal rows serves the claim query, and a unique partial index on dedup_key (where it is set) backs deduplication. The table is created on first connect unless auto_migrate=False, guarded so replicas booting together do not race the DDL.
Managing the schema with Alembic
Set auto_migrate=False and run the DDL from your own migration. PostgresOutboxAdapter returns the exact statements the outbox runs, so your migration never drifts from the library:
from grelmicro.outbox.postgres import PostgresOutboxAdapter
def upgrade() -> None:
op.execute(PostgresOutboxAdapter.create_table_sql())
def downgrade() -> None:
op.execute(PostgresOutboxAdapter.drop_table_sql())
Pass a table name to either method to match a custom table. If you prefer autogenerate, model the table from this DDL in your own metadata.
Configuration
OutboxConfig is a plain Pydantic model. Component defaults read from the environment under GREL_OUTBOX_ unless you set fields directly.
| field | default | description |
|---|---|---|
table |
grelmicro_outbox |
table name |
relay |
True |
run the background relay on this replica |
poll_interval |
1.5 |
seconds between fallback polls |
batch_size |
100 |
claim ceiling per cycle, capped by free handler slots |
lease_duration |
30 |
seconds a claimed message stays invisible |
max_attempts |
10 |
attempts before dead-lettering |
retry_base |
1 |
base backoff in seconds |
retry_max |
300 |
maximum backoff in seconds |
retry_jitter |
1 |
jitter fraction applied to backoff |
concurrency |
50 |
maximum handlers running at once |
dead_letter |
True |
move exhausted messages to the dead state |
keep_delivered |
False |
keep delivered rows instead of deleting them, or a timedelta to keep and auto-purge them after that window |
auto_migrate |
True |
create the table on first connect |
notify |
True |
use LISTEN/NOTIFY for low-latency wakeups |