Repository navigation
Parked Supabase query changes and when to reopen them #326
Description
Key: SPR-19 · Type: record · Part of: #327 · Blocked by: none · Checked against: 3bb26de
Problem
The 2026-09-28 prod performance review parked 4 query changes and rejected 14 ideas on the numbers. This issue records both lists, so no one proposes them again without new evidence. The do-not-do table has 15 rows, because it splits the foreign-key and unused-index idea into Q-3 and Q-4.
The prod database is almost idle. Every SQL statement together uses 35.0 s of DB time per hour. That is 0.97% of one CPU core. Supabase bills compute by instance size, not by use, so query work saves $0. Each item below saves a fraction of that already small total.
This is a record, like #259, #260 and #261. Nothing here is to be built now. Reopen an item only when its trigger fires.
Context
Source. The maintainer's local performance report of 2026-09-28. It used a 69.8-minute prod window (10:27:21Z to 11:37:07Z) from pg_stat_statements. Local benches ran on PostgreSQL 15.8 with pgmq 1.4.4, N=15, median reported.
Tags. [prod] = measured on prod. [local] = measured on a local database with prod-shaped data. [pred] = predicted, not measured. The local host has 18 cores and prod probably has 2. Read local results as ratios, not as prod milliseconds.
Baseline [prod], per hour, current window.
| Consumer | Calls/h | DB ms/h | % of one core |
|---|---|---|---|
realtime.list_changes (Supabase Realtime) |
5,659 | 27,317 | 0.76% |
get_cron_job_health (our worker) |
120 | 3,736 | 0.104% |
aggregate_document_view_stats (cron, every 5 min) |
12 | 665 | 0.018% |
get_pgmq_queue_metrics (our worker) |
480 | 514 | 0.014% |
| All statements | 16,543 | 35,009 | 0.97% |
One change from the same review is not parked. #323 ships it: get_cron_job_health sampled every 180 s instead of 60 s. After #323, that RPC should drop to about 40 calls/h and about 1,245 ms/h [pred].
Useful conversion. 0.1% of one core = 3,600 ms of DB time per hour.
Parked changes
| Id | Change | Cost now | Saving | Bench | Reopen when |
|---|---|---|---|---|---|
| cron-2 | Rewrite the get_cron_job_health body: max(runid) per job plus one primary-key probe, no sort |
about 1,245 ms/h after #323 [pred] | about 540 ms/h, to about 705 ms/h [pred] | 5.335 → 2.309 ms p50, 2.3x [local] | This RPC goes back over 0.1% of one core (3,600 ms/h) on prod |
| cron-4 | Bound the daily part of aggregate_document_view_stats on the partition key viewed_at |
665 ms/h [prod] | about 2 ms per run, about 24 ms/h [pred] | 3.237 → 1.203 ms p50, 6 partitions pruned [local] | Someone rewrites this function for another reason; add the one line in that change |
| RT-2 | Remove push_subscriptions and email_queue from the supabase_realtime publication |
noise | 0.04 ms per Realtime poll [local] | 0.509 → 0.470 ms p50 per poll [local] | Someone edits the publication, or does a prod-drift cleanup pass |
| P2 | Sample get_pgmq_queue_metrics every 2 ticks (30 s) instead of every 15 s tick |
480 calls/h, 514 ms/h [prod] | 240 calls/h, about 257 ms/h [pred] | none: a rate change | Someone changes the sampler cadences in workerMetricsSampler.ts for another reason, or this RPC goes over 0.1% of one core |
cron-2 detail. The current body is in packages/supabase/scripts/16-cron-jobs.sql ~63-90 (public.get_cron_job_health). It uses (array_agg(d.status order by d.start_time desc))[1], which sorts every row of cron.job_run_details on each call. Lifetime rows read by this RPC on prod: 129,781,904,655 [prod]. The daily cleanup job (cleanup-cron-job-run-details, same file ~42) now keeps the table small. That cut the call cost from a lifetime mean of 103 ms to 31.0 ms. The parked body:
create or replace function public.get_cron_job_health()
returns table (
jobname text,
last_run_status text,
last_success_at timestamptz
)
language sql
security definer
stable
set search_path = public
as $$
select
j.jobname::text,
(select d.status from cron.job_run_details d where d.runid = a.last_runid) as last_run_status,
a.last_success_at
from cron.job j
left join (
select jobid,
max(runid) as last_runid,
max(end_time) filter (where status = 'succeeded') as last_success_at
from cron.job_run_details
group by jobid
) a using (jobid);
$$;Why it is safe:
- pg_cron takes
runidfromcron.runid_seqwhen it inserts the row at launch. Somax(runid)per job is the latest run. - A run still in
startinghas a nullstart_time. The oldDESCsort put nulls first, andmax(runid)also picks that newest row. The local bench found 0 mismatched rows against the current body. - The status comes from one probe per job on
job_run_details_pkey(runid). No new index is needed.postgrescannot create one, because it does not owncron.job_run_details. - Same name, arguments and return columns, so
create or replacekeeps the ACL. Keep therevoke ... from public, anon, authenticatedandgrant ... to service_rolelines after it, as the current script does (~89-90).
cron-4 detail. The function is public.aggregate_document_view_stats() in packages/supabase/scripts/09-document-views.sql ~497. Its CTE daily_raw (~602) filters on view_date >= current_date - 1 (~612) only. view_date is not the partition key, so each run scans every document_views partition. The parked one-line change:
where view_date >= current_date - 1 -- Only today and yesterday
and viewed_at >= (current_date - 2)::timestamptz -- lets pg prune old partitionsWhy the result cannot change: the only writer, process_document_views_queue, sets view_date = (viewed_at at time zone 'UTC')::date. So view_date >= C-1 implies viewed_at >= (C-1) 00:00 UTC. The new bound is (C-2) 00:00 in the session zone. That is earlier for any offset from -12h to +14h.
Honest saving: about 2 ms per run in absolute terms. One sampled prod run took 44.8 ms, and the window averaged about 55 ms per run (665 ms/h over 12 runs) [prod]. Most of that is not the daily scan. A cold backend still plans over every partition. So scale the absolute 2 ms, not the 2.7x ratio.
Do not fold in these two ideas with it:
- An early exit when no new views arrived.
update_view_durationchangesduration_msandis_bouncewithout a newviewed_at, so the daily rows would go stale. - An
IS DISTINCT FROMguard on the upsert. It saves about 12 row updates per run.
RT-2 detail. Prod published 7 tables in supabase_realtime when this was written [prod]. #434 has since removed users (migration 20261009120500, applied in production). So 6 remain: channels, messages, channel_members, channel_message_counts, push_subscriptions, email_queue. No client code subscribes to postgres_changes on push_subscriptions or email_queue. In the repo:
packages/supabase/scripts/17-realtime-replica.sqldrops the publication (~9) and re-creates it with 5 tables (~18-26).packages/supabase/scripts/07-4-push-notifications-pgmq.sql~699-704 addspush_subscriptionsto it. The seed runs07-4before17, so a local reset ends with 5 tables. Inpackages/supabase/seed.sqlthe add is at ~1699 and the re-create at ~9868.- Prod got
push_subscriptionsfrom the applied migration20260519200000_scripts_functions_triggers_parity.sql, which carries the07-4add. - No script or migration adds
email_queueto the publication. That membership is prod drift. The table itself is defined in07-5-email-notifications-pgmq.sql~26. - The comment in
17-realtime-replica.sql~14-15 namespackages/admin-dashboard/src/pages/users.tsx. The real path isapps/admin-dashboard/src/pages/users.tsx(useRealtimeSubscription, ~122-123). The admin dashboard also subscribes tochannels(apps/admin-dashboard/src/pages/channels.tsx~39-40). packages/supabase/scripts/31-connected-app-token-gate.sql~59-82 puts the restrictive policy "Connected apps use only the MCP server" on every published table. Its migration20260928120000is not on prod yet (Ship the three migrations that prod's history lacks #313). Once it is, prod gates 7 tables, and a local reset gates 5.- Drop that policy with the table, so prod stays equal to a local reset. It does not open the Data API: the pre-request hook in the same script (~10-35) already refuses connected-app tokens there. The maintainer's performance report said to leave the policy alone. Its later breakage review replaced that advice with the drop.
P2 detail. apps/hocuspocus.server/src/lib/workerMetricsSampler.ts ~139-145 (sampleWorkerMetrics) calls samplePgmqQueueMetrics() on every 15 s tick (QUEUE_METRICS_INTERVAL_MS, ~15). Prometheus scrapes every 30 s (scripts/observability/prometheus/prometheus.yml ~2). So half of the samples are never read. The pgmq-message-age alert fires only at more than 15 min for 5 min (scripts/observability/grafana/provisioning/alerting/rules-supabase-jobs.yml ~47-50). The review judged 0.007% of one core not worth a second constant.
Do-not-do list
Each idea was measured and rejected. Reopen one only with new numbers that beat the row.
| Id | Idea | Why not |
|---|---|---|
| RT-4 | Move all chat postgres_changes to broadcast |
The most it can save is 27.3 s/h, 0.76% of one core, and $0. It rewrites the settled chat contract. That contract covers the INSERT echo that flips a pending send to sent, the UPDATE row merge, and anon reads of PUBLIC channels. See apps/webapp/src/components/chatroom/CLAUDE.md. |
| RT-3 | Move one table to broadcast | Saves about 0. The Realtime poller keeps running for the other tables. |
| Q-1 | Add the advisor index channel_members (channel_id) |
Redundant. idx_channel_members_channel_id_member_id UNIQUE (channel_id, member_id) already leads with channel_id (packages/supabase/scripts/11-indexes.sql ~33). So does idx_channel_members_channel_member_lastread (~78). The planner never picked the new index [local]; timings were 0.002-0.007 ms with and without it. Lifetime cost of the query: 1,421 ms. It would add about 100 kB and a write on every member update. |
| P3 | Use pgmq.read_with_poll long-poll in the queue consumers |
Harmful. One 1 s call ran 28,336 reads, a busy loop, in pgmq 1.4.4 [local]. A plain pgmq.read on an empty queue costs 0.069 ms. It would also hold PostgREST connections open. Keep pgmq.read (07-4-push-notifications-pgmq.sql ~325, 07-5-email-notifications-pgmq.sql ~732). |
| cron-3 | Lateral LIMIT 1, an index, or VACUUM FULL on cron.job_run_details |
We do not own the table. The lateral query used 7x more buffers for a 9% gain [local]. |
| cron-5 | Early exits or slower schedules for the queue workers | They already exit after one empty read, at 0.02-0.07 ms on a warm backend [local]. |
| P1 | Raise the email consumer idle cap from 10 s to 60 s | Saves 0.008% of one core. Email pickup would slow from about 3 s to about 20 s on average. The cap is BACKOFF_CAP_MS in apps/hocuspocus.server/src/lib/pgmqConsumer.ts ~78. |
| Q-3 | Index the 20 unindexed foreign keys the advisor lists | Under 1 ms/h on tables of 3,000 rows or fewer. The indexes on document_views and its partitions would add write cost. |
| Q-4 | Drop the 7 unused indexes the advisor lists | Under 1 ms/h either way. (PushSentLog_expiresAt_idx is one of them; #320 owns that table.) |
| Q-2 | Rewrite get_unread_notif_count |
Under 30 ms/h over the lifetime, and 0 calls in the window. |
| cron-6 | Run internal.cleanup_push_subscriptions hourly |
Saves about 118 ms/h. It needs a schedule change and a migration. |
| P4 | Remove the anon health probe (select id from users) |
33 ms/h. It serves the Docker healthchecks and a blackbox probe. |
| P6 | Stop PostgREST schema reloads | About 40 ms/h over the lifetime. Our cron DDL is not the cause. |
| cost | Downgrade prod to Micro now | Saves about $5 a month. The July memory report estimated 70-90% RAM use on Small; that was not measured. #324 measures first. |
| pg_net | Separate work on the pg_net worker | 21 ms/h. #321 removes pg_net if its prechecks pass. |
What to do
There is nothing to build now. Keep this issue open as the record. When a trigger fires, follow these steps.
-
[maintainer] Measure the RPC in question on prod. Run this read-only query, wait one hour, and run it again:
select now(), s.queryid, s.calls, round(s.total_exec_time::numeric, 1) as total_ms from pg_stat_statements s where s.query ilike '%get_cron_job_health%' or s.query ilike '%get_pgmq_queue_metrics%' or s.query ilike '%aggregate_document_view_stats%';
Per-hour cost = the difference in
total_ms. Percent of one core = that difference / 3,600,000 x 100. Post both snapshots as a comment here. -
[maintainer] If the number crosses the bar in the table, open a new issue for that one item. Link it here. Copy the item's detail block from this issue into it.
-
[agent] For cron-2 or cron-4, work scripts-first. Edit the function in its script (
16-cron-jobs.sqlor09-document-views.sql). Then hand-write a timestamped migration inpackages/supabase/migrations/that holds only that onecreate or replacebody. Neither16-cron-jobs.sqlnor09-document-views.sqlis in thegenerate:functions-parity-migrationallowlist (packages/supabase/generate-functions-parity-migration.ts~16-33). So do not use that generator. -
[maintainer] Before an agent writes the cron-4 migration, read the prod body and check the bound on real rows. No repo migration has ever carried this body, so prod may differ from
scripts/09:select pg_get_functiondef('public.aggregate_document_view_stats()'::regprocedure); -- must return 0 select count(*) from public.document_views where view_date >= current_date - 1 and viewed_at < (current_date - 2)::timestamptz;
-
[agent] For RT-2, write a new timestamped migration in
packages/supabase/migrations/, later than20260928130000. It drops each table from the publication and drops its gate policy, only if present:set local lock_timeout = '5s'; do $$ declare t text; begin foreach t in array array['push_subscriptions', 'email_queue'] loop if exists (select 1 from pg_publication_tables where pubname = 'supabase_realtime' and schemaname = 'public' and tablename = t) then execute format('alter publication supabase_realtime drop table public.%I', t); end if; if to_regclass(format('public.%I', t)) is not null then execute format( 'drop policy if exists "Connected apps use only the MCP server" on public.%I', t); end if; end loop; end $$;
Also delete the
add table push_subscriptionsblock in07-4-push-notifications-pgmq.sql~699-704, and keep thedrop table notificationsblock above it. Fix the stale path in the17-realtime-replica.sqlcomment (~14-15) and add thechannelspage. Do not regenerate the applied migration20260519200000_scripts_functions_triggers_parity.sqlfor this. Local resets skip migrations (packages/supabase/config.toml~53-58). So run the new file once against the local database withpsql; it is a no-op there. -
[agent] For P2, add a separate cadence constant in
workerMetricsSampler.ts(2 ticks = 30 s, matching the scrape). Do not shareCRON_HEALTH_SAMPLE_EVERY_TICKS: after Ship and measure the cron-health sampling change #323 it is 12 ticks (180 s). No house doc names the pgmq metrics cadence today. The cron-health bullet inapps/hocuspocus.server/CLAUDE.md(~162) describes the tick. Add one sentence there for the new constant, in the same change. -
[agent] After any SQL change: regenerate the seed with
bun run --filter @docs.plus/supabase_back seed, then runbun run --filter @docs.plus/supabase_back types. The signatures do not change, soapps/webapp/src/types/supabase.tsshould show no diff. Include it if it does. -
[agent] For cron-4, prove equal output locally. Run the old and new body in one rolled-back transaction. Compare
document_views_dailywithEXCEPTboth ways, not only by row count. -
[maintainer] After deploy, run step 1 again and post the before and after numbers on the new issue.
Out of scope
- Ship and measure the cron-health sampling change #323 ships the
get_cron_job_health60 s → 180 s sampling change (cron-1). This issue parks only what comes after it. - Measure prod memory, swap and CPU for 7 days before any compute change #324 measures memory, swap and CPU before any compute size change, including Micro.
- Find the cause of the 13-15 s realtime
list_changesspikes #325 owns the 13-15 srealtime.list_changesspikes. - Remove
pg_netfrom prod #321 removespg_net. - Remove prod-only drift: four functions and the
PushSentLogtable #320 owns the prod-only functions and thePushSentLogtable, including its unused index. - Port the 2026-09-28 prod grant and RLS fix into scripts and a paired migration #314 owns grants and RLS. None of the parked changes touches a grant;
create or replacekeeps each ACL.
Acceptance criteria
This is a record. It is done when it is filed and each row is traceable.
- Each of the 4 parked items has its numbers, its bench, and a reopen trigger in this issue.
- Each of the 15 do-not-do rows has a number or a named contract that explains the rejection.
- No parked item is built while its trigger has not fired. A new issue for an item links back here and carries a before-and-after measurement from step 1.
- This issue closes only when a later ruling overturns it with new evidence. It also closes when every parked item has shipped or been dropped.
Verification
-
The file references still hold at the current tree. Run from the repo root:
grep -n "array_agg(d.status order by d.start_time desc)" packages/supabase/scripts/16-cron-jobs.sql grep -n "view_date >= current_date - 1" packages/supabase/scripts/09-document-views.sql grep -n "add table push_subscriptions" packages/supabase/scripts/07-4-push-notifications-pgmq.sql grep -n "void samplePgmqQueueMetrics()" apps/hocuspocus.server/src/lib/workerMetricsSampler.ts
Each command prints one line while its item is still parked. An empty result means someone changed that code. Then update or close the row.
-
For RT-2 after deploy, this read-only query on prod lists 4 tables:
channel_members,channel_message_counts,channels,messages. (usersleft with Stop exposing every user's online status and last-active time #434.)select tablename from pg_publication_tables where pubname = 'supabase_realtime' order by 1; -- expect: the same 5 public tables, plus realtime.messages and storage.objects select schemaname, tablename from pg_policies where policyname = 'Connected apps use only the MCP server' order by 1, 2;
House rules
- Scripts first, then a paired timestamped migration, then regenerate the seed and run
bun run --filter @docs.plus/supabase_back types(packages/supabase/CLAUDE.md). - Never edit an applied migration. Never hand-edit
packages/supabase/seed.sqlorapps/webapp/src/types/supabase.ts. - Bun only. Never run
npm,yarn,pnpmornpxin this repo. - No commits unless the maintainer asks (
/commit-review). No branches. - Tests are opt-in. The cron-4
EXCEPTcheck is a one-off local proof, not a new test file.
Activity
HMarzban commentedon Oct 15, 2026
Related issues
usersin prodsupabase_realtime. Make presence heartbeats HOT updates and drop the duplicate username index #491 records that Stop exposing every user's online status and last-active time #434 removed it there, so RT-2 should expect one table fewer.Cross-link review, 2026-10-15. Generated by Claude Code