Skip to content

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 recurring pg_relay.health snapshot are all one schedule_job() call.
  • expire_at already 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, and unschedule_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 by purge_queue().
  • Expired (p_expire_at passed): the launch resolves as expired, 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 error audit 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 VACUUM and CREATE INDEX CONCURRENTLY port 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 -c rules). 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 pgrelay role by default — not as the scheduling user the way pg_cron runs jobs. Grant the pgrelay role what the SQL touches and schema-qualify names, or use p_run_as for pg_cron's own semantics: the job runs as a role you hold (validated when you schedule), once the DBA has run GRANT <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.