Jobs and Queues
The three queues, the job lifecycle, and how pgbr behaves when things go wrong.
Every operation pgbr performs is a job on a queue held in Postgres — the same database that stores your job history. The dashboard produces; the worker consumes. There is no separate queue service.
The three queues
| Queue | Payload | Produced by |
|---|---|---|
backup | BackupJobPayload | The backup dialog, and backup schedules |
restore | RestoreJobPayload | The restore dialog |
migrate | MigrateJobPayload | The migration page |
All three live in one job_queue table, distinguished by the queue column.
Each gets its own poll loop with its own concurrency, so a long migration never
starves the backup queue. WORKER_CONCURRENCY (default 5) applies to each
queue independently — one worker at the default can be running 15 jobs at once.
Claiming a job
Workers claim with FOR UPDATE SKIP LOCKED, so concurrent workers never block
each other and no job is ever handed to two of them:
UPDATE job_queue q
SET status = 'active', locked_by = $worker, locked_at = now(),
heartbeat_at = now(), attempts = q.attempts + 1
FROM (SELECT id FROM job_queue
WHERE queue = $1 AND status = 'pending' AND run_at <= now()
ORDER BY run_at, created_at
FOR UPDATE SKIP LOCKED
LIMIT $2) AS c
WHERE q.id = c.id
RETURNING q.*;The statement commits on its own. No transaction is ever held open across a
pg_dump.
A worker wakes on a NOTIFY from the insert trigger and also polls every five
seconds. Notifications are fire-and-forget, so the poll is the correctness
floor — a dropped notification costs latency, never a lost job.
Job status
A job has two rows: its queue row in job_queue, and its history row in
backup_jobs / restore_jobs / migration_jobs. They share an ID.
job_queue.status | Meaning |
|---|---|
pending | Waiting to be claimed. |
active | A worker holds it and is heartbeating. |
completed | The handler returned. |
failed | The handler threw, or the job stalled out. |
The history row is written by the processor before the work starts and finalised in both the success and failure paths, so a job never silently vanishes. Any failure after the row exists — dump, non-zero exit, tar, upload — is caught and recorded.
Finished queue rows are purged after seven days. The history tables are the permanent record.
Job IDs
Every job gets a UUID generated where it originates — the dashboard for
user-triggered runs, the scheduler for scheduled ones. That UUID is the
job_queue row's ID and the history row's ID, which is what lets the UI
correlate a request with its row, and what makes a re-delivered job land on the
same row instead of orphaning the first one.
Interruption recovery
pgbr has no "mark every running row failed on boot" sweep. That would be unsafe the moment more than one worker exists — a booting replica would fail jobs another replica was actively running.
Instead every worker stamps heartbeat_at on the jobs it holds, every 15
seconds, in a single statement. A reaper requeues anything whose heartbeat is
more than 90 seconds stale. Because it keys off the heartbeat rather than a
deadline, a job that legitimately runs for six hours is never mistaken for a
dead one.
A stall is not a failure, so it doesn't consume the job's attempt budget — it
has its own counter. A job that stalls twice is assumed to be what's killing
the worker and is failed for good rather than looping. When that happens the
reaper also closes out its history row, which would otherwise sit in
running forever.
A re-delivered backup re-runs pg_dump from scratch. Restores and migrations
are re-delivered too — a re-run restore replays into the target database. With
Single transaction on (the default), a partial first attempt rolled back, so
the replay starts clean.
On a clean shutdown the worker stops claiming, waits for in-flight jobs to drain within the container's stop grace period, and hands back anything still running so a restart picks it up immediately instead of waiting out the reaper.
Retries
Jobs are enqueued with max_attempts = 1, meaning no automatic retries on
failure. A failed backup stays failed and visible. This is intentional: a dump
that failed on a schema error will fail identically on retry, and retrying a
restore is a decision with side effects. Re-run it yourself once you know why it
failed.
Scheduled jobs
A backup schedule is one row in backup_schedules, and that row is the
scheduler. next_run_at says when it fires next; there is no derived state
anywhere else and nothing to reconcile.
Every 30 seconds a worker takes a transaction-scoped advisory lock — so exactly
one replica ticks — and, for each enabled schedule that has come due, inserts a
backup job and computes the next occurrence from the cron expression and
timezone. Both happen in the same transaction, so a schedule can't fire without
being rescheduled, or vice versa.
The next occurrence is always computed from now, never from the run that was missed. A worker down for a day fires each schedule once when it returns and resyncs — it does not replay a day of backups.
A next_run_at of NULL means "not scheduled yet". The tick computes the next
occurrence for those without firing them, which is what keeps a fresh install —
or an upgrade that just added the column — from stampeding every schedule at
once.
Creating, editing, enabling, or disabling a schedule is a single write to that one row. Deleting a database takes its schedules with it through the foreign key cascade.
Live updates
/api/events is an SSE stream carrying progress, completed, and failed
from all three queues as { queue, event }. It emits a heartbeat comment every
25 seconds to keep proxies from closing the connection, and cleans up its
listener when the client disconnects.
Events travel over Postgres LISTEN/NOTIFY. Each one carries the ID of the
user who owns the job, so the route filters by comparison rather than looking
every event's job up. You're told when your own jobs move and nothing else —
another account's activity isn't even visible as a timing signal.
progress — not active — marks a job's start, because the worker writes its row
and then reports progress. By the time the browser refreshes, there's
something new to render.
LISTEN doesn't work through PgBouncer in transaction pooling mode. The
bundled compose has no pooler; if you put one in front of pgbr_db, it needs
session pooling.
pgbr