txoutbox
Storage-agnostic Transactional Outbox relay for asyncio. Bring your own table, bring your own broker.
Zero dependencies. Fully typed. Python 3.11+.
The problem
Your service writes to the database and must tell the world about it: Kafka, NSQ, RabbitMQ, a webhook. Publishing straight from request code is unreliable. If the broker is down after commit, the event is lost. If you publish before commit and the transaction rolls back, you announced something that never happened.
The Transactional Outbox pattern fixes that: write the event to an outbox table in the same transaction
as your business data, and let a separate relay process deliver it. The idea is simple. The relay is not.
Every team writes one, and every team hits the same bugs: two workers grab the same row, a crashed worker
holds a row forever, events for one order get reordered, an empty table gets polled every 10 ms, retries
hammer a broker that is already down, and SIGTERM kills the process mid-batch.
Existing libraries solve this bundled with infrastructure: their table, their ORM, their broker, sometimes their whole framework. If your stack is Django ORM + NSQ, raw asyncpg + webhooks, or an outbox table you are not allowed to change, you are back to writing your own relay.
What txoutbox does
Only the relay. You give it a storage (five methods over your table) and a publisher (one function), and it handles the hard parts:
| Concern | How |
|---|---|
| Concurrent workers | Leases: claim() hands each row to one worker for a limited time. Every write back is fenced by worker_id, so a worker whose lease expired cannot touch a row someone else re-claimed |
| Crashed workers | Leases expire, the row is claimed again, the attempt is counted |
| Ordering | Messages sharing a key are published sequentially, in insertion order; different keys run in parallel. A failure holds back the rest of its key without burning their attempts |
| Retries | Exponential backoff with jitter, per message, scheduled in storage |
| Poison messages | Dead-lettered after max_attempts, followers of a dead head carry on |
| Idle polling | Adaptive: sleep grows from 50 ms to 5 s while the table is empty, resets on work; wake() for LISTEN/NOTIFY |
| Broker hangs | Per-message publish_timeout (5 s by default). The defaults keep the worst-case round (25 s) under the lease (60 s), and RelayConfig warns when yours do not |
| Shutdown | First SIGTERM/SIGINT finishes the round in flight, second one cancels it and returns quietly. Published messages are acked even on cancel |
| Observability | Hooks for every outcome, stats() with queue depth and oldest-pending age for alerting |
Install
pip install "txoutbox[postgres]" # asyncpg adapter
pip install txoutbox # core only, for your own adapter
Quick start (Postgres)
import asyncio
import asyncpg
from txoutbox import Relay, OutboxMessage
from txoutbox.adapters.postgres import PostgresStorage
async def main() -> None:
pool = await asyncpg.create_pool("postgresql://app@localhost/app")
outbox = PostgresStorage(pool)
await outbox.create_schema() # or paste outbox.schema_sql() into your migrations
# Producer side: the business row and the event commit together.
async with pool.acquire() as conn, conn.transaction():
order_id = await conn.fetchval("INSERT INTO orders (total) VALUES ($1) RETURNING id", 42)
await outbox.insert(conn, "orders.created", {"order_id": order_id}, key=f"order-{order_id}")
# Relay side: the publisher is one function. Kafka, NSQ, HTTP, whatever you use.
async def publish(message: OutboxMessage) -> None:
print(message.topic, message.key, message.json())
await Relay(outbox, publish).run(handle_signals=True)
asyncio.run(main())
Run the relay in as many processes as you like. handle_signals=True is for a process the relay owns:
it takes over SIGINT/SIGTERM on the event loop, and handlers a web server registered there cannot be put
back. Inside a web app use async with Relay(outbox, publish): ... or call relay.stop() from the
framework's shutdown hook. From a cron job, run one round at a time with await relay.run_once().
Not polling: LISTEN/NOTIFY
outbox = PostgresStorage(pool, notify_channel="outbox") # schema_sql() now includes the trigger
relay = Relay(outbox, publish)
listener = await outbox.listen(relay.wake) # new row -> relay wakes up immediately
The listener reconnects by itself if the connection drops; in the gap the relay falls back to polling,
so nothing is lost. With visibility_delay set, the wake-up is deferred by that delay.
Kafka in three lines
from aiokafka import AIOKafkaProducer
async def main() -> None:
producer = AIOKafkaProducer(bootstrap_servers="kafka:9092")
await producer.start()
async def publish(message: OutboxMessage) -> None:
await producer.send_and_wait(
message.topic, message.payload, key=message.key.encode() if message.key else None
)
await Relay(outbox, publish).run(handle_signals=True)
Your own storage
PostgresStorage is a convenience. The point of txoutbox is that the relay does not care where the rows live.
A storage is four fenced write-backs plus claim:
class Storage(Protocol):
async def claim(self, *, batch_size: int, lease: timedelta, worker_id: str) -> Sequence[OutboxMessage]: ...
async def ack(self, ids: Sequence[MessageId], *, worker_id: str) -> None: ...
async def nack(self, message_id: MessageId, *, worker_id: str, error: str, retry_at: datetime) -> None: ...
async def release(self, ids: Sequence[MessageId], *, worker_id: str, retry_at: datetime) -> None: ...
async def dead_letter(self, message_id: MessageId, *, worker_id: str, error: str) -> None: ...
The rules:
claimreturns due, unleased, pending rows in insertion order, leases them forleaseon behalf ofworker_id, and incrementsattemptsat claim time. A crash mid-batch must burn an attempt.- Every other method only touches rows currently leased by
worker_id. Ignore the rest. ackmarks rows delivered.nackrecords the error and schedulesretry_at.releasegives rows back withattempts - 1and no error: the relay never tried them.dead_letterparks the row.- Optional but recommended, strict ordering: hold back a row while an earlier pending row with the same
key is not claimable right now. The built-in adapters do this with one
NOT EXISTSclause and, on Postgres, a transaction-scoped advisory lock so concurrent workers see committed state.
Then let the contract tests find what you got wrong. They cover lease expiry, fencing, concurrent claims, retry scheduling and ordering under a race:
import pytest
from txoutbox.testing import StorageContract
class TestMyStorage(StorageContract):
@pytest.fixture
async def storage(self):
return MyStorage(...) # fresh, empty, per test
async def insert(self, storage, topic, payload, key=None):
return await storage.add(topic, payload, key=key)
A complete Django ORM storage is in examples/django_outbox/: a model, the five methods with
sync_to_async, and select_for_update(skip_locked=True).
Built-in adapters
| Adapter | Module | Notes |
|---|---|---|
PostgresStorage |
txoutbox.adapters.postgres |
asyncpg, FOR UPDATE SKIP LOCKED, advisory lock for strict ordering, LISTEN/NOTIFY, visibility_delay, purge(), stats() |
SqliteStorage |
txoutbox.adapters.sqlite |
Stdlib sqlite3. Tests, tooling, single-process apps |
MemoryStorage, MemoryPublisher |
txoutbox.adapters.memory |
Deterministic in-process doubles for your test suite |
SQL adapters expose insert(conn, ...) so the outbox row goes into your transaction, schema_sql() for
your migration tool, stats() for dashboards, and purge(older_than) to keep the table small.
Configuration
from datetime import timedelta
from txoutbox import Backoff, RelayConfig
RelayConfig(
batch_size=50, # rows per round
lease=timedelta(seconds=60), # must exceed ceil(batch_size / concurrency) * publish_timeout
concurrency=10, # ordering groups published in parallel
max_attempts=10, # then dead-letter
backoff=Backoff(base=timedelta(seconds=1), factor=2, maximum=timedelta(minutes=5), jitter=0.25),
publish_timeout=5.0, # seconds per publish, None = no limit
poll_min=0.05, poll_max=5.0, poll_factor=2.0,
storage_error_delay=1.0, # sleep after a storage exception
)
Delivery semantics
- At-least-once. A crash between
publishandackredelivers the batch after the lease expires. Consumers must be idempotent; a message'sidis a natural deduplication key. - Ordered per key. Messages sharing a key are published one after another. A failure holds back the rest of that key: they are released untried, with the same retry time and no attempt burned. With strict-ordering storage this holds across retries and across workers. Keyless messages are independent.
- Insertion order is id order, not commit order. On Postgres a
BIGSERIALid is assigned atINSERT; a transaction with a smaller id can commit later and be picked up after a larger one. If that matters for your keys, setPostgresStorage(visibility_delay=timedelta(seconds=2))to only claim rows older than your longest producer transaction. - Leases bound duplicates. A round must finish inside its lease.
RelayConfigwarns at construction whenceil(batch_size / concurrency) * publish_timeoutexceedslease, and the relay warns after the fact when a round ran long. Leavepublish_timeouton. - Retry times use the application clock.
retry_atis computed by the relay and compared with the database'snow(). Clock skew between the two shifts retries by the same amount. - Strict ordering on Postgres serialises claims per table with an advisory lock. A claim takes
milliseconds, so throughput is bounded at roughly
batch_size * 1000 / claim_msmessages per second no matter how many workers you add; publishing still runs in parallel. Setstrict_ordering=Falseif you do not use keys. - Bookkeeping failures are tolerated. If
nackitself fails, lease expiry recovers the row.
What txoutbox is not
Not a broker, not a task queue, not an ORM. It does not own your table and does not promise exactly-once.
Deduplication on the consumer side is a separate concern (see inboxd, the companion library).
License
MIT
Metadata
Release files for txoutbox 0.2.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| txoutbox-0.2.2.tar.gz | 38.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| txoutbox-0.2.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 70.0 kB
Release files / txoutbox-0.2.2.tar.gz
| Download URL | txoutbox-0.2.2.tar.gz |
|---|---|
| Size | 38.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5ef733f6f13cafd2923da8191c6c14819a9a0da38f32fd8ee8be4748641376f9
|
|
BLAKE2b-256 checksum How to use checksums |
e2768e4b84dbf7cea743cde53b015a7c87d5ecf700f4ef57c9bfd4d01d329b27
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 12, 2026.
Transparency logRelease files / txoutbox-0.2.2-py3-none-any.whl
| Download URL | txoutbox-0.2.2-py3-none-any.whl |
|---|---|
| Size | 31.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
13f0e2b197fb7292891602a49a5806272526f5e1e638c4af24ced4f22ec812e9
|
|
BLAKE2b-256 checksum How to use checksums |
9d4993928f29cb269f63cb5947484bf186321277bc69d8aae7decb93e30c3e7f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 12, 2026.
Transparency log