Skip to content

Stop the ad dashboard losing its graphs to a timed-out query - #228

Merged
ralyodio merged 1 commit into
masterfrom
worktree-ads-graph-timeout
Sep 2, 2026
Merged

ralyodio merged 1 commit into
masterfrom
worktree-ads-graph-timeout

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

The ad dashboard renders no graphs, intermittently — the chart replaced by "No delivery in this range." and the campaign sparklines by "no traffic yet", over a network that delivered 177,844 impressions in that window. Delivery was healthy throughout. Three separate faults produce that one symptom.

1. The reporting RPCs were sitting at the statement_timeout wall

Measured over 24h of production edge logs:

RPC avg max 500s
ad_account_series 1,453ms 8,039ms 5
ad_campaign_daily_series 1,719ms 8,027ms 4
ad_campaign_totals — — 5

Against the 8s statement_timeout on authenticated. 14 of ~150 reporting calls returned HTTP 500, so roughly one page load in ten came back empty and a reload "fixed" it. That intermittency is the tell.

This is not the RLS nested loop from #226 — that fix held, and the plan is a clean hash join. Every dashboard load simply re-aggregated the entire archive from scratch:

Seq Scan on ad_impressions  (actual rows=177858)
  Filter: ((NOT duplicate) AND (ts >= now() - '30 days'))
  Rows Removed by Filter: 198394
  Buffers: shared hit=9767          -- the entire 78MB heap, every load

and the page fires three such RPCs per render. ad_impressions is 376k rows growing ~90k/day, so the cost tracked the size of the archive rather than the window being asked for.

No index fixes this. The 30-day window selects 47% of the table, far past where an index scan can win. A covering partial index (kept, it serves the live-edge reads) measured 157ms against 194ms for the seq scan — a 20% gain on a query that needed to be 10x faster.

So pre-aggregate. Three rollups, each at the grain its surface actually plots:

table key growth
ad_stats_owner_hourly (owner_id, hour) ~24 rows/day
ad_stats_campaign_daily (campaign_id, day) ~135 rows/day
ad_stats_slot_daily (slot_id, day) ~27 rows/day

Refreshed by pg_cron every ten minutes. Closed periods come from the rollup; the current hour and current UTC day always come from raw, so a stale — or completely un-run — refresh can never show a wrong live edge, and the raw slice read is never wider than one day. A 30-day account query now reads ~720 rows where it read 246,506.

Per-campaign is deliberately daily, not hourly: (campaign_id, hour) is 2,997 distinct cells in a single day against 139 campaigns, ~170k rows over the archive — barely smaller than the raw table, so it would buy nothing. Account-wide has no campaign dimension, which is what makes hourly affordable there, and hourly is required there because the 1W range plots 4-hour buckets.

2. ad_campaign_daily_series had outgrown PostgREST's 1000-row cap

139 campaigns x 30 days is 2,731 rows. The call was coming back 206 Partial Content, content-range 0-999/2731. The function has no ORDER BY, so which two thirds got dropped was down to join order: campaigns silently lost days off the end of their sparkline, and five lost every row — rendering "no traffic yet" beside a row reading "Impressions: 24".

It returns a single jsonb array now: one row, whatever the campaign count. Live, this took the page from 9 "no traffic yet" rows down to the 4 that genuinely have no delivery.

3. The chart still asserted emptiness when the query had failed

AccountTrend and MiniTrend both receive a zero-filled series on failure, so "No delivery in this range." was a statement of fact about the network made from a query that never returned — sitting directly underneath the banner #226 added saying the figures could not be loaded. Both now take failed and say so instead. Tracked per loader, because the chart and the sparklines come from different RPCs and either can fail on its own.

Verification

  • Equivalence: swapped the five functions inside a REPEATABLE READ transaction and diffed old vs new across all 8 ranges and 3 day-windows — byte-identical output on every relation, and 3,312ms -> 380ms for the whole battery.
  • Reconciliation: the rollups match raw events exactly across all 57 closed days, on both the advertiser and publisher sides — including after the first scheduled mid-day refresh ran.
  • Live: all three RPCs now return 200 in 0.13–0.57s; /dashboard/ads renders its chart and 137 sparklines with no failure banner.
  • Ads test suites pass (79), plus 7 new tests pinning the jsonb parsing.

One bug caught during verification and fixed before it shipped: now() - interval '2 days' lands mid-day, so the refresh recomputed 08-31 from 13:40 onwards and upserted that partial count over the complete row. The window now snaps down to whole periods.

Both migrations are already applied to prod (the DB change is what fixed the symptom; the old TypeScript parses the jsonb fine, so the page improved before this merges). Rollback for the RPCs is 20260901153000_ad_reporting_rpcs_security_definer.sql, plus a drop function ad_campaign_daily_series(integer) first since the return type changed.

Unrelated, found while checking the other ad surfaces

/dashboard/ads/slots has a 21s TTFB. Every DB query on it is under 0.3s, so it is the un-timeboxed fetchSupportedTokens() awaited after the Promise.all. Pre-existing and untouched here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y7rVKbcZdT7tABboosr1Re

