API reference¶
Everything below is exported from django_domain_events directly.
Declaring¶
event ¶
event(
cls: type | None = None,
*,
name: str | None = None,
version: int = 1,
retention: timedelta | Retention | None = None,
) -> type | Callable[[type], type]
Register a frozen dataclass as an event, bare or called.
The default name is <app_label>.<ClassName>. Pin it with name= when
renaming the class would otherwise strand rows written under the old one.
A name may not be longer than the column that holds it, 255 characters,
whether given or derived: a longer one is refused here with ValueError,
because Postgres raises at fire() time while SQLite enforces nothing.
retention says how long prune_events keeps this event's rows: a
timedelta for a window of its own, Retention.SUCCEEDED or
Retention.SETTLED to delete it once consumed, or None for
RETENTION_DAYS. fire() copies it onto every row it records, so a
later change here does not reach events already recorded.
receiver ¶
receiver(
event_class: type[E],
*,
mode: DeliveryMode = DeliveryMode.DURABLE,
takes_context: Literal[False] = False,
key: str | None = None,
max_attempts: int = 5,
eager: bool = False,
site: str = "relay",
lease_seconds: int | None = None,
on_failure: Callable[[DeliveryFailure], Awaitable[None] | None] | None = None,
targets: Targets[E] | None = None,
backoff_base_seconds: float | None = None,
backoff_cap_seconds: float | None = None,
lane: str = DEFAULT_LANE,
give_up_after: timedelta | None = None,
due: timedelta | Due[E] | None = None,
) -> Callable[[Plain[E]], Plain[E]]
receiver(
event_class: type[E],
*,
mode: DeliveryMode = DeliveryMode.DURABLE,
takes_context: Literal[True],
key: str | None = None,
max_attempts: int = 5,
eager: bool = False,
site: str = "relay",
lease_seconds: int | None = None,
on_failure: Callable[[DeliveryFailure], Awaitable[None] | None] | None = None,
targets: Targets[E] | None = None,
backoff_base_seconds: float | None = None,
backoff_cap_seconds: float | None = None,
lane: str = DEFAULT_LANE,
give_up_after: timedelta | None = None,
due: timedelta | Due[E] | None = None,
) -> Callable[[WithContext[E]], WithContext[E]]
receiver(
event_class: type[E],
*,
mode: DeliveryMode = DeliveryMode.DURABLE,
takes_context: bool = False,
key: str | None = None,
max_attempts: int = 5,
eager: bool = False,
site: str = "relay",
lease_seconds: int | None = None,
on_failure: Callable[[DeliveryFailure], Awaitable[None] | None] | None = None,
targets: Targets[E] | None = None,
backoff_base_seconds: float | None = None,
backoff_cap_seconds: float | None = None,
lane: str = DEFAULT_LANE,
give_up_after: timedelta | None = None,
due: timedelta | Due[E] | None = None,
) -> Callable[[Callable[..., None]], Callable[..., None]]
Register a callable to receive one event type, or every event.
event_class may be AnyEvent, which declares a wildcard: the receiver
is owed every event fired, including events declared by apps that load
after it. See AnyEvent for what that is for and what it costs.
takes_context is the spelling django.tasks.task uses for the same
idea. The overloads make a checker enforce the arity it implies, so
declaring one and writing the other fails at the decorator rather than in
the relay hours later.
key is written onto every delivery row of a DURABLE receiver and is
what the row is found by, so it may not be longer than the column that holds
it, 255 characters. A longer one - explicit, or derived from a very long
function name - is refused here with ValueError, because the database
either truncates it, orphaning the delivery, or raises at fire time; SQLite
enforces nothing, so it would pass a test suite and fail in production. The
rule holds at every mode, though INLINE and ON_COMMIT store no
key, so that a key stays valid when its mode changes.
max_attempts is copied onto each delivery row at fire time.
site is the execution knob, separate from mode on purpose: timing is
what a receiver promises about the transaction, and where its code runs is a
different question that only a queue answers. "relay" runs it in the
relay worker; "task" hands it to the configured task backend, which then
acknowledges the row when it finishes.
eager additionally attempts delivery immediately after commit, in the
firing process, with the relay as the fallback for whatever process death
loses. It is what stops DURABLE feeling slow: outbox durability at
on-commit latency, at the cost of a duplicate when the process dies
mid-receiver - which at-least-once already required everyone to tolerate.
The receiver may be declared async def, at every mode. It is run to
completion where a function would be called - in fire() for INLINE, at
commit for ON_COMMIT, in the relay, a task or an eager attempt for DURABLE -
through asgiref.sync.async_to_sync, while the calling thread serves its
sync_to_async calls, so Django's async ORM writes land in the
transaction carrying the acknowledgement and roll back with it. Outside
ASGI each call runs on a fresh event loop of its own, closed when it
returns; under ASGI an INLINE or ON_COMMIT coroutine runs on the server's
loop. Either way a client bound to a loop cannot be shared between calls.
What it buys is the ability to call async libraries, not concurrency: the
relay still runs one delivery at a time. A coroutine that is cancelled is
a failed attempt. A sync callable that returns an awaitable - a
functools.wraps decorator around an async def - or an async def
containing yield is refused at the call with TypeError, rather than
recorded as done with its body never run; mark such a wrapper with
asgiref.sync.markcoroutinefunction. on_failure may be async def
on the same terms. targets and due may not; see below.
on_failure is called after a failed attempt has been recorded, outside
the transaction that was just rolled back, so a receiver can keep a durable
record of its own failure. It could not before: everything a receiver writes
commits with its acknowledgement and is discarded the moment it raises, so
the attempts worth logging were exactly the ones that could not be logged.
The hook must not raise; one that does is logged and swallowed, because a
failure path that fails is worse than a lost log line.
lease_seconds overrides LEASE_SECONDS for this receiver alone, and
is the answer for one that legitimately runs long. A receiver still working
when its lease lapses has its row taken by another worker and its own work
rolled back - correct, and entirely wasted. Declaring it here rather than
offering the receiver a way to extend its lease from the inside, because
that cannot work: the receiver runs inside the transaction that carries its
acknowledgement, so anything it writes is invisible to every other worker
until it has already finished. Where every durable receiver in its lane
declares the same lease, it is also the lease the lane's claims stamp - at
most LEASE_SECONDS, plus 10 percent of headroom - so it changes how big
a batch the lane can work through before the batch's tail lapses.
targets makes the receiver a fan-out: a callable taking the event and
a DeliveryContext, returning the strings this event is owed to - endpoint
ids, tenant slugs, anything that names a destination. fire() writes one
delivery row per target instead of one per receiver, each with its own
attempt count, backoff and dead-letter, and hands the target to the receiver
as DeliveryContext.target. A target returned twice is delivered once,
and a callable returning nothing writes no row at all, which is how a
wildcard receiver says "not this event". Each target must be a non-empty
string; anything else is refused where it is returned.
The callable runs inside the caller's transaction, at fire time, exactly
as the event row is written - so fire()'s transactional contract is its
contract too. What it reads is what the business change can see, including
that change's own uncommitted rows. If it raises, fire() raises, and
the caller's transaction fails with it: the change, the event and every
delivery row roll back together. That is deliberate. A fan-out that swallowed
the error and delivered to nobody would commit a change whose consequences
silently never happened, which is the failure this package exists to rule
out. Keep the callable's failure modes in view: it is code in the middle of
somebody else's write.
It is also a query in the hot path. fire() calls every fan-out
receiver's callable on every event it is owed - for a wildcard, every event
fired - so a callable that queries a table costs one query per fired event
per fan-out receiver. For a transport replacing an event per destination
that is a clear saving; for one declared carelessly it is a cost paid by
every write in the system. Make the lookup indexed, and make it return
early for events it can rule out without one.
replay_events calls it again, so a replay goes to the targets that exist
at replay time.
It must be a plain function, not async def, and so must a due
callable: each would cost an event loop per receiver per fire, inside the
caller's request, for what is a database read. Either is refused here.
backoff_base_seconds and backoff_cap_seconds override
BACKOFF_BASE_SECONDS and BACKOFF_CAP_SECONDS for this receiver alone,
as lease_seconds overrides LEASE_SECONDS. They are read when an
attempt fails, not copied onto the row, so a curve changed by a deploy
applies to deliveries already in flight. Either may be declared without the
other, which then comes from its setting; only when both are declared is the
cap refused for being below the base, since a setting can change after the
declaration is read. Full jitter applies to this curve as to the
default: each wait is drawn from zero up to the ceiling, so a base of
sixty seconds means a first retry somewhere in the next minute, possibly
after a few seconds, never "in a minute". A list of delays or a callable is
not offered, because the catalogue could not publish one.
lane names the relay processes that claim this receiver's rows:
deliver_events --lane mail claims only receivers declared with
lane="mail", and a relay started without --lane claims every row no
named lane takes - including rows whose receiver has since been deleted,
which it records orphaned. It is read from the registry at claim time, so
moving a receiver to another lane moves its owed rows with it. It is how a
slow receiver stops holding up the rest, and it is declared here rather than
as relay flags because the default relay is the one that must exclude it,
and an exclusion list kept in deployment manifests goes stale. An
eager=True attempt still runs in the firing process, whatever the lane.
give_up_after is the bound that ends a delivery whose receiver defers
with RetryAfter(seconds, counts=False). That deferral spends no
attempt, so max_attempts cannot end it; this does. Once a row has been
owed for this long - since it was written, or since a replay or requeue
reopened it - the next deferral dead-letters it, as a spent budget would.
It is a timedelta because a bare number has no unit. Without it, a
deferral that does not count is counted after all, with a warning, so that
every delivery still ends. It bounds deferrals alone: an ordinary failure
or a counting RetryAfter is still ended by max_attempts, however
long the row has been owed.
due makes the receiver a scheduled reaction: its delivery of an
event is written now and runs later. A timedelta counts from the
event's occurred_at, the domain's own time; a callable is handed the
event and returns an aware datetime, for a time the event itself names.
The time belongs to the receiver rather than to fire() because one
event drives reactions at different times - send the offer now, expire it
in 48 hours.
The callable runs inside the caller's transaction, at fire time, as a
targets callable does, and if it raises, or returns anything but an
aware datetime, fire() raises: a reaction that silently never
happened is the failure this package exists to rule out. A naive datetime
is refused rather than read as UTC or as local time, either of which is
wrong for somebody by hours. A due time already past - an event fired with
an old occurred_at - is due as soon as the row is written, and owed
from then.
The row is written with its available_at and its due_at at the due
time, so the relay claims it then and not before; DeliveryContext.due_at
hands the time to the receiver, so it can recompute the due time from
current state and answer an early run with NotYet. Everything else
about the delivery is unchanged - attempts, backoff, dead letters,
site="task", lanes - and starts at the due time. replay_events
resolves it again and writes the later of then and now.
Refused beside a non-durable mode, which has no row to wait in; beside
eager=True, which means "attempt it at commit"; and beside
targets, because a fan-out resolved at fire time would keep that
recipient set until the delivery came due while a replay resolves it when
it runs. A scheduled receiver that needs a fan-out fires a follow-up event
whose targets resolve when it is due.
AnyEvent ¶
Declare a receiver for every event: @receiver(AnyEvent, ...).
A marker, never fired and never instantiated. registry.receivers_for
returns the receivers declared for an event's class plus every receiver
declared for this one, so a wildcard is matched when an event is fired - not
when the receiver is declared. That is the whole difference from walking the
registry at startup and declaring a receiver per event, which silently
misses every event declared by an app that loads afterwards.
Meant for a transport - something that forwards events without knowing
what they are, such as a webhook sender or a bridge to a broker. A wildcard
on its own writes a delivery row for every event the system fires, which is
why it is usually declared with targets=: a callable returning nothing
for an event nobody wants writes no row at all.
The receiver is handed whatever was fired, so its event parameter is
honestly typed object.
It appears in the catalogue in a section of its own rather than under every
event, and what_listens_to(OrderPlaced) leaves it out; ask
what_listens_to(AnyEvent) for the wildcards.
Firing¶
fire ¶
fire(
event: object, *, dedupe_key: str | None = None, occurred_at: datetime | None = None
) -> int | None
Record an event and owe it to its durable receivers. Returns its id.
Returns the event id, or None when suppression discarded it without
recording.
This does not call durable receivers; it records intent. The event row and
one delivery row per durable receiver are written inside the caller's
transaction, so the obligation exists if and only if the business change
committed. A durable receiver therefore cannot signal failure back here -
only INLINE receivers can, by raising.
A durable receiver declared with targets= gets one row per target
instead, and its callable runs here, inside the caller's transaction.
If it raises, this raises, and the caller's change rolls back with the event:
a fan-out that delivered to nobody would be a committed change whose
consequences silently never happened. It also costs whatever the callable
costs, on every event it is owed - one query per fired event per fan-out
receiver, for one that reads a table.
A durable receiver declared with due= has its row written due later:
available_at and due_at at its due time, never earlier than now. A
due callable runs here too, on the same terms as targets: if it
raises, or answers with anything but an aware datetime, this raises.
The event's declared retention is copied onto the row. A dedupe_key
is refused, with ValueError, for an event declared to delete on
consumption: the key is unique on the row, so it would protect only until
the prune deleted it.
attributed ¶
attributed(
*,
actor: Any = None,
actor_key: str = "",
actor_label: str = "",
correlation_id: UUID | None = None,
**data: Any,
) -> Iterator[Scope]
Attach ambient facts to every event fired inside this block.
Nested blocks layer: an inner one overrides what it sets and inherits the rest, so a request-level actor survives a block that only adds a source. The actor is one such thing - naming any part of it replaces all of it, because a key from one block beside a user id from another describes two actors.
actor is any object; its identity is derived once, here. Only an instance
of the user model reaches the actor column, which is a foreign key to it;
everything else is identified by actor_key alone.
current_scope ¶
The scope in effect right now. Read at fire time, never later.
A fresh empty Scope when none is set, not a shared one: a singleton's dict would be handed to callers and to receivers, and one mutation would reach every later event in the process.
suppressed ¶
Fire these events without delivering them, and say why.
The reason is required and lands on the row. A silently dropped event is unauditable, which is the failure mode suppression is most likely to cause, so the default writes the event and marks it rather than discarding it.
record=False discards instead. It exists because a hundred-thousand-row
import writing a hundred thousand suppressed rows is a surprise, and it
trades the audit trail for the write - which is the whole point of the
default, so it is named rather than defaulted.
Nested blocks accumulate rather than replace. A library that suppresses its own event type inside your block must not re-enable yours: the innermost matching reason is the one recorded, and any matching block asking not to record wins, because that is the safer half of the disagreement.
caused_by ¶
Mark events fired inside this block as descended from event_id.
Set around every receiver, at every execution site, so an event a receiver fires records its parent with no ceremony at the call site - a parameter threaded through every receiver is a parameter someone forgets.
Both values come off the parent's row, never from a ContextVar: by the
time a durable delivery runs, the block that attributed the parent has long
exited, possibly in another process. Causation is one hop; the correlation
id is the whole tree, and carrying it here is what keeps a grandchild fired
hours later in the same chain as the request that started it.
propagate_scope ¶
Carry the current scope into a thread you start yourself.
threading.Thread and ThreadPoolExecutor.submit start with an empty
context, not a copy of yours - so a worker thread silently loses the
attribution of whoever spawned it. This is the gotcha that actually bites,
because nothing fails: events simply arrive with no actor.
executor.submit(propagate_scope(fn), *args)
Call it at submit time, not as a @propagate_scope decorator. It
captures the scope when it is called, and at decoration time - import time -
there is none, so the decorator form silently carries nothing, which is the
exact failure it exists to prevent.
It captures the scope's values rather than a contextvars.Context, so
the wrapper is reusable: one Context cannot be entered twice, and a fan-out
that submits the same wrapped callable per item would raise on the second.
Not needed across sync_to_async / async_to_sync, which carry context
both ways, nor for asyncio tasks, which inherit a copy at creation. And
it cannot help across a process boundary, where the answer is the event row.
Delivery¶
run_relay ¶
run_relay(
*,
worker_id: str,
passes: int | None = None,
now: Callable[[], datetime] = timezone.now,
sleep: Callable[[float], None] = time_module.sleep,
wait: Callable[[float], bool] | None = None,
stop: Callable[[], bool] = lambda: False,
allow_unsafe_concurrency: bool = False,
lane: str | None = DEFAULT_LANE,
batch_size: int | None = None,
monotonic: Callable[[], float] = time_module.monotonic,
) -> dict[DeliveryStatus, int]
Claim and deliver until passes is spent, stop says so, or forever.
The clock and the sleep are arguments rather than module calls so the loop is testable without waiting: every branch here turns on time, and a suite that had to elapse real seconds to reach one would either be slow or never reach it.
stop is read before every claim and before every delivery. When it
returns true the relay finishes the delivery in hand, hands back the rest
of its claimed batch, and returns: the rows it gives back are claimable by
any worker at once, rather than when their lease lapses. An idle relay
reads it when its wait ends, so it stops within POLL_SECONDS.
deliver_events wires it to SIGTERM and SIGINT.
The relay survives its database. A claim, a delivery or a wait that fails
is logged and the loop goes on; after a database error the connection is
closed so the next query opens a fresh one, and a failed claim is retried
after a backoff that doubles from POLL_SECONDS up to 15 seconds.
Refuses to start where the database cannot express a skipped lock. Two
workers there would hand the same row to two receivers on every pass, which
at-least-once tolerates but nobody wants as a steady state.
allow_unsafe_concurrency lifts that for a deployment running exactly one
relay, which is a real shape in development; running two under it is the
thing the guard exists to prevent.
lane is the lane this process serves: only the rows of receivers
declared with that lane=, read from the registry on every claim. The
default lane is every row no named lane takes, including the rows of a
receiver that has since been deleted, and it is what a relay serves unless
told otherwise - the default relay staying out of a slow lane is the whole
of the isolation. None serves every lane, for a single relay in development.
A lane no receiver is declared in is refused at start, because a relay
serving it would claim nothing and look healthy doing it.
Concurrency is the number of processes. Deliveries run one at a time within
a relay, so a lane that must go faster runs more relays with the same
lane; nothing here limits the rate across them.
batch_size sizes this relay's claims in place of BATCH_SIZE, which
also sizes prune batches and requeue chunks. Every row of a batch is
claimed under one lease, and a row still waiting its turn when that lease
lapses is taken by another relay, so a slow lane wants a batch it can work
through inside the lease its claim stamps. That is the lane's own lease
plus 10 percent where every durable receiver in it shares one no longer
than LEASE_SECONDS, and LEASE_SECONDS plus 10 percent otherwise: 66 seconds, not
LEASE_SECONDS, for a lane of receivers declaring lease_seconds=60.
A row that starts while its claimed lease is still its receiver's own
starts without extending it, which saves a write per delivery, so a batch
whose rows all start inside a tenth of the lease costs the fewest.
A relay that finds nothing to claim also prunes, so an event declared to be
deleted on consumption goes within minutes without a prune_events cron
line. The sweep is prune_events() with its defaults, run from the idle
branch at most once per RELAY_PRUNE_SECONDS, the first one an interval
after the relay starts rather than at its first idle pass: a relay that is
restarted often, or run for a few passes, then never sweeps at all. Every
relay sweeps whatever its lane. Nothing is lost or deleted twice, because
the prune re-checks at the delete that an event is still due; the interval
is what keeps N relays at N cheap queries per interval. RELAY_PRUNE set
false turns it off for a project that schedules the prune itself. stop
is read between the sweep's batches, so a stop request waits for at most
one batch of PRUNE_BATCH_ROWS rather than for a whole backlog.
monotonic is the sweep's clock, apart from now because that one is a
wall-clock reading a test sets to any value it likes for the claim, and a
monotonic clock is not moved by an NTP step, which would silence or flood
the sweep. It advances
only when a sweep was attempted, and a failed one counts: it is not retried
before the next interval.
now defaults to django.utils.timezone.now, so its readings follow
USE_TZ as the columns they are compared with do; an injected clock may
answer aware under either setting, and is converted to the project's form
(test_a_relay_on_an_aware_clock_claims_and_delivers).
A receiver deferring with RetryAfter(seconds, counts=False) pauses its
lane in this process for seconds, measured on now: the lane's
unstarted rows in the batch are handed back rather than attempted - each
would cost a call to learn what the first already said - and are claimable
by any worker at once. While the pause lasts, a relay serving that lane
claims nothing and idles, and one serving every lane leaves it out of its
claims and goes on delivering the others, the rest of a mixed batch
included. The pause is per process and shares nothing: every relay in the
lane learns of the throttle from its own first deferral, at the cost of one
call each. It ends at the first pass after seconds, so within
POLL_SECONDS of it.
deliver_one ¶
deliver_one(
delivery_id: int,
*,
worker_id: str | None = None,
claimed_by: str | None = None,
claimed_at: str | None = None,
) -> DeliveryStatus | None
Run one delivery and record its outcome. None means this call recorded none.
The receiver's work and the acknowledgement commit together, so a receiver touching only this database is effectively once: the duplicate at-least-once owes you cannot be observed. Side effects outside the database are at-least-once, as promised.
Every write here is a compare-and-set against the claim this call read, and
the lease is extended to cover this one delivery before the receiver runs,
unless the claim already leased it for this receiver.
Both exist for the same reason: a claim can lapse while its worker is still
alive, and a worker that has lost its row must not go on to write a verdict
over whoever legitimately took it. Losing the row returns None rather
than raising - it is an ordinary outcome of a lease expiring, not a fault.
A row that is not owed is refused the same way, whoever asks: one already
settled (succeeded, dead or orphaned), one that failed and is waiting out
its backoff, or one whose due time has not come. Running any of them would
repeat committed work, resurrect a dead letter past its budget, ignore a
RetryAfter the destination sent, or do scheduled work early.
A receiver answering NotYet has its row moved to the time it named,
with no attempt spent, and this returns PENDING: the one outcome that
leaves the row owed and not failed.
claimed_by and claimed_at are the claim a task message carries, as
the relay passed them to TaskBackend.enqueue. Given them, this call
first takes the row: one conditional update, committed before anything
else runs, that succeeds only while the row is still CLAIMED under exactly
that claim, and moves it to worker_id - or to an id minted for this one
take. A second copy of the message, a copy the queue held past its lease,
and a copy for a row that has since settled all fail the take and return
None without running the receiver.
A receiver deferring with RetryAfter(counts=False) is recorded here as
anywhere, but pauses nothing: a pause belongs to the relay process that
claimed the row's batch, and a call made directly holds no batch.
deliver_pending ¶
deliver_pending(
limit: int | None = None,
*,
worker_id: str = "deliver_pending",
ignore_backoff: bool = False,
lane: str | None = None,
batch_size: int | None = None,
now: datetime | None = None,
) -> dict[DeliveryStatus, int]
Claim and deliver what is owed, and report the outcome.
limit=None means everything owed, claimed in batches until none is left;
a number caps it at that many. Claims go through the same leased path the
relay uses, so a pass here and a running relay do not hand the same row to
two receivers - on a backend with row locking. SQLite has none, so two
concurrent passes there can both take the same row.
lane delivers only that lane's rows, as run_relay(lane=...) claims
them; "default" is every row no named lane takes. None, the default
here, is every lane, which is what this function did before lanes existed
and what drain_outbox promises. deliver_events --once passes the
default lane unless told otherwise, as the relay does.
batch_size sizes each claim in place of BATCH_SIZE. It cannot be
combined with limit, which is one claim of exactly that many rows under
one lease: accepting both would silently ignore the batch, and claiming a
hundred rows at once is what a small batch is asked for to prevent
(test_a_limit_and_a_batch_size_together_are_refused).
A receiver deferring with RetryAfter(counts=False) sets its lane aside
for the rest of the call: the lane's unstarted rows in the batch are
handed back rather than attempted, and later claims leave the lane out,
while every other lane goes on being delivered
(test_a_deferral_sets_its_lane_aside_and_the_pass_delivers_the_rest).
now is the moment every claim of the call is made at, in place of the
clock: what is due, which backoff has run out and which lease has lapsed
are all read against it. It is how drain_outbox(now=...) moves a test
forward to a receiver's due time. Only the claims read it; the attempt
itself runs and is recorded on the real clock, since a receiver's
acknowledgement is a fact about when it ran.
claim_batch ¶
claim_batch(
*,
worker_id: str,
now: datetime,
lease: timedelta,
limit: int,
only_ids: list[int] | None = None,
ignore_backoff: bool = False,
lane: str | None = None,
exclude_lanes: Collection[str] = (),
exclude_ids: Collection[int] = (),
) -> list[int]
Take ownership of up to limit deliveries and return their ids.
Claims are leased, not marked: a worker that dies without acknowledging becomes re-claimable when its lease lapses, which is the same path as an ordinary retry rather than a special case.
Rows are selected by available_at and never by primary key. A
transaction holding a lower id can commit after one holding a higher id, so
a row can become visible "in the past"; a high-water mark would skip it
forever.
ignore_backoff claims rows whose retry is still scheduled. It exists for
the test helper, which cannot wait out a jittered hour to observe a retry.
It never claims a row whose due time is still ahead of now - a receiver
declared with due= - because that is not a wait the helper is skipping
but the time the work exists for
(test_ignoring_the_backoff_never_ignores_the_schedule).
lane claims only the rows of the receivers declared in it, read from the
registry on every call; "default" claims every row no named lane takes,
which includes the rows of a receiver that no longer exists; None claims
every lane. The filter is applied to the whole of the owed condition, both
arms, so a relay never takes over another lane's lapsed claims
(test_a_lapsed_claim_in_a_named_lane_stays_out_of_the_default_lane).
exclude_lanes leaves lanes out of the claim, and is how a relay serving
every lane keeps the others flowing while one is paused after a deferral.
It is each lane's own condition negated, so it covers both arms as lane
does - the rows a deferral handed back are lapsed claims, and exactly the
ones not to take straight back
(test_a_paused_lane_covers_its_lapsed_claims_too) - and excluding the
default lane leaves only the named ones, or nothing where none is declared
(test_a_paused_default_lane_with_no_named_lanes_claims_nothing). Empty,
the claim is the one it was without it.
exclude_ids leaves those rows out, and is how a pass keeps a row it is
done with from coming straight back when the pass was told a now beyond
the real clock: one its receiver answered with NotYet, moved to a time
that now has passed, or one handed to a task backend under a lease the
real clock extended and now reads as lapsed. A pass on the real clock
passes none, since neither is claimable again there until its time has
really come
(test_a_row_rescheduled_in_place_on_the_real_clock_is_not_excluded).
Empty, it adds nothing to the claim: Django compiles a negated __in over
no values to nothing at all, so the relay's claim is the one it was
(test_without_named_lanes_the_default_lane_sends_the_unfiltered_claim).
backoff ¶
How long to wait before attempt number attempt may be retried.
jitter is supplied by the caller as a value in [0, 1) rather than drawn
here, so the schedule is a pure function of its arguments and a test can
assert the curve instead of sampling it.
Full jitter: the delay is drawn from the whole window up to the exponential ceiling, not added on top of it. Retrying a shared downstream at ceiling-plus-a-bit keeps every failed delivery in the same cohort, which is the thundering herd the backoff was meant to break up.
PermanentFailure ¶
Bases: Exception
Raised by a durable receiver to say this delivery can never succeed.
The relay dead-letters the row on the attempt that raised it, instead of
spending the rest of the attempt budget first. The budget is otherwise the
only thing that can end a delivery, so a receiver that already knows the
destination is gone - a 410 Gone, a deleted account, a revoked
credential - would go on retrying for hours against something that has told
it, in so many words, that it will never accept another.
An exception rather than a return value, and that is not a style choice. A
receiver returns None by contract, and every mode calls it the same way,
so a return convention would have to be invented for all three and would be
silently ignored by two of them. Raising already means "this attempt
failed"; this narrows it to "and every later one would too".
Only the relay reads it. An INLINE receiver raising it fails the caller's
transaction exactly as any other exception does, and an ON_COMMIT one has
it logged: neither has a row to dead-letter.
The row is recorded as DEAD with the attempt that raised it, and an
on_failure hook sees DEAD, exactly as it would had the budget run out.
A consumer's failure log therefore needs no second case to recognise it.
Beside deliver rather than at the package root, because that is the one
module that reads it. It imports nothing, so it cannot close a cycle.
RetryAfter ¶
Bases: Exception
Raised by a durable receiver to say when its next attempt should run.
The relay schedules the retry seconds from now instead of drawing it from
the backoff curve. This is the one case where the other side has told us the
answer: a 429 or 503 carrying Retry-After: 120 is a destination
saying when it will be ready, and the exponential curve is a guess. Guessing
against a rate limiter means arriving early and being refused again, which
the curve then punishes by waiting longer than was asked.
By default it consumes an attempt. A retry that did not count would let
a destination keep a row alive forever by answering 429 to every
attempt, and nothing would ever declare it dead. Counting it keeps every
delivery bounded by the budget it was fired with; a receiver expecting to
be rate limited widens max_attempts at its declaration. On the last
attempt of the budget the row is dead-lettered like any other failure, and
the requested time is moot.
counts=False is a deferral instead, for a destination limiting how
much it accepts rather than refusing this row: a throttle, a quota. The
attempt is not counted and max_attempts is not spent; what ends the
delivery is give_up_after= on the receiver, a bound in time measured
from when the row became owed, past which the next deferral dead-letters
it. The next attempt runs between seconds and twice that from now -
never earlier than asked, spread so a throttled burst does not come back as
one wave - and the relay that ran it pauses the receiver's lane for
seconds and hands back the rest of its batch rather than spending a
call on each row to learn what the first already said.
A receiver raising counts=False without declaring give_up_after has
nothing that would ever end the delivery, so the deferral is counted like
any other RetryAfter - no jitter, no pause - and a warning naming the
receiver is logged once per process.
It is capped at MAX_RECEIVER_RETRY_DELAY_SECONDS, and a request past
the cap is clamped with a warning naming both numbers. The cap is not
BACKOFF_CAP_SECONDS, which bounds a curve: clamping a legitimate one-hour
Retry-After to a backoff ceiling would hammer a destination that asked to
be left alone, which is the opposite of the feature.
Everything else about a failed attempt is unchanged: the attempt is counted,
the message is stored as last_error, and on_failure is called with
FAILED. A deferral that does not count is recorded the same way, with
the row FAILED and on_failure told the attempt number the receiver
was given - which the next run is given again, since nothing was spent.
A deferral that does not count must ask for a wait above zero, since a zero
would return at once and, spending nothing, would loop until
give_up_after. A negative delay is refused where it is constructed. The relay would record
that refusal as an ordinary failure on the ordinary curve, with a message
saying why, rather than scheduling an attempt in the past.
NotYet ¶
Bases: Exception
Raised by a durable receiver to say its work is not due until until.
The answer to a delivery that runs early. A receiver declared with due=
has its due time resolved when the event fires, and the world moves on
after that: an admin extends an offer from 48 hours to 72, the 48-hour row
runs, finds the offer still open - and returning would succeed and leave
nothing that ever expires it. Raising this instead moves the row to
until and lets it run again then, so the package does not depend on
the application remembering to fire a follow-up event for every change
that moves a due time.
The relay writes until to the row's available_at and due_at
together, sets it back to PENDING and does nothing else:
- No attempt is spent, and the next run is handed the same attempt
number, because nothing failed.
max_attemptscannot end it. - It is not capped by
MAX_RECEIVER_RETRY_DELAY_SECONDS, and not bounded bygive_up_after. Those exist because a destination's advice about its own readiness cannot be trusted to be sane; this is the application's own declaration of when its work is due, asdue=is.outbox_health()shows the row as scheduled until then. - It is not a failure:
last_erroris not written andon_failureis not called. - It sets nothing else aside. A
RetryAfter(counts=False)pauses its lane because a throttle limits every row behind it; this is about one row, and the rest of the batch is delivered.
DeliveryContext.due_at on the next run is until, which is what the
receiver compares against: recompute the due time from current state, and
go ahead once it is no later than the context's. The comparison is
if moved > max(ctx.due_at, timezone.now()): raise NotYet(moved), with
the clock as a floor: a row that runs after the moved time has nothing to
wait for, and comparing against ctx.due_at alone would raise a time
already come, which is refused below and recorded as an ordinary failure
on every retry
(test_the_documented_pattern_does_the_work_of_a_row_that_runs_after_the_moved_time).
until is an aware datetime, and in the future when this is
constructed. A naive one is refused under USE_TZ = True, as due=
refuses one; under False it is local time, the project's own
convention, and an aware one is converted to local time, so until is
always in the form the row will store. A time already come is refused
whatever the setting: spending no attempt, the row would be claimed straight
back and answered the same way, a hot loop nothing would end. Both are
refused where the exception is built, so a receiver raising either has
raised an ordinary TypeError or ValueError instead, which the relay
records as an ordinary failure, counted, with that message.
Not a RetryAfter spelling, although it rests on the same machinery -
an outcome read at the one boundary every execution site shares, recorded
without touching attempts. RetryAfter is a destination's advice,
so it is counted by default, capped, jittered, bounded by
give_up_after and pauses its lane; this is none of those, and a mode of
that class switching each of them off would be more to learn than a class
with one behaviour.
Only a durable receiver can be rescheduled: the relay reads it and nothing
else does. An INLINE receiver raising it fails the caller's transaction
like any other exception
(test_an_inline_receiver_raising_it_fails_the_callers_change), and an
ON_COMMIT one has it logged
(test_an_on_commit_receiver_raising_it_has_it_logged).
notify_relay ¶
notify_relay(
*,
supported: bool | None = None,
connection: Any | None = None,
clock: Callable[[], float] = time_module.monotonic,
) -> None
Tell a listening relay that something is owed.
Fire-and-forget: a notification sent while nobody is listening is simply lost, which is why the relay's poll stays as the floor rather than being replaced by this. It removes latency and never carries the obligation - the delivery row does.
Sends nothing under WAKE = "poll", and at most one per
NOTIFY_COALESCE_SECONDS per database from this process. Skipping one is
safe for the same reason losing one is: a single wake makes the relay claim
everything due, and the poll picks up whatever the skipped one announced.
clock is injected, as sleep is below, so a test moves time rather
than waiting for it.
The hook around each durable attempt is a setting rather than a symbol: see
AROUND_DELIVERY. It is handed the
DeliveryContext below, whose receiver_key names the receiver.
Operations¶
cancel_deliveries ¶
cancel_deliveries(
event_ids: Iterable[int],
*,
receiver_key: str | None = None,
delivery_ids: Iterable[int] | None = None,
) -> dict[str, int]
Withdraw the owed deliveries of these events, so they never run.
The scheduled case is the one this exists for: an offer accepted before its expiry is due, a reminder for an order already paid. A receiver that finds nothing to do and returns is still the answer when the world moved on and nobody said so; this is the answer when the application knows ahead of time, and wants neither the run nor a success recorded for work that was never done.
A delivery is withdrawn if it is unclaimed and owed: pending, which is
a row waiting for its due time or for the relay, or failed, which is one
waiting out its backoff - a scheduled row that came due and failed is
still work, and "never runs" has to cover its retries. It becomes
CANCELLED, a settled status of its own, with completed_at set and
nothing else touched. Not only scheduled rows: an immediate row the relay
has not reached yet is withdrawn the same way, though the relay may well
get there first.
A claimed row is left alone. A worker holds it and may be running the receiver now, so settling it under the worker would record an outcome the receiver contradicts; the receiver re-checks current state when it runs, as it always did. It is counted, so the caller learns how many it could not stop. A settled row - succeeded, dead, orphaned or already cancelled - keeps its outcome: a dead letter withdrawn here would leave the dead-letter counts without anyone having read or requeued it.
Narrowed to one receiver by receiver_key, because one event usually
drives an immediate reaction and a scheduled one, and only the scheduled
one became moot. Narrowed to rows by delivery_ids, which intersects
with event_ids rather than widening it: a stale id list cannot reach
another event's work.
Returns {"cancelled": n, "in_flight": m}: the rows withdrawn, and the
claimed rows in the same scope that were left to run.
Called inside a transaction on the database the package writes to, it
joins it, so the cancel commits with the change that made the work moot or
not at all. replay_events() reopens a cancelled row like any settled
one; requeue_dead() does not touch it.
Concurrency, on a backend with row locks. Each batch locks its events
first, in primary key order, the lock replay_events, requeue_dead
and prune_events take: a cancel issued while a replay of the event is
open waits for it and then withdraws what it reopened
(test_a_cancel_issued_during_a_replay_is_not_overtaken_by_it), and two
callers locking overlapping events queue rather than deadlock, which the
ordering in test_the_events_are_locked_before_their_rows_are_written holds.
Against the prune the lock buys nothing observable: a prune deletes only
an event with nothing owed and this writes only owed rows, so the two never
contend for a delivery row
(test_a_prune_during_a_cancel_loses_nothing_and_takes_the_event_after
passes without it). A relay's
claim takes no event lock, so the write itself carries the status
predicate: a claim committed first leaves the row claimed and the write
skips it (test_it_will_not_cancel_a_row_claimed_after_the_lock, and on two
connections test_a_cancel_waits_for_a_claim_in_flight_and_leaves_the_row_
claimed), and a write committed first leaves nothing for a claim to take.
Each condition of the write is one branch arc, so each has a test that fails without it: pending by test_unclaimed_rows_are_settled_and_never_run; failed by test_a_failed_row_awaiting_its_retry_is_cancelled_too; the event scope by test_only_the_named_events_are_touched; the receiver by test_it_can_be_narrowed_to_one_receiver; the named rows by test_it_can_be_narrowed_to_named_rows, their intersection with the events by test_a_named_row_of_an_event_not_named_is_left_alone, and an empty list meaning no rows rather than every row by test_an_empty_row_list_cancels_nothing.
prune_events ¶
prune_events(
older_than: timedelta | None = None,
*,
now: datetime | None = None,
batch_size: int | None = None,
limit: int | None = None,
stop: Callable[[], bool] | None = None,
) -> int
Delete the events that are due, and return how many went.
An outbox without a prune story becomes the largest table in the database, and it becomes it quietly - which is why this ships rather than waiting for someone to notice. Nothing else deletes: an event's retention is carried out here or not at all.
Due means one of three things, read off the row rather than the registry, so re-declaring or deleting an event class changes nothing for events already recorded:
- Consumed, for an event recorded under
Retention.SUCCEEDED(every delivery succeeded or was cancelled) orRetention.SETTLED(every delivery terminal). No window: it goes at the first prune after it is consumed, and an event with no delivery rows at all is consumed as soon as it commits. - Past its own window, for an event declared with a
timedelta, and settled. - Past the ordinary window,
older_thanorRETENTION_DAYS, and settled, for every other event - including one recorded underRetention.SUCCEEDEDthat a dead letter kept, so the dead letter stays inspectable for the window rather than forever.older_thanreplaces the ordinary window only; an event with a window of its own keeps it.
Settled means no delivery pending, failed or claimed: one still owed is work nobody recorded as lost, and deleting it would drop it. An event with no delivery rows - suppressed, or fired with no durable receivers - is settled by definition.
Deletes in batches of at most batch_size rows (default
PRUNE_BATCH_ROWS), counting each event's delivery rows and the event
row itself, so one transaction's lock and write volume stay bounded however
the rows are spread. An event with more delivery rows than that has them
deleted a batch at a time, each chunk in its own transaction, and goes
with the last of them; until then it is an event with fewer deliveries,
every one of them terminal. limit counts events.
Every batch records each receiver's newest success among the rows it
deletes, in the same transaction, which is what lets quiet_receivers()
still report a receiver whose events are all gone.
Four queries, one per policy and window, none of which reads history:
the ordinary window is a range on recorded_at; each policy and the
windows of their own read a partial index holding only their own live
events, and check each against a partial index of unfinished delivery
rows. With nothing due, the cost grows with how many events with a
retention of their own are alive, and not with how much the tables have
ever held. docs/retention.md has the measurement.
replay_events ¶
replay_events(
event_ids: Iterable[int], *, receiver_keys: Iterable[str] | None = None
) -> dict[str, int]
Make these events owed again, and report what changed.
The receiver set freezes at fire time, so deploying a new receiver does not hand it a backlog of week-old events. That is deliberate - and this is the other half of it: replay is an operation somebody invokes, with a name, and not an accident of a deploy.
Two things happen, and they are counted separately because they are different decisions. A terminal delivery is reopened: it ran, and you want it to run again. A receiver with no row for the event is added: it did not exist when the event fired, and you are choosing to give it the backlog.
Both are owed now, so both get due_at set to the moment of the
replay. A receiver's give_up_after is measured from it, and measuring
from the event's own timestamp would dead-letter a replayed month-old event
on its first deferral
(test_a_replayed_old_event_is_not_dead_lettered_on_its_first_deferral
and test_a_row_a_replay_adds_is_owed_from_the_replay_too).
A receiver declared with due= is the exception: its due time is
resolved again, through the resolver fire() uses, and both columns are
written with the later of that and now. Replay is how a scheduled receiver
is adopted - declare the expiry, replay the recent offers - and writing now
would expire every open offer at once; an offer whose time has already
passed is expired by the replay, owed from it. A due callable is called
again, as a targets callable is, and raises to the operator on the same
terms.
A delivery still in flight is left alone. Reopening a claimed row would hand the same work to two receivers, which is the one thing the lease exists to prevent.
A receiver declared with targets= has its callable called again, so
a replay goes to the targets that exist now: a target it still returns is
reopened or added exactly as a receiver is, and one it no longer returns is
left as it was - neither reopened nor counted - because the callable has
just said it is not owed this event. That includes a blank row written
before the receiver gained targets=. A replay is a new delivery, not a
re-run of an old one, and a target registered after the event was fired
receiving it is the point rather than a side effect.
Calling the callable means rebuilding the event, so a fan-out receiver's replay fails loudly on a payload that no longer decodes, where a plain receiver's reopened row would dead-letter in the relay instead. Either is raised to the operator who asked; only the second waits to be found.
requeue_dead ¶
requeue_dead(
*,
receiver_key: str | None = None,
delivery_ids: Iterable[int] | None = None,
limit: int | None = None,
) -> int
Give dead-lettered deliveries their attempt budget back.
Dead is where a delivery stops on its own; it is not where it stops for
good. Attempts reset to zero rather than staying spent, because a row
requeued at its limit dead-letters again on the first failure and the
operator learns nothing they did not already know. due_at resets to now
for the same reason, applied to time: a receiver's give_up_after is
measured from it, and a dead letter requeued a month after its event would
otherwise be past a one-day bound on its first deferral
(test_a_requeued_row_is_owed_from_the_requeue).
Scoped by receiver, because the usual reason to requeue is that one downstream was broken and now is not. Scoped by row as well, because the other reason is an operator reading a dead-letter list and picking the four they understand.
Introspection¶
catalogue ¶
Describe every declared event, its payload shape and its receivers.
The artefact a team wants and signals cannot produce: what exists, what listens, and under which guarantee - generated from the declarations rather than maintained beside them, so it cannot drift.
Sorted by name throughout. A catalogue is written to a file and diffed against the last one, and import order is not a difference.
Wildcard receivers are listed once, in their own section, and not under each event they receive - which is every event.
render_catalogue ¶
Render a catalogue as a document, in Markdown or JSON.
Both, because they answer different questions: Markdown is read by a person onboarding onto a codebase, JSON is diffed by a pipeline that wants to fail a pull request for removing a field other teams consume.
Both end in exactly one newline. These are written to a file and diffed, and a file with no final newline reports a change on its last line forever.
what_listens_to ¶
Every receiver declared for one event, sorted by key - plus every wildcard.
The question signals cannot answer: a Django signal's receivers are a list of weak references keyed by an opaque dispatch uid, so "who reacts to this" is answerable only by grepping.
The wildcards are the "plus", and they are not repeated here. A receiver
declared for AnyEvent receives this event as it receives every other,
and listing a transport under every event answers the question two hundred
times without saying anything about this one. Ask for them directly with
what_listens_to(AnyEvent), which returns exactly those.
listens_for ¶
The event one receiver is declared for, or None if no such receiver.
The inverse direction, and the one an operator needs: a dead-letter row names a receiver key, and the next question is always what it was supposed to be receiving.
None covers two cases that look the same from a delivery row - a key never
declared, and one whose event class was deleted out from under it - and a
third that does not: a wildcard receiver, declared for AnyEvent, is owed
every event and so names no single one. Its delivery row already says which
event it was.
quiet_receivers ¶
quiet_receivers(
*, within: timedelta | None = None, now: datetime | None = None
) -> list[QuietReceiver]
Declared receivers that have succeeded at nothing inside the window.
The query an event log makes possible and a signal never will: "this receiver has not run since June" is a fact here, not a guess, because every durable delivery left a row.
Driven by the registry rather than by the table, so a receiver that has never received anything appears - which is the answer worth having, and exactly the one a query over delivery rows alone cannot produce.
Only DURABLE receivers write rows, so only they are reported. An INLINE or ON_COMMIT receiver has no delivery history to be quiet about, and listing it as silent forever would train the reader to ignore the output.
Read off succeeded_at rather than completed_at, and with no status
filter at all. Both of those describe the current cycle: replay and
requeue reopen a row and clear them, so an operator who replays yesterday's
events would then be told the receiver had never run. Max ignores nulls,
so a receiver whose every delivery failed still reads as never having
succeeded without a predicate saying so.
The rows are not the whole record: the prune deletes them, within a sweep
for an event declared to delete on consumption, and writes each receiver's
newest success among what it deleted to ReceiverLastSuccess as it goes.
The answer is the later of that and the live rows, so a receiver whose
every event has been pruned still reports when it last ran.
The window defaults to RETENTION_DAYS, the window an ordinary event's deliveries are kept for.
outbox_health ¶
How far behind the outbox is.
The gap the package left until now: quiet_receivers() answers whether a
receiver is running, and nothing answered whether the queue is draining.
Those fail differently - a relay that has been down for an hour has every
receiver quiet and a backlog climbing, while a single wedged receiver has a
backlog and everything else fine.
Owed means "not terminal and due", and a superset of what the relay can
claim right now - a row inside its backoff window is owed and not yet
claimable. The superset is the useful side: this cannot report an empty
queue while work is still outstanding. A row whose due time is still ahead
of now is not outstanding yet, so it is counted as scheduled
instead, and is never what the backlog age is read from: a receiver
declared with due= fourteen days out would otherwise hold a five-minute
alert open for the fortnight.
Testing¶
drain_outbox ¶
drain_outbox(
limit: int | None = None,
*,
respect_backoff: bool = False,
now: datetime | None = None,
) -> dict[DeliveryStatus, int]
Deliver everything owed, from a test, through the production code path.
Deliberately not a task_always_eager equivalent: bypassing the transport
hides both the serialisation boundary and the timing, which is how a suite
passes while production breaks. This runs the same claim, encode, decode and
acknowledgement as the relay, and skips only the waiting.
Skipping the waiting includes the retry backoff, which is why this ignores
it by default: a failed delivery is scheduled a jittered interval ahead - up
to an hour - and a test cannot sit that out. Pass respect_backoff=True
to assert the schedule itself.
It never skips a receiver's due=. A row whose due time has not come is
left alone, because a scheduled reaction is not a wait to be skipped but
the time the work exists for: a test that makes an offer would otherwise
expire it in the same breath. now is how a test moves forward instead -
drain_outbox(now=placed + timedelta(hours=48)) delivers what is due by
then, reading the backoff, when respected, against the same moment. The
receiver still runs on the real clock; DeliveryContext.due_at is what it
should compare against.
A row is attempted at most once a call when its receiver answers
NotYet, and handed to a task backend at most once a call when its
receiver declares site="task". A handed-off row is not counted in the
result, since nothing has happened to it here, and that holds for a backend
that runs the task inside the hand-off too. The pass keeps such a row out of
its later claims only when it was given now: on the real clock the time
a NotYet moved a row to and the lease a hand-off extended are ahead of
the clock when written, so a later claim takes the row again only once that
time has really come, and nothing is excluded or read back
(test_a_task_row_on_the_real_clock_is_not_read_back_or_excluded).
assert_fired ¶
Assert an event was fired, and return the decoded events.
Reads the log rather than patching fire: a mock records that a function
was called, while the row is what the rest of the system reacts to. Decoding
on the way out means a payload that cannot round-trip fails here too.
Types¶
DeliveryMode ¶
Bases: Enum
Timing and guarantee, declared per receiver.
Timing is separate from where a receiver's code runs; a queue is only ever an answer to the second question.
INLINE
class-attribute
instance-attribute
¶
Inside the firing transaction, and free to abort it by raising.
Needs no durability: its failure mode is a rollback, so nothing can be owed.
ON_COMMIT
class-attribute
instance-attribute
¶
After commit, in the firing process, best effort. Not recoverable.
DURABLE
class-attribute
instance-attribute
¶
After commit, at-least-once, retried, with a row recording the debt.
DeliveryStatus ¶
Bases: TextChoices
Status of one delivery: an event, a durable receiver, and its target if any.
FAILED
class-attribute
instance-attribute
¶
Raised, with attempts remaining. Distinct from PENDING so that "has this ever failed" is answerable without reading the attempt count.
ORPHANED
class-attribute
instance-attribute
¶
Addressed to a receiver key the registry no longer has.
CANCELLED
class-attribute
instance-attribute
¶
Settled by cancel_deliveries before it ran: the work was known to be
moot ahead of time, so it never will. Not a success, because the receiver
did not run, and not a dead letter, because nothing failed.
Retention ¶
Bases: Enum
Delete an event as soon as it has been consumed, rather than when a retention window passes.
Passed to @event(retention=...), the alternative to a timedelta.
Copied onto every event row fire() records, so the fate of an event is
settled when it is recorded: re-declaring or deleting the class later
changes nothing for rows already written.
The values are what lands in EventRecord.delete_when. Nothing deletes
until prune_events runs, so a policy is only as prompt as the schedule
running it.
SUCCEEDED
class-attribute
instance-attribute
¶
Once every delivery has succeeded or been cancelled.
A dead or orphaned delivery keeps its event under the ordinary
RETENTION_DAYS window, so the dead letter stays inspectable and
requeue_dead still has a row to reopen. A cancelled one does not: it
was withdrawn on purpose by cancel_deliveries, nothing failed and
nothing is left to requeue, so with every other row succeeded every row of
the event is done.
SETTLED
class-attribute
instance-attribute
¶
Once every delivery is terminal, dead letters included.
A dead letter goes with its event, at the next prune, so nothing is left
for requeue_dead or the dead-letter counts to find.
DeliveryContext
dataclass
¶
Delivery metadata, passed to receivers declaring takes_context.
Also to a targets= callable, at fire time, and to the
AROUND_DELIVERY hook, once per durable attempt.
Frozen data read off the event row, never a live handle: a durable delivery can run in another process hours after the scope that produced it has gone.
target
class-attribute
instance-attribute
¶
Which target of a fan-out receiver this delivery is for.
Blank for a receiver declared without targets=, and for any row written
before the receiver gained one - which is the case worth handling: adding
targets= to a receiver with deliveries still owed leaves those rows
blank, and they are delivered as they were written.
Last, and defaulted, so adding it does not break a consumer constructing one - this is an exported type.
delivery_id
class-attribute
instance-attribute
¶
The primary key of the delivery row this attempt is for.
Set only on the context the relay or a task builds for a delivery. None
where no row exists: the context targets= callables receive at fire
time, which is what decides the rows, and the one INLINE and ON_COMMIT
receivers receive, which never have one. Optional and last for the same
reason as target.
due_at
class-attribute
instance-attribute
¶
When this delivery became due.
For a receiver declared with due=, the due time its row was written
with - which is what a scheduled receiver compares against: recompute the
due time from current state, and if it has moved later since the row was
written (the offer was extended), answer with NotYet rather than doing
the work early. For any other row, when it became owed: the moment a
replay or requeue reopened it, or else when its event was recorded.
None where no row exists, as for delivery_id. Optional and last for the
same reason as target.
receiver_key
class-attribute
instance-attribute
¶
The key of the receiver this context was built for.
Set wherever a receiver is known: on every durable delivery, on the context
an INLINE or ON_COMMIT receiver receives, and on the one a targets=
callable is asked with. What an AROUND_DELIVERY hook labels a span, a
metric or a log line by, since the hook is handed this context and not the
receiver. Blank only on a context built by hand. Optional and last for the
same reason as target.
DeliveryFailure
dataclass
¶
One failed attempt, handed to a receiver's on_failure hook.
Carries the delivery's identity rather than the delivery row, deliberately. The hook runs at a moment when the row has just been written by the relay, and handing over a model instance invites a second write against a row another worker may now own.
status
instance-attribute
¶
FAILED while attempts remain, DEAD once the budget is spent. The
hook is called for both, because "it failed again" and "it will not be
tried again" are different things to record.
error
instance-attribute
¶
The message the relay stored, already truncated as the column is.
target
class-attribute
instance-attribute
¶
The target this delivery was for, blank for a receiver without
targets=. Part of the delivery's identity for a fan-out receiver, since
receiver_key alone no longer says which of its deliveries failed.
Defaulted so adding it does not break a consumer constructing one.
Scope
dataclass
¶
The ambient facts captured onto every event fired inside a block.
Frozen, and nested blocks build a new one rather than mutating: a scope that could be edited from inside would let a receiver rewrite the attribution of events fired after it.
merged ¶
Layer another scope over this one, keeping what it does not set.
Catalogue
dataclass
¶
Every declared event and what listens to it, at one moment.
A snapshot rather than a live view: it is built to be written to a file and compared against the one from last release, which is the whole point of having it.
wildcard_receivers
class-attribute
instance-attribute
¶
Receivers declared for AnyEvent, which receive every event above.
A section of their own rather than a row under every event: a transport listed under all two hundred events is a catalogue nobody reads, and it says nothing about any one of them. Defaulted so adding it does not break a consumer constructing one.
CatalogueEvent
dataclass
¶
One declared event, its payload shape and everything listening to it.
migrates_older_rows
class-attribute
instance-attribute
¶
Whether the class declares upgrade. Worth publishing: it is the
difference between an event whose old rows still decode and one whose old
rows dead-letter after the next breaking change.
Last, and defaulted, so adding it does not break a consumer constructing one - these are exported types.
retention_seconds
class-attribute
instance-attribute
¶
The window of its own the event is declared with, in seconds, or None.
Published as fire() records it on the row rather than as the declared
timedelta or Retention, so the JSON form stays plain values, and a
pipeline diffing catalogues sees an event start or stop deleting early.
Defaulted for the same reason as migrates_older_rows.
delete_when
class-attribute
instance-attribute
¶
"succeeded" or "settled" for an event deleted once consumed (the
Retention value), or blank. At most one of this and
retention_seconds is set; with neither, the event follows
RETENTION_DAYS.
CatalogueField
dataclass
¶
One field of an event payload, as the catalogue describes it.
required
instance-attribute
¶
False when the field has a default, which is also the condition under which adding it is a non-breaking change to a log with rows already in it.
CatalogueReceiver
dataclass
¶
One receiver of one event, as the catalogue describes it.
lease_seconds
class-attribute
instance-attribute
¶
Defaulted so adding it does not break a consumer constructing one.
targets
class-attribute
instance-attribute
¶
Where a fan-out receiver's targets callable lives, or None for a
receiver that writes one delivery per event. Published because it changes
what a delivery is for this receiver - one per target rather than one per
event - and a reader diffing catalogues should see that change. Defaulted
for the same reason as lease_seconds.
backoff_base_seconds
class-attribute
instance-attribute
¶
The receiver's own retry curve, or None where it uses the setting.
Defaulted, like every field after lease_seconds.
backoff_cap_seconds
class-attribute
instance-attribute
¶
The ceiling of that curve, or None where it uses the setting.
lane
class-attribute
instance-attribute
¶
Which relay processes claim this receiver's rows: those started with
--lane naming it, or, for "default", those started with none.
give_up_after_seconds
class-attribute
instance-attribute
¶
How long a delivery may stay owed while it defers without counting,
in seconds, or None where the receiver declares no bound. Seconds rather
than the declared timedelta so the JSON stays plain numbers, as the
curve does.
due_seconds
class-attribute
instance-attribute
¶
For a receiver declared with due= and a delay, that delay in seconds,
counted from the event's occurred_at; None otherwise. Published because
it changes when the work happens, which a reader diffing catalogues should
see.
due_callable
class-attribute
instance-attribute
¶
For a receiver declared with due= and a callable, where the callable
lives, as targets is published; None otherwise. Two fields rather than
one that is sometimes a number and sometimes a path, so a JSON reader never
switches on a value's type. At most one of the two is set, and a receiver
with neither runs as soon as its event is recorded.
OutboxHealth
dataclass
¶
Whether the outbox is keeping up, in one value.
Counts and one timestamp rather than rates: a rate needs two samples and a place to keep the first, which is a metrics system's job. This is what that system scrapes.
owed
instance-attribute
¶
Deliveries due and not yet settled: pending, failed or claimed, with
their due time come. A delivery scheduled for later is counted in
scheduled instead, because it is work that has not arrived.
oldest_owed_at
instance-attribute
¶
When the oldest still-owed delivery became owed, or None when nothing is owed.
That is its due_at where the row has one - the due time of a receiver
declared with due=, or the moment a replay or requeue reopened it - and
its event's recorded_at otherwise. Work arrives when it becomes due, so
a row scheduled for a fortnight ahead does not age while it waits, and one
failing after it came due ages from its due time rather than from an event
three days older.
Not when it next becomes available: a failed delivery has its
available_at pushed into the future by the backoff, so an alert written
against that reads a negative age exactly while a receiver is failing.
This rises monotonically for as long as work sits undone, which is what a
threshold needs.
A receiver that fails all the way to dead leaves the owed set entirely, so
watch dead alongside it. One number does not cover both.
lapsed_leases
instance-attribute
¶
Claimed rows whose lease has expired. Steady non-zero means workers are dying mid-delivery, or a receiver outruns its lease and has its work thrown away every time - see lease_seconds= on the receiver.
receivers
instance-attribute
¶
Only receivers with something owed, dead or scheduled, worst backlog first.
scheduled
class-attribute
instance-attribute
¶
Deliveries not yet settled whose due time is still ahead: the rows of a
receiver declared with due= that are waiting for it. Not owed and not
aged, so a backlog alert does not fire for work that is on time. Defaulted
and last, so adding it does not break a consumer constructing one.
QuietReceiver
dataclass
¶
A declared receiver with nothing delivered to it inside the window.
last_succeeded_at
instance-attribute
¶
None means never - which is the more interesting answer of the two.
ReceiverBacklog
dataclass
¶
What one receiver is behind on.
oldest_owed_at
instance-attribute
¶
When the oldest still-owed delivery became owed. See
OutboxHealth.oldest_owed_at for what that is read from.
scheduled
class-attribute
instance-attribute
¶
Deliveries whose due time has not come yet, which owed leaves out.
Defaulted and last, so adding it does not break a consumer constructing
one - this is an exported type.
claimed
class-attribute
instance-attribute
¶
How many of owed a worker holds right now, the per-receiver half of
OutboxHealth.claimed. A receiver whose owed rows are all claimed and
none finishing is wedged, or its task queue is holding them, where the same
total spread across receivers is a relay at work. Defaulted and last for the
reason scheduled is.
RegisteredEvent
dataclass
¶
retention
class-attribute
instance-attribute
¶
How long fire() records this event for: its own window, a
:class:Retention policy, or None for RETENTION_DAYS.
Last, and defaulted, so adding it does not break a consumer constructing one - this is an exported type.
retention_columns
property
¶
retention as the event row records it: (retention_seconds,
delete_when).
Exactly one of the two is set, or neither. The schema cannot say so -
the check-constraint keyword changed inside the supported Django range
- so this is where it holds, for fire() and the catalogue alike.
RegisteredReceiver
dataclass
¶
on_failure
class-attribute
instance-attribute
¶
Called after a failed attempt has been recorded, or None.
The hook exists because a receiver cannot otherwise keep anything about its own failure: it runs inside the transaction that carries its acknowledgement, so everything it wrote is rolled back before the failure is recorded. A receiver that wants a durable log of what went wrong had no way to write one.
It runs outside that transaction, after the delivery row is updated, so what it writes survives. It must not raise: a hook that does is logged and swallowed, because the alternative is a failure path that fails.
lease_seconds
class-attribute
instance-attribute
¶
None means the LEASE_SECONDS setting. Defaulted because it is the one field of a declaration that is genuinely optional: every other value here is something the decorator always resolves.
targets
class-attribute
instance-attribute
¶
Called at fire time to name this receiver's targets, or None.
None writes one delivery row per event, as every receiver always has. A
callable writes one per string it returns, each with its own attempt count,
backoff and dead-letter, and none when it returns nothing. It runs inside
the caller's transaction; see receiver for what that obliges.
backoff_base_seconds
class-attribute
instance-attribute
¶
None means the BACKOFF_BASE_SECONDS setting.
Read from here when an attempt fails, never copied onto the row, so a
changed curve reaches deliveries already in flight. max_attempts is the
opposite on purpose: it is copied, so lowering it cannot dead-letter them.
backoff_cap_seconds
class-attribute
instance-attribute
¶
None means the BACKOFF_CAP_SECONDS setting. Read like the base.
lane
class-attribute
instance-attribute
¶
Which relay processes claim this receiver's rows.
Read from here at claim time, never copied onto the row, so moving a receiver to another lane moves the deliveries it is still owed with it.
give_up_after
class-attribute
instance-attribute
¶
How long a delivery may stay owed while deferring, or None.
What ends a delivery whose receiver defers with RetryAfter(counts=False),
which spends no attempt: once the row has been owed this long - measured
from due_at, or the event's recorded_at where that is NULL - the
next deferral dead-letters it. None means a deferral that does not count is
counted after all, because nothing else would end the delivery. Read live,
like the curve.
due
class-attribute
instance-attribute
¶
When this receiver's delivery of an event comes due, or None for at once.
A timedelta counts from the event's occurred_at; a callable is
handed the event and returns an aware datetime. Resolved at fire time,
inside the caller's transaction, and written onto the row as both its
available_at and its due_at - never read again from here, so a
deploy changing the delay reaches the next event, not the rows already
waiting. replay_events resolves it afresh.
TaskBackend ¶
Bases: Protocol
Where a durable receiver's code runs when the relay hands it off.
The relay claims the row either way; this only decides who executes it. That is the whole reason the execution site is a separate knob from the timing: a queue is only ever an answer to the second question.
The backend may lose an enqueue without consequence. The delivery row is the record, so anything dropped is reclaimed when the lease lapses - which is what makes a lossy queue safe here, and what keeps this a small protocol.
It may also deliver one twice, or late, which is what acks_late and a
broker's visibility timeout do. The message carries the claim the row was
handed off under, and the task takes the row under exactly that claim
before running anything, so a second copy or a copy the queue held past its
lease finds the claim gone and does nothing.
enqueue ¶
Arrange for the delivery to run somewhere, carrying its claim.
The worker must call deliver_one(delivery_id, claimed_by=claimed_by,
claimed_at=claimed_at) with the three values exactly as given.
claimed_at is an ISO 8601 string rather than a datetime, so all
three are JSON and any queue can carry them unchanged. The relay passes
the claim by keyword.
PayloadCodec ¶
Bases: Protocol
How an event instance becomes a payload, and comes back.
A seam because the two halves cost differently: encoding needs nothing this package does not have, while decoding a nested payload back into a frozen dataclass is worth a dependency. Every codec consumes an ordinary frozen dataclass, so the choice never reaches how events are declared.
PayloadUpgradeFailed ¶
Bases: Exception
An event class's upgrade hook raised while migrating an old row.
Its own type because an operator reading last_error needs to know the
hook ran and what it said, not that something unspecified went wrong
somewhere between the row and the receiver.
At the package root rather than under codecs/: it is raised before the
codec sees the payload, and importing it from there pulls in
codecs/__init__, which imports the module that imports this one.
Task backends¶
Not exported from the package root. Each is named by its dotted path in
TASK_BACKEND, so the framework it wraps is imported
only by a project that chose it.
DjangoTasksBackend ¶
Hands deliveries to Django's Tasks framework.
Django Tasks rather than Celery first: django.tasks is in core from 6.0
and the django-tasks backport covers 4.2 through 6.0, so it spans this
package's whole supported range with no broker to install.
The import is lazy because the framework is not a dependency - a consumer using the relay directly should not have to have it.
CeleryBackend ¶
Hands deliveries to Celery.
For a project whose workers already run Celery. A project without one is
better served by DjangoTasksBackend, which needs no broker.
Redelivery is safe: acks_late and a broker's visibility timeout both
hand a worker a message whose task already ran, and the claim the message
carries is what turns that copy into a no-op. Recovery from a worker that
died mid-delivery comes from the row's lease, not from the redelivery: the
second copy finds the row taken by the worker that died and leaves it for
the relay to reclaim.