Durable jobs¶
The event bus is best-effort: a failed handler is logged and the event is gone.
Durable jobs are the other option — every (event, subscriber) pair becomes a
database row that is retried with backoff and dead-lettered if it never succeeds.
Requires the jobs extra and a database.
jasil_settings.configure(
jasil_settings.JasilSettings(
jobs=jasil_settings.JobSettings(enabled=True),
)
)
The pipeline¶
publish(..., db=session)
│
▼
event_outbox ─────relay─────▶ processing_jobs ─────worker─────▶ subscriber
(one row per (one row per (claim, run,
event) event × subscriber) complete or fail)
Two stages, deliberately. The producer writes one row and returns; fanning out to N subscribers happens later, off the request path. The fan-out is idempotent, so re-running the relay is harmless.
Registering a subscriber¶
import jasil.jobs.registry as jobs_registry
jobs_registry.registry.register("order.created", "invoice.render", render_invoice)
The subscriber_id is a stable identifier independent of the Python module
path — it is stored on every job row, so renaming a module must not orphan
queued work.
The handler must raise on failure. That is what drives retry.
Every registration has a named queue. Existing calls use default; select one
when the subscriber is registered when independent worker capacity matters:
jobs_registry.registry.register(
"order.created",
"invoice.render",
render_invoice,
queue="campaign",
)
Queue ownership belongs to the subscriber rather than the event. One event can
fan out to several subscribers on different queues, and the relay stamps each
subscriber's queue on its own job. Names are 1–100 lowercase ASCII characters:
letters, digits, ., _, and -; an invalid name raises before persistence.
The job state machine¶
┌──────────────── backoff ◀────────────┐
▼ │
enqueue ──▶ pending ──claim──▶ claimed ──success──▶ completed
│
├──failure, attempts left──▶ pending
├──failure, ceiling hit───▶ dead_letter
└──lease expired──────────▶ pending | dead_letter
| Status | Meaning |
|---|---|
pending |
Waiting to be claimed, no earlier than available_at. |
claimed |
Leased to a worker until lease_expires_at. |
completed |
Terminal. Prunable. |
dead_letter |
Terminal. Kept for operator review; never pruned. |
Claiming¶
A worker claims a batch of due jobs, taking a time-bounded lease. On PostgreSQL
the claim uses FOR UPDATE SKIP LOCKED, so concurrent workers take disjoint
batches with no coordinating lock.
A worker can select a non-empty queue allowlist. Omitting it consumes every queue, preserving the original behavior. A selective claim includes the queue predicate in the locked query, so it cannot lease work from another queue. Workers consuming several queues rotate their queue cursor between batches; a permanently busy queue cannot starve another one indefinitely. Rotation considers only the worker's allowlist. Queues outside it are never claimed by that worker and need another matching worker.
Moving a subscriber to another queue
A job keeps the queue it was enqueued on. Only rows carrying the database
default default queue follow their subscriber to its currently assigned
queue. If a subscriber moves from queue a to b and no worker still selects
a, its pending rows on a wait forever. Keep a worker on the old queue until
it drains, or reassign the rows once:
UPDATE processing_jobs SET queue = 'b' WHERE queue = 'a' AND subscriber_id = '<id>' AND status = 'pending'.
The attempt is counted at claim time
Not at completion. A worker that crashes mid-run still consumes an attempt, which is what bounds a crash loop — otherwise a job that reliably kills its worker would be retried forever.
Failure and backoff¶
A failed job is rescheduled with an exponentially growing delay:
base_seconds * 2 ** (attempts - 1), clamped to backoff_max_seconds.
Equal jitter is applied: the delay is randomised to between 50% and 100% of the computed value. Without it, a batch of jobs that failed together during a downstream outage would all retry at the same instant and stampede the recovering dependency.
Once attempts reaches max_attempts, the job becomes dead_letter.
Lease reclamation¶
A worker that dies holds its lease until it expires. The reaper returns those
jobs to pending — or dead-letters them if they have no attempts left — so work
is never stranded by a crash.
A running worker does not lose its leases. Its heartbeat renews every lease it
holds, the running job's and the waiting jobs' of its batch, on each beat, so
lease_seconds is how long a worker may go silent, not how long a handler may
run. See Long-running handlers.
Idempotency¶
(event_id, subscriber_id) is unique in the database. A repeated enqueue is a
no-op, so a subscriber never runs twice for the same event even if the relay
overlaps with another replica's.
This is a database constraint rather than relay logic on purpose: it holds under concurrency, restarts, and manual intervention.
Reconciliation nets¶
Durable is not the same as guaranteed. A Redis-Streams consumer can drop a message, a provider can be briefly down, and some write paths persist rows without publishing anything at all. A subscriber that writes durable derived state therefore needs a scheduled backfill that re-derives whatever the create path missed.
Declare one per subscriber:
from jasil.jobs.reconciliation import DurableSubscriberNet
NETS = [
DurableSubscriberNet("invoice.render", backfill=backfill_missing_invoices),
DurableSubscriberNet("cache.warm", backfill=None, exempt_reason="rebuilt on read"),
]
Exactly one of backfill / exempt_reason must be set — neither is refused at
construction. A subscriber with no net and no stated reason is one whose derived
state goes missing silently, which is the failure the type exists to prevent.
Hold every subscriber to it with one conformance test:
import jasil.jobs.registry as jobs_registry
from jasil.jobs.reconciliation import assert_nets_complete
def test_every_durable_subscriber_declares_a_net():
assert_nets_complete(ALL_NETS, registry=jobs_registry.registry)
Import every subscriber module first, or the registry will be empty and the test
will pass by vacuum. undeclared_subscribers is the same check as a plain query
when you want to report the gap rather than fail on it.
Running the workers¶
Local: one process and SQLite¶
import jasil.jobs.service as jobs_service
jobs_service.start_job_worker() # in-process worker thread
jobs_service.schedule_job_maintenance(scheduler) # relay + reaper on APScheduler
This worker consumes all named queues serially and rotates the starting queue
between batches for process-lifetime fairness. SQLite supports exactly this
topology: one process and one in-process consumer. JASIL refuses to start a
consumer on SQLite when the configured deployment is multi-process — several
web_workers, profile=distributed, or job_worker_mode=standalone — because it
has enough information to know those shapes are unsafe. start_job_worker()
requires job_worker_mode=in_process, which is the default.
Distributed: API plumbing and PostgreSQL workers¶
The API process schedules the relay and reaper but does not start a domain-job consumer:
jobs_service.schedule_job_maintenance(scheduler)
Declare the shape with job_worker_mode=standalone, and load it in the API
processes as well as the workers: a worker is one more process reaching state,
events, the lock and mail, and the API's own startup checks can only refuse a
wiring the workers cannot share if it knows they exist. Standalone worker
processes then use the supported blocking entry point, which requires that mode.
It must run in a dedicated worker process, never on an API request or event-loop
thread:
import signal
import threading
import jasil.jobs.service as jobs_service
stop = threading.Event()
signal.signal(signal.SIGTERM, lambda *_: stop.set())
jobs_service.run_job_worker(
queues=("campaign", "intake"),
role="domain-worker",
label="campaign-intake-1",
metadata={"zone": "eu-1"},
stop=stop,
)
Pass one queue per process group when each queue needs independent concurrency.
Run several processes with the same queue to get competing consumers; PostgreSQL
claims remain FOR UPDATE SKIP LOCKED, so each job is processed once per claim.
Omit queues to consume all queues, and never pass an empty allowlist.
During a rolling upgrade, an old relay can still write the database-side
default queue. A selective new worker also adopts those legacy rows when the
subscriber is now registered to one of its selected queues. It does not adopt
rows belonging to a subscriber that is genuinely registered to default, so
queue isolation remains intact while old writers drain. Conversely, a selective
default worker excludes legacy rows for subscribers now assigned to a named
queue; the two workers cannot race for the same compatibility row.
Both rules are decided from the registrations of the worker process that makes
the claim, so every worker process must register every durable subscriber,
even those it will not consume, before it starts. A worker that has not
registered a subscriber cannot tell that a legacy default row belongs to
another queue, and will claim it, find no handler, and dead-letter it. Routing
reads the registrations on every claim, but the one-time retag of legacy rows
runs on the worker's first batch, so register subscribers before starting the
worker. Alternatively, let the default queue drain before rolling out.
The relay and reaper may run on every API replica. Their locking and the
idempotent (event_id, subscriber_id) fan-out make a single-runner lock
unnecessary. Durable jobs use the database only; Redis is not a job-queue
dependency.
Dead letters¶
Dead-lettered jobs are rare and human-actionable, so they are never pruned. Once the cause is fixed:
import jasil.admin as jasil_admin
jasil_admin.replay_dead_letter_job(job_id)
which returns the job to pending with a fresh attempt budget.
jasil_admin.get_jobs_summary() gives counts by status and queue, recent
throughput, backlog age, handler durations, and the dead-letter list (each with
the worker that ran its last attempt) for an operations dashboard.
jasil_admin.get_job(job_id) returns a dead letter's whole history: which worker
ran each attempt, how long it took, and why it failed.
jasil_admin.get_workers_summary() reports running, stale, and gracefully
stopped worker instances one bounded page at a time; pass its next_cursor into
the next call. See Observability.
Why jasil.admin and not jasil.jobs.crud
The CRUD layer is where these queries live, but it is the wrong thing to wire
a route to: it reaches a model at import time (so it cannot be imported
before map_models has run) and every function commits the session it is
given. jasil.admin is importable from anywhere and takes no session, so an
admin route cannot accidentally hand JASIL its own open transaction.
Sending mail¶
There is no separate mail queue: durable jobs are the queue. Publish an event,
and have a durable subscriber call platform.mail.send(...). A raised
MailError gets backoff, retries and the dead-letter queue like any other
failure.
- Give mail its own named queue. Register the mail subscriber with
queue="mail"(or similar), so a slow relay cannot hold up other subscribers' jobs. The isolation comes from a dedicated worker group callingrun_job_worker(queues=("mail",)): the single local consumer still runs mail serially with everything else. - Mail needs a real relay once workers are standalone. The handler runs in
the worker process, so that process's
mail.uriis the one that sends.memory://there would record every message in that process's memory and complete the job with nothing delivered. Withjob_worker_mode=standalonedeclared, the consistency checks refuse that wiring at startup, in the API and in the workers. - An unconfigured mail backend is retried too.
MailNotConfiguredErroris aMailError, so a job on a deployment with nomail.uriuses its whole retry budget before dead-lettering. Catch it in the handler if that is not wanted. - Delivery is at-least-once. A retry after a lost acknowledgement sends the message twice.
- Size
jobs.max_runtime_secondsfor the whole handler, not one send. One SMTP send can take up to 90 seconds, and the DNS lookups before it are not counted against that deadline. A handler that sends N messages can take N times that. The worker's heartbeat keeps the job's lease alive while it runs, so the lease itself no longer needs to be that long; the cap ismax_runtime_seconds(default 30 minutes). Past it the job is abandoned and a retry may deliver the same message again. - A lost lease stops the send. The mail backends check
jasil.jobs.lease_lost()before connecting, so a handler that loops over recipients does not keep sending after its job has moved to another worker. Give a dedicated mail worker group a smalljobs.batch_size, so one process does not hold claimed messages while the rest of the group sits idle. - Do not put secrets or rendered bodies in the event payload. Payloads are stored verbatim and dead-lettered rows are never pruned. Carry identifiers and build the message inside the handler.
Tuning¶
| Setting | Raise it when | Lower it when |
|---|---|---|
batch_size |
Throughput is the bottleneck | Individual jobs are slow or heavy |
lease_seconds |
Heartbeats are slow or the database is flaky | Crashed workers strand work too long |
max_runtime_seconds |
Handlers legitimately run for a long time | A hung handler should be abandoned sooner |
max_attempts |
Failures are usually transient | Failures are usually permanent |
backoff_base_seconds |
The dependency needs time to recover | Retries should be prompt |
poll_interval_seconds |
The queue is usually empty | Latency matters |
heartbeat_interval_seconds |
Heartbeat write volume matters | Faster stale detection, or a shorter lease, matters |
lease_seconds is how long a worker may go silent. One heartbeat renews leases
and proves the worker alive, so it must be at least three
heartbeat_interval_seconds (two missed beats must not expire a lease), and
max_runtime_seconds must not be below it; build_runner refuses anything else
at startup. A worker is therefore reported stale no later than its leases start
expiring.
A late worker finalizes with its worker id and attempt generation, so even a handler that outlives its lease cannot overwrite the replacement worker's newer claim.
Long-running handlers¶
A handler may run far longer than lease_seconds. While it does, the worker's
heartbeat renews the job's lease, so the reaper leaves it alone and it is not run
twice. Three things bound that:
max_runtime_seconds. Past it the job is abandoned: it is no longer renewed, the handler is signalled, anERRORis logged, and the rest of the worker's batch is released back to the queue (no attempt is spent on jobs that never started) instead of waiting behind a worker that is stuck. Python cannot kill a thread, so a truly hung handler still occupies its worker; the log is the operator's signal.- Self-fencing. If renewals fail for almost a whole lease (the database is unreachable), the handler is signalled before another worker can have reclaimed the job.
- A stolen lease. If the job is found to belong to another worker, the handler is signalled and its result is discarded.
An abandoned job is signalled but not fenced: if the handler returns while this
worker still holds the claim, its result is recorded as usual (a mail send made
after the signal raises MailError, so that job is retried with backoff).
The signal is cooperative. A handler that loops, or does several side effects, should check it between units of work and return early:
import jasil.jobs
def send_digest(event):
for recipient in recipients_of(event):
if jasil.jobs.lease_lost():
return # another worker owns this job now
platform.mail.send(build_message(recipient))
lease_lost() is False outside a durable job. The mail backends check it
themselves. A ContextVar does not follow work a handler hands to its own
threads unless it copies the context (contextvars.copy_context()).
Delivery remains at-least-once: a handler that ignores the signal mid-send, or a database partition, can still produce a duplicate side effect, so handlers must stay idempotent.
Queue rotation is intentionally in-memory and resets when a worker process restarts. It prevents starvation across batches in a healthy process; it is not a global scheduling guarantee across repeated crashes or deployments. Use queue-specific process groups when queues require independent capacity.