/dashboard/ads renders no graphs, intermittently: the chart is replaced
by "No delivery in this range." and the campaign sparklines by "no
traffic yet", over a network that delivered 177,844 impressions in the
window. Delivery was healthy the whole time. Three separate faults.

1. The reporting RPCs sat at the statement_timeout wall. Measured over
   24h: ad_account_series avg 1,453ms / max 8,039ms, and
   ad_campaign_daily_series avg 1,719ms / max 8,027ms, against the 8s
   timeout on `authenticated` -- 14 of ~150 calls returned HTTP 500, so
   roughly one load in ten came back empty and a reload "fixed" it.

   Not the RLS nested loop from #226; that fix held and the plan is a
   clean hash join. Every load simply re-aggregated the whole archive:

     Seq Scan on ad_impressions  (actual rows=177858)
       Filter: ((NOT duplicate) AND (ts >= now() - '30 days'))
       Rows Removed by Filter: 198394
       Buffers: shared hit=9767      -- the entire 78MB heap, per load

   and the page fires three such RPCs per render. ad_impressions is 376k
   rows growing ~90k/day, so cost tracked the archive rather than the
   window asked for. No index fixes that: the 30-day window selects 47%
   of the table. A covering partial index measured 157ms against 194ms
   for the seq scan -- a 20% gain on a query needing to be 10x faster.

   So pre-aggregate. Three rollups, each at the grain its surface plots
   (owner/hour, campaign/day, slot/day), refreshed by pg_cron every ten
   minutes. Closed periods come from the rollup, the current hour and
   current day always from raw, so a stale or un-run refresh can never
   show a wrong live edge and the raw slice is never wider than a day.
   A 30-day account query now reads ~720 rows where it read 246,506.

   Verified by swapping the functions inside a REPEATABLE READ
   transaction and diffing old against new across all 8 ranges and 3 day
   windows: byte-identical output, 3,312ms -> 380ms for the battery.

2. ad_campaign_daily_series had outgrown PostgREST's 1000-row cap. 139
   campaigns x 30 days is 2,731 rows and the call was returning
   `206 Partial Content, content-range 0-999/2731`. With no ORDER BY,
   which two thirds got dropped was down to join order: campaigns lost
   days off their sparkline and five lost every row, rendering "no
   traffic yet" beside a row reading "Impressions: 24". It returns one
   jsonb array now -- one row, whatever the campaign count. Live, that
   took the page from 9 "no traffic yet" rows to the 4 that really have
   no delivery.

3. AccountTrend and MiniTrend still asserted emptiness on failure. Both
   receive a zero-filled series when the query dies, so "No delivery in
   this range." was a claim about the network made from a query that
   never returned -- and it sat directly under the banner #226 added
   saying the figures could not be loaded. They now take `failed` and
   say so. Tracked per loader, since the chart and the sparklines come
   from different RPCs and either can fail alone.

Both migrations are applied to prod. The rollups reconcile exactly
against raw events across all 57 closed days on both the advertiser and
publisher sides, including after the first scheduled mid-day refresh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7rVKbcZdT7tABboosr1Re
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

39 finding(s)

HIGH/CRITICAL: 2 | MEDIUM: 28 | LOW: 9

Severity Rule Location
HIGH tls-verification-disabled lib/onion.ts:47
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
MEDIUM js-unescaped-html-sink app/(app)/dashboard/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM js-unescaped-html-sink app/(app)/dashboard/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:67
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:97
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:104
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:110
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:186
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:190
MEDIUM js-unescaped-html-sink app/c/[project]/[slug]/page.tsx:77
MEDIUM js-unescaped-html-sink app/c/[project]/page.tsx:57
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:228
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:285
MEDIUM js-unescaped-html-sink app/layout.tsx:129
MEDIUM js-open-redirect app/login/form.tsx:39
MEDIUM js-unescaped-html-sink app/r/[token]/page.tsx:176
MEDIUM js-open-redirect app/signup/form.tsx:43
MEDIUM js-open-redirect components/billing/buy-credits-modal.tsx:98
MEDIUM js-unescaped-html-sink components/json-ld.tsx:8
MEDIUM js-unescaped-html-sink components/report/markdown-view.tsx:15
MEDIUM js-unescaped-html-sink lib/careers/page-templates.ts:198
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:41
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:324
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:98
MEDIUM redos-nested-quantifier lib/tracker/agent-gate.ts:61
MEDIUM sh-remote-script-execution prober/deploy/provision.sh:30
MEDIUM sql-template-interpolation scripts/detect-slot-themes.ts:31
MEDIUM sql-template-interpolation scripts/purge-constructed-keywords.ts:163
MEDIUM sql-template-interpolation scripts/purge-offniche-keywords.ts:124
LOW secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:145
LOW secret-generic-credential lib/sp/platforms/linkedin.ts:25
LOW js-dynamic-code-execution tests/careers-page-templates.test.ts:21
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:19
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:69
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:51
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:52
LOW secret-generic-credential tests/contract/posthog-integration.test.ts:13
LOW secret-generic-credential tests/lead-campaign.test.ts:16

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 37c85db into master Sep 2, 2026
10 checks passed
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