Skip to content

Stop the ad dashboard reporting a cancelled query as zero delivery - #226

Merged
ralyodio merged 1 commit into
masterfrom
fix/ad-reporting-rpc-timeouts
Sep 1, 2026
Merged

Stop the ad dashboard reporting a cancelled query as zero delivery#226
ralyodio merged 1 commit into
masterfrom
fix/ad-reporting-rpc-timeouts

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What was wrong

/dashboard/ads read 0 for everything, intermittently, while the account was delivering 176,264 impressions over the window. The free-tier measure was fine — #199 and #225 both hold — and the data was all there. The RPCs were being cancelled.

Two independent faults, stacked:

1. The queries got too slow. ad_account_series, ad_campaign_totals and the two daily-series RPCs are security invoker, so the RLS policy on ad_impressions ("slot is mine OR campaign is mine") joins the plan. With it the planner abandons the hash join for a nested loop — one index scan per owned campaign, 139 loops, ~176k random heap fetches, 401,791 buffers (~3GB) touched per page load. ad_impressions passed 364k rows / 154MB, and traffic ran 10x baseline on 2026-09-01 (14,580/hr against ~500/hr). That tipped it over the 8s statement_timeout on authenticated: 34 canceling statement due to statement timeout errors in two hours, surfacing as HTTP 500 on three RPCs.

2. A failed query was indistinguishable from no data. Every loader swallowed the error into a zero-filled result (error ? [] : rows), so the page reported four confident zeros over a live network — and the error object was discarded at the point of failure, so the only evidence lived in Postgres' own logs.

The fix

DB — run the five reporting RPCs as security definer. Each already did its own authorisation and never relied on RLS for it: every read is gated by <x>_id in (select id from owned) where owned is owner_id = auth.uid(). Dropping the RLS subplans lets the planner pick the hash join again:

buffers time
invoker (before) 401,791 932ms
definer (after) 11,818 208ms

Byte-identical output (3/176264/0/5570/0 both ways). Verified a stranger's JWT still returns 0 rows from all five. The guard is in, not not in, so an anon caller gets an empty owned rather than everything.

App — the zero-fill stays (one bad panel should not take the page down), but the loaders now return Loaded<T> carrying failed, log the error instead of discarding it, and the four ad surfaces render "couldn't load" in place of the zeros. The PDF report says so too — that document goes to accountants, where a silent zero is read as fact.

Status

The migration is already applied to prod (via MCP, per this repo's by-hand convention) — the dashboard is reading correctly again as of now. This PR carries the migration file so the repo matches, plus the app-side change.

Testing

Updated tests/ads-stats-box.test.ts and tests/ads-earnings-free-tier.test.ts for the new return shape, and added a case asserting the thing that was missing: a failing client and an empty-result client sum to the same zeros, and failed is the only thing that tells them apart.

⚠️ Not run locally — this checkout's node_modules has no typescript or vitest (known-broken install), so CI needs to gate this one.

🤖 Generated with Claude Code

https://claude.ai/code/session_01318XDMF7H8AtH7h4ZjweTS

/dashboard/ads read 0 for everything, intermittently, while the account
was delivering 176,264 impressions over the window. The measure was
right this time -- #199 and #225 both hold -- and the data was there.
The RPCs were being cancelled.

ad_account_series, ad_campaign_totals and the two daily-series functions
are security invoker, so the RLS policy on ad_impressions ("slot is mine
OR campaign is mine") joins the plan. With it the planner abandons the
hash join for a nested loop: one index scan per owned campaign, 139
loops, ~176k random heap fetches, 401,791 buffers (~3GB) touched per
page load. ad_impressions passed 364k rows / 154MB and traffic ran 10x
baseline on 2026-09-01, which tipped it over the 8s statement_timeout on
`authenticated` -- 34 cancellations in two hours, surfacing as HTTP 500
on three RPCs.

Each function already did its own authorisation and never relied on RLS
for it: every read is gated by `<x>_id in (select id from owned)` where
owned is `owner_id = auth.uid()`. Running them as definer drops the RLS
subplans and the planner picks the hash join again: 11,818 buffers /
208ms against 401,791 / 932ms, byte-identical output. Verified with a
stranger's JWT that all five still return 0 rows. Note the guard is `in`
and not `not in`, so an anon caller gets an empty `owned` rather than
everything.

The second half is why this took a log dive to find. Every loader
swallowed the error into a zero-filled result, so a cancelled query and
a genuinely quiet range produced identical output and the page reported
four confident zeros over a live network. The zero-fill stays -- one bad
panel should not take the page down -- but the loaders now return
Loaded<T> carrying `failed`, log the error instead of discarding it, and
the four ad surfaces render "couldn't load" in place of the zeros. The
PDF report says so too: that document goes to accountants, where a
silent zero is read as fact.

Migration is already applied to prod.

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

github-actions Bot commented Sep 1, 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 a4dc4b5 into master Sep 1, 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