Skip to content

Monitoring and History

Two functions answer the two questions pg_cron users ask of cron.job and cron.job_run_details: what is scheduled? and how did it go?

What is scheduled — list_scheduled_jobs()

SELECT * FROM pgrelay.list_scheduled_jobs();

One row per live job, ordered by name:

Column Meaning
id The job's stable id — its pgrelay.log history key, accepted by cancel()
schedule_name The name you gave schedule_job()
channel The channel each run fires
schedule The spec, exactly as registered
payload The payload each run passes to the action
next_run When the Processor will next launch it (infinity while paused indefinitely)
expire_at The schedule's end date, if any
created_at When the job was first registered
updated_at When schedule_job() last (re-)registered it
first_run_at When its first run launched (NULL until then)
run_count How many runs have launched, ever
paused The effective pause state right now
paused_until infinity, a timestamp, or NULL — see the pause chapter

Handy variations:

-- Jobs due in the next 10 minutes:
SELECT schedule_name, next_run FROM pgrelay.list_scheduled_jobs()
WHERE NOT paused AND next_run < now() + interval '10 minutes'
ORDER BY next_run;

-- Jobs that have never run (waiting on a future first_run, or just created):
SELECT schedule_name, next_run FROM pgrelay.list_scheduled_jobs()
WHERE first_run_at IS NULL;

-- Everything currently paused:
SELECT schedule_name, paused_until FROM pgrelay.list_scheduled_jobs() WHERE paused;

run_count and first_run_at survive log purges

These two live on the job's own row, maintained at each launch — they are not derived from the audit log, so pgrelay.purge() trimming old log rows never zeroes a job's lifetime counters. A launch that crashed mid-run and rolled back is not counted (it never happened, transactionally); the re-fired attempt is.

How did it go — scheduled_job_runs()

-- The last 100 runs across every job:
SELECT * FROM pgrelay.scheduled_job_runs();

-- One job's recent history:
SELECT * FROM pgrelay.scheduled_job_runs('nightly_report');

-- Just the latest run:
SELECT * FROM pgrelay.scheduled_job_runs('nightly_report', 1);

-- Everything that completed in the last hour, across every job:
SELECT * FROM pgrelay.scheduled_job_runs(p_within := '1 hour');

-- Just today's failures:
SELECT * FROM pgrelay.scheduled_job_runs(p_within := '1 day', p_status := 'error');

Newest first, one row per audited run, read straight from pgrelay.log via the job's stable id. The three filters — a job name, p_within (a trailing time window), and p_status (one outcome, case-insensitive) — combine freely, and an unknown name or status simply returns no rows:

Column Meaning
job_id, schedule_name, channel Which job
run_log_id The pgrelay.log row id for this run
status ok, error, retry_scheduled, invalid, overlap_skipped (a detached ad-hoc occurrence skipped because the previous one was still running)
error The failure text, when there is one
elapsed_ms How long the action took
ran_at When it ran

The statuses are the ordinary pg_relay outcomes, and they mean the ordinary things: error — the action raised (the schedule continued regardless); retry_scheduled — the action raised a transient error and a one-off retry row was queued (a retried run shows both an error row carrying the failure detail and a retry_scheduled row naming the retry); invalid — the job fired while its channel was disabled. A withdrawn or expired job leaves the live set, so its final cancelled/expired audit rows appear only in pgrelay.log itself (queried by the job's id, below), not here.

History reaches back exactly as far as your log retentionpgrelay.purge() trims these rows like any other audit rows, which is why the lifetime counters above live on the job itself. For anything a filter can't answer, the raw audit log is always there:

SELECT * FROM pgrelay.log
WHERE queue_id = (SELECT id FROM pgrelay.list_scheduled_jobs()
                  WHERE schedule_name = 'nightly_report')
ORDER BY actioned_at DESC;

psql shortcuts

The queries above come packaged as psql shortcuts in the repository's deploy/pg_relay_shortcuts.sql. Reference it from your ~/.psqlrc

\i /path/to/pg_relay_shortcuts.sql

— and monitoring becomes two keystrokes and a colon. The full set of shortcuts the file defines:

Shortcut Shows
:schedules Every live scheduled job, paused included
:schedules5m Jobs whose next run is due within the coming 5 minutes
:schedules15m … within the coming 15 minutes
:schedules1h … within the coming hour
:schedules4h … within the coming 4 hours
:schedules8h … within the coming 8 hours
:schedules1d … within the coming day
:jobs The last 99 completions of scheduled jobs, newest first, every status
:jobs5m Completions in the trailing 5 minutes
:jobs15m … in the trailing 15 minutes
:jobs1h … in the trailing hour
:jobs4h … in the trailing 4 hours
:jobs8h … in the trailing 8 hours
:jobs1d … in the trailing day
:jobs_err The last 99 completions that were not ok, newest first
:jobs5m_err Not-ok completions in the trailing 5 minutes
:jobs15m_err … in the trailing 15 minutes
:jobs1h_err … in the trailing hour
:jobs4h_err … in the trailing 4 hours
:jobs8h_err … in the trailing 8 hours
:jobs1d_err … in the trailing day
:queue The pending (undispatched) event rows, in dispatch order, up to 99 — ids feed cancel()
:queue_done The last 99 completed queue items of any kind, from the audit log

For example:

relay_dev=# :schedules1h    -- what fires within the hour?
relay_dev=# :jobs1d_err     -- what went wrong today?
relay_dev=# :queue          -- what is waiting right now?

Each shortcut is a plain query macro over the management read functions (list_scheduled_jobs(), scheduled_job_runs(), queue_pending(), log_report()), so it works for any role with the pgrelay.grant_user() grant — nothing extra to install in the database.

When a job doesn't fire — a checklist

  1. Is it paused? list_scheduled_jobs()paused.
  2. Is next_run actually in the past? If it's infinity or far in the future, the job is doing what it was told — check the spec with SELECT pgrelay._next_run('<spec>') and remember cron runs on server time.
  3. Is the channel active? A disabled channel makes runs log invalid — visible in scheduled_job_runs() — while the job keeps ticking.
  4. Is a run still executing? A job never overlaps itself; a stuck run blocks the next occurrence until it finishes. elapsed_ms in the history shows how long runs normally take.
  5. Is the Processor running — and is the fleet paused? Scheduled jobs ride the same Processor as everything else: check pgrelay.queue_stats() and the fleet's pause state like you would for any quiet queue.
  6. Multi-node: a job on a node-restricted channel runs only on its own node; an unrestricted job created on a dead node is adopted per your mode's rules.

Housekeeping

A live job's row is never touched by purge_queue() — only a withdrawn job's cancelled remains are, on the same schedule as every other processed row. Run history is pgrelay.log, governed by pgrelay.purge(). Nothing new to schedule — and if you'd like the purging itself to be a scheduled job, that's now one call.


Continue to How It Works.