Skip to content

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.

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.

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.

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

import jasil.jobs.service as jobs_service

jobs_service.start_job_worker()  # in-process worker thread
jobs_service.register_scheduled_jobs(scheduler)  # relay + reaper on APScheduler

The relay and the reaper run on every replica. Both use SKIP LOCKED, and the fan-out is idempotent, so no single-runner lock is needed.

Dead letters

Dead-lettered jobs are rare and human-actionable, so they are never pruned. Once the cause is fixed:

import jasil.jobs.crud as jobs_crud

jobs_crud.replay_dead_letter_job(job_id, now=clock.now(), db=db)

which returns the job to pending with a fresh attempt budget.

jobs_crud.get_jobs_summary(db) gives counts by status, recent throughput, and the dead-letter list, for an operations dashboard.

Tuning

Setting Raise it when Lower it when
batch_size Throughput is the bottleneck Individual jobs are slow or heavy
lease_seconds Jobs legitimately run long Crashed workers strand work too long
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

lease_seconds must exceed the slowest realistic job duration. If a lease expires while a job is still running, the reaper requeues it and it runs twice concurrently — which your handler must tolerate anyway, but is wasteful.