Events & outbox¶
Every event goes through one publish seam, so changing how events are delivered is a configuration change rather than an edit to every call site.
The envelope¶
from jasil.events import Event, new_event
event = new_event(
"order.created",
{"order_id": 42},
source="api:create_order",
metadata={"tenant_id": 7},
)
| Field | Meaning |
|---|---|
event_id |
UUIDv4, stable across retries — also the deduplication key. |
event_type |
Dot-notation channel, owned by the publishing domain. |
source |
Where it originated. |
timestamp |
ISO-8601 UTC, of the first publish. |
payload |
Domain data, homogeneous per event_type. |
metadata |
Correlation context. |
schema_version |
Which version of the payload's shape this carries. |
The envelope is frozen. Handlers share one instance, so a mutation would be visible to every other handler.
Event types and payload shapes are owned by the publishing domain, not
defined here. JASIL only knows the envelope. The one metadata key it defines is
META_REQUEST_ID; everything else in metadata is yours to name.
What not to put in one¶
An event is not a private in-memory message. Know where the two open fields end up before you fill them:
| Field | Where it goes |
|---|---|
payload |
Written verbatim to event_outbox and processing_jobs, to event_log when the trail is enabled, and serialized onto the Redis stream on the redis:// bus. |
metadata |
The same three tables and the same stream — and into log records, whole, whenever a best-effort subscriber fails. |
So:
No secrets. No tokens, passwords, API keys, or session identifiers. A
correlation id is fine; a bearer token is not. Metadata in particular is logged
in full by best_effort, so anything you put there should be
safe to read in a log aggregator.
Think about personal data. Whatever you publish inherits the retention window of the tables it lands in, not the retention rules of the record it describes — and a dead-lettered job keeps its payload until an operator clears it. Publishing an id and having the subscriber re-read the row keeps deletion in one place.
Keep it small. JASIL does not cap payload size: the value is stored as JSON in every row it reaches, and each durable subscriber gets its own copy. Reference a blob through the storage provider rather than inlining it.
Identifiers are bounded, because they are indexed columns: event_type is
capped at 100 characters, source at 50, and a durable subscriber_id at 200.
Going over raises at new_event rather than failing at the write.
Publishing¶
from jasil.publisher import publish
publish("order.created", {"order_id": 42}, source="api:create_order", db=db)
Publishing never raises
Delivery failures are logged and swallowed. Your domain row is the source of truth, and a publish that breaks the request that produced it would be worse than a missed side effect. Every subscriber therefore needs a reconciliation path — a backfill or sweeper that re-derives missed work. See Reconciliation nets.
The ambient correlation id is stamped automatically; see Configuration.
Committing variants¶
publish assumes you have already committed. When you have not, and want the
event to be atomic with your domain write, hand over the commit:
from jasil.publisher import publish_committing
publish_committing(
"order.created",
{"order_id": 42},
source="api:create_order",
db=db,
commit=db.commit,
)
Behaviour depends on the delivery route:
- Durable — the outbox row is staged on your session without committing,
then
commit()flushes your domain rows and the outbox row in one transaction. The event cannot be lost relative to the change that produced it. A staging failure propagates so the whole unit of work rolls back. - Best-effort —
commit()runs first, so the domain row is durable regardless, then the event is dispatched and any dispatch failure is swallowed.
publish_many_committing is the batch form, for producers that touch many rows
in one unit of work. An empty batch still commits exactly once.
Publishing without owning the commit¶
publish_committing makes the publisher own the commit, so a function that
publishes cannot be composed with other writes. When the transaction belongs to
someone else, stage the event and let that owner commit:
from jasil.publisher import publish_many_on_commit, publish_on_commit
publish_on_commit(
"order.created",
{"order_id": 42},
source="api:create_order",
db=db,
)
# ... more writes, more events ...
db.commit() # whoever owns the transaction
- Durable — the outbox row is staged on your session immediately and commits or rolls back atomically with your domain rows. A staging failure raises.
- Best-effort — the event is held on the session and dispatched after the outermost commit, in publish order. A rolled-back (or closed) transaction, or a rolled-back savepoint, discards its events; events from a released savepoint wait for the outermost commit. Dispatch failures are logged and swallowed.
Details worth knowing: the envelope (and ambient request id) is captured when you
publish, not when you commit; publishing starts the session's transaction if none
is active; "outermost" means the session's outermost transaction, so a session
joined to an external transaction (join_transaction_mode="create_savepoint")
dispatches on its own commit; an event published from a subscriber during
dispatch waits for the next commit. publish_many_on_commit([]) is a no-op.
Only synchronous Session objects are supported; anything else (an AsyncSession,
a scoped_session proxy) raises TypeError.
Delivery routing¶
An event takes the durable route only when both are true:
jobs.enabledis set, and- at least one durable subscriber is registered for its
event_type, and - the caller supplied a session.
Otherwise it goes on the event bus. Writing to the outbox with nothing to relay to would strand the row forever, so the registry check is not optional.
publish(...)
│
├─ durable jobs on + subscriber registered + db given
│ └─▶ event_outbox ──relay──▶ processing_jobs ──▶ subscriber
│
└─ otherwise
└─▶ event bus ──▶ subscriber (in-process, or via Redis Streams)
Subscribing¶
A durable handler must raise on failure, so the runner can retry and eventually dead-letter. A bus subscriber must not, so derived work cannot fail the request that produced the event. Write the raising core once and wrap it:
from jasil.subscribers import best_effort
def render_invoice(event: Event) -> None: ... # raises on failure
on_order_created = best_effort(render_invoice) # bus subscriber
best_effort logs the event type, id, subscriber name and the whole metadata
dict, then swallows. The raising original stays available for durable
registration.
Payload versioning¶
Events outlive the code that wrote them — in the outbox, in a Redis stream, and during a rolling deploy where old and new replicas run at once. A consumer that silently ignores unknown keys would read a renamed field as its default and do the wrong thing quietly.
from typing import ClassVar
from jasil.event_versioning import VersionedPayload, parse_payload
class OrderCreated(VersionedPayload):
SCHEMA_VERSION: ClassVar[int] = 2
UPGRADERS: ClassVar[dict] = {
1: lambda p: {**p, "currency": "EUR"}, # v1 had no currency
}
order_id: int
currency: str
def handle(event: Event) -> None:
payload = parse_payload(OrderCreated, event)
- Older payload — walked forward one version at a time through
UPGRADERS. Evolving 1 → 3 needs a 1→2 and a 2→3 entry, not a 1→3 jump. - Newer payload — refused with
UnsupportedEventVersionError. On the bus this is logged and swallowed; in a durable job it drives retry and eventually dead-lettering, so the event waits for the replica that understands it. - Missing upgrader — also refused. An evolution that shipped without its migration fails loudly instead of corrupting data.
Bump SCHEMA_VERSION when a field's shape or meaning changes. Purely additive
optional fields do not need one.
Delivery guarantees, honestly¶
publish is best-effort from the producer's perspective. Without the
committing variants, the outbox write is not atomic with the domain change, so a
crash between them can drop an event.
"Durable" means retryable once written, not never lost. Use
publish_committing or publish_on_commit when you need the stronger guarantee, and give every
subscriber a reconciliation net regardless.