Skip to content

fix(analytics): stop the dashboard from starving the execution path - #2315

Open
joelorzet wants to merge 9 commits into
stagingfrom
fix/analytics-poll-amplifier
Open

fix(analytics): stop the dashboard from starving the execution path#2315
joelorzet wants to merge 9 commits into
stagingfrom
fix/analytics-poll-amplifier

Conversation

@joelorzet

Copy link
Copy Markdown
Contributor

What was wrong

A single analytics dashboard could take every database connection a pod has, and the runs it starved were then filed as platform errors.

The chain, measured on production:

  1. The stream is recycled server-side every 5 minutes (MAX_LIFETIME_MS). The browser sees onerror on a healthy connection, and the handler called EventSource.close(), which also cancels the native reconnect, then started the 10s poll. Nothing ever returns to the stream, so every viewer ends up polling within minutes of opening the page.
  2. The poll ran on setInterval, so a tick fired whether or not the previous pass had finished. Each pass begins by aborting the pass before it, so a pass slower than 10s was cancelled before it could deliver: the runs table never updated and loading never cleared.
  3. Aborting the request does not cancel the query. At a 120s pass that is twelve generations of five requests in flight against one viewer, all still running on the server.
  4. lib/analytics/queries.ts read through the shared max:10 pool. Four pods gives 40 connections. During the incident all 40 were active and 37 of them were analytics queries, the longest at 1m48s.
  5. With no connection left, the executor could not record progress. The reaper filed those runs as system_error / infrastructure in batches of 128, 129, 128 and 89 across four passes. That batch write is what paged.

One managed organization absorbed 179 of the reaped runs, 50 of them inside the alert window, only because it runs the most workflows. The cause was not specific to it.

What changed

Four commits, one fault each.

Reconnect the stream instead of falling back to polling. onerror now reopens with an exponential backoff (1s, 2s, 4s) and polls only once those attempts are spent. A delivered message resets the counter, so a routine recycle costs one reconnect rather than a permanent demotion to 30 requests a minute.

Run one fetch at a time. The poll rearms on settle rather than on the wall clock, so one pass is in flight at a time and a slow pass simply polls less often. A fast pass is unchanged.

Group the stream-triggered refreshes. Run events are leading-edge debounced over 2s. An idle dashboard still reacts with no added latency. Grouping cannot lose a run, because the refresh reads the whole window rather than a delta. A viewer can miss an intermediate state of a run that changes twice inside the window, never its final state.

Give the analytics reads their own pool. max:3 with a 15s statement_timeout, the same shape the metrics collector has used since the 2026-05-29 incident. Whatever the dashboard does it cannot reach the app pool, so the write path never queues behind a chart. Analytics requests queue instead. That trade is deliberate: the page degrades, the platform does not.

Why the pool cap matters on its own

#2313 made the gas filter cheap and #2303 bounded the app pool at 30s. Both help. Neither stops a slow analytics query from taking the connections the executor needs, and #2313 notes the endpoint is still slow at 30d because the page selection scans workflow_executions three times. The cap is what makes a slow query a slow page rather than dropped runs.

Configuration

ANALYTICS_STATEMENT_TIMEOUT_MS overrides the 15s bound, alongside the existing APP_STATEMENT_TIMEOUT_MS and METRICS_STATEMENT_TIMEOUT_MS. No schema change, no migration, no new index.

Not in this PR

Verification

596 test files, 21,998 tests pass, pnpm check and pnpm type-check clean. New unit tests cover the retry decision, the scheduler under a task slower than its interval, the debounce grouping, and the pool options read off the real module rather than a restated literal.

The stream is recycled server-side on a fixed lifetime, so the browser sees
onerror on a healthy connection every few minutes. The handler treated that
close as fatal: it called EventSource.close(), which also cancels the native
reconnect, and started the 10s poll. Nothing ever returns to the stream, so
every viewer ends up polling within minutes of opening the page.

The poll costs far more than the stream. Each tick fires five requests and the
next tick aborts whatever is still in flight, so a viewer on a wide range
produces 30 requests a minute and discards most of the answers.

onerror now reopens the stream with an exponential backoff (1s, 2s, 4s) and
falls back to polling only once those attempts are spent. A delivered message
resets the counter, so a routine recycle costs one reconnect rather than a
permanent demotion. An auth abort clears a pending backoff so a 401 cannot
reopen the stream.

The decision is a pure function so it can be tested without a DOM.
The poll ran on setInterval, so a tick fired whether or not the previous pass
had finished. Every pass starts by aborting the pass before it, so a pass that
outlives the 10s interval is cancelled before it can deliver: the runs table
and the facet counts never updated, loading never cleared, and the queries
behind the abandoned requests kept running to their statement timeout. At a
120s pass that is twelve generations in flight against a single viewer.

The poll now rearms on settle rather than on the wall clock, so one pass is in
flight at a time and a slow pass simply polls less often. A fast pass is
unchanged apart from the milliseconds it takes to return.

