Core (Outbox/Inbox)
nats_outbox.core.outbox
outbox_transaction — core context manager for the Transactional Outbox Pattern.
Design principles
-
Framework-agnostic: this module has zero FastAPI dependency. It only requires a SQLAlchemy AsyncSession. FastAPI integration is a thin layer on top (see examples/fastapi_integration.py).
-
Single transaction: both domain objects and outbox events are committed atomically in one SQL COMMIT. If the commit fails, neither is persisted — no partial state, no phantom events.
-
Nats-Msg-Id frozen at write time: the event_id (which becomes the Nats-Msg-Id NATS header) is generated when publish_event() is called, not when the relay publishes. This is the critical invariant:
- Retry 1: relay publishes with Nats-Msg-Id = event_id → ACK received → OK
- Retry 2 (after crash): same Nats-Msg-Id → JetStream dedup silently drops the duplicate within the dedup window. If we generated a new UUID per publish attempt, a lost ACK would produce a duplicate message in the stream — defeating the at-least-once guarantee.
-
Session lifecycle: outbox_transaction commits the session on aexit. The caller must NOT commit the session afterward. For FastAPI dependencies that normally handle commit/rollback, exclude the session from auto-commit when wrapping with outbox_transaction.
If you need to use outbox_transaction inside an existing
async with session.begin(): block, call tx._flush_events() manually
and let the outer transaction commit.
Trade-offs
- outbox_transaction always commits: simpler API, but means you can't stage multiple independent logical units then commit them all at once. For that use case, instantiate OutboxTransaction directly and call _flush_events() before your own commit.
- No nested transaction support in V1: using outbox_transaction inside an already-begun SQLAlchemy transaction will cause a double-commit. Documented as a known limitation.
OutboxTransaction
A thin wrapper around AsyncSession that adds outbox-aware event staging.
Call publish_event() to stage events. They are written to the database (within the same transaction as your domain objects) when the surrounding outbox_transaction context manager exits successfully.
The session object is still accessible via .session for advanced use cases (raw SQL, bulk inserts, etc.).
Source code in nats_outbox/core/outbox.py
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 | |
session
property
Escape hatch: the underlying SQLAlchemy session.
Use for operations not covered by the OutboxTransaction proxy (e.g. raw SQL, bulk operations, SQLAlchemy-specific features).
staged_events
property
Read-only view of staged (not yet committed) events. Useful for testing.
add(instance)
Add a domain object to the SQLAlchemy session.
Mirrors session.add() — provided as a convenience so callers don't need to hold a reference to both tx and session separately.
Source code in nats_outbox/core/outbox.py
add_all(instances)
publish_event(subject, payload, *, aggregate_id=None, aggregate_type=None, scheduled_at=None, headers=None, event_id=None)
Stage an outbox event to be persisted within this transaction.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
subject
|
str
|
NATS subject (e.g. "photo.created", "org.42.user.invited"). |
required |
payload
|
dict[str, Any]
|
Event body. Must be JSON-serializable. |
required |
aggregate_id
|
str | None
|
ID of the source entity (e.g. str(photo.id)). Used for ordering guarantees and observability. Optional but strongly recommended. |
None
|
aggregate_type
|
str | None
|
Class/type name of the source entity (e.g. "Photo"). Optional. |
None
|
scheduled_at
|
datetime | None
|
Earliest publish time. Defaults to now() for immediate delivery. Pass a future datetime for delayed publish. |
None
|
headers
|
dict[str, str] | None
|
Extra NATS headers to include. Nats-Msg-Id is always set to event_id and cannot be overridden here. |
None
|
event_id
|
UUID | None
|
Override the auto-generated UUID. Useful when you need the event_id before calling publish_event (e.g. to store it as a FK in a domain object). If omitted, a UUID4 is generated. |
None
|
Returns:
| Type | Description |
|---|---|
UUID
|
The event_id that was assigned. Store this if you need to correlate the outbox row with consumer-side inbox deduplication. |
Nats-Msg-Id invariant
The Nats-Msg-Id header is set here, not in the publisher. The publisher reads it from OutboxEvent.headers and enforces it (no override). This guarantees the same ID is used across all retry attempts, enabling JetStream's dedup window to absorb retries transparently.
Source code in nats_outbox/core/outbox.py
outbox_transaction(session)
async
Async context manager that wraps a SQLAlchemy session with outbox support.
On successful exit (aexit with no exception): 1. All staged OutboxEvent rows are added to the session. 2. session.commit() is called — domain objects AND events are persisted atomically.
On exception: session.rollback() is called — neither domain objects nor events are persisted.
Usage (framework-agnostic): ::
engine = create_async_engine(settings.database_url)
async_session = async_sessionmaker(engine)
async with async_session() as session:
async with outbox_transaction(session) as tx:
photo = Photo(url="https://...", user_id=42)
tx.add(photo)
tx.publish_event(
subject="photo.created",
payload={"photo_id": str(photo.id)},
aggregate_id=str(photo.id),
aggregate_type="Photo",
)
# session is committed (and closed by the outer async with)
FastAPI dependency injection: ::
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSession(engine) as session:
yield session
# NOTE: do NOT commit here — outbox_transaction does it.
@router.post("/photos")
async def create_photo(session: AsyncSession = Depends(get_session)):
async with outbox_transaction(session) as tx:
photo = Photo(url=request.url)
tx.add(photo)
await session.flush() # flush to get photo.id if needed
tx.publish_event(
subject="photo.created",
payload={"photo_id": str(photo.id)},
)
Known limitation (V1):
Do NOT use inside an existing async with session.begin(): block.
outbox_transaction will issue a commit that also commits the outer
transaction. If you need composable transactions, call
await tx._flush_events() and manage the commit yourself.
Source code in nats_outbox/core/outbox.py
nats_outbox.core.inbox
Inbox Pattern — consumer-side event deduplication.
Why this is needed
The outbox relay provides at-least-once delivery: an event will eventually be published, but may be published more than once (e.g. relay crash after publish but before marking as published). JetStream's Nats-Msg-Id dedup window handles duplicates within the dedup window (default 2 minutes), but consumers must handle duplicates that arrive outside that window or via different streams.
Strategy
INSERT INTO inbox_events (event_id, consumer_group) ON CONFLICT DO NOTHING
This is an atomic check-and-insert: if the row already exists, the INSERT returns zero rows (conflict), indicating the event is a duplicate. No SELECT is needed — avoids the TOCTOU race condition of SELECT then INSERT.
The consumer_group column lets multiple consumers (e.g. "billing", "analytics") independently deduplicate events with the same event_id — each group tracks its own processed events.
Usage
::
async with AsyncSession(engine) as session:
async with session.begin():
deduplicator = InboxDeduplicator(session, consumer_group="billing")
if await deduplicator.is_duplicate(event_id):
return # already processed, skip
# ... process event business logic here ...
# The inbox row + business effect commit together atomically.
Table schema (run create_inbox_table() or use the provided migration) ::
CREATE TABLE inbox_events (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL,
consumer_group TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_inbox_event UNIQUE (event_id, consumer_group)
);
InboxDeduplicator
Stateless helper for consumer-side event deduplication.
Thread-safety: one instance per request/task — do not share across concurrent coroutines with the same session.
Source code in nats_outbox/core/inbox.py
is_duplicate(event_id)
async
Check whether event_id has already been processed by this consumer group.
Uses INSERT ... ON CONFLICT DO NOTHING — atomic, no SELECT needed. Returns True if the event is a duplicate (already in inbox_events). Returns False and inserts the row if the event is new.
IMPORTANT: This must be called within an open transaction. The inbox row and the business effect must commit together, otherwise a crash between the two commits can leave the inbox row without the business effect (or vice versa).
Source code in nats_outbox/core/inbox.py
InboxEvent
Bases: Base
Records events that have been successfully processed by a consumer group. Enables idempotent consumers without application-level locking.
Source code in nats_outbox/core/inbox.py
create_inbox_table(engine)
async
Create the inbox_events table. For tests and quick-start scripts.
nats_outbox.core.models
SQLAlchemy ORM model for the outbox_events table.
Schema design decisions (full rationale in the project README):
-
BIGSERIAL PK: sequential → zero B-tree fragmentation at high insert rates. UUID is present as a business key (event_id), not the structural PK.
-
JSONB (not JSON): stored as binary → faster reads, supports GIN indexes for observability queries (e.g. payload @> '{"user_id": 42}').
-
status TEXT + CHECK (not ENUM): adding a new status value with ENUM requires ALTER TYPE which takes AccessExclusiveLock. TEXT + CHECK supports ALTER TABLE ... ADD CONSTRAINT NOT VALID + VALIDATE in two lock-free steps.
-
updated_at: maintained via SQLAlchemy's onupdate mechanism (client-side, evaluated on every ORM UPDATE). Useful for debugging stuck events without parsing last_error timestamps.
-
scheduled_at separate from created_at: enables delayed/scheduled publish at zero extra implementation cost in the relay (just check scheduled_at <= now()).
-
Three partial indexes (pending, published, aggregate) keep index size small because the majority of rows are in status='published'.
Base
Bases: DeclarativeBase
Shared declarative base. Import this into your own Base if you want outbox_events co-located with your application tables.
OutboxEvent
Bases: Base
Represents a single event staged in the transactional outbox.
State machine: pending ──(publish ok)──► published pending ──(retry exhausted)──► failed
The relay only reads rows in status='pending'.
Published rows are kept for retention_days then purged by the cleanup task.
Failed rows are kept indefinitely (alert on them, then resolve manually).
Source code in nats_outbox/core/models.py
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | |
create_tables(engine)
async
Create all tables defined in this module and in core.inbox.
Imports InboxEvent here to ensure its table is registered with Base.metadata before create_all() is called.
Convenience helper for tests and quick-start scripts. For production, prefer Alembic migrations generated from this model.
Source code in nats_outbox/core/models.py
drop_tables(engine)
async
Drop all tables. For tests only — never call in production.