perf: serve dashboards from worker-maintained hourly aggregates#216
Open
jrhizor wants to merge 5 commits into
Open
perf: serve dashboards from worker-maintained hourly aggregates#216jrhizor wants to merge 5 commits into
jrhizor wants to merge 5 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
jrhizor
force-pushed
the
perf-slow-page-queries
branch
from
April 24, 2026 23:04
daed7c2 to
9aad922
Compare
jrhizor
force-pushed
the
perf-slow-page-queries
branch
from
April 25, 2026 01:34
390b947 to
019a9a7
Compare
jrhizor
force-pushed
the
perf-slow-page-queries
branch
from
April 25, 2026 23:48
019a9a7 to
8035705
Compare
jrhizor
force-pushed
the
perf-slow-page-queries
branch
from
April 26, 2026 00:16
8035705 to
d229f03
Compare
Adds docs/perf-daily-aggregates.md proposing a worker-maintained set of pre-computed hourly aggregate tables (hourly_*) to replace per-request scans of prompt_runs and citations on the overview, visibility, citations, and prompt-detail pages. Background: we benchmarked five other approaches (covering indexes, planner hints, write-fence, payload caps, mega-CTE / TABLESAMPLE) and none of them break the multi-second floor at 90-day lookbacks — the cost is fundamentally the per-request scan of hundreds of thousands of rows. Pre-aggregating moves the work off the read path entirely. Hourly chosen over daily after measuring against production: hourly buckets cost only 1.01x the row count of daily across all four aggregate tables (because runs of the same brand+prompt+model on a given day cluster in the same UTC hour when the worker batches them). Buying hourly granularity for ~1% extra rows lets every query re-bucket to the viewer's browser timezone for free at read time — no per-tenant TZ config, correct for half-hour TZs (IST/ACST), no 24-hour fudge near day boundaries. Plan covers schema (4 new hourly_* tables + 1 state row, ~2.3 GB total, +1.0 GB net after dropping unused 1.3 GB index), worker design (per-minute pg-boss job rebuilds whole UTC days, with a 1-hour overlap to absorb late writes plus a nightly full rebuild), per-query migration (~16 functions in postgres-read.ts including all single-prompt detail queries — every chart/stat now uses the user's browser TZ via `(hour AT TIME ZONE $tz)::date`), backfill (~15-20 min for current data), how we surface freshness in the UI, and measured all-time storage estimates. No code changes in this commit — just the design doc.
jrhizor
force-pushed
the
perf-slow-page-queries
branch
from
April 26, 2026 00:21
d229f03 to
ffa7d09
Compare
Replaces per-request scans of `prompt_runs` and `citations` with reads
against four new `hourly_*` aggregate tables maintained by a per-minute
pg-boss job. Page wall time at 90-day lookbacks drops from tens of
seconds (with multi-second tails) to well under a second.
See docs/perf-daily-aggregates.md for the full design — including why
worker-managed tables instead of Postgres materialized views, and why
hourly buckets instead of daily.
Schema (migration 0009_hourly_aggregates.sql):
- hourly_prompt_runs (brand, prompt, hour, model, web_search)
- hourly_prompt_run_competitors (per-competitor mention counts)
- hourly_citations (per-domain counts)
- hourly_citation_urls (per-URL counts + title + sum_citation_index)
- aggregate_refresh_state (singleton row tracking worker watermark
and resumable backfill cursor)
- Seeds the singleton state row.
Worker (apps/worker):
- jobs/rebuild-hourly-bucket.ts: shared DELETE+INSERT helper for one
(brand_id, UTC date) bucket across all four tables.
- jobs/refresh-hourly-aggregates.ts: per-minute pg-boss job. Acquires
advisory lock, reads `last_refreshed_through`, finds affected
(brand, date) buckets in `[watermark - 1h, now() - 30s]`, rebuilds
each, advances watermark. Whole tick in one transaction; failure
metadata recorded out-of-band.
- scripts/backfill-hourly-aggregates.ts: resumable CLI. Tracks
progress via `(backfill_cursor_brand_id, backfill_cursor_date)` so
a crashed/killed run picks up at the next bucket on rerun. On
completion, primes `last_refreshed_through = backfill_started_at`
so the live worker only catches up the (small) gap from backfill
start to deploy.
- index.ts / handlers.ts: queue + handler + cron schedule with
`singletonKey` so a tick is skipped if the previous one is still
running (matches "every minute unless one is already running").
Read paths (apps/web/src/lib/postgres-read.ts):
- 16 analytics functions rewritten to source from `hourly_*`. Same
TypeScript signatures; same returned shapes. Charts re-bucket the
UTC `hour` column to the viewer's browser TZ via
`(hour AT TIME ZONE $tz)::date`.
- Single-prompt detail page queries also moved (same aggregates work
by `prompt_id` thanks to the `(prompt_id, hour)` indexes).
- `getPromptWebQueriesForMapping` / `getPromptWebQueryCounts` stay on
raw — `web_queries` is a `text[]` and would explode the aggregate.
- Admin queries unchanged for now; can move in a follow-up.
The "Last updated X ago" stat on the overview footer keeps its precise
timestamp because each bucket carries `first_run_at` / `last_run_at` —
`getDashboardSummary.last_updated` is now `max(last_run_at)` from the
aggregate.
Deploy order:
1. Apply migration 0009.
2. Run `pnpm tsx --env-file=../web/.env src/scripts/backfill-hourly-aggregates.ts`
from `apps/worker` to completion.
3. Merge this PR — the worker starts running, read paths cut over.
Contributor
Author
|
Need to remove the backfill columns after merging this. |
The four analytics server fns that didn't accept timezone (dashboard summary, citations, prompts summary, prompt stats) silently fell back to UTC, which made charts mis-bucket near day boundaries for non-UTC viewers. The visibility/batch-chart paths already accepted timezone correctly. Now all four also accept it and their hooks send `Intl.DateTimeFormat().resolvedOptions().timeZone` from the browser. scripts/perf/bench-aggregate-vs-raw.ts is a self-contained side-by-side benchmark + correctness check between the OLD raw-table SQL and the NEW hourly-aggregate SQL for every page-driving query. It pins both paths to `aggregate_refresh_state.last_refreshed_through` as their upper bound so they read identical source sets, then diffs the canonical row hashes and reports raw_ms / agg_ms / speedup. Run after backfill, before merging the read-path cutover. Plan doc updated to call out the required follow-up: drop the four `backfill_*` columns from `aggregate_refresh_state` once the benchmark confirms the migration is correct. Recommended ship sequence is now: apply migration → run backfill → run bench → drop backfill columns → merge.
# Conflicts: # .gitignore
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces every analytics chart and stat on the overview, visibility, citations, and prompt-detail pages with reads against four worker-maintained
hourly_*aggregate tables. Page wall time at 90-day lookbacks drops from tens of seconds (with multi-second tails) to well under a second.Three commits:
docs:— design doc (`docs/perf-daily-aggregates.md`)perf:— implementation (schema + migration + worker + backfill + read-path rewrite + changeset)perf:— thread the browser timezone end-to-end + add side-by-side benchmark scriptWhy a worker, not a Postgres materialized view
The "obvious" Postgres-native alternative would be a materialized view. We're not using one because:
Same explanation lives in `docs/perf-daily-aggregates.md`.
Schema (migration `0009_hourly_aggregates.sql`)
Each `hourly_prompt_runs` bucket carries `first_run_at` / `last_run_at` so the existing "Last updated X ago" footer keeps its precise timestamp via `max(last_run_at)`.
Hourly chosen over daily because measured against production data, hourly buckets cost only 1.01× the row count of daily — runs of the same (brand, prompt, model) on a given day cluster in the same UTC hour because the worker batches them. Hourly granularity is what lets every chart re-bucket to the viewer's browser TZ at read time via `(hour AT TIME ZONE $tz)::date`.
Worker (`apps/worker/src/jobs/`)
Backfill — resumable
`apps/worker/src/scripts/backfill-hourly-aggregates.ts`. Run with:
Tracks progress in `aggregate_refresh_state.(backfill_started_at, backfill_cursor_brand_id, backfill_cursor_date, backfill_completed_at)`. If the script crashes, gets killed by a deploy, or hits any transient failure, re-running it picks up at the next (brand, date) tuple after the cursor. Snapshot cutoff (`backfill_started_at - 30 s`) is sticky across resumes.
On completion the script primes `last_refreshed_through = backfill_started_at`, so the live worker only catches up the (small) gap from backfill start to deploy on its first tick.
Estimated runtime: ~15–20 min on the current data volume.
Read-path rewrite (`apps/web/src/lib/postgres-read.ts`)
16 analytics functions rewritten to source from `hourly_*`. TypeScript signatures unchanged; returned shapes unchanged. Charts re-bucket the UTC `hour` column to the viewer's browser TZ via `(hour AT TIME ZONE $tz)::date` — Postgres's native handling correctly covers half-hour TZs like IST/ACST.
Single-prompt detail page queries also moved (the `(prompt_id, hour)` indexes serve those by `prompt_id` filter).
`getPromptWebQueriesForMapping` / `getPromptWebQueryCounts` stay on raw — `web_queries` is a `text[]` and would explode the aggregate row count for a low-traffic single-prompt query.
Browser TZ wired end-to-end
Four server functions previously hardcoded `timezone = "UTC"` and silently ignored what the client sent (or didn't send). All four now accept `timezone` in their input validator, and their corresponding client hooks send `Intl.DateTimeFormat().resolvedOptions().timeZone` so charts bucket correctly in the viewer's local time:
The visibility / batch-chart / per-prompt-chart functions already had this wiring.
Benchmark + correctness check
`scripts/perf/bench-aggregate-vs-raw.ts` is a self-contained script that runs the OLD raw-table SQL and the NEW aggregate SQL side-by-side for every page-driving query. It:
Run after backfill completes (and ideally before merging the read-path cutover) to verify both correctness and speedup.
Required follow-up
Once backfill is verified via the benchmark, drop the four backfill cursor columns from `aggregate_refresh_state`:
The live worker doesn't read or write these columns; they're only used during the one-time backfill.
Deploy order
Steps 1–4 don't change any user-visible behavior (the new tables are unread until step 5). The worker's first tick after step 5 catches up the gap between backfill end and deploy.
Test plan