Timers¶
Schedule an outbox row to fire later by passing activate_in (relative)
or activate_at (absolute, tz-aware) — exactly one. Pass timer_id to
deduplicate per (queue, timer_id); cancel a not-yet-leased timer with
broker.cancel_timer(...).
Scheduling¶
import datetime as dt
# Fire 30 seconds from now, deduplicated by timer_id:
order_id = 1
await broker.publish(
{"order_id": order_id},
queue="orders",
session=session,
activate_in=dt.timedelta(seconds=30),
timer_id=f"order-confirm-{order_id}",
)
# Fire at a specific UTC instant:
await broker.publish(
{"x": 1}, queue="orders", session=session,
activate_at=dt.datetime(2027, 6, 1, 9, tzinfo=dt.UTC),
)
publish returns the inserted row's id, or None if a row with the
same (queue, timer_id) already exists.
Mutually exclusive¶
Passing both activate_in and activate_at raises ValueError. They are
two ways to say the same thing — "make this row invisible to fetch until
the given moment".
Timezone-aware activate_at¶
activate_at must be timezone-aware. A naive datetime raises an
explicit ValueError rather than guessing your intended zone.
Server-side vs client-side scheduling¶
For publish with activate_in, next_attempt_at is computed server-side
via now() + make_interval(secs => :s) to stay clock-skew-safe. With
activate_at, you supply an absolute instant, so it is stored verbatim and
compared against the worker's clock at fetch time — only activate_in is
skew-safe; activate_at is as accurate as your producers' and workers'
clocks agree. For publish_batch, activate_in is also client-side
(datetime.now(UTC) + activate_in) because executemany doesn't compose
cleanly with column-level SQL expressions, and the few-ms drift is harmless
for user-supplied scheduling.
Deduplication with timer_id¶
timer_id flows into a String(255) column with a partial unique index
on (queue, timer_id) WHERE timer_id IS NOT NULL. The producer switches
to pg_insert(...).on_conflict_do_nothing(...) so re-publishing the same
id is a silent no-op (returns None):
first = await broker.publish(
{"order_id": 1}, queue="orders", session=session,
activate_in=dt.timedelta(seconds=30),
timer_id="order-confirm-1",
)
assert first is not None
# Re-publish — no row inserted, no NOTIFY emitted, returns None.
second = await broker.publish(
{"order_id": 1}, queue="orders", session=session,
activate_in=dt.timedelta(seconds=30),
timer_id="order-confirm-1",
)
assert second is None
NOTIFY is skipped when the row is genuinely future-dated (a future
activate_in / activate_at) OR the conflict path returned no row — both
cases would either wake listeners that find nothing, or wake them
prematurely. A past activate_at is already eligible, so it still
notifies.
timer_id is only available on single publish, not on publish_batch
(per-row dedup makes no sense for a batch).
timer_id dedups only live rows¶
The unique index is partial — (queue, timer_id) WHERE timer_id IS NOT
NULL. It constrains only rows currently in the table. Once a timer fires
(the row is deleted) or is cancelled, the same timer_id can be inserted
fresh. So timer_id is a dedup key for in-flight / pending timers, not
a permanent idempotency key — it won't stop a value from being re-published
after the original delivery has completed.
Cancellation¶
broker.cancel_timer(*, queue, timer_id, session) issues a DELETE on
the caller's session, but only if the row is not yet leased:
deleted = await broker.cancel_timer(
queue="orders",
timer_id="order-confirm-42",
session=session,
)
# True if a row was deleted; False if it didn't exist or was already in flight.
The underlying SQL is DELETE WHERE queue=? AND timer_id=? AND
acquired_token IS NULL. The acquired_token IS NULL guard is
load-bearing: it preserves the lease-token invariant by refusing to
clobber a row whose handler is already running. If the timer fired in the
race window between your application logic deciding to cancel and the
DELETE landing, the delivery completes normally and cancel_timer
returns False.
Latency floor¶
Timer firing latency is bounded by the subscriber's max_fetch_interval
(default 10 seconds) after next_attempt_at elapses. NOTIFY does not
help here — listeners can't act on a future row, so the fetch loop has to
poll for it.
Lower max_fetch_interval for sub-10s precision. Sub-second precision is
not a goal of this broker; if you need it, schedule the row early and
sleep inside the handler, or use a different scheduler.
Test broker note¶
In tests using TestOutboxBroker (default run_loops=False mode),
activate_in / activate_at are ignored and timers fire immediately
— sync dispatch ignores next_attempt_at. This trades production parity
for test ergonomics: tests can assert handler effects without time travel.
The schedule is still recorded on the fake row — bind the
TestOutboxBroker to a name and read
tb.fake_client.rows[0].next_attempt_at — if a test needs to assert on it.
Pass run_loops=True if you need scheduled delivery to actually
wait. See Testing.