Transactional Outbox Pattern for PostgreSQL + NATS JetStream
Project description
natsbox
Transactional Outbox Pattern for Python — PostgreSQL + NATS JetStream
What problem does this solve?
In event-driven architectures, a service typically needs to do two things atomically:
- Persist a domain state change to PostgreSQL (e.g. insert a
Photorow) - Publish a corresponding event to NATS JetStream (e.g.
photo.created)
These two operations touch two different systems with no shared distributed transaction. This creates silent failure scenarios — the dual-write problem:
- DB commit succeeds → service crashes → event lost (silent inconsistency)
- Event published → DB rollback → phantom event (consumers react to nothing)
- NATS temporarily down → event lost or duplicated without a retry mechanism
natsbox solves this with the Transactional Outbox Pattern:
Write events to a dedicated
outbox_eventstable in the same SQL transaction as your domain data. A separate relay process reads this table and publishes events reliably to NATS JetStream.
Features
- Atomic writes — event and domain data commit together (ACID guaranteed by Postgres)
- At-least-once delivery — events are never lost, even across relay crashes
- Idempotent publishes —
Nats-Msg-Id = event_idenables JetStream dedup on retry - Exponential backoff — failed publishes are retried with backoff via
scheduled_at - Dead-letter queue — events that fail
max_retriestimes are flagged (not silently dropped) - Inbox pattern — optional consumer-side deduplication via
INSERT ... ON CONFLICT - Delayed publish — schedule events in the future with
scheduled_at - Horizontal scaling — multiple relay instances are safe via
SELECT FOR UPDATE SKIP LOCKED - Prometheus metrics — pending count, publish latency, failure rate
- Framework-agnostic core — works with any SQLAlchemy async session
Quick Start
pip install natsbox[cli,all]
from sqlalchemy.ext.asyncio import AsyncSession
from nats_outbox.core.outbox import outbox_transaction
async def create_photo(session: AsyncSession, url: str, user_id: int):
async with outbox_transaction(session) as tx:
photo = Photo(url=url, user_id=user_id)
tx.add(photo)
await session.flush() # get photo.id before commit
tx.publish_event(
subject="photo.created",
payload={"photo_id": str(photo.id), "user_id": user_id},
aggregate_id=str(photo.id),
aggregate_type="Photo",
)
# Both photo row AND outbox event are committed atomically
Start the relay:
OUTBOX_DATABASE_URL=postgresql+asyncpg://... \
OUTBOX_NATS_URL=nats://localhost:4222 \
natsbox relay start
Architecture
┌─────────────────────────────────────────┐
│ Your Application │
│ │
HTTP Request ────►│ async with outbox_transaction(db) as tx:│
│ tx.add(Photo(...)) │
│ tx.publish_event("photo.created") │
│ │ │
└───────────────────────────┼──────────────┘
│ SQL COMMIT (atomic)
▼
┌─────────────────────────────────────────┐
│ PostgreSQL │
│ │
│ photos outbox_events │
│ ┌──────────┐ ┌─────────────────────┐ │
│ │ id url │ │ status subject ... │ │
│ │ 1 ... │ │ pending photo.created│ │
│ └──────────┘ └─────────────────────┘ │
└───────────────────────────┬──────────────┘
│ SELECT FOR UPDATE SKIP LOCKED
▼
┌─────────────────────────────────────────┐
│ Relay (separate process) │
│ │
│ PollingRelay.start() │
│ ├── poll every 1s │
│ ├── publish to JetStream │
│ └── mark status=published │
└───────────────────────────┬──────────────┘
│ publish (Nats-Msg-Id=event_id)
▼
┌─────────────────────────────────────────┐
│ NATS JetStream │
│ │
│ Stream: PHOTOS │
│ Subject: photo.created │
│ Dedup window: 2 min (Nats-Msg-Id) │
└─────────────────────────────────────────┘
Table Schema
The outbox_events table is the heart of the pattern:
| Column | Type | Purpose |
|---|---|---|
id |
BIGSERIAL | Sequential PK — no B-tree fragmentation |
event_id |
UUID | Business ID — used as Nats-Msg-Id header (stable across retries) |
subject |
TEXT | NATS subject |
headers |
JSONB | NATS headers (always includes Nats-Msg-Id) |
payload |
JSONB | Event body |
status |
TEXT | pending → published or failed |
retry_count |
SMALLINT | Failed publish attempts |
last_error |
TEXT | Last exception for debugging |
aggregate_id |
TEXT | Source entity ID (for ordering & observability) |
aggregate_type |
TEXT | Source entity class name |
created_at |
TIMESTAMPTZ | When event was written to outbox |
updated_at |
TIMESTAMPTZ | Last relay modification |
scheduled_at |
TIMESTAMPTZ | Earliest publish time (backoff pushes this forward) |
published_at |
TIMESTAMPTZ | When JetStream ACK was received |
Relay Strategies
V1 — Polling (available now)
nats-outbox relay start --strategy polling
| Latency | Up to OUTBOX_POLLING_INTERVAL (default 1s) |
| DB load | Constant light read load (mitigated by partial index) |
| Ordering | FIFO within single relay; weakened under concurrent instances |
| Complexity | Low — no Postgres superuser, no replication slot |
V2 — WAL Tailing (planned)
nats-outbox relay start --strategy wal # NotImplementedError — coming soon
| Latency | Sub-millisecond (reacts to WAL INSERT in real time) |
| DB load | Zero read polling |
| Ordering | Strict by LSN (total order) |
| Complexity | Requires wal_level=logical, REPLICATION privilege, replication slot |
Configuration
All configuration is via environment variables (prefix: OUTBOX_):
OUTBOX_DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/myapp
OUTBOX_NATS_URL=nats://localhost:4222
OUTBOX_POLLING_INTERVAL=1.0 # seconds between idle polls
OUTBOX_BATCH_SIZE=100 # events per tick
OUTBOX_MAX_RETRIES=5 # before dead-lettering
OUTBOX_RETENTION_DAYS=7 # cleanup window for published events
OUTBOX_JETSTREAM_DEDUP_WINDOW=120 # match your stream's DuplicateWindow
OUTBOX_METRICS_PORT=9090 # 0 = disabled
Examples
See the examples/ directory for a complete working FastAPI integration:
Running Integration Tests
Tests use real Docker containers via testcontainers:
make test
Roadmap
V2 — WAL Tailing (Logical Replication)
We are planning to implement a sub-millisecond latency relay using Postgres logical replication (pgoutput) via psycopg3. This will replace the polling mechanism for high-throughput systems.
See the full technical roadmap in implementation_plan.md.
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file nats_outbox-0.1.0.tar.gz.
File metadata
- Download URL: nats_outbox-0.1.0.tar.gz
- Upload date:
- Size: 37.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d5d4634d2ef45f4207d709e1a5a06527273f08ed8a16be8d92c96497e7c424ee
|
|
| MD5 |
7586a45785890c0367b3bac7670c3a3d
|
|
| BLAKE2b-256 |
ed75253fccb4db646cec16586519ad5a49142a03100ee74de9f894e7c4002caf
|
File details
Details for the file nats_outbox-0.1.0-py3-none-any.whl.
File metadata
- Download URL: nats_outbox-0.1.0-py3-none-any.whl
- Upload date:
- Size: 32.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5caa4942479653c15a0a2a09ee818d56fda54f101ee68b64e7f7a0a8a7b55d31
|
|
| MD5 |
b87a0dc1f1deacb7e64491c64b35af98
|
|
| BLAKE2b-256 |
fda2a70c5baebcc2082fefa1ccb5cfb9a655fe99153dea089856e962ecd2bfef
|