Health dashboard renders in 5 s when the Notification Outbox holds ~1M Parked rows (KPI tile timeout and uncached) #122
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found by the #49
[xc-8]UI run (docs/plans/2026-11-10-wp4-residual-scope-results.md§6,docker/measure-ui-responsiveness.sh).Symptom. On the docker rig,
/monitoring/healthtakes up to 5.0 s to render (idle P50 3.4 s, max 5.05 s over 5 loads). Every other page renders in 25–425 ms. The Notification Outbox tile then shows "Notification Outbox KPIs unavailable: KPI query timed out."Cause.
Notificationstable holds 4.35 M rows: 944,597Parked(soak traffic since 2026-08-01) and 3.4 MDelivered.NotificationOutboxRepository.ComputeKpisAsynchas to count every Parked row. Measured on the rig: a parallel clustered scan, 374,043 logical reads, 1.3 s elapsed warm, per query.Health.razor's 5 sTileTimeout.OnInitializedAsync, so prerender blocks the whole page for the timeout.Why it matters. Parked rows are not purged (only terminal rows age out), so any central with a long-running delivery fault will build a backlog like this. A misconfigured SMTP for a few weeks is enough.
Options (not decided).
(Status, NextAttemptAt)index coversStatus = 'Parked', but the OR'd predicate makes the optimizer scan the clustered index. Split the conditional aggregate into per-status seeks.Fixed in
424db155.Root cause, re-measured. My earlier 1.3 s figure was wrong.
sqlcmdruns withQUOTED_IDENTIFIER OFFby default, which disables filtered indexes. Re-run withsqlcmd -I, the global outbox KPI query on the rig (4.35 M rows: 945 k Parked, 3.4 M Delivered) took 0.7–2.6 s and 76 k logical reads, with a parallel clustered scan. The cause is the OR ofPending | Retrying | Parked | (Delivered AND DeliveredAt >= since)in the pre-filter: it stops the optimizer from seeking any of the three indexes that cover the legs.Fix.
NotificationOutboxRepository.ComputeKpisAsync/ComputePerSiteKpisAsync/ComputePerNodeKpisAsyncnow run three status-equality legs and merge them in memory:Status IN (Pending, Retrying): depth, stuck, oldest(Status, NextAttemptAt)Status = Parkedcount(Status, NextAttemptAt)Delivered AND DeliveredAt >= sinceIX_Notifications_DeliveredGlobal snapshot: 0.7–2.6 s → ~0.12 s. Per-site/per-node still have to read every parked row to group it (~1 s on the rig), but the live and delivered legs no longer ride along. No schema change and no migration.
NotificationOutboxRepositoryKpiQueryShapeTestsnow pins at most three queries per snapshot, and that no query ORsParkedwith the live statuses. A new merge test covers keys that appear in only one leg.I did not change the Health page's caching or prerender: with the query at ~0.12 s the 5 s tile timeout is no longer reached. Rig check after redeploy: five lone-viewer cold-cache loads of
/monitoring/health, 12 s apart, rendered in 0.12–0.52 s (was P50 3.4 s, max 5.05 s), with every KPI tile populated.