fix(dashboards): bound TF queries during page render, degrade to client fetch - #475
Conversation
…nt fetch Dashboard page render ran TimeFusion queries inline with no timeout anywhere on the path: processConstant, processVariable and processEagerWidget all had to finish before the shell shipped, and mkHasqlPool sets only a 30s *acquisition* timeout (chart queries don't even use that pool — they go through the postgresql-simple timefusionPgPool). So when TF wedged on 2026-08-02, every render thread parked on it, the three replicas grew into their 24GB cap, and Swarm OOM-killed them in a loop while /status timed out. log_explorer stayed up throughout because it already splits chrome (apiLogH) from data (logExplorerDataH); dashboards had no such split. Wrap each render-time query in a 4s budget and fall back to the un-prefilled value. That is exactly the state the client already recovers from: table shells always emit hx-trigger="load" and a spinner when html is absent, stat content includes `load` whenever it has no data, and chartWidget shows "Loading chart…" and fetches on intersection when dataset.source is empty. A slow TF now degrades dashboards from server-rendered to client-fetched instead of from working to down. lazyWidget must clear `eager` as well as html/dataset: renderStatContent reads the flag itself as "data is present" and drops `load` from the trigger, so a widget left flagged-but-empty renders a spinner nothing ever resolves. WidgetLazySpec pins both directions. WTAnomalies is excluded deliberately — it reads Postgres, not TF, and widget_ renders nothing at all without html. Also move the container HEALTHCHECK from /status to /ping. statusH runs `select version()`, which made every replica's liveness depend on one shared Postgres, so a single DB hiccup failed all three probes at once and Swarm killed the whole service. A probe whose failure action is "kill the container" must test liveness only; dependency health already alerts via InfraHealthCheck.
ReviewSolid, well-documented incident fix — the root-cause writeup and the budget/fallback design are easy to follow, and the Possible bug: eager
|
Review catch: the budget branch tested `eager` first, so a WTAnomalies widget that also set `eager: true` got budgeted and lazyWidget'd on timeout — blanking the card, which is exactly what the exclusion existed to prevent. `eager` is a free Maybe Bool with no type-level tie to wType, so config can set both. Reorder as a MultiWayIf with anomalies first, and label the budget warning by widget id rather than title (untitled widgets all logged the same string).
|
Solid fix, well-documented, and the design (bound the render-time prefill, fall back to the client-fetch shell every widget kind already supports) is the right shape for this failure mode — no page can be held hostage by TF again. A few notes, roughly in priority order. 1.
2. 3. The core risk here — does Nits: Overall: good incident response, root-caused correctly (unbounded inline query + a healthcheck coupled to shared Postgres), and the fallback story is sound. Nothing above blocks merge; #1 is the only one I'd actually ask to fix before merging. |
Why
During the 2026-08-02 outage all three monoscope replicas crash-looped (exit 137 OOM + exit 1 healthcheck kills) and
app.monoscope.techserved nothing for ~70 minutes.The trigger was TimeFusion self-starving (its tokio runtime was CPU-saturated, so object-store futures went unpolled and it logged 379s "S3 timeouts" while R2 was actually 3ms away from the host). But monoscope died rather than degraded, and that part is ours:
Dashboard page render ran TF queries inline, unbounded.
processConstant,processVariableandprocessEagerWidgetall had to finish before the shell shipped, and there was no timeout anywhere on the TF path —mkHasqlPoolsets only a 30s acquisition timeout, and chart queries don't even use that pool (they go through the postgresql-simpletimefusionPgPool). Render threads parked forever, heap climbed into the 24GB cap, Swarm killed the containers, repeat.log_explorerstayed up throughout, because it already splits chrome (apiLogH) from data (logExplorerDataH). Dashboards had no such split.What
Wrap each render-time query in a 4s budget (
withRenderBudget) and fall back to the un-prefilled value — exactly the state the client already knows how to recover from:renderTableShellalways emitshx-trigger="load, …"+ spinner whenhtmlis absentrenderStatContentincludesloadwheneverhasDatais FalsechartWidgetshows "Loading chart…" and fetches on intersection whenopt.dataset.sourceis emptySo a slow TF degrades dashboards from server-rendered to client-fetched, instead of from working to down. Healthy TF is unaffected — these queries return in milliseconds and the timeout never fires.
The subtle bit
lazyWidgetmust cleareageras well ashtml/dataset.renderStatContentcomputeshasData = isTrue widget.eager || …, so a widget left flagged-but-empty renders a spinner that nothing ever resolves.WidgetLazySpecpins both directions, including a test that fails if the flag is left set.WTAnomaliesis excluded deliberately: it reads Postgres (not TF) andwidget_renderswhenJust w.html toHtmlRaw, i.e. nothing at all without html, so degrading it would blank the card.Also: HEALTHCHECK /status → /ping
statusHrunsselect version(), which made every replica's liveness depend on one shared Postgres — a single DB hiccup fails all three probes simultaneously and Swarm kills the entire service. A probe whose failure action is "kill the container" must test liveness only./pingis a pure handler and still proves the warp accept loop and handler threads are alive, which is what this probe exists to catch. Dependency health already alerts via InfraHealthCheck.Testing
test/unit/Pkg/WidgetLazySpec.hs— 3 new cases, all passNote
The bound is per query and render has three sequential phases, so a total TF stall bounds the shell at ~12s rather than ~4s. That is documented at
renderQueryBudgetMicros. Still a bounded page instead of an unbounded one; happy to tighten if you'd prefer.