Events¶
Events with a transactional outbox: an event is stored in the same transaction as the change that caused it, then published and delivered to its handlers in the background, at least once. One outbox, one relay and one set of background workers serve an application and the libraries it uses.
Quickstart¶
The outbox is one repository of the application's bundle and shares the unit of work with the others. Wire the background workers once and start them with the application:
from dataclasses import dataclass
from sqlalchemy.orm import sessionmaker
from sqlmodel import Session, SQLModel, create_engine
from commons.database import SqlUnitOfWorkFactory
from commons.events import CommitNotifyingUnitOfWorkFactory, Outbox, SqlOutbox, background_workers_from_settings
@dataclass
class Repositories: # the app's bundle; anything with an `outbox` works
invoices: InvoicesRepository
outbox: Outbox
engine = create_engine("sqlite:///app.db")
SQLModel.metadata.create_all(engine) # creates `outbox_events` with the app's tables
repositories = CommitNotifyingUnitOfWorkFactory(
SqlUnitOfWorkFactory(
sessionmaker(bind=engine, class_=Session),
lambda session, user: Repositories(invoices=SqlInvoices(session), outbox=SqlOutbox(session)),
)
)
workers = background_workers_from_settings(repositories) # reads [app.events]
workers.subscribe(group="invoice-validation", event_type=InvoiceUploaded, handler=validate_invoice)
# In the application's lifespan, e.g. FastAPI:
# workers.start() on startup
# await workers.stop() on shutdown
CommitNotifyingUnitOfWorkFactory wakes the relay after every commit, so events are processed right away. Without it, the relay finds new events by polling.
Concepts¶
- Event (
Event): an immutable fact that happened, e.g. "an invoice was uploaded". A Pydantic model with atopic, anevent_typeand its own fields. - Envelope (
EventEnvelope): the wire format of an event, routing metadata plus the JSON payload. Outbox and broker only see envelopes. - Topic: the log an event is appended to. Related events share a topic; a library uses topics of its own (e.g.
conversations-runs). - Key: optional partition key; events with the same key are processed in order, events with different keys in parallel.
- Outbox (
Outbox,SqlOutbox): the tableoutbox_eventsthe events are stored in, in the same transaction as the change that caused them. One per application. - Relay (
OutboxRelay): publishes the stored events to the broker and marks them published. - Broker (
LogBrokeron anEventLog): stores the published events per topic and delivers them to the consumers. Kafka or Azure Service Bus could replace it. - Consumer group: a named reader of a topic with its own position (offset). Every group sees every event of the topics it subscribed to.
- Handler (
EventHandler): an async function that processes one typed event. It must be idempotent: it may see the same event twice. - Dead letter: an event whose handler kept failing; it is set aside so it cannot block its key.
Configuration¶
The background workers are configured under [app.events]. All settings are optional; unknown keys are rejected.
| Setting | Type | Default | Description |
|---|---|---|---|
mode |
"file" | "memory" |
"file" |
file: durable topic logs on disk; memory: lost on restart, for tests and development. |
dir |
path | <data_dir>/events |
Directory of the file broker. |
poll_interval_seconds |
float | 5.0 |
How often the relay and the consumers look for work they were not woken for. |
handler_max_concurrency |
int | 10 |
Handlers of one consumer group running at the same time, for events with different keys. |
handler_max_attempts |
int | 3 |
Attempts of a handler before the event is dead-lettered. |
outbox_retention_days |
int | 7 |
How long published events are kept in the outbox. |
maintenance_interval_seconds |
float | 60.0 |
How often the outbox is pruned and the maintenance jobs run. |
Usage¶
Define an event¶
Derive from Event, set topic and event_type, and add the fields the handler needs. Keep events small: reference entities by id. Override key if the events of one entity must be processed in order.
from typing import ClassVar
from commons.events import Event
class InvoiceUploaded(Event):
"""An invoice was uploaded and waits to be validated."""
topic: ClassVar[str] = "invoices"
event_type: ClassVar[str] = "invoice_uploaded"
invoice_id: str
customer_id: str
@property
def key(self) -> str:
"""The invoices of one customer are validated in the order they were uploaded."""
return self.customer_id
- Name events in the past tense or as a request that happened (
InvoiceUploaded,RunRequested), not as commands. event_typemust be unique within its topic and never change: it is stored in the outbox and the broker.- Add optional fields only; never rename or remove one. For an incompatible change, define a new event type.
Publish an event¶
Add the event to the outbox in the same unit of work as the change. Never publish to the broker directly.
async def upload_invoice(repositories, customer_id: str, data: bytes) -> str:
with repositories.for_system() as (repos, commit):
invoice = repos.invoices.add(customer_id, data)
repos.outbox.add_event(InvoiceUploaded(invoice_id=invoice.id, customer_id=customer_id))
await commit() # stores invoice and event together; wakes the relay
return invoice.id
If the unit of work is rolled back, neither the invoice nor the event is stored.
Handle an event¶
A handler is an async function that takes the typed event. Subscribe it with a consumer group before starting the workers. Raising makes the consumer retry the event; after handler_max_attempts it is dead-lettered.
async def validate_invoice(event: InvoiceUploaded) -> None:
invoice = ... # load the invoice by event.invoice_id
if invoice is None or invoice.status != "uploaded":
return # redelivered or outdated event: nothing to do
... # validate it, store the outcome
workers.subscribe(group="invoice-validation", event_type=InvoiceUploaded, handler=validate_invoice)
- Be idempotent. Check the state before acting, or use
event.event_idto detect duplicates. - Load current state. The event says what happened; the database says what is true now.
- Raise only for transient errors that a retry can fix (e.g. a network error). Record business failures in the domain state and return normally.
- One handler per event type and group. Use separate groups for independent reactions to the same event.
- Persist long work step by step, each step in its own short unit of work, so no transaction stays open during slow calls; chain steps with further events.
Libraries¶
A library adds its events to the application's outbox: its repository bundle gets the outbox from the application's session (e.g. SqlOutbox(session)), and it offers a function that subscribes its handlers, e.g. subscribe_processes(workers, registry). The application calls it before starting the workers.
Add maintenance jobs¶
Periodic jobs, e.g. failing work left behind by a crash, run at start and every maintenance_interval_seconds, after the outbox is pruned:
Test asynchronous code¶
- Use
EventsSettings(mode="memory")for the workers and SQLite in memory for the outbox. - Test handlers directly by calling them with an event; they are plain async functions.
- Test that a domain function adds the event: read
repos.outbox.list_unpublished(...)after it ran. - For end-to-end tests, start the workers, call the domain function and wait until the handler's effect is visible.
Behavior¶
- At least once. Every committed event is delivered to every subscribed group at least once; after a crash it may be delivered again.
- Order per key. Within a topic, a group processes the events of one key one after the other, in the order they were committed. Events with different keys, and events without a key, are processed in parallel, at most
handler_max_concurrencyat a time. - Redelivery after a crash. The committed offset only moves past events that are all processed: every unfinished event is delivered again, and so are finished events after an unfinished one.
- A failing event only holds up its own key, until it is dead-lettered after
handler_max_attempts. Unreadable events (the payload no longer validates) are dead-lettered right away. - Events nobody subscribed to are skipped; a group subscribed later does not see them.
- Processing starts after the commit. Within the domain function the work is not done yet; return the pending state to the caller.
- Crashed tasks restart. A background task that crashes is logged and restarted;
stopnever waits longer than its timeout. - Single application instance. The relay and
LogBrokerassume one running instance; several would publish events twice.
Errors¶
| Exception | Raised when | What to do |
|---|---|---|
ValueError |
subscribe is called twice for the same group and event type. |
Use one handler per event type and group, or a second group. |
RuntimeError |
BackgroundWorkers.subscribe is called after start, or start is called twice. |
Subscribe all handlers before starting the workers. |
ValueError |
Event.from_envelope gets an envelope of another type, or one whose payload doesn't validate. |
Only happens when converting envelopes by hand. |
ValueError |
FileEventLog gets a topic or group name that is not a safe file name. |
Use letters, digits, ., _ and -. |
ValidationError |
[app.events] has an unknown key or an invalid value. |
Fix the settings. |
API reference¶
commons.events
¶
Events with a transactional outbox: stored with the change that caused them, delivered at least once.
- Events (
Event,EventEnvelope): immutable facts with a topic, a type and an optional ordering key. - Ports (
Outbox,HasOutbox,EventPublisher,EventSubscriber,EventConsumer,EventHandler). - Broker (
LogBrokeron anEventLog:InMemoryEventLog,FileEventLog): in-process, Kafka-like, single instance. - SQL outbox (
SqlOutbox,SqlOutboxEvent): the tableoutbox_events, written in the caller's unit of work. - Relay and workers (
OutboxRelay,BackgroundWorkers,CommitNotifyingUnitOfWorkFactory): publish committed events and run the handlers in the application's lifespan, configured byEventsSettings([app.events]) and wired withbackground_workers_from_settings.
Modules:
| Name | Description |
|---|---|
broker |
|
event |
|
log |
|
ports |
|
relay |
|
settings |
|
sql |
|
workers |
|
Type Aliases:
| Name | Description |
|---|---|
CommitListener |
Called after a successful commit, e.g. |
EventHandler |
Processes one typed event; raising makes the consumer retry the event. |
Maintenance |
A periodic maintenance job, e.g. failing stale runs. |
Classes:
| Name | Description |
|---|---|
LogBroker |
An in-process message broker with Kafka-like semantics on top of an |
Event |
Base of all events: an immutable fact, published via the outbox. |
EventEnvelope |
The wire format of an |
DeadLetter |
An event a consumer group failed to process, with the error. |
EventLog |
The storage of a |
FileEventLog |
An |
InMemoryEventLog |
An |
EventConsumer |
Consumes events from the message broker, e.g. Kafka consumer groups. |
EventPublisher |
Appends events to the message broker, e.g. a Kafka producer. |
EventSubscriber |
Registers the handlers of events, e.g. an |
HasOutbox |
A repository bundle of a unit of work that contains the outbox, e.g. an app's |
Outbox |
The transactional outbox: events stored together with the change that caused them. |
OutboxRelay |
Publishes the events of the outbox and marks them published. |
EventsSettings |
The settings of the message broker, the outbox relay and the maintenance, read from |
SqlOutbox |
The SQL adapter of the |
SqlOutboxEvent |
The persistence row of one outbox event ( |
BackgroundWorkers |
The background tasks of the (single) application instance, started and stopped by its lifespan. |
CommitNotifyingUnitOfWorkFactory |
Wraps a |
Functions:
| Name | Description |
|---|---|
background_workers_from_settings |
Wire the message broker, the outbox relay and the event consumer. |
__all__
module-attribute
¶
__all__ = [
"BackgroundWorkers",
"CommitListener",
"CommitNotifyingUnitOfWorkFactory",
"DeadLetter",
"Event",
"EventConsumer",
"EventEnvelope",
"EventHandler",
"EventLog",
"EventPublisher",
"EventSubscriber",
"EventsSettings",
"FileEventLog",
"HasOutbox",
"InMemoryEventLog",
"LogBroker",
"Maintenance",
"Outbox",
"OutboxRelay",
"SqlOutbox",
"SqlOutboxEvent",
"background_workers_from_settings",
]
EventHandler
¶
Processes one typed event; raising makes the consumer retry the event.
CommitListener
¶
Called after a successful commit, e.g. OutboxRelay.wake.
Maintenance
¶
A periodic maintenance job, e.g. failing stale runs.
LogBroker
¶
LogBroker(
log: EventLog,
*,
poll_interval: float = 1.0,
batch_size: int = 100,
max_concurrency: int = 10,
max_attempts: int = 3,
retry_backoff: float = 1.0
)
An in-process message broker with Kafka-like semantics on top of an EventLog.
Implements EventPublisher and EventConsumer. Publishing appends to the topic's log and wakes the consumers of
that topic. Each (group, topic) subscription reads its topic in order from the group's committed offset and
processes the events in order per key: events with the same key one after the other, events with different
keys (and events without a key) in parallel, at most max_concurrency handlers at a time and at most
batch_size events in flight.
The committed offset only moves past events that are all processed, so after a crash every unfinished event is
delivered again, and some finished ones too: at least once. A failing handler is tried max_attempts times in
total, then the event is dead-lettered and skipped; an event that does not validate against its type is
dead-lettered right away. Either way only its key waits, not the others. Consumers also poll every
poll_interval seconds, in case an append bypassed publish.
Single process only: a Kafka or Service Bus adapter would replace this.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
log
|
EventLog
|
The storage of the topics, offsets and dead letters. |
required |
poll_interval
|
float
|
Seconds between reads of a topic when the consumer was not woken by a publish. |
1.0
|
batch_size
|
int
|
The maximum number of events read at once, and in flight (running or waiting for their key) per subscription. |
100
|
max_concurrency
|
int
|
The maximum number of handlers running at the same time per subscription. |
10
|
max_attempts
|
int
|
How often a handler is tried before the event is dead-lettered. |
3
|
retry_backoff
|
float
|
Seconds to wait before a retry, multiplied by the number of the failed attempt. |
1.0
|
Methods:
| Name | Description |
|---|---|
publish |
|
subscribe |
|
run |
|
Source code in src/commons/events/broker.py
publish
async
¶
publish(envelope: EventEnvelope) -> None
Source code in src/commons/events/broker.py
subscribe
¶
subscribe(
*,
group: str,
event_type: type[E],
handler: EventHandler[E]
) -> None
Source code in src/commons/events/broker.py
run
async
¶
Source code in src/commons/events/broker.py
Event
¶
Bases: BaseModel
Base of all events: an immutable fact, published via the outbox.
Subclasses set topic (the log the event is appended to) and event_type (its name on the wire, unique
within the topic), and may override key: events with the same key are processed in order, events with
different keys in parallel. Delivery is at least once, so handlers must be idempotent; event_id is stable
across redeliveries.
Attributes:
| Name | Type | Description |
|---|---|---|
event_id |
str
|
The unique id of the event, a time-ordered UUIDv7 by default. |
occurred_at |
datetime
|
When the event occurred (UTC). |
Example
Methods:
| Name | Description |
|---|---|
to_envelope |
Return the event in its wire format. |
from_envelope |
Return the typed event of an envelope. |
event_id
class-attribute
instance-attribute
¶
occurred_at
class-attribute
instance-attribute
¶
occurred_at: datetime = Field(default_factory=utc_now)
key
property
¶
The partition key: events with the same key keep their order. None means no ordering guarantee.
to_envelope
¶
to_envelope() -> EventEnvelope
Return the event in its wire format.
Source code in src/commons/events/event.py
from_envelope
classmethod
¶
from_envelope(envelope: EventEnvelope) -> Self
Return the typed event of an envelope.
Raises:
| Type | Description |
|---|---|
ValueError
|
If the envelope carries another event type, or its payload does not validate against this
class (Pydantic's |
Source code in src/commons/events/event.py
EventEnvelope
¶
Bases: BaseModel
The wire format of an Event: routing metadata plus the JSON payload.
The outbox, the relay and the brokers only handle envelopes, so they need no knowledge of the concrete event
classes. Consumers turn an envelope back into its typed event with Event.from_envelope.
Attributes:
| Name | Type | Description |
|---|---|---|
event_id |
str
|
The unique id of the event; stable across redeliveries. |
topic |
str
|
The log the event is appended to. |
key |
str | None
|
The partition key: events with the same key keep their order. None means no ordering guarantee. |
event_type |
str
|
The name of the event on the wire, unique within its topic. |
occurred_at |
datetime
|
When the event occurred (UTC). |
payload |
dict[str, Any]
|
The event's fields as JSON. |
DeadLetter
dataclass
¶
DeadLetter(group: str, envelope: EventEnvelope, error: str)
An event a consumer group failed to process, with the error.
Attributes:
| Name | Type | Description |
|---|---|---|
group |
str
|
|
envelope |
EventEnvelope
|
|
error |
str
|
|
EventLog
¶
Bases: Protocol
The storage of a LogBroker: append-only topic logs plus the offsets of the consumer groups.
An offset is an opaque position in a topic's log: 0 is its start, and every event read comes with the offset
right after it, the one to commit once the event is processed.
Methods:
| Name | Description |
|---|---|
append |
Durably append the envelope to the log of its topic. |
read |
Return at most |
committed_offset |
Return the offset |
commit_offset |
Store |
dead_letter |
Keep an envelope that |
append
¶
append(envelope: EventEnvelope) -> None
read
¶
read(
topic: str, offset: int, limit: int
) -> list[tuple[EventEnvelope, int]]
Return at most limit events of topic from offset on, each with its next offset.
committed_offset
¶
commit_offset
¶
dead_letter
¶
dead_letter(
group: str, envelope: EventEnvelope, error: str
) -> None
FileEventLog
¶
An EventLog in plain files below root, durable across restarts.
<root>/<topic>.jsonl one envelope per line, append-only
<root>/_offsets/<group>/<topic>.json committed offset of the group
<root>/_dead_letters/<group>/<topic>.jsonl events the group failed to process
The offset of an event is the byte position after its line. A line without its trailing newline (an append cut short by a crash) is never read.
Single process only: appends and offset files are not locked.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
root
|
Path
|
The directory of the log. |
required |
Methods:
| Name | Description |
|---|---|
append |
|
read |
|
committed_offset |
|
commit_offset |
|
dead_letter |
|
Attributes:
| Name | Type | Description |
|---|---|---|
root |
|
Source code in src/commons/events/log.py
read
¶
read(
topic: str, offset: int, limit: int
) -> list[tuple[EventEnvelope, int]]
Source code in src/commons/events/log.py
committed_offset
¶
commit_offset
¶
Source code in src/commons/events/log.py
dead_letter
¶
dead_letter(
group: str, envelope: EventEnvelope, error: str
) -> None
Source code in src/commons/events/log.py
InMemoryEventLog
¶
An EventLog in process memory, for tests and development; everything is lost on restart.
The offset of an event is its index in the topic's list, plus one.
Methods:
| Name | Description |
|---|---|
append |
|
read |
|
committed_offset |
|
commit_offset |
|
dead_letter |
|
Attributes:
| Name | Type | Description |
|---|---|---|
topics |
dict[str, list[EventEnvelope]]
|
|
offsets |
dict[tuple[str, str], int]
|
|
dead_letters |
list[DeadLetter]
|
|
Source code in src/commons/events/log.py
append
¶
append(envelope: EventEnvelope) -> None
read
¶
read(
topic: str, offset: int, limit: int
) -> list[tuple[EventEnvelope, int]]
committed_offset
¶
commit_offset
¶
dead_letter
¶
dead_letter(
group: str, envelope: EventEnvelope, error: str
) -> None
EventConsumer
¶
Bases: EventSubscriber, Protocol
Consumes events from the message broker, e.g. Kafka consumer groups.
Each consumer group reads every topic it subscribed to from its own committed offset and processes the events
in order per key (Event.key): events with the same key one after the other, events with different keys,
or without a key, possibly in parallel. Handlers must not rely on an order across keys. An offset is committed
only once the event and all events before it were processed: delivery is at least once. An event whose handler
keeps failing is dead-lettered, so it cannot block its key. Events of types the group has no handler for are
skipped.
Methods:
| Name | Description |
|---|---|
run |
Consume all subscriptions until cancelled. |
EventPublisher
¶
Bases: Protocol
Appends events to the message broker, e.g. a Kafka producer.
Only the outbox relay publishes; domain functions add events to the Outbox instead.
Methods:
| Name | Description |
|---|---|
publish |
Durably append the envelope to its topic; raise if it was not accepted. |
publish
async
¶
publish(envelope: EventEnvelope) -> None
EventSubscriber
¶
Bases: Protocol
Registers the handlers of events, e.g. an EventConsumer or the background workers.
Methods:
| Name | Description |
|---|---|
subscribe |
Let |
subscribe
¶
subscribe(
*,
group: str,
event_type: type[E],
handler: EventHandler[E]
) -> None
Let group process the events of event_type with handler.
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in src/commons/events/ports.py
HasOutbox
¶
Bases: Protocol
A repository bundle of a unit of work that contains the outbox, e.g. an app's Repositories dataclass.
The relay needs nothing else of the bundle, so any app's or library's bundle with an outbox field works.
Attributes:
| Name | Type | Description |
|---|---|---|
outbox |
Outbox
|
The outbox, sharing the unit of work's transaction. |
Outbox
¶
Bases: Protocol
The transactional outbox: events stored together with the change that caused them.
An outbox shares the unit of work of the other repositories, so an added event is stored if and only if the change is committed. The outbox relay later publishes the stored events and marks them published.
Methods:
| Name | Description |
|---|---|
add_event |
Store the event for publishing once the unit of work commits. |
list_unpublished |
Return at most |
mark_published |
Mark the events with the given ids published; unknown ids are ignored. |
delete_published_before |
Delete the events published before |
OutboxRelay
¶
OutboxRelay(
repositories: UnitOfWorkFactory[HasOutbox],
publisher: EventPublisher,
*,
poll_interval: float = 5.0,
batch_size: int = 100,
retention: timedelta = timedelta(days=7)
)
Publishes the events of the outbox and marks them published.
The only component that publishes: run relays everything unpublished at start (events left over from a crash),
then again whenever wake is called (after a commit) or poll_interval seconds passed. An event is marked
published only after the broker accepted it, so a crash in between publishes it again: at least once. Events are
published in the order they were added.
Single application instance only: several relays would publish the same events. With several instances, claim
rows with SELECT ... FOR UPDATE SKIP LOCKED (or elect one leader) before publishing.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
repositories
|
UnitOfWorkFactory[HasOutbox]
|
Opens the units of work to read and update the outbox with. |
required |
publisher
|
EventPublisher
|
The broker the events are published to. |
required |
poll_interval
|
float
|
Seconds between relays when the relay was not woken. |
5.0
|
batch_size
|
int
|
The maximum number of events read from the outbox at once. |
100
|
retention
|
timedelta
|
How long published events are kept in the outbox before |
timedelta(days=7)
|
Methods:
| Name | Description |
|---|---|
wake |
Relay now instead of at the next poll; safe to call from any thread. |
run |
Relay until cancelled; a failed relay is logged and retried at the next poll. |
relay_pending |
Publish all unpublished events, oldest first; return how many were published. |
prune |
Delete the events published longer ago than the retention; return how many. |
Source code in src/commons/events/relay.py
wake
¶
Relay now instead of at the next poll; safe to call from any thread.
Source code in src/commons/events/relay.py
run
async
¶
Relay until cancelled; a failed relay is logged and retried at the next poll.
Source code in src/commons/events/relay.py
relay_pending
async
¶
Publish all unpublished events, oldest first; return how many were published.
Source code in src/commons/events/relay.py
prune
async
¶
Delete the events published longer ago than the retention; return how many.
Source code in src/commons/events/relay.py
EventsSettings
¶
Bases: BaseModel
The settings of the message broker, the outbox relay and the maintenance, read from [app.events].
Attributes:
| Name | Type | Description |
|---|---|---|
mode |
Literal['file', 'memory']
|
|
dir |
Path | None
|
The directory of the |
poll_interval_seconds |
float
|
How often the relay and the consumers look for work they were not woken for. |
handler_max_concurrency |
int
|
How many event handlers of one consumer group may run at the same time, for events with different keys. |
handler_max_attempts |
int
|
How often an event handler is tried before the event is dead-lettered. |
outbox_retention_days |
int
|
How long published events are kept in the outbox. |
maintenance_interval_seconds |
float
|
How often the outbox is pruned and the maintenance jobs run. |
Methods:
| Name | Description |
|---|---|
events_dir |
Return the directory of the |
read |
Read the settings from |
poll_interval_seconds
class-attribute
instance-attribute
¶
handler_max_concurrency
class-attribute
instance-attribute
¶
handler_max_attempts
class-attribute
instance-attribute
¶
outbox_retention_days
class-attribute
instance-attribute
¶
maintenance_interval_seconds
class-attribute
instance-attribute
¶
events_dir
¶
read
staticmethod
¶
read() -> EventsSettings
Read the settings from [app.events]; the defaults if the section is missing.
Source code in src/commons/events/settings.py
SqlOutbox
¶
The SQL adapter of the Outbox port, on the table outbox_events.
The outbox never commits; the caller (the unit of work) owns the transaction, so an added event is stored together with the change that caused it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
Session
|
The session of the unit of work. |
required |
Methods:
| Name | Description |
|---|---|
add_event |
|
list_unpublished |
|
mark_published |
|
delete_published_before |
|
Attributes:
| Name | Type | Description |
|---|---|---|
session |
|
Source code in src/commons/events/sql.py
SqlOutboxEvent
¶
Bases: SQLModel
The persistence row of one outbox event (outbox_events).
Not historized: the outbox is plumbing, not domain state. sequence (an autoincrement key) keeps the order the
events were added in; published_at is NULL until the relay published the event. One table per
application: the app and the libraries it uses add their events to it, on their own topics.
Methods:
| Name | Description |
|---|---|
from_envelope |
Return the row of an envelope. |
to_envelope |
Return the envelope of the row. |
Attributes:
| Name | Type | Description |
|---|---|---|
__tablename__ |
|
|
sequence |
int | None
|
|
event_id |
str
|
|
topic |
str
|
|
key |
str | None
|
|
event_type |
str
|
|
occurred_at |
datetime
|
|
payload |
dict[str, Any]
|
|
published_at |
datetime | None
|
|
sequence
class-attribute
instance-attribute
¶
event_id
class-attribute
instance-attribute
¶
event_id: str = Field(unique=True, max_length=ID_LENGTH)
key
class-attribute
instance-attribute
¶
key: str | None = Field(
default=None, max_length=NAME_LENGTH
)
published_at
class-attribute
instance-attribute
¶
published_at: datetime | None = Field(
default=None, sa_type=UTCDateTime, index=True
)
from_envelope
staticmethod
¶
from_envelope(envelope: EventEnvelope) -> SqlOutboxEvent
Return the row of an envelope.
Source code in src/commons/events/sql.py
to_envelope
¶
to_envelope() -> EventEnvelope
Return the envelope of the row.
Source code in src/commons/events/sql.py
BackgroundWorkers
¶
BackgroundWorkers(
relay: OutboxRelay,
consumer: EventConsumer,
*,
maintenance: list[Maintenance] | None = None,
maintenance_interval: float = 60.0
)
The background tasks of the (single) application instance, started and stopped by its lifespan.
- the outbox relay, publishing committed events,
- the event consumer, executing the handlers subscribed with
subscribe, - maintenance: pruning the outbox and the given
maintenancejobs, at start and everymaintenance_intervalseconds.
A task that crashes is logged and restarted, so one failure cannot silently stop event processing.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
relay
|
OutboxRelay
|
Publishes the events of the outbox. |
required |
consumer
|
EventConsumer
|
Delivers the published events to their handlers. |
required |
maintenance
|
list[Maintenance] | None
|
Further periodic jobs, run after the outbox is pruned. |
None
|
maintenance_interval
|
float
|
Seconds between maintenance runs. |
60.0
|
Methods:
| Name | Description |
|---|---|
subscribe |
Let consumer |
start |
Start all tasks on the running event loop. |
stop |
Cancel all tasks and wait until they ended, at most |
maintain |
Prune the outbox and run the maintenance jobs once. |
Source code in src/commons/events/workers.py
subscribe
¶
subscribe(
*,
group: str,
event_type: type[E],
handler: EventHandler[E]
) -> None
Let consumer group process the events of event_type with handler.
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If the workers are already running; subscribe before |
ValueError
|
If |
Source code in src/commons/events/workers.py
start
¶
Start all tasks on the running event loop.
Source code in src/commons/events/workers.py
stop
async
¶
Cancel all tasks and wait until they ended, at most timeout seconds.
A task still running is cancelled again every half second: a library in a handler (e.g. anyio in an LLM
client) may swallow a cancellation, and the task's loop then carries on. Tasks left after timeout are
logged and abandoned, so a stuck task never blocks the shutdown.
Source code in src/commons/events/workers.py
maintain
async
¶
CommitNotifyingUnitOfWorkFactory
¶
CommitNotifyingUnitOfWorkFactory(
inner: UnitOfWorkFactory[T],
)
Bases: UnitOfWorkFactory[T]
Wraps a UnitOfWorkFactory to call listeners after every successful commit.
Lets the outbox relay publish events right after the transaction that stored them, without domain functions having to know about the relay. A failing listener is logged and never fails the (already committed) unit of work.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
inner
|
UnitOfWorkFactory[T]
|
The wrapped factory. |
required |
Methods:
| Name | Description |
|---|---|
add_commit_listener |
Call |
for_user |
|
for_system |
|
__enter__ |
Open a unit of work like entering the wrapped factory (for its current user), with notifying commit. |
__exit__ |
|
Source code in src/commons/events/workers.py
add_commit_listener
¶
add_commit_listener(listener: CommitListener) -> None
for_user
¶
for_user(user: User) -> UnitOfWork[T]
for_system
¶
for_system() -> UnitOfWork[T]
__enter__
¶
__enter__() -> tuple[T, CommitFunction]
Open a unit of work like entering the wrapped factory (for its current user), with notifying commit.
Source code in src/commons/events/workers.py
background_workers_from_settings
¶
background_workers_from_settings(
repositories: UnitOfWorkFactory[HasOutbox],
settings: EventsSettings | None = None,
maintenance: list[Maintenance] | None = None,
) -> BackgroundWorkers
Wire the message broker, the outbox relay and the event consumer.
The broker is selected by [app.events] mode. If repositories signals commits (a
CommitNotifyingUnitOfWorkFactory), the relay publishes right after every commit; otherwise it relies on
polling. Subscribe the event handlers with BackgroundWorkers.subscribe before starting the workers.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
repositories
|
UnitOfWorkFactory[HasOutbox]
|
Opens the units of work whose repositories contain the outbox. |
required |
settings
|
EventsSettings | None
|
The settings. Read from |
None
|
maintenance
|
list[Maintenance] | None
|
Further periodic jobs, run after the outbox is pruned. |
None
|