Problem
The admin dashboard materialized views defined in control-plane/migrations/001_init.sql
are refreshed on a cron by scheduler/src/admin-stats-refresh.ts. Every refresh is a
full recompute from scratch — there is no incremental path.
Three of them each embed the same subquery independently:
SELECT message_id, count(*) FROM session_events GROUP BY message_id
So a single refresh cycle aggregates the entire session_events table three times.
session_events is typically the largest table in the schema and grows without bound.
admin_daily_stats also computes active_workspaces as a range join of
generate_series(first_workspace_date, CURRENT_DATE, '1 day') against sessions
on a 7-day last_active_at window — cost grows with days × sessions, i.e. close to
quadratic as the deployment ages.
Impact
Observed on a long-running deployment:
| view |
buffer hit ratio |
temp files written per refresh |
| admin_daily_stats |
65% |
tens of MB |
| admin_workspace_stats |
43% |
tens of MB |
| user_daily_interactions |
59% |
~10 MB |
| admin_token_daily_stats |
28% |
— |
Each of the heavy views takes seconds to rebuild; a full cycle runs well over ten
seconds. The outputs are tiny — a few hundred rows, tens of kB each.
The wall-clock cost is not the real problem. Each pass reads far more than
shared_buffers holds, so it evicts the OLTP working set — ordinary queries go back
to disk right after every refresh. This showed up as periodic latency spikes across
the whole database, not just on the admin dashboard. The low buffer hit ratios in the
table above are the same effect seen from the refresh side.
Proposed direction
- Maintain a shared message → event-count table incrementally (trigger on
session_events, or a watermark-driven rollup). Removes three full aggregations
of the largest table per cycle.
- Roll up by day. Closed days never change, so recompute only today plus a short
lookback for late arrivals.
- Rewrite
active_workspaces as a 7-day window over a per-day distinct-workspace
rollup instead of the generate_series × sessions range join.
admin_workspace_stats has no date dimension (it groups all messages by
workspace_id) and needs its own incremental design.
admin_token_daily_stats scans workspace_usage_events and gets the same
per-day treatment.
Workaround in place
Lowering the refresh cadence (ADMIN_STATS_REFRESH_CRON, default */10 * * * *)
reduces the eviction duty cycle but does not bound the cost, which keeps growing
with the data.
Problem
The admin dashboard materialized views defined in
control-plane/migrations/001_init.sqlare refreshed on a cron by
scheduler/src/admin-stats-refresh.ts. Every refresh is afull recompute from scratch — there is no incremental path.
Three of them each embed the same subquery independently:
So a single refresh cycle aggregates the entire
session_eventstable three times.session_eventsis typically the largest table in the schema and grows without bound.admin_daily_statsalso computesactive_workspacesas a range join ofgenerate_series(first_workspace_date, CURRENT_DATE, '1 day')againstsessionson a 7-day
last_active_atwindow — cost grows with days × sessions, i.e. close toquadratic as the deployment ages.
Impact
Observed on a long-running deployment:
Each of the heavy views takes seconds to rebuild; a full cycle runs well over ten
seconds. The outputs are tiny — a few hundred rows, tens of kB each.
The wall-clock cost is not the real problem. Each pass reads far more than
shared_buffersholds, so it evicts the OLTP working set — ordinary queries go backto disk right after every refresh. This showed up as periodic latency spikes across
the whole database, not just on the admin dashboard. The low buffer hit ratios in the
table above are the same effect seen from the refresh side.
Proposed direction
session_events, or a watermark-driven rollup). Removes three full aggregationsof the largest table per cycle.
lookback for late arrivals.
active_workspacesas a 7-day window over a per-day distinct-workspacerollup instead of the
generate_series×sessionsrange join.admin_workspace_statshas no date dimension (it groups allmessagesbyworkspace_id) and needs its own incremental design.admin_token_daily_statsscansworkspace_usage_eventsand gets the sameper-day treatment.
Workaround in place
Lowering the refresh cadence (
ADMIN_STATS_REFRESH_CRON, default*/10 * * * *)reduces the eviction duty cycle but does not bound the cost, which keeps growing
with the data.