User Guide¶
omni-box provides production-ready primitives for Transactional Outbox and Transactional Inbox patterns in async Python services.
Installation¶
Optional extras (see the extras table for the full list): postgres, kafka, metrics, opentelemetry, settings, dishka.
Basic concepts¶
- Outbox — guarantees that an event reaches the broker iff the originating DB transaction committed. The publisher is a separate background job that reads pending rows.
- Inbox — guarantees that each incoming broker message is handled at most once per
(message_id, consumer_group)thanks to the inbox unique index, regardless of broker redelivery.
Transactional Outbox¶
1. Define your payload schema (optional)¶
2. Persist events inside your business transaction¶
from omni_box import OmniBoxDomainService
domain = OmniBoxDomainService()
async def create_user(uow, email: str):
async with uow.transaction() as tx:
user = await tx.users.create(email=email)
event = domain.create_outbox_event(
event_type="user.created",
topic="users",
partition_key=str(user.id),
payload={"user_id": str(user.id), "email": email},
aggregate_type="user",
aggregate_id=user.id,
)
await tx.outbox.create(event)
tx.outbox is your service-owned repository — typically PostgresOutboxRepository bound to the same AsyncSession as the rest of your UoW.
3. Run the publisher¶
One cycle of publish_batch fetches and locks pending rows, publishes each of them, and
writes the outcome back. All three happen through the session you handed the repository, and
the library never commits: open a transaction per cycle and commit it yourself.
import asyncio
from omni_box import OutboxPublisher
from omni_box.core.converters import EnvelopeEventConverter
from omni_box.infra.brokers.kafka import KafkaEventPublisher
from omni_box.infra.storage.postgres import PostgresOutboxRepository
broker = KafkaEventPublisher(
producer=kafka_producer, # caller-owned AIOKafkaProducer
converter=EnvelopeEventConverter(),
)
while not shutdown:
async with session_factory() as session, session.begin(): # the commit is yours
repo = PostgresOutboxRepository(session, model_class=OutboxEventDB)
result = await OutboxPublisher(repo, broker).publish_batch(
worker_id="publisher-1",
batch_size=100,
)
if not result.processed_event_ids:
await asyncio.sleep(1.0)
Drop the transaction and the cycle publishes for nothing: the lock and the completion roll
back with the session, the rows are still pending, and the next cycle sends them again.
When the broker is down. A publish failure that is about the broker rather than about the
row — the connection, the node, a request that timed out, the publish timeout itself — does
not spend the row's attempt budget. KafkaEventPublisher raises TransientError once its
own max_infra_retries are spent, and the outbox step records the row with
attempts_made untouched and stops publishing for the rest of the cycle: the remaining rows
are recorded the same way without being sent, since they were going to the same broker. So an
outage costs one probe per cycle, the rows stay pending however long it lasts, and the first
cycle after the broker answers publishes the backlog. A payload the broker rejects, or a topic
it says it does not have, is about the row and still spends an attempt — that is what
max_attempts and failed are for. Raise TransientError from your own publisher or handler
to get the same treatment.
Transactional Inbox¶
Option A — drive consumption with InboxConsumerRunner¶
This is the typical "one Kafka message per transaction" loop with configurable commit semantics.
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from omni_box import AckStrategy, InboxConsumerRunner, InboxEvent
from omni_box.core.protocols import InboxEventRepository
from omni_box.core.protocols.transaction import InboxTransactionProviderProtocol
class InboxTxProvider(InboxTransactionProviderProtocol):
def __init__(self, session_factory, repo_factory) -> None:
self._session_factory = session_factory
self._repo_factory = repo_factory
@asynccontextmanager
async def transaction(self) -> AsyncIterator[InboxEventRepository]:
async with self._session_factory() as session, session.begin():
yield self._repo_factory(session)
async def handle_inbox_event(event: InboxEvent, repo: InboxEventRepository) -> None:
await repo.session.execute( # the transaction the inbox row is in
invoices.insert().values(order_id=event.payload["order_id"])
)
runner = InboxConsumerRunner(
consumer=kafka_consumer_adapter,
transaction_provider=InboxTxProvider(session_factory, lambda s: PostgresInboxRepository(s, model_class=InboxEventDB)),
handler=handle_inbox_event, # optional; runs inside the same transaction
worker_id="worker-1",
consumer_group="identity-service",
ack_strategy=AckStrategy.EXACTLY_ONCE_INBOX,
)
await runner.start()
try:
await runner.run_forever()
finally:
await runner.stop()
The handler runs inside the transaction that inserts the inbox row, and repo.session is that
transaction. Write the side effect through it and the invoice and the inbox row commit
together — or roll back together when the handler raises. A session the handler opens itself
is a second transaction and does not get that.
Option B — batch processing already-stored inbox rows¶
Use create_inbox_processor when you want to ingest messages quickly (commit on persist) and process them in a separate workload.
from omni_box import InboxEvent, create_inbox_processor
from omni_box.core.protocols import InboxEventRepository
from omni_box.infra.storage.postgres import PostgresInboxRepository
async def my_handler(event: InboxEvent, repo: InboxEventRepository):
print(f"Processing {event.event_type} ({event.message_id})")
async with session_factory() as session, session.begin(): # the commit is yours here too
processor = create_inbox_processor(
repo=PostgresInboxRepository(session, model_class=InboxEventDB),
handler=my_handler,
job_name="my_inbox_job",
)
await processor.process_batch(worker_id="worker-1", batch_size=50)
The batch processor owns the retry budget: a handler that fails leaves the row pending with
attempts_made + 1 until max_attempts, which is why draining rows here — rather than in the
runner's handler — is how you get retries out of the table.
Option C — automated event routing¶
EventRouter keys handlers by (topic, event_type, schema_version), and
create_dispatching_processor dispatches on the event's source as the topic.
The @event_handler decorator only marks a method — it registers nothing on its own. Give
the router a plain function through register_handler, or a BaseEventHandler subclass it
can sweep:
from omni_box import (
BaseEventHandler,
EventRouter,
InboxEvent,
create_dispatching_processor,
event_handler,
)
from omni_box.core.protocols import InboxEventRepository
router = EventRouter()
# … either a plain function, registered explicitly …
async def handle_user_deleted(event: InboxEvent, repo: InboxEventRepository, uow) -> None:
...
router.register_handler(event_type="user.deleted", topic="users", handler=handle_user_deleted)
# … or a class whose decorated methods the router sweeps.
class UserHandlers(BaseEventHandler):
topic = "users" # the source the events arrive with
@event_handler("user.created") # optional: topic=..., schema_version=...
async def on_created(self, event: InboxEvent, repo: InboxEventRepository, uow) -> None:
...
router.register_instance(UserHandlers())
processor = create_dispatching_processor(
repo=inbox_repo,
router=router,
dependencies={"uow": uow}, # passed as keyword arguments to the handler
)
Extra keyword arguments named in dependencies are passed to every handler, so each handler
signature must accept them. An event with no matching handler comes back as a failed
EventHandlerResult reading No handler for topic=… event_type=… v=….
Building the pipeline yourself? create_dispatching_handler(router, **dependencies) from
omni_box.core.dispatch is the router-backed handler this factory installs.
Customising the pipeline¶
Need full control? Build the processor yourself.
from omni_box import EventProcessorBuilder
from omni_box.core.pipeline.steps import (
CircuitBreakerStep,
DLQStep,
HandlerExecutionStep,
OpenTelemetryStep,
PublisherExecutionStep,
SiblingDeduplicationStep,
)
from omni_box.core.pipeline.strategies import (
BulkCommitStrategy,
DistributedLockingFetchStrategy,
)
builder = (
EventProcessorBuilder(inbox_repo)
.add_step(OpenTelemetryStep(service_name="my-service"))
.add_step(CircuitBreakerStep(failure_threshold=5, recovery_timeout_seconds=60))
.add_step(DLQStep(dlq_storage))
.add_step(SiblingDeduplicationStep())
.add_step(HandlerExecutionStep(my_handler, timeout=30))
.with_fetch_strategy(DistributedLockingFetchStrategy())
.with_commit_strategy(BulkCommitStrategy())
)
processor = builder.build()
The builder auto-picks DistributedLockingFetchStrategy + BulkCommitStrategy when the repository advertises matching capabilities, so the with_* calls above are usually optional.
For an outbox, use PublisherExecutionStep(broker.publish, timeout=30) in place of
HandlerExecutionStep — it is what create_outbox_processor installs, and it is the step
that keeps a broker outage off the attempt budget.
Outbox payload envelopes¶
OutboxPublisher delegates the body shape to a converter:
| Converter | Body |
|---|---|
RawEventConverter |
Raw event.payload. |
SchemaVersionedConverter |
{"schema_version": …, "payload": …}. |
EnvelopeEventConverter |
Full envelope: payload + tracing identifiers. |
from omni_box import EnvelopeEventConverter
from omni_box.infra.brokers.kafka import KafkaEventPublisher
broker = KafkaEventPublisher(producer=p, converter=EnvelopeEventConverter())
Observability¶
Structured logging¶
omni-box emits structured events through structlog. Configure renderers in your application bootstrap — for example:
import structlog
structlog.configure(
processors=[
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer(),
]
)
omni-box does not ship a PII sanitiser; redact sensitive fields in your own structlog processor if needed.
Metrics¶
Pass metrics= to any factory or to MetricsStep. Either implement InboxMetrics / OutboxMetrics yourself or use the Prometheus adapters from omni_box.infra.metrics (extra: metrics).
OpenTelemetry¶
Add OpenTelemetryStep(service_name="my-service") to your pipeline (extra: opentelemetry).
Dead Letter Queue¶
from omni_box.core.pipeline.steps import DLQStep, DLQStorage
class MyDLQStorage(DLQStorage):
async def move_to_dlq(self, event, error: str) -> None:
await db.save_to_dlq(event, error)
builder.add_step(DLQStep(MyDLQStorage()))
DLQStep is best-effort and non-transactional. Transient failures (count_as_attempt=False) are never routed to DLQ.
Circuit breaker¶
from omni_box.core.pipeline.steps import CircuitBreakerStep
builder.add_step(CircuitBreakerStep(failure_threshold=5, recovery_timeout_seconds=60))
The breaker state is held in-process — it does not survive restarts and is not shared between replicas. For multi-worker deployments add an external coordination layer (e.g. Redis-backed counters) on top of this step.
Maintenance¶
Run periodically against the same repository:
from omni_box import OmniBoxMaintenanceService
m = OmniBoxMaintenanceService(repo=outbox_repo)
await m.release_stale_locks(stale_timeout_seconds=300)
await m.cleanup_old_events(retention_days=14)
Both methods require the repository to implement SupportsRetentionPolicies. PostgresOutboxRepository and PostgresInboxRepository do.