Deploy tasks
One-time work owed by a release — a backfill, a data fix, a cache warm — run once, on the new code, after the deploy has rolled over.
What a deploy task is#
A deploy task runs once per (key, version), and only after every live pgbus process reports the new app revision and at least one worker on that revision is alive. So it:
- never runs on old code during the deploy overlap window;
- runs once per rollover in normal operation, however many hosts you have — two schedulers never both fire it;
- is not forgotten — a task that is owed waits until the fleet has rolled over.
It is not an exactly-once guarantee: a failure between the enqueue and recording the run can repeat it after a scheduler restart (see "What ran means" below), so make deploy tasks idempotent.
Declare tasks in deploy_tasks.yml#
Same shape as recurring.yml, minus schedule:.
Each task is a key with either a class (an ActiveJob class) or a command (Const.method, run by Pgbus::Recurring::CommandJob, with the same rules as a recurring command:). queue, args, priority and description are optional. A class: that is not an ActiveJob is skipped with a warning when the scheduler boots.
production:
backfill_order_totals:
class: BackfillOrderTotalsJob # or command: "Orders.backfill_totals!"
version: 2 # bump to run again after the next rollover (default 1)
queue: maintenance
args: [{ batch_size: 500 }]
description: Recompute totals after the currency migration
warm_fragment_caches:
command: "FragmentCacheWarmer.run"
every_deploy: true # runs after every rollover, dedup'd on the app revisionconfig.deploy_tasks_files; explicit files replace the default one. config.deploy_tasks takes a hash of the same shape.version: or every_deploy:#
| Setting | Runs | Use for |
|---|---|---|
version: 1 | Once per version, after the next rollover. Compared as a string; default "1". | A data fix. Bump the version in the same commit as a new fix to run it again. |
every_deploy: true | After every rollover, dedup'd on the app revision. | A cache warm. A rollback deploy re-fires it; a scheduler crash-restart does not. |
version is ignored for an every_deploy task (a warning is logged).
The app revision#
How pgbus knows which release a process runs.
The revision is the first non-blank value of:
config.app_revision(a String, or a callable returning one)ENV["PGBUS_REVISION"]ENV["DASH_VERSION"]ENV["KAMAL_VERSION"]- the
REVISIONfile inRails.root(Capistrano)
dash injects KAMAL_VERSION into every app container, and Kamal sets it, so neither needs configuring. Every pgbus process (supervisor, worker, dispatcher, scheduler, consumer, outbox poller) stamps the revision into its heartbeat row (metadata["revision"]); the Processes page shows it in the expanded row.
The rollover gate#
Checked by the scheduler at most every 5 seconds.
A live process is one with a heartbeat in the last 5 minutes. The gate is closed while any live process reports another revision, or while no live worker runs the new revision. A process with no revision at all (a pre-upgrade pgbus) counts as lagging.
A gracefully stopped process deletes its heartbeat row at once, so a clean dash or Kamal deploy opens the gate within a scheduler tick after the last old container drains. A hard-killed old container keeps the gate closed until its row goes stale, up to 5 minutes.
Every scheduler that boots syncs its own declarations. If an old-revision scheduler crash-restarts inside the overlap window, it removes the rows of tasks only the new release declares; the new scheduler restores them on its next boot.
Who runs it#
The scheduler, the process that already runs recurring tasks. The supervisor forks it when recurring tasks or deploy tasks are configured (config.deploy_tasks_enabled, default true); --scheduler-only works as before. At boot the scheduler syncs the declared tasks into pgbus_deploy_tasks and arms the owed ones. A compare-and-set claim on the row means two schedulers (the overlap window, several hosts) never both fire, and an arm can only be fired by a scheduler declaring that version or revision.
Tasks that are due together are enqueued in key order, with no completion ordering between them. If one depends on another, make it a single job.
Run now and Disable#
On the Deploy tasks page, /pgbus/deploy_tasks.
The page shows a banner with the rollover status (ready, waiting for N processes on another revision, waiting for a worker, app revision not configured, or no scheduler running) and live process counts per revision. Each task says in words whether it ran (when, on which revision), is waiting (and why), is due ("runs on the scheduler's next tick"), is disabled, or never ran. A task's page shows its configuration and its last 25 runs.
- Run now enqueues immediately, with no gate and no claim, and records a manual run. It does not cancel a pending rollover run: Disable the task for that.
- Enable / Disable is honoured by the scheduler on every tick.
What "ran" means#
Enqueued, like recurring tasks.
The task is enqueued through ActiveJob perform_later, so uniqueness, batches, concurrency, priority, retries and the dead-letter queue behave as for any job. The job's outcome is on the Jobs page.
If the enqueue fails (perform_later raises, returns false, or does not enqueue the job), the task is re-armed, the failure is logged and reported to your error reporters, and the scheduler retries it after 5 seconds, then 10, 20, and so on up to 5 minutes. Disable the task to stop the retries. Each enqueue emits pgbus.deploy_task.enqueue (task, version, revision, triggered_by).
The run row is what marks a version as done. If it cannot be written right after a successful enqueue (logged and reported), the job still runs, but the next scheduler boot sees the version as owed and runs it again: make deploy tasks idempotent.
Run history#
Runs are kept in pgbus_deploy_task_runs for config.deploy_task_run_retention (default 90 days; nil keeps them forever). The dispatcher prunes hourly. The read-only MCP tool pgbus_deploy_tasks lists tasks, their state and the rollover status.
Install the tables#
rails generate pgbus:add_deploy_tasks # migration + config/deploy_tasks.yml
rails generate pgbus:add_deploy_tasks --database=pgbus # for a separate database
rails db:migraterails generate pgbus:update detects the missing tables and queues the generator for you. Fresh installs (pgbus:install) already have them.