Skip to content

perf: serve dashboards from worker-maintained hourly aggregates#216

Open
jrhizor wants to merge 5 commits into
mainfrom
perf-slow-page-queries
Open

perf: serve dashboards from worker-maintained hourly aggregates#216
jrhizor wants to merge 5 commits into
mainfrom
perf-slow-page-queries

Conversation

@jrhizor

@jrhizor jrhizor commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

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:

  1. docs: — design doc (`docs/perf-daily-aggregates.md`)
  2. perf: — implementation (schema + migration + worker + backfill + read-path rewrite + changeset)
  3. perf: — thread the browser timezone end-to-end + add side-by-side benchmark script

Why a worker, not a Postgres materialized view

The "obvious" Postgres-native alternative would be a materialized view. We're not using one because:

  1. `REFRESH MATERIALIZED VIEW` rebuilds the entire view from scratch. No incremental refresh in vanilla Postgres; `pg_ivm` exists but Supabase doesn't ship it. A per-minute refresh would scan all 12.6 M citations rows every minute. Our worker only re-aggregates the (brand, date) buckets that actually had inserts since the last tick — typically 1–3 buckets, ~150 ms of work.
  2. `REFRESH … CONCURRENTLY` is more work, not less. It computes the new contents and diffs against the old.
  3. Non-CONCURRENT `REFRESH` takes `ACCESS EXCLUSIVE` on the view. Every dashboard hangs during the refresh.
  4. No partial state, no resumable backfill. "Backfill" with a matview is one giant `REFRESH` that runs to completion or doesn't. With a regular table we own the cursor and the per-bucket transactions.
  5. Schema evolution is destructive. Adding a column means `DROP MATERIALIZED VIEW … CREATE … REFRESH …`, which loses data and re-computes from scratch.
  6. Four derived datasets, one source-side scan budget. Four matviews would each refresh independently, each scanning the source from scratch. The worker does all four DELETE+INSERTs against the same already-warm source pages.
  7. Triggers would solve the staleness/scan problems but slow the write path on every prompt-run insert. Out of scope.

Same explanation lives in `docs/perf-daily-aggregates.md`.

Schema (migration `0009_hourly_aggregates.sql`)

Table Grain Drives
`hourly_prompt_runs` (brand, prompt, hour, model, web_search) overview / visibility / prompt-detail
`hourly_prompt_run_competitors` (brand, prompt, hour, model, competitor) per-prompt competitor charts
`hourly_citations` (brand, prompt, hour, model, domain) citations chart, domain rollups
`hourly_citation_urls` (brand, prompt, hour, model, url) top-URL list, per-prompt URL stats
`aggregate_refresh_state` singleton worker watermark + resumable backfill cursor

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/`)

  • `rebuild-hourly-bucket.ts` — shared DELETE+INSERT helper for one (brand_id, UTC date) bucket across all four tables. Used by both the live worker and the backfill script, so the bucket-rebuild logic has one canonical implementation.
  • `refresh-hourly-aggregates.ts` — pg-boss handler. Per tick: take advisory lock, read `last_refreshed_through`, find affected (brand, date) buckets in `[watermark - 1h, now() - 30s]` (1-hour overlap absorbs late writes), rebuild each, advance watermark. Whole tick in one transaction.
  • Wired into `apps/worker/src/index.ts` with `boss.schedule("refresh-hourly-aggregates", "* * * * *", …, { singletonKey: "refresh-hourly-aggregates" })` so a tick is skipped if the previous one is still running.

Backfill — resumable

`apps/worker/src/scripts/backfill-hourly-aggregates.ts`. Run with:

cd apps/worker
pnpm tsx --env-file=../web/.env src/scripts/backfill-hourly-aggregates.ts

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:

  • `getDashboardSummaryFn` ↔ `use-dashboard-summary`
  • `getCitationsFn` ↔ `use-citations`
  • `getPromptsSummaryFn` ↔ `use-prompts-summary`
  • `getPromptStatsFn` ↔ `use-prompt-stats`

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:

  1. Pins both paths to `aggregate_refresh_state.last_refreshed_through` as the upper bound, so they read the same source set regardless of how far behind the worker / backfill is.
  2. Times both paths (median of 3 runs per query).
  3. Diffs the canonical row hashes — exits non-zero if anything mismatches.
  4. Prints raw_ms / agg_ms / speedup per (page × lookback) pair, plus an overall ✓/✗ summary.

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`:

ALTER TABLE aggregate_refresh_state
    DROP COLUMN backfill_started_at,
    DROP COLUMN backfill_completed_at,
    DROP COLUMN backfill_cursor_brand_id,
    DROP COLUMN backfill_cursor_date;

The live worker doesn't read or write these columns; they're only used during the one-time backfill.

Deploy order

  1. Apply migration `0009_hourly_aggregates.sql` (creates the tables, seeds the singleton state row).
  2. Run the backfill script to completion (resumable, see above).
  3. Run the benchmark `scripts/perf/bench-aggregate-vs-raw.ts` and confirm everything is ✓ and the speedups look right.
  4. Apply the column-drop migration for the backfill cursor.
  5. Merge this PR — the worker starts running on next deploy and the read paths cut over to the new tables.

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

  • Apply migration on a non-prod DB; verify the four tables and singleton row exist.
  • Run backfill on non-prod; verify it converges and the resume path works (kill mid-run, re-launch, watch it pick up).
  • Run `scripts/perf/bench-aggregate-vs-raw.ts` on non-prod; confirm all-✓ and reasonable speedups (expect ~10–100× at 30 d / 90 d).
  • Apply column-drop migration on non-prod; confirm worker tick still succeeds.
  • Repeat 1–4 on prod.
  • After merge: verify worker tick logs (`[refresh-hourly-aggregates] tick complete (source=scheduled, buckets=N, M ms)`) and that `last_refreshed_through` advances each minute.
  • Smoke the overview / visibility / citations / prompt-detail pages at 7 d / 30 d / 90 d / 1 y for a high-volume brand and confirm they're sub-second.
  • Verify charts bucket correctly in non-UTC browser timezones (e.g. `America/Los_Angeles`, `Asia/Tokyo`).

@vercel

vercel Bot commented Apr 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
elmo Ready Ready Preview, Comment Jun 3, 2026 5:53am

Request Review

@jrhizor
jrhizor force-pushed the perf-slow-page-queries branch from daed7c2 to 9aad922 Compare April 24, 2026 23:04
@jrhizor
jrhizor force-pushed the perf-slow-page-queries branch from 390b947 to 019a9a7 Compare April 25, 2026 01:34
@jrhizor jrhizor changed the title perf: speed up overview/visibility/citations queries for high-volume brands docs: plan for worker-maintained daily aggregates Apr 25, 2026
@jrhizor
jrhizor force-pushed the perf-slow-page-queries branch from 019a9a7 to 8035705 Compare April 25, 2026 23:48
@jrhizor
jrhizor force-pushed the perf-slow-page-queries branch from 8035705 to d229f03 Compare April 26, 2026 00:16
@jrhizor jrhizor changed the title docs: plan for worker-maintained daily aggregates docs: plan for worker-maintained hourly aggregates Apr 26, 2026
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.
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.
@jrhizor jrhizor changed the title docs: plan for worker-maintained hourly aggregates perf: serve dashboards from worker-maintained hourly aggregates Apr 26, 2026
@jrhizor

jrhizor commented Apr 26, 2026

Copy link
Copy Markdown
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant