Creating and Changing Jobs¶
A scheduled job connects a name, a schedule spec, and something to run — either SQL of its own (the pg_cron way), or one of your registered channels with a payload.
Submitting a job¶
The parameter order deliberately mirrors cron.schedule(jobname, schedule,
command), so the simplest form reads exactly like pg_cron:
-- Run this SQL every night at 03:00:
SELECT pgrelay.schedule_job('nightly_report', '0 3 * * *', 'CALL build_nightly_report()');
The full signature:
SELECT pgrelay.schedule_job(
p_name := 'nightly_report', -- the job's identity
p_schedule := '0 3 * * *', -- when (any of the four forms)
p_payload := 'CALL f()', -- the SQL to run — or a channel's $1
p_channel := 'pg_relay_adhoc', -- optional: which channel fires
p_first_run_at := NULL, -- optional: override the first occurrence
p_expire_at := NULL, -- optional: when the schedule ends
p_run_as := NULL, -- ad-hoc only: run the SQL as this role
p_timeout_seconds := NULL, -- ad-hoc only: per-run limit (default 300s)
p_detached := false -- ad-hoc only: the long-maintenance lane
);
| Parameter | Default | Meaning |
|---|---|---|
p_name |
— | Case-insensitive job identity, unique among live jobs. 'Nightly_Report' and 'nightly_report' are the same job. |
p_schedule |
— | The spec — see Schedule Syntax. Always validated, even when p_first_run_at is given. |
p_payload |
NULL |
On the default channel: the SQL each run executes. On a named channel: the ordinary $1 payload passed to that channel's action. |
p_channel |
'pg_relay_adhoc' |
The channel each run fires. The default is the seeded ad-hoc channel (below); name any registered, active channel to fire it instead — validated exactly like pgrelay.notify(), an unknown or disabled channel raises immediately. |
p_first_run_at |
NULL |
The first run defaults to the next occurrence of the spec from now. Supply a timestamp to override it — taken verbatim, so a past value fires on the next Processor tick. Recurrence afterwards follows the spec either way. |
p_expire_at |
NULL |
The schedule's end date. When it passes, the job's next launch resolves as expired and the job is removed. NULL = runs forever. |
p_run_as |
NULL |
Ad-hoc jobs only: run the SQL as this role instead of pgrelay — you must hold the role, and the DBA must have run GRANT <role> TO pgrelay once. |
p_timeout_seconds |
NULL |
Ad-hoc jobs only: per-run limit, 1–43200 (12 h). Unset: 300 s for inline jobs, unlimited for detached ones. |
p_detached |
false |
Ad-hoc jobs only: run in the maintenance lane (below). |
Supplying neither a payload nor a channel is an error — the defaults land
on the ad-hoc channel with nothing to run. The return value is the job's
id — the pgrelay.queue row that is the job. It stays the same for the
job's whole life, keys its entire audit history, and is accepted by
pgrelay.cancel().
The ad-hoc channel: pg_relay_adhoc¶
The default channel is seeded by pg_relay itself and does one thing: it executes the job's payload as SQL. Because a channel that runs its payload inverts pg_relay's usual rule (payloads are inert data), it is reserved — a protected row, not just a convention:
pgrelay.notify()— the PUBLIC entry point — refuses it. The only ways to put SQL on this channel areschedule_job()andrun_sql(), which require a management grant: the same trust boundary pg_cron draws aroundcron.schedule.- It cannot be unregistered, and its action cannot be repointed by
update()— the row is permanently pg_relay's. Tuning still works (update()formax_retriesorconcurrency_mode := 'channel'to serialise all ad-hoc jobs), anddisable()/enable()remain available as a deliberate "suspend every ad-hoc job" lever.
What ad-hoc SQL can be, and how it runs:
- The Processor executes it on a dedicated connection, outside any
transaction — so
VACUUM,CREATE INDEX CONCURRENTLY, andREINDEX CONCURRENTLYcan be scheduled.psql -crules apply: a non-transactional command must be the payload's only statement, while multi-statement payloads ('DELETE FROM myapp.stale; ANALYZE myapp.stale;') run atomically in an implicit transaction. - By default it executes as the
pgrelayrole — grant that role whatever the SQL touches, and schema-qualify names (myapp.refresh(), notrefresh()). Or give the job ap_run_asrole for least-privilege execution: you can only nominate a role you actually hold (pg_cron's rule), and the DBA opts each job role in once withGRANT <role> TO pgrelay. Session state never leaks between jobs — the connection is reset after every payload.
Inline or detached — two lanes¶
An ad-hoc job runs in one of two lanes:
- Inline (the default) — for short jobs. The run is bounded by
p_timeout_seconds(default 300 s); a run that would exceed it is cancelled and logged as anerror. While an inline job runs it occupies one of this Processor instance's workers and delays that instance's next poll. With a fleet of two or more instances the others keep dispatching, so if you set long inline timeouts (the cap is 12 h), run more than one instance. Crash recovery re-fires the occurrence (at-least-once). - Detached (
p_detached := true) — for maintenance-class work like a longVACUUM. The job runs on its own connection in the background. The Processor keeps its 1-second cadence for everything else, and any number of other channels tick along. A still-running previous occurrence is never overlapped: the new occurrence is skipped with anoverlap_skippedrow in the run history instead. No default timeout (set one if you want a runaway guard, up to 12 h). How many detached jobs may run at once per instance is a fleet setting:SELECT pgrelay.set_option('exec_max_detached', '10');(default 5; picked up at startup and on fleet reload). At the cap, a due detached job simply waits — it starts the moment a slot frees. A crash mid-run loses that occurrence and the next one proceeds (at-most-once — exactly what you want for maintenance).
-- The classic maintenance job, in full:
SELECT pgrelay.schedule_job('weekly-vacuum', '0 2 * * 0',
'VACUUM ANALYZE myapp.big_table',
p_detached := true,
p_run_as := 'maintenance_jobs',
p_timeout_seconds := 21600); -- 6h runaway guard
One of each spec form¶
-- Daily maintenance at 03:00 (traditional cron; one call, no channel needed):
SELECT pgrelay.schedule_job('daily_purge', '0 3 * * *',
'SELECT pgrelay.purge(p_hours := 168)');
-- A near-real-time refresh every 10 seconds (seconds cron — pg_cron can't):
SELECT pgrelay.schedule_job('dash_tick', '*/10 * * * * *',
'REFRESH MATERIALIZED VIEW CONCURRENTLY ops.dash_mv');
-- Keep a cache warm every 90 minutes, whenever that lands (interval),
-- firing one of YOUR channels with a payload:
SELECT pgrelay.register('warm_chan', 'CALL ops.warm_cache($1)');
SELECT pgrelay.schedule_job('cache_warm', '90 minutes', 'eu-region', 'warm_chan');
-- The 2nd Tuesday at 09:00 UTC (pgrelay.schedule companion value):
SELECT pgrelay.schedule_job('board_pack', pgrelay.nth_dow('tue', '09:00', 2)::text,
'CALL reports.build_board_pack()');
The channel form is what connects scheduling to everything else pg_relay can
do: a 'notify' channel makes a recurring email, a pg_relay.health channel
a recurring host snapshot, and a
node-restricted channel a per-node job — the payload always arriving as the
action's ordinary $1.
Deferring the first run¶
p_first_run_at decouples when the job starts from its rhythm — start a
weekly digest next Monday, not the Monday that already passed this week:
SELECT pgrelay.schedule_job('weekly_digest', '0 6 * * 1', 'weekly', 'report_chan',
p_first_run_at := '2026-09-07 06:00+10');
A past p_first_run_at is honoured too: the job fires on the next
Processor tick and then falls into its rhythm — handy for "run it once right
now, then nightly":
SELECT pgrelay.schedule_job('nightly_report', '0 3 * * *', 'CALL build_nightly_report()',
p_first_run_at := now());
Giving a job an end date¶
p_expire_at ends a schedule automatically — a campaign job that must stop at
the end of the financial year, with no one needing to remember:
SELECT pgrelay.schedule_job('eofy_export', '0 22 * * *', 'CALL exports.eofy()',
p_expire_at := '2027-06-30 23:59+10');
Running SQL once¶
Not everything recurs. pgrelay.run_sql() is schedule_job()'s one-shot
sibling: it submits a single ad-hoc statement — no name, no schedule spec —
and returns a queue row id:
-- Run it on the Processor's next 1-second poll:
SELECT pgrelay.run_sql('VACUUM ANALYZE myapp.big_table');
-- Or defer it — "at 02:00 tonight, once" — and give up on it after 04:00:
SELECT pgrelay.run_sql('REINDEX INDEX CONCURRENTLY myapp.big_idx',
p_run_at := date_trunc('day', now()) + interval '1 day 2 hours',
p_expire_at := date_trunc('day', now()) + interval '1 day 4 hours');
The statement runs exactly as a scheduled ad-hoc job's would — on the same
reserved channel, outside a transaction, in either lane — and run_sql()
takes the same three execution options: p_run_as, p_timeout_seconds, and
p_detached (a detached one-shot is ideal for kicking off a long VACUUM
right now without occupying the Processor). What differs from a job:
- No name, no listing. Identity is the returned id — it never appears in
list_scheduled_jobs().pgrelay.cancel(<id>)withdraws it while it is still pending, andSELECT * FROM pgrelay.log WHERE queue_id = <id>is its audit trail after it runs. - No retries. A failed one-shot is recorded in the audit log for you to inspect and resubmit — arbitrary SQL is not retried blind.
- Consumed, not perpetual. Once run (or cancelled, or expired), the row is
finished and
purge_queue()eventually removes it.
Together the three submission functions form a deliberate triangle:
notify() fires a one-shot event on your channel (PUBLIC — its payload is
inert data), run_sql() a one-shot SQL statement on pg_relay's channel
(management-granted), and schedule_job() a recurring job of either kind.
Altering a job¶
There is no separate "alter" function, because schedule_job() is the
alter. Calling it again with the same name updates the job in place — same
id, same audit history, same run counters. The new channel, payload, spec,
first-run, and end date take effect immediately:
-- The 03:00 report moves to 04:30 and now runs the summary variant:
SELECT pgrelay.schedule_job('nightly_report', '30 4 * * *', 'CALL build_nightly_report(''summary'')');
What an update keeps and what it replaces:
| Kept | Replaced |
|---|---|
The job id (and with it the whole pgrelay.log history) |
Channel, payload, schedule spec |
created_at, first_run_at, run_count |
The next run time (recomputed from the new spec, or your new p_first_run_at) |
| — | expire_at (set to whatever you pass — NULL means no end date, not unchanged) |
| — | Any pause (next chapter) — re-registering a job also resumes it |
Pass every option you still want
Because the update is a full re-registration, options you leave at their
defaults are reset to those defaults — an expire_at you set last month
disappears if you re-register without passing it again. Read the job back
with list_scheduled_jobs() first if you're not sure what it carries.
Withdrawing a job¶
SELECT * FROM pgrelay.unschedule_job('nightly_report');
-- channel | outcome | log_id
-- --------------+-----------+--------
-- report_chan | cancelled | 87161900114116700
unschedule_job() goes through the same machinery as
pgrelay.cancel(): the job's row is
stamped cancelled, a 'cancelled' audit row is written (that's the log_id
returned), and purge_queue() eventually removes the remains. The name is
immediately reusable — scheduling 'nightly_report' again creates a fresh
job with a fresh id.
Two conventions worth knowing:
- An unknown name — or a job something else is touching at this exact moment —
returns
outcome = 'skipped'and changes nothing.unschedule_job()never raises and never blocks. - Since the job id is a queue row id,
SELECT * FROM pgrelay.cancel(<id>)withdraws a job too. Use whichever handle you have.
A run that is already executing when you withdraw the job finishes normally (and is audited); there is simply never a next one.
Permissions¶
Everything on this page is management-grade — none of it is callable by default. Grant the whole scheduling set (alongside channel management) with:
The Processor's own role needs nothing new: scheduled rows travel through the claim/complete functions it could already execute.
Continue to Pausing, Resuming, and Running Now.