pgbr
Core Concepts

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 BullMQ queue backed by Redis. The dashboard produces; the worker consumes.

The three queues

QueuePayloadProduced by
backupBackupJobPayloadThe backup dialog, and cron schedulers
restoreRestoreJobPayloadThe restore dialog
migrateMigrateJobPayloadThe migration page

Each queue gets its own Worker instance 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.

Job status

Job rows in Postgres move through four statuses:

StatusMeaning
pendingDefined in the schema; in practice rows are written as running.
runningThe worker has claimed the job and written the row.
completedThe process exited 0 and, for backups, the artifact uploaded.
failedSomething failed. error holds the tool's stderr.

The row is written 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.

Job IDs

Job IDs differ by origin, and the difference matters:

  • User-triggered jobs get a UUID generated by the dashboard, passed as both the BullMQ job ID and the database row ID. This is what lets the UI correlate a request with its row.
  • Scheduled jobs carry no dashboard-generated ID. The worker falls back to the BullMQ job ID, which for a scheduler looks like repeat:<scheduler-id>:<ts>.

That fallback is deliberate: the BullMQ ID is stable across a re-delivery, so a stalled scheduled job that another worker picks up lands on the same row rather than orphaning the first one in running.

Scheduler job IDs are sanitized before they become object keys — everything outside [A-Za-z0-9._-] becomes _, so repeat:abc:123 yields backups/repeat_abc_123.backup.

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 it relies on BullMQ's stalled-job detection: if a worker dies mid-job, the job's lock expires and another worker reprocesses it. The processors' idempotent upserts are what make that safe.

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.

Retries

Jobs are enqueued with BullMQ's defaults, 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 in Redis

Repeating jobs are registered as BullMQ job schedulers — one per enabled backup schedule, keyed by the schedule's ID.

Postgres is the source of truth. The schedulers in Redis are derived state built by buildScheduleTemplate, which both the dashboard's actions and the worker's boot reconciliation call, so the two can't drift.

Scheduled jobs cap their Redis history at 100 completed and 100 failed entries. Postgres holds the real history; without the cap, repeat jobs would accumulate in Redis forever.

Reconciliation on boot

Every worker, on startup, tries to take a Redis lock (pgbr:reconcile-lock, 60s, set-if-not-exists). Whoever gets it:

  1. Re-registers a scheduler for every enabled schedule with an owner
  2. Removes any registered scheduler with no matching active schedule

Both directions are idempotent, so it heals drift from a Redis flush or a dashboard action that died between the database write and the Redis write.

Write ordering

When you create a schedule, the dashboard writes Postgres first and Redis second — and rolls the row back if registration fails. You never end up looking at a schedule that silently doesn't run. Reconciliation is the backstop, not the primary path.

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 listeners when the client disconnects.

BullMQ's queue events are instance-wide, so the route looks each event's job up by ID and forwards it only if job.data.userId matches the session. 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 calls updateProgress. By the time the browser refreshes, there's something new to render.

On this page