Extensions, scheduled jobs and HTTP from SQL
A project is a real Postgres, so it can be taught new things with extensions. Three of them get their own sections here because people build on them: pg_cron runs SQL on a schedule, pg_net makes HTTP requests from SQL, and the two together call a URL on a schedule.
The function names are the upstream ones (cron.schedule, net.http_post), so a snippet written
for another hosted Postgres that ships these extensions runs here unchanged.
Switching an extension on
Every extension the database image carries is listed in the dashboard's Extensions tab
(dashboard.snoutdata.com, open the project, Extensions). The list is read from the database
itself. Switching one on runs create extension and takes effect at once, with no restart.
The same thing in SQL, from the SQL tab, snoutdata db psql or any client:
create extension if not exists pg_cron;
create extension if not exists pg_net;
Your project's owner role is not a superuser, and it does not need to be for these. The image lets the owner create the extensions it vouches for, including PostGIS, pgvector, pg_cron, pg_net, pg_graphql, hypopg, pgaudit and pg_repack, and grants the owner what each one creates. An extension that is not on that list fails with Postgres's own refusal rather than doing nothing.
Time series: SnoutTime
SnoutTime, part of SnoutData Cloud, is on that list on every plan: create extension snouttime
turns tables into series tables partitioned by time, seals old partitions into a compressed column
store, and keeps rollups current. It runs its own background worker, so it needs no pg_cron job.
It has its own section: start at Time series with SnoutTime.
Scheduled jobs (pg_cron)
A job is a SQL statement and a schedule. It runs inside your database, as the role that scheduled it, with nothing else to deploy.
From the dashboard
Open the project and choose the Cron tab. It shows every job you have scheduled, from anywhere, because it reads pg_cron's own tables:
- each job's schedule, its SQL, and whether it is active;
- the last run: succeeded or failed, when, and the error message when it failed;
- how many times it failed in the last day;
- Runs, the last 25 runs of one job with how long each took;
- a switch to pause or resume a job, and Delete;
- Schedule a job, with common schedules and three starting points (trim the run history, call a URL, refresh a materialized view).
If pg_cron is off, the tab offers to switch it on.
From SQL
-- every day at 03:00 UTC; scheduling again under the same name replaces the job
select cron.schedule('nightly-cleanup', '0 3 * * *',
$$delete from public.sessions where expires_at < now()$$);
-- every 30 seconds (intervals of 1 to 59 seconds are allowed)
select cron.schedule('heartbeat', '30 seconds', $$insert into public.beats default values$$);
-- what is scheduled, and how the runs went
select jobid, jobname, schedule, active from cron.job;
select jobid, status, start_time, return_message
from cron.job_run_details order by start_time desc limit 20;
-- pause, resume, remove
select cron.alter_job(job_id := 1, active := false);
select cron.alter_job(job_id := 1, active := true);
select cron.unschedule('nightly-cleanup');
Schedules are standard five-field cron, in UTC.
From the CLI
The CLI has no separate cron command: jobs are SQL, and snoutdata db psql runs SQL against the
linked project (it needs psql installed).
snoutdata db psql -- -c "select cron.schedule('nightly-cleanup', '0 3 * * *', \$\$delete from public.sessions where expires_at < now()\$\$)"
snoutdata db psql -- -c "select jobid, jobname, schedule, active from cron.job"
snoutdata db psql -- -c "select cron.unschedule('nightly-cleanup')"
Because a job is SQL, it can also live in a migration file and arrive with snoutdata db push.
What to know before relying on it
-
A paused project runs no jobs. On a plan that pauses idle projects, a job's time that passes while the project is paused is skipped, not caught up after it wakes. A project that must run a job on time needs a plan that does not pause, or traffic that keeps it awake.
-
At most three jobs run at the same moment. A fourth that is due waits for one to finish. Keep a job short, or split it.
-
A job runs as the role that scheduled it. It can do what that role can do, and nothing it would be refused interactively.
-
pg_cron never deletes its run history.
cron.job_run_detailsgains a row per run for as long as the job exists. Schedule the trim yourself (the Cron tab's first template is this one):select cron.schedule('trim-cron-history', '0 3 * * *',$$delete from cron.job_run_details where end_time < now() - interval '7 days'$$); -
Cron jobs you schedule are yours alone.
cron.jobandcron.job_run_detailsonly show a role the jobs it scheduled.
HTTP requests from SQL (pg_net)
pg_net sends HTTP requests from inside the database without waiting for the answer. The call
returns a request id straight away, a background worker makes the request, and the response
lands in net._http_response.
create extension if not exists pg_net;
select net.http_post(
url := 'https://example.com/hook',
headers := '{"content-type": "application/json"}'::jsonb,
body := jsonb_build_object('event', 'signup', 'at', now())
) as request_id;
select net.http_get('https://example.com/status');
-- a moment later
select id, status_code, error_msg, left(content, 200)
from net._http_response order by id desc limit 10;
Responses are kept for six hours and then removed.
Calling a URL on a schedule
The two together are a webhook on a timer, or a way to call one of your Snout Functions regularly:
select cron.schedule('ping-my-function', '*/5 * * * *', $$
select net.http_post(
url := 'https://<ref>.api.snoutdata.com/functions/v1/sync',
headers := jsonb_build_object('content-type', 'application/json',
'authorization', 'Bearer <service_role key>'),
body := '{}'::jsonb
)
$$);
A key written into a job is stored in cron.job in plain text, readable by the role that owns
the job. Use a key you can rotate (snoutdata keys shows them, and a rotation is one command).
Who can use it
When pg_net is switched on, only the project's owner role and service_role can call it. anon
and authenticated, the roles every request through the data API runs as, get nothing until you
decide otherwise:
grant usage on schema net to authenticated;
grant execute on function net.http_post(text, jsonb, jsonb, jsonb, integer) to authenticated;
Think before you do: a signed-in user who can call it can make your database send requests anywhere on the internet.
Where a request can go
The public internet. A request to a private address (the cloud provider's metadata service, the
network the database runs on, or any other private range) is refused at the network, whatever SQL
asks for it, and comes back in net._http_response with an error such as
Couldn't connect to server.