The scheduler is a standalone helper so the stacking can be tested with fake
timers rather than through the hook.
The stream emits an event per run, and each one called fetchData directly.
Because every pass aborts the pass before it, an organization that starts runs
faster than a refresh completes cancelled every refresh but the last, while
still paying on the server for the queries behind all of them.

The refresh is now leading-edge debounced over a 2s window: the first event
refreshes at once, further events inside the window join one trailing refresh.
An idle dashboard is unchanged, which is what a viewer watching for a single
run expects, and only a burst is grouped.

Grouping cannot lose a run. The refresh reads the whole window rather than a
delta, so one pass reflects every event that arrived while it was booked. What
a viewer can miss is an intermediate state of a run that changes twice inside
the window, never its final state.
The /analytics read path shared lib/db's max:10 pool with everything else these
pods do, including the execution writes that record a run's progress. A
dashboard fans out five requests per pass and its heaviest are unbounded in the
range they scan, so a couple of viewers can hold every connection a pod has.
The executor then cannot write progress, the reaper files those runs as system
errors at the stale threshold, and a slow page becomes dropped runs.

lib/analytics/queries.ts now reads through a dedicated pool at max:3, the same
shape the metrics collector already uses for the same reason. Whatever the
dashboard does it cannot reach the app pool, so the write path never queues
behind a chart. Analytics requests queue instead, which is the intended trade:
the page degrades, the platform does not.

statement_timeout is 15s rather than the app pool's 30s, overridable with
ANALYTICS_STATEMENT_TIMEOUT_MS. The client abandons a pass well before either
bound, and an abandoned request keeps its connection until Postgres cancels the
statement, so the tighter bound returns it sooner.
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Resolved. Exempt: pull request carries the 'no-issue-required' label.

@joelorzet joelorzet added the no-issue-required PR exempt from the issue-first gate label Sep 4, 2026
Tying the refresh to a stream failure froze the page. The stream emits only
summary and heartbeat frames; nothing in the repository emits new-run or
run-updated. formatSSE writes a bare data: line with no event name, so a
heartbeat reaches onmessage and reset the reconnect counter every 30s, while
the lifetime recycle is 5 minutes. The counter never reached its limit, so the
fallback never started and fetchData ran once at mount and never again. The
runs table, the chart, the network panel and the status counts held their
mount-time values for the life of the page, while the header kept showing a
recent update time from the summary event.

The refresh now starts with the stream and runs whatever the stream is doing,
which is what the fallback used to provide once it demoted. The stream carries
the summary and nothing else, so a stream that will not reopen costs summary
freshness alone. The retry decision says reconnect or stop rather than
reconnect or poll, because polling is no longer its business.

The reconnect keeps its own value: a routine recycle no longer ends the summary
events for the rest of the session.
createLeadingDebounce grouped new-run and run-updated events. Nothing emits
those: the stream sends summary and heartbeat only, so the debounce and its
call site were both unreachable.

It was also wrong for the job it claimed. The window was measured from the
start of a pass, not from its settle, so a pass slower than the window still
started a second on top of it. The second aborts the first, pendingCount never
reaches zero and loading never clears, which is the stacking the poll scheduler
was written to avoid. All five of its tests used a task that settled at once,
so none of them showed it.

Removing it leaves the stream independent of the row filters, so the stream and
the refresh now hold separate effects and changing a filter no longer recycles
the connection.

If per-run events are added later, the grouping has to gate on settle and carry
a test with a task slower than its window.
stop() followed by start() while a pass was in flight left two live chains. The
pending finally read the boolean as false and armed a timer that overwrote the
handle start() had just stored, so the earlier chain kept ticking with nothing
holding its handle and stop() could never reach it.

Every stop and start now opens a generation, and a pass only rearms while its
own generation is current. startPolling builds a fresh scheduler each time so
the hook did not reach this, but the function is exported and had no test for
it.

The test is mutation checked: three calls against the previous implementation,
two against this one.
getAnalyticsChecksum ran its three arms through Promise.all, and the stream
calls it per open connection every poll interval. On a pool of three that meant
one tick of one stream held every connection, so page requests queued behind
the streams that were watching them for changes. A queue wait is invisible to
statement_timeout, which only starts once a connection is held, so the wait
surfaced as an edge timeout with no server-side signal.

The arms now run in sequence. Each is an index-only lookup, so serialising
costs a few ms of wall clock and turns a tick from three connections into one.

The pool goes from three to five, which is the fan-out of a single refresh
pass, so one viewer no longer queues against itself. Five per pod still leaves
the app pool's ten untouched, and it is well under the forty analytics
connections that saturated the database.

The test helper now fails when more than one pool matches its size, rather than
silently picking whichever came first.
The import aliased analyticsDb to db for the whole module, which moved every
function in it onto the analytics pool, not only the dashboard reads.
getSpendCapData is not one: it serves the organization limits panel and the MCP
spending-limits tool, and it started queuing behind chart traffic on a pool
sized for charts.

Both pools are now imported under their own names and each read says which one
it uses. The dashboard reads take analyticsDb, getSpendCapData keeps db.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-issue-required PR exempt from the issue-first gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant