How It Works¶
You don't need this chapter to use scheduled jobs — but the mechanics explain the guarantees, and the pg_cron cookbook at the end maps a migration call-for-call.
A job is a perpetual queue row¶
pg_relay didn't grow a scheduler beside its queue; the queue is the
scheduler. pgrelay.queue has always had run_at — the moment a row becomes
eligible — and the Processor has always polled for run_at <= now() every
second. A scheduled job is simply a queue row that is never consumed: its
run_at always holds the next occurrence, and after each launch it is
advanced to the occurrence after that, forever, until you withdraw the job.
That is why the feature composes so cleanly with everything else:
- Any action type. The row fires like any other event — a
'sql'channel, a'notify'email, or a recurringpg_relay.healthsnapshot are all oneschedule_job()call. expire_atalready meant "discard after this time" — for a job it reads as the schedule's end date.cancel()already meant "make sure this never runs" — for a job it reads as withdrawal, andunschedule_job()is a name-addressed wrapper around exactly that.- The audit log already keyed every result to its queue row — for a job that key never changes, so one id carries the entire run history.
- Multi-master modes already decided which node dispatches which row — jobs inherit those rules verbatim, including adoption of an unrestricted job whose home node dies, and per-node pinning on node-restricted channels.
The advance happens at launch, before the action¶
The moment the Processor claims a due job — before the action runs — the
row's run_at moves to the next occurrence, its launch counter increments,
and its first-run time is stamped, all inside the claim's own transaction.
That placement is deliberate, and three behaviours fall straight out of it:
Fixed cadence. The next occurrence is computed from launch time, not
completion time. A '30 minutes' job launched at 03:00 that takes 20 minutes
next runs at 03:30 — not 03:50, drifting a little later on every run.
Crash safety. If the Processor dies mid-run, the whole transaction — claim, advance, counter — rolls back, leaving the row due exactly as it was. The job re-fires on the next poll: the same at-least-once contract every pg_relay event has, with nothing extra to configure.
Failures never stop the schedule. An action that raises is contained (its
run is audited as error, and the channel's
retry policy may queue a one-off
retry row), but the advance has already happened — the next occurrence
proceeds. A schedule stops only when you stop it, or its expire_at
passes.
Why a job can never overlap itself¶
While a run executes, the job's row is locked and its run_at already points
to the future — so to every other worker on every other Processor it is
neither visible nor due. There is exactly one row per job, so there is
exactly one possible run at a time, across the whole fleet. No advisory
locks, no WHERE NOT EXISTS guards in your job SQL.
If a run overruns its own next slot (a '1 minute' job that took 90
seconds), completion re-advances run_at past now — the missed slot is
skipped, honouring the same skip-missed rule as an outage, rather than
firing back-to-back to catch up.
Withdrawn, expired, and broken jobs¶
- Withdrawn (
unschedule_job()/cancel()): the row is stamped like any cancelled event, audited, and later removed bypurge_queue(). - Expired (
p_expire_atpassed): the launch resolves asexpired, audited, and the row is deleted — the schedule has ended. - Broken spec: specs are validated at registration, so this takes
something external — in practice, uninstalling the
schedule companion while jobs still use
its values. Such a job stops loudly, once: a single
erroraudit row explains the cause and the job is resolved — never an error every second, and never a silent skip. Re-register the job after reinstalling.
Migrating from pg_cron — the cookbook¶
schedule_job() takes its arguments in pg_cron's order, so most entries port
by changing the function name:
| pg_cron | pg_relay |
|---|---|
SELECT cron.schedule('nightly-vacuum', '0 3 * * *', 'VACUUM ANALYZE myapp.big_table') |
SELECT pgrelay.schedule_job('nightly-vacuum', '0 3 * * *', 'VACUUM ANALYZE myapp.big_table', p_detached := true); |
SELECT cron.alter_job(jobid, schedule := '0 4 * * *') |
SELECT pgrelay.schedule_job('nightly-vacuum', '0 4 * * *', 'VACUUM ANALYZE myapp.big_table', p_detached := true); — same name = update in place |
SELECT cron.unschedule('nightly-vacuum') |
SELECT * FROM pgrelay.unschedule_job('nightly-vacuum'); |
SELECT cron.alter_job(jobid, active := false) … active := true |
SELECT * FROM pgrelay.pause_job('nightly-vacuum'); … resume_job(...) |
SELECT * FROM cron.job |
SELECT * FROM pgrelay.list_scheduled_jobs(); |
SELECT * FROM cron.job_run_details ORDER BY start_time DESC |
SELECT * FROM pgrelay.scheduled_job_runs(); |
'30 seconds' interval syntax |
'30 seconds' — identical; or the 6-field '*/30 * * * * *' for clock-aligned seconds |
cron.timezone setting |
Cron specs run on server time (the Processor session's timezone); pgrelay.schedule values are UTC |
Differences to keep in mind while porting:
- Non-transactional commands work — declare the lane. Ad-hoc SQL runs on
a dedicated connection outside any transaction, so
VACUUMandCREATE INDEX CONCURRENTLYport directly; put maintenance-class jobs in the detached lane (p_detached := true) so they never hold up the Processor, and remember a non-transactional command must be the payload's only statement (psql -crules). Short jobs run inline under a timeout (default 300 s, cap 12 h — a long inline timeout stalls that one instance's dispatch for the run's duration, so run ≥2 instances if you use them). - The command runs as the
pgrelayrole by default — not as the scheduling user the way pg_cron runs jobs. Grant thepgrelayrole what the SQL touches and schema-qualify names, or usep_run_asfor pg_cron's own semantics: the job runs as a role you hold (validated when you schedule), once the DBA has runGRANT <role> TO pgrelay. See the security model. - Overlap protection is built in — if your pg_cron jobs carried
pg_try_advisory_lock()guards against double-running, delete them. - Run-anything-now exists (
run_job_now()), so the "temporarily schedule it a minute from now" migration-testing dance isn't needed. - pg_cron's
$$…$$multi-statement commands port verbatim (dynamic EXECUTE accepts them); for SQL you also want callable on demand or shared between jobs, register it once as a channel of your own and schedule that channel with a payload instead.
That's the whole book. For the function signatures in reference form, see the Function Reference; for what shipped when, the Changelog.