Repository navigation
Make presence heartbeats HOT updates and drop the duplicate username index #491
Description
Summary
Each presence heartbeat rewrites one public.users row, and every heartbeat is a non-HOT update today. So each heartbeat adds an entry to all 5 indexes on the table and writes more WAL than it needs. This issue changes one partial index, so a heartbeat becomes a HOT update. It also drops idx_users_username, which repeats users_username_key.
This is part of the stuck-Online presence set in #496. It does not fix the stuck-Online bug, and it changes no behaviour. It removes a cost that grows with every visible tab.
This is not urgent. The 1x scale here is about 181 users, the production public.users row count from the 2026-09-28 Supabase review (not measured here). The Step 0 total gives the live count. At 1x, WAL goes from about 13 MB to 7 MB a day (derived). Ship it as cheap cleanup, ideally after #489.
Root cause
- The heartbeat writes only
status, once every 60 s per visible tab (useHandleUserStatus.ts#L9, #L34-L41, updateUser.ts#L4-L9). - On every status write, the trigger
trigger_update_user_online_at(BEFORE UPDATE OF status) setsNEW.online_at := now()(10-1-func-users.sql#L133-L150). Bring back push on Chrome and Android devices the server switched off #417 (migration20261006120000_push_online_at_and_preference_checks.sql) made it stamp on every write. Before Bring back push on Chrome and Android devices the server switched off #417, it stamped only whenstatuschanged. The Stamp online_at only on ONLINE writes and clear users stuck ONLINE #489 body stamps on everyONLINEwrite, so it also stamps on each heartbeat. idx_users_online_statuskeys(id, online_at) WHERE status = 'ONLINE'(02-users.sql#L83-L87). Each heartbeat changesonline_at, which is an indexed column. So Postgres cannot do a HOT update, and the heartbeat adds an entry to every index.idx_users_username(11-indexes.sql#L8-L10) repeatsusers_username_key. Theuniqueat 02-users.sql#L13 creates that index.- No migration creates either index, so production most likely got them by hand. Step 0 reads the production shape.
Evidence
- Measured locally (shared dev DB, read-only, 2026-10-14).
public.usershas 3 rows, 73,481 updates and 2,182 HOT updates (3.0 %).users_pkeyis 11.8 MB andusers_email_keyis 14.1 MB. Index churn from non-HOT updates explains this. - Measured locally (scratch container
public.ecr.aws/supabase/postgres:15.8.1.073, since removed). The table copies thepublic.userscolumns and the stamp trigger. Each heartbeat isUPDATE … SET status = 'ONLINE' WHERE id = …in its own transaction.
| Index shape | Rows / online / heartbeats | HOT | WAL per heartbeat |
|---|---|---|---|
current (id, online_at) |
181 / 10 / 600 | 0 % | 893 B |
(id) partial |
181 / 10 / 600 | 100 % | 495 B |
current (id, online_at) |
181,000 / 500 / 10,000 | 0 % | 936 B |
(id) partial |
181,000 / 500 / 10,000 | 99.1 % | 554 B |
(id), fillfactor 90 |
181,000 / 500 / 10,000 | 100 % | 565 B |
| no partial index | 181,000 / 500 / 10,000 | 99.1 % | 553 B |
- ONLINE↔OFFLINE flips stay non-HOT with
(id), becausestatusis in the index predicate. They are HOT only with no partial index. - The cron sweep at 181,000 rows took 0.10 ms today, 0.41 ms with
(id), and 13.8 ms with no partial index (5,818 buffer reads). Measured locally, scratch container.
Scale
Assumption, not measured: about 5 % of users have a visible tab. Heartbeats per day are derived from one heartbeat every 60 s per tab. WAL per day is derived from the table above.
| Scale | Users / tabs | Heartbeats per day | WAL per day, now → after (derived) |
|---|---|---|---|
| 1x | 181 / 10 | 14,400 | 13 MB → 7 MB |
| 10x | 1.8k / 100 | 144,000 | 130 MB → 71 MB |
| 100x | 18k / 1k | 1.44 M | 1.3 GB → 0.8 GB |
| 1000x | 181k / 10k | 14.4 M (167/s) | 13.5 GB → 8.0 GB |
At 1000x the change also removes about 72 M index inserts a day (derived: 14.4 M heartbeats × 5 indexes). #434 took public.users out of supabase_realtime in production (its closing comment), so a heartbeat sends no Realtime change.
Readers of the two indexes
| Reader | Effect |
|---|---|
Cron update-user-status (16-cron-jobs.sql#L8-L16) |
Bitmap scan on the same index. It checks online_at on the heap. #489 does not change the sweep. |
internal.is_user_online (10-1-func-users.sql#L167-L178) and its 07-4 copy |
Same result. It needs one heap fetch per call. The cost is negligible, because heartbeats already clear the visibility-map bit on online rows. |
| Admin Online Now (admin-stats.controller.ts#L53-L57) | Bitmap scan on the same index. |
| Mentions (10-func-notifications.sql#L58), username loop (10-1-func-users.sql#L86), getSimilarUsername.ts#L12 | Move to users_username_key (index scan or index-only scan, checked locally). |
| Workspace username search (10-functions.sql#L776-L777), admin user search (admin-stats.controller.ts#L307) | users_username_key serves the sort the same way. Neither index serves ILIKE. No change. |
Retention and ghost audit (22-…, 28-…) |
Sequential scans. No change. |
No index on public.users is the replica identity (relreplident = d, checked locally).
Decisions
Maintainer rulings are posted as a comment.
- Keep the partial index as
(id), or drop it? Ruled 2026-10-15: keep(id). At 181,000 rows the sweep costs 0.41 ms with it and 13.8 ms without it (measured locally). Dropping it would make flips HOT too, and that is also defensible. But flips are rare next to heartbeats. A tab sends 60 heartbeats an hour (derived from the 60 s interval). We assume a session flips about twice an hour (assumption, not measured).
Depends on
- Stamp online_at only on ONLINE writes and clear users stuck ONLINE #489: ship it first if possible; it does not block this issue. Its stamp body still stamps every
ONLINEwrite, so the HOT half still helps after it. - Make the chat bell use the online lease instead of the raw status flag #490: independent. The two issues touch different objects, so either order works. Take the next free migration timestamp ([Tracking] Stuck-Online users and the presence review follow-ups #496 Shared rules).
- Stop exposing every user's online status and last-active time #434: independent. Its migration
20261009120500changed grants and the publication, not indexes. Its closing comment says it is applied in production.
Fix plan
Step 0. Production read (the maintainer runs it; read-only)
Paste this into the production SQL editor as one statement. It returns one row with one JSON value. Copy that value into a comment on this issue. Run it before Step 1, and again just before Step 4. The second run is reading R0 for Step 6. If the paste follows on the same day, one run serves both.
select json_build_object(
'read_at', now(),
'current_user', current_user,
'can_drop', (select pg_has_role(current_user, tableowner, 'MEMBER') from pg_tables where schemaname = 'public' and tablename = 'users'),
'total', (select count(*) from public.users),
'counters', (select row_to_json(t) from (
select n_tup_upd, n_tup_hot_upd,
round(100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0), 1) as hot_pct
from pg_stat_user_tables where relid = 'public.users'::regclass) t),
'indexes', (select json_agg(t order by t.index) from (
select indexrelid::regclass::text as index, pg_get_indexdef(indexrelid) as def,
indisunique, indisreplident
from pg_index where indrelid = 'public.users'::regclass) t)
) as reading;This query ran on the local DB (measured locally, 2026-10-15). It returned can_drop: true and the 5 indexes.
Stop and post the reading if any of these is true:
can_dropisfalse. The paste cannot drop the indexes.- An index other than
idx_users_online_statushas adefthat namesonline_at. The migration guard would raise. users_username_keyis not a unique btree on(username), oridx_users_usernameexists and is not a plain btree on(username).- An index on
public.usershasindisreplidenttrue.
Step 1. Repo change
-
In
packages/supabase/scripts/02-users.sql, replace lines 83-87 with:-- The cron sweep reads this index. online_at stays out of the key, -- so a heartbeat is a HOT update. create index if not exists idx_users_online_status on public.users (id) where status = 'ONLINE';
-
In
packages/supabase/scripts/11-indexes.sql, delete lines 8-10 and the blank line after them. -
Add
packages/supabase/migrations/20261014120200_users_heartbeat_hot_index.sql. If any file inpackages/supabase/migrations/sorts at or after it, use a timestamp newer than the newest file ([Tracking] Stuck-Online users and the presence review follow-ups #496 Shared rules). Withif not existsalone, the old index would stay, so a guard drops it while its definition namesonline_at.-- Heartbeats become HOT updates: no index on public.users names online_at. set local lock_timeout = '5s'; drop index if exists public.idx_users_username; do $$ begin if exists (select 1 from pg_indexes where schemaname = 'public' and indexname = 'idx_users_online_status' and position('online_at' in indexdef) > 0) then drop index public.idx_users_online_status; end if; end $$; create index if not exists idx_users_online_status on public.users (id) where status = 'ONLINE'; do $$ begin if exists (select 1 from pg_indexes where schemaname = 'public' and tablename = 'users' and position('online_at' in indexdef) > 0) then raise exception 'an index on public.users still names online_at; heartbeats stay non-HOT'; end if; end $$;
-
In
apps/hocuspocus.server/CHANGELOG.md, under## [Unreleased], add a### Changedheading above### Fixedif it is missing. Add this bullet under it, word for word. If item 3 took a later timestamp, use the real migration name.- **Presence heartbeats no longer touch every `users` index.** `idx_users_online_status` now keys only `id`, so a heartbeat is a HOT update. The duplicate `idx_users_username` is dropped; `users_username_key` serves the same lookups. Apply the Supabase migration `20261014120200_users_heartbeat_hot_index` (#491).
-
Do not run
types, and do not commitapps/webapp/src/types/supabase.ts([Tracking] Stuck-Online users and the presence review follow-ups #496 Decision 1).
Step 2. Regenerate and apply locally
Run each command from the repo root.
-
Regenerate the seed.
seed.sqlchanges. No app code changes.bun run --filter @docs.plus/supabase_back seed
-
Apply the migration locally, twice. Always use
--single-transactionandON_ERROR_STOP. Plainpsql <runs in autocommit, soset localonly warns, and a failed guard leaves the drops committed.docker exec -i supabase_db_docsplus_supabase psql -U postgres -d postgres -v ON_ERROR_STOP=1 --single-transaction < packages/supabase/migrations/20261014120200_users_heartbeat_hot_index.sql
The first run prints
SET,DROP INDEX,DO,CREATE INDEX,DO. The second run prints the same tags plus only these two notices:index "idx_users_username" does not exist, skippingandrelation "idx_users_online_status" already exists, skipping.
Step 3. Prove by sabotage
Run from the repo root. The block puts back the old index shape, sends one heartbeat, then runs the migration file and sends one more. It ends in rollback;, so it leaves no change. Before each heartbeat, the block ages online_at by one hour. That update leaves status alone, so the trigger does not fire. The heartbeat's stamp then really changes online_at, even inside one transaction. pg_stat_xact_user_tables counts only this transaction, so other local writers cannot change the result.
M=packages/supabase/migrations/20261014120200_users_heartbeat_hot_index.sql
{ cat <<'SQL'
begin;
set local lock_timeout = '5s';
-- sabotage: the Rollback body (old shape)
drop index if exists public.idx_users_online_status;
create index idx_users_online_status on public.users (id, online_at) where status = 'ONLINE';
create index if not exists idx_users_username on public.users (username);
select id as u from public.users order by id limit 1 \gset
update public.users set status = 'ONLINE' where id = :'u';
update public.users set online_at = now() - interval '1 hour' where id = :'u';
select 'old before' as t, n_tup_upd, n_tup_hot_upd from pg_stat_xact_user_tables where relid = 'public.users'::regclass;
update public.users set status = 'ONLINE' where id = :'u';
select 'old after' as t, n_tup_upd, n_tup_hot_upd from pg_stat_xact_user_tables where relid = 'public.users'::regclass;
SQL
cat "$M"
cat <<'SQL'
update public.users set online_at = now() - interval '1 hour' where id = :'u';
select 'new before' as t, n_tup_upd, n_tup_hot_upd from pg_stat_xact_user_tables where relid = 'public.users'::regclass;
update public.users set status = 'ONLINE' where id = :'u';
select 'new after' as t, n_tup_upd, n_tup_hot_upd from pg_stat_xact_user_tables where relid = 'public.users'::regclass;
rollback;
SQL
} | docker exec -i supabase_db_docsplus_supabase psql -U postgres -d postgres -v ON_ERROR_STOP=1 -qExpect n_tup_hot_upd to stay the same from old before to old after, and to grow by 1 from new before to new after. n_tup_upd grows by 1 in both pairs. A local run with this migration body gave 2|0 → 3|0 and 4|1 → 5|2 (measured locally, 2026-10-15). If the old pair also grows by 1, the stamp did not change online_at; check the local trigger body before you go on.
Step 4. Production paste (the maintainer; no app deploy)
Paste at low traffic. The drop holds ACCESS EXCLUSIVE until commit. While it waits for that lock, reads of users queue behind it for up to 5 s, and message lists join users.
-
Take reading R0 (Step 0), unless the Step 0 reading is from the same day.
-
Paste
begin;, the migration body from Step 1 item 3 andcommit;into one tab, and run the tab once. Separate runs may use different pooled connections, sobegin;would not cover the rest. -
On a
lock_timeouterror, the transaction rolled back and nothing was applied. Run the tab again.
If the nextdb pushran before the paste, it applied this migration already. Skip the paste and run only item 4. -
Check on production. Run each
selecton its own, because the SQL editor may show only the last result.select pg_get_indexdef('public.idx_users_online_status'::regclass); -- expect: CREATE INDEX idx_users_online_status ON public.users USING btree (id) WHERE (status = 'ONLINE'::user_status) select to_regclass('public.idx_users_username'); -- expect: null
A plain build is fine at this size. A plain rebuild at 181,000 rows took 22.8 ms (measured locally, scratch container). A plain build blocks heartbeat writes while it runs, and that wait grows with the table.
Step 5. Migration history
Do not run db push for this issue alone. The CLI pushes every pending file, so this migration ships with the maintainer's next planned db push. #496 The next db push gives the steps for that push. Do not run migration repair for this version. A re-run is harmless, so a repair is not needed (packages/supabase/CLAUDE.md:23).
That re-run changes nothing and prints only skipping notices. create index if not exists still takes a brief ShareLock on users. That lock blocks heartbeat writes but not reads, and lock_timeout limits the wait.
Step 6. Close-out reading
24 h after the paste, the maintainer runs Step 0 again. This is reading R1. Compute the delta: R1 n_tup_upd and n_tup_hot_upd minus R0. Post R1 and the delta here.
Success means 80 % HOT or more in the delta. Heartbeats alone were 99.1 % HOT (measured locally, scratch container). Flips and profile edits stay non-HOT, so 80 % leaves room for them. If R1 n_tup_upd is lower than R0, the counters were reset; take a new R0 and read again 24 h later. Under 80 %, post the readings and leave the issue open.
Rollback
No data changes. Paste into one tab and run it once:
begin;
set local lock_timeout = '5s';
drop index if exists public.idx_users_online_status;
create index idx_users_online_status on public.users (id, online_at) where status = 'ONLINE';
create index if not exists idx_users_username on public.users (username);
commit;In the repo, revert the change. If remote history already has the migration, ship a forward migration with this body. Leave out begin; and commit;, because the CLI wraps each file in a transaction.
Verify
Run on the local DB after Step 3. Save the block as /tmp/i3-verify.sql, outside the repo, and run docker exec -i supabase_db_docsplus_supabase psql -U postgres -d postgres < /tmp/i3-verify.sql. The block ends in rollback;, so set local does not stay.
begin;
select indexrelid::regclass, pg_get_indexdef(indexrelid)
from pg_index where indrelid = 'public.users'::regclass order by 1;
-- expect 4 rows: users_pkey, users_username_key, users_email_key,
-- idx_users_online_status ... (id) WHERE (status = 'ONLINE'::user_status)
select to_regclass('public.idx_users_username');
-- expect: null
select count(*) from pg_indexes
where schemaname = 'public' and tablename = 'users' and position('online_at' in indexdef) > 0;
-- expect: 0
set local enable_seqscan = off; -- the local table has 3 rows, so without it the planner picks a Seq Scan
explain (costs off) select id from public.users where username = 'abc';
-- expect: Index Scan (or Index Only Scan) using users_username_key
explain (costs off) select count(*) from public.users where status = 'ONLINE';
-- expect: a scan that uses idx_users_online_status
rollback;The two explain lines ran locally with enable_seqscan = off (measured locally, 2026-10-14). They gave Index Scan using users_username_key and Index Only Scan using idx_users_online_status.
Acceptance criteria
- A Step 0 reading is posted before Step 1, and none of its stop rules applies.
-
scripts/02-users.sql,scripts/11-indexes.sql, the new migration and the regeneratedseed.sqlmatch Step 1. - The
[Unreleased]CHANGELOG bullet names the real migration. -
apps/webapp/src/types/supabase.tsis not in the change ([Tracking] Stuck-Online users and the presence review follow-ups #496 Decision 1). - The local apply runs twice with
--single-transaction. The second run raises no error, and its only notices are the twoskippingnotices in Step 2 item 2. - The Step 3 block shows no HOT gain for the old shape. It shows a HOT gain of 1 after the migration.
- The Verify block returns the expected rows locally. The Step 4 item 4 checks return the expected rows on production.
-
db pushandmigration repairwere not run for this issue alone. - R0 and R1 are posted here, and the delta is 80 % HOT or more.
Out of scope
- The stamp body in
update_user_online_at: Stamp online_at only on ONLINE writes and clear users stuck ONLINE #489 owns it. - Making ONLINE↔OFFLINE flips HOT:
statusstays in the index predicate. Decision 1 keeps the index. - Fillfactor 90: the scratch run gave 100 % HOT against 99.1 %, for more WAL per heartbeat.
- A new automated test: Step 3 is the real-stack check, and its sabotage half proves it.
- Removing
public.usersfrom the Realtime publication: Stop exposing every user's online status and last-active time #434 already did it in production. - Using
CONCURRENTLYat today's size: a plain build takes milliseconds below about 1M rows. - Fixing the generator drift in
apps/webapp/src/types/supabase.ts: [Tracking] Stuck-Online users and the presence review follow-ups #496 Decision 1 sends it to its own issue.
Generated by Claude Code
Activity
HMarzban commentedon Oct 15, 2026
Maintainer ruling, 2026-10-15
Decision 1: keep the partial index as
(id). The sweep keeps its index (0.41 ms with it, 13.8 ms without it at 181,000 rows, measured locally).All decisions in this issue are now ruled. The Step 0 production readings still gate the build.
HMarzban commentedon Oct 15, 2026
Related issues
usersin prodsupabase_realtime. This issue records that Stop exposing every user's online status and last-active time #434 removed it there, so RT-2 expects one table fewer.Cross-link review, 2026-10-15. Generated by Claude Code
HMarzban commentedon Oct 19, 2026
Step 0 reading, production (read-only), 2026-10-19 18:00 UTC
{"read_at":"2026-10-19T18:00:54.91704+00:00","current_user":"postgres","can_drop":true,"total":189, "counters":{"n_tup_upd":38294,"n_tup_hot_upd":23456,"hot_pct":61.3}, "indexes":[ {"index":"idx_users_online_status","def":"CREATE INDEX idx_users_online_status ON public.users USING btree (id, online_at) WHERE (status = 'ONLINE'::user_status)","indisunique":false,"indisreplident":false}, {"index":"idx_users_username","def":"CREATE INDEX idx_users_username ON public.users USING btree (username)","indisunique":false,"indisreplident":false}, {"index":"users_email_key","def":"CREATE UNIQUE INDEX users_email_key ON public.users USING btree (email)","indisunique":true,"indisreplident":false}, {"index":"users_pkey","def":"CREATE UNIQUE INDEX users_pkey ON public.users USING btree (id)","indisunique":true,"indisreplident":false}, {"index":"users_username_key","def":"CREATE UNIQUE INDEX users_username_key ON public.users USING btree (username)","indisunique":true,"indisreplident":false}]}Stop rules: none applies.
can_dropis true.idx_users_online_statusnamesonline_at.users_username_keyis a unique btree on(username), andidx_users_usernameis a plain btree on(username).indisreplident.Note: prod is already at 61.3 % HOT updates, against the 3.0 % measured on the local dev DB. The stats counters date from the last reset, so this is a mix of old and new traffic. Step 6 compares against R0, taken just before Step 4.
feat(supabase): make presence heartbeats HOT updates
HMarzban commentedon Oct 21, 2026
Step 4 check and reading R0, production (read-only), 2026-10-21 07:03 UTC
The next
db pushapplied20261019140000_users_heartbeat_hot_indexon 2026-10-20, so the paste was skipped (Step 4 item 3). It sits insupabase_migrations.schema_migrations.Step 4 item 4 passes:
pg_get_indexdef('public.idx_users_online_status'::regclass)returnsCREATE INDEX idx_users_online_status ON public.users USING btree (id) WHERE (status = 'ONLINE'::user_status).to_regclass('public.idx_users_username')returnsnull.Reading R0, the Step 0 query:
{"read_at":"2026-10-21T07:03:27.971299+00:00","current_user":"postgres","can_drop":true,"total":189, "counters":{"n_tup_upd":38400,"n_tup_hot_upd":23485,"hot_pct":61.2}, "indexes":[ {"index":"idx_users_online_status","def":"CREATE INDEX idx_users_online_status ON public.users USING btree (id) WHERE (status = 'ONLINE'::user_status)","indisunique":false,"indisreplident":false}, {"index":"users_email_key","def":"CREATE UNIQUE INDEX users_email_key ON public.users USING btree (email)","indisunique":true,"indisreplident":false}, {"index":"users_pkey","def":"CREATE UNIQUE INDEX users_pkey ON public.users USING btree (id)","indisunique":true,"indisreplident":false}, {"index":"users_username_key","def":"CREATE UNIQUE INDEX users_username_key ON public.users USING btree (username)","indisunique":true,"indisreplident":false}]}The table has the 4 expected indexes, and no index names
online_at.R0 is taken after the migration, so the Step 6 delta measures only the new index shape. R1 is owed at about 2026-10-22 07:00 UTC.
Small sample. Since the Step 0 reading on 2026-10-19 18:00 UTC,
n_tup_updgrew by only 106. At that rate the 24 h delta holds about 70 updates. If the delta is under 80 % HOT on so few rows, take R1 again after a busier day before you judge.HMarzban commentedon Oct 21, 2026
Step 6 close-out reading, production (read-only), 2026-10-21
Deviation from Step 6: a 10-minute controlled window, not 24 hours. Production has almost no signed-in traffic right now.
n_tup_upddid not move at all from R0 (07:03 UTC) to 10:52 UTC, so a 24-hour delta could have held no heartbeats. The maintainer opened one production pad, signed in, in a visible tab, and kept it in front.The window starts after the sign-in flip, so it measures the heartbeats. The flip itself is a non-HOT update by design, because it changes the partial index predicate.
read_at(UTC)n_tup_updn_tup_hot_updPass: 95.2 % is above the 80 % bar. The local scratch measure was 99.1 % for heartbeats alone. The one non-HOT row in the window fits a hide and show flip.
The Step 4 item 4 checks passed on 2026-10-21 (R0 comment above). Every acceptance line now holds, so this issue closes.