pg-cron-recipes
Skill10 starsChanged 6 months agoMITView on GitHub
Use when you need a copy-pasteable pg_cron recipe — cleanup of soft-deleted rows, expiring trials, materialized view refresh, retry queue, scheduled notifications, log table trimming. Cookbook companion to pg-cron-scheduled-jobs.
In this repository56 other files it gives its agents.
- accessibility-passSkill
- analytics-eventsSkill
- api-error-handlingSkill
- audit-logging-backendSkill
- background-jobs-and-cronSkill
- dark-mode-and-themingSkill
- dashboard-charts-rechartsSkill
- edge-functions-and-webhooksSkill
- email-and-notificationsSkill
- error-states-and-empty-uiSkill
- feature-flags-and-togglesSkill
- file-upload-storageSkill
- i18n-multi-languageSkill
- lovable-prompting-playbookSkill
- lovable-ship-checklistSkill
- migration-playbookSkill
- multi-tenant-backendSkill
- onboarding-copySkill
- optimistic-updatesSkill
- payment-failed-and-recoverySkill
- payment-webhook-idempotencySkill
- payment-webhook-testingSkill
- performance-budgetSkill
- pg-cron-scheduled-jobsSkill
- pg-cron-with-pg-net-edgeSkill
- postgres-full-text-searchSkill
- postgres-triggers-and-functionsSkill
- pricing-and-billingSkill
- programmatic-seo-content-qualitySkill
- programmatic-seo-pagesSkill
- rate-limiting-edgeSkill
- realtime-and-subscriptionsSkill
- refactor-safe-diffSkill
- release-notesSkill
- seo-core-web-vitalsSkill
- seo-landing-pageSkill
- seo-meta-tags-spaSkill
- seo-sitemap-generationSkill
- seo-ssr-and-prerenderingSkill
- seo-structured-dataSkill
- server-input-validationSkill
- shadcn-patternsSkill
- soft-delete-and-archivingSkill
- stripe-one-time-payment-webhooksSkill
- stripe-payment-webhooksSkill
- supabase-auth-flowsSkill
- supabase-database-designSkill
- supabase-rls-and-authSkill
- support-replySkill
- tanstack-infinite-queriesSkill
- tanstack-mutations-and-invalidationSkill
- tanstack-prefetch-and-hydrationSkill
- tanstack-query-alternativeSkill
- tanstack-router-data-loadersSkill
- tanstack-start-server-routesSkill
- typed-api-hooks-formsSkill
The file
We split this skill into the rules it gives an agent
1 rule. About 0 are common, 1 is distinctive, and 0 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
0
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 14
Every recipe follows the same shape:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
pg_cron recipes (cookbook)
Battle-tested recipes for common scheduled jobs. All examples assume pg_cron and (where noted) pg_net are enabled. See pg-cron-scheduled-jobs for the fundamentals and pg-cron-with-pg-net-edge for HTTPS calls.
Every recipe follows the same shape:
- SQL function (
security definer, idempotent). cron.schedulecall by stable name.- Notes on tuning the cadence.
1. Cleanup soft-deleted rows (daily)
create or replace function public.cleanup_soft_deleted()
returns table(table_name text, deleted_rows bigint)
language plpgsql
security definer
set search_path = public
as $$
declare
rec record;
n bigint;
begin
for rec in
select unnest(array['posts','comments','attachments']) as t
loop
execute format(
'delete from %I where deleted_at < now() - interval ''30 days''',
rec.t
);
get diagnostics n = row_count;
table_name := rec.t;
deleted_rows := n;
return next;
end loop;
end;
$$;
select cron.schedule('cleanup-soft-deleted', '0 3 * * *',
$$ select public.cleanup_soft_deleted() $$);Off-peak (03:00 UTC). Returns row counts per table for easy auditing.
2. Expire free trials (hourly)
create or replace function public.expire_trials()
returns int
language plpgsql
security definer
as $$
declare
n int;
begin
update organizations
set plan = 'free',
trial_expired_at = now()
where plan = 'trial'
and trial_ends_at < now()
and trial_expired_at is null;
get diagnostics n = row_count;
return n;
end;
$$;
select cron.schedule('expire-trials', '5 * * * *', -- 5 past every hour
$$ select public.expire_trials() $$);Off the hour boundary (5 * * * *) to avoid lock contention with other top-of-hour jobs.
3. Refresh materialized view (hourly, non-blocking)
create materialized view if not exists public.dashboard_stats as
select org_id,
count(*) as project_count,
sum(monthly_revenue) as mrr
from projects
group by org_id;
create unique index if not exists dashboard_stats_org_id_idx
on public.dashboard_stats(org_id); -- required for concurrent refresh
create or replace function public.refresh_dashboard_stats()
returns void
language plpgsql
security definer
as $$
begin
if not pg_try_advisory_lock(hashtext('refresh_dashboard_stats')) then
return;
end if;
refresh materialized view concurrently public.dashboard_stats;
perform pg_advisory_unlock(hashtext('refresh_dashboard_stats'));
exception when others then
perform pg_advisory_unlock(hashtext('refresh_dashboard_stats'));
raise;
end;
$$;
select cron.schedule('refresh-dashboard-stats', '*/15 * * * *',
$$ select public.refresh_dashboard_stats() $$);concurrently avoids locking readers; unique index is required. Advisory lock prevents overlap if a refresh takes > 15 min.
4. Scheduled notifications dispatch (every minute)
Notifications are inserted with scheduled_for. Cron sweeps due ones, marks them sent, and (optionally) calls an edge function to actually deliver.
create or replace function public.dispatch_due_notifications()
returns int
language plpgsql
security definer
as $$
declare
ids uuid[];
begin
with claimed as (
select id from notifications
where sent_at is null
and scheduled_for <= now()
order by scheduled_for
limit 200
for update skip locked
)
update notifications n
set sent_at = now()
from claimed
where n.id = claimed.id
returning n.id into ids;
if array_length(ids, 1) is not null then
perform net.http_post(
url := 'https://YOUR-PROJECT.functions.supabase.co/deliver-notifications',
headers := jsonb_build_object(
'Content-Type','application/json',
'Authorization','Bearer ' || current_setting('app.cron_secret', true)
),
body := jsonb_build_object('ids', to_jsonb(ids))
);
end if;
return coalesce(array_length(ids, 1), 0);
end;
$$;
select cron.schedule('dispatch-notifications', '* * * * *',
$$ select public.dispatch_due_notifications() $$);for update skip locked makes this safe even if two runs overlap. Batch size 200 keeps each tick cheap.
5. Retry failed webhook deliveries (every 5 min)
create or replace function public.retry_failed_webhooks()
returns int
language plpgsql
security definer
as $$
declare
n int;
begin
with due as (
select id from webhook_deliveries
where status = 'failed'
and attempts < 5
and next_attempt_at <= now()
for update skip locked
limit 50
)
update webhook_deliveries w
set status = 'pending',
attempts = attempts + 1,
next_attempt_at = now() + (power(2, attempts) || ' minutes')::interval
from due
where w.id = due.id;
get diagnostics n = row_count;
return n;
end;
$$;
select cron.schedule('retry-webhooks', '*/5 * * * *',
$$ select public.retry_failed_webhooks() $$);Exponential backoff in SQL: 2, 4, 8, 16, 32 minutes between attempts.
6. Trim audit / log tables (daily)
create or replace function public.trim_audit_log(keep_days int default 90)
returns bigint
language plpgsql
security definer
as $$
declare
n bigint;
begin
delete from audit_log where created_at < now() - (keep_days || ' days')::interval;
get diagnostics n = row_count;
return n;
end;
$$;
select cron.schedule('trim-audit-log', '30 3 * * *',
$$ select public.trim_audit_log(90) $$);Pair with table partitioning by month for cheaper bulk drops once tables get large.
7. Trim pg_cron and pg_net history (daily)
These tables grow forever unless you trim:
create or replace function public.trim_cron_history()
returns void
language sql
security definer
as $$
delete from cron.job_run_details where end_time < now() - interval '14 days';
delete from net._http_response where created < now() - interval '7 days';
$$;
select cron.schedule('trim-cron-history', '45 3 * * *',
$$ select public.trim_cron_history() $$);8. Weekly digest fan-out (Mondays 14:00 UTC)
create or replace function public.enqueue_weekly_digest()
returns int
language plpgsql
security definer
as $$
declare
n int;
begin
insert into notifications (user_id, kind, payload, scheduled_for)
select u.id,
'weekly_digest',
jsonb_build_object('week_of', date_trunc('week', now())),
now()
from users u
where u.email_digest_optin
and u.last_digest_sent_at < now() - interval '6 days';
get diagnostics n = row_count;
update users
set last_digest_sent_at = now()
where email_digest_optin
and last_digest_sent_at < now() - interval '6 days';
return n;
end;
$$;
select cron.schedule('enqueue-weekly-digest', '0 14 * * 1',
$$ select public.enqueue_weekly_digest() $$);Notice this only enqueues — actual sending goes through the per-minute dispatcher (#4).
9. Heartbeat / liveness ping (every minute)
create table if not exists ops.heartbeat (
id int primary key default 1,
last_seen timestamptz not null
);
insert into ops.heartbeat values (1, now()) on conflict (id) do nothing;
create or replace function ops.heartbeat_tick()
returns void
language sql
security definer
as $$
update ops.heartbeat set last_seen = now() where id = 1;
$$;
select cron.schedule('heartbeat', '* * * * *', $$ select ops.heartbeat_tick() $$);External monitor can alert if now() - last_seen > 5 minutes.
10. Anonymize PII on inactive accounts (weekly)
create or replace function public.anonymize_inactive_users(after_days int default 365)
returns int
language plpgsql
security definer
as $$
declare
n int;
begin
update users
set email = 'deleted+' || id || '@example.invalid',
full_name = 'Deleted user',
avatar_url = null,
phone = null,
anonymized_at = now()
where last_active_at < now() - (after_days || ' days')::interval
and anonymized_at is null
and deleted_at is not null;
get diagnostics n = row_count;
return n;
end;
$$;
select cron.schedule('anonymize-inactive', '0 4 * * 0', -- Sunday 04:00 UTC
$$ select public.anonymize_inactive_users(365) $$);Helps meet GDPR / data minimization obligations.
Cadence quick-reference
| Job class | Suggested cron |
| Hot queue dispatch | * * * * * |
| Retries / backoff | */5 * * * * |
| Aggregation refresh | */15 * * * * or 0 * * * * |
| Expiration sweeps | 5 * * * * |
| Daily cleanups | 0 3 * * * (off-peak UTC) |
| Trims / log retention | 30 3 * * * |
| Weekly digests | 0 14 * * 1 |
| Heavy reports | 0 5 * * 0 (Sunday early) |
Avoid
- Running all daily jobs at
0 0 * * *— they pile up. Stagger by 15–30 minutes. - Skipping
for update skip lockedon queue-style sweeps (causes double-sends). - Letting
cron.job_run_details/net._http_responsegrow forever. - Writing one giant function that does five unrelated things — split per concern.
Checklist
- Each recipe is wrapped in a
security definerfunction. - Schedules staggered across the hour and day.
- Queue sweeps use
for update skip locked+ batch size. - History tables (cron, pg_net) have their own trim job.
- Heartbeat or external alert on long-running / failed jobs.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

