The canonical convention: what happens to a background job from enqueue to terminal — the same for every lane and consumer. Queue and worker topology is on queues and workers; where the lock gets its correctness is on the Redis ⟂ Postgres hard boundary.
Enqueue is idempotent by job_id: a repeat
with the same id spawns no second job. A retry is the same job, the same
id, a new attempt — not a second row in the journal.
enqueue → queued → running → succeeded / failed → reaping. Terminal and lock release are a separate step (see terminal and DLQ, uniqueness); a worker that crashes without cleanup doesn't free the job — the watchdog reaps it (see heartbeat).
The heartbeat is a live job's pulse: a long run stamps
heartbeat_at into its own Postgres row every ~30s. By that
pulse the watchdog tells a working job from a dead one.
heartbeat_at is fresh — the worker is running; the
uniqueness lock holds and the next such job waits.
failed / stale, the uniqueness lock is released, the slot
is free.
Without a pulse a crashed job would stay running forever and
block the next one — reaping closes that deadlock.
N replicas would race each other on cleanup.
Its home is the same dedicated singleton
that ticks cron. The cost of that pairing: while the singleton is down,
reaping stalls too — a crashed job's lock isn't released, and that job
type waits for the replica to come back. The outage is bounded: on return
the watchdog immediately clears the stale work, and cadences are coarse —
acceptable for v1.
Two levels by pause length — a short one we wait out inside the job, a long one we return to the queue rather than keep a worker sleeping.
Retry-After — the job returns to the queue with a
deferred start, and the worker slot is freed. We don't sleep inside the
job.
Mutual exclusion is held by Postgres, not the broker: a partial unique index next to the journal plus a heartbeat. The lock is correctness-critical — that's why it lives where the system of record is.
UNIQUE … WHERE state IN ('queued','running') — while a job is
active, the index won't admit a second one with the same key. A repeat
launch (double click, scheduler race, retry) hits the index and gets
skipped, not a duplicate. After terminal the row falls out
from under the condition — the index is free again.
job_id is held for the window, so a repeat
enqueue under at-least-once delivery doesn't run the job again.
job_id is released quickly — an operator or the
retry policy is free to resubmit the same work.
The window's source of truth is the journal in Postgres; the key in Redis (see ephemeral keys) is only a fast path, duplicating the journal rather than replacing it.
The consumer reads the terminal: a run's or delivery's outcome is a status in its own Postgres row, not something lost in the broker. Each consumer's failed jobs settle in its own home, from which a curation pass re-picks them.
reconcile
/ dlq-retry re-picks.
failed with cause and step stats)
in curation_runs; the next curation pass reprocesses the
graph anew.
failed with cause) in the
agent_runs run row.
We unify the contract. The lifecycle skeleton is one for all consumers; the data home and the meaning of a re-pick are domain-owned. That way the monitor and the watchdog work over any run without collapsing different domains into one store.
queued · running · succeeded · failed (+ stale as the watchdog's sub-outcome)
attempts
attempt counter against the retry cap
heartbeat_at
a live run's pulse; by it the watchdog spots a dead one
last_error
terminal cause + step stats
next_retry_at
deferred start of a retry; NULL if none scheduled
The monitor reads each run table in its own domain home and merges them in
the admin summary (module, job_id, state, attempts, age, error):
one view of "what failed and where" without a central table. A single
UNION ALL projection across the run tables is a
v2 convenience once the run tables grow many.