Skip to content

perf(control-plane): fast Runs list and run page under live traffic - #1172

Closed
santoshkumarradha wants to merge 4 commits into
mainfrom
fix/runs-list-under-load
Closed

santoshkumarradha wants to merge 4 commits into
mainfrom
fix/runs-list-under-load

Conversation

@santoshkumarradha

Copy link
Copy Markdown
Member

Summary

Under steady traffic the Runs page and a large run's page got slow. Three things caused it: the runs list aggregated every execution in the table on each request, every live event restarted the page's queries, and the run page downloaded the whole VC chain to show one warning.

 web: live refreshes (useSSEQuerySync.tsx, useRunDetail.ts)
-  each execution event -> invalidate runs, dashboard-summary, run-dag, step-detail (cancelRefetch: true)
+  invalidations coalesced: first one immediate, then at most one flush per 2 s, cancelRefetch: false
-  running run polls every 2.5 s
+  running run polls every 10 s while the event stream is connected (safety net only)

 control-plane: QueryRunSummaries (storage/execution_records.go)
-  SELECT <all summary columns> FROM executions GROUP BY run_id ORDER BY <key> LIMIT n OFFSET m
+  phase 1: pick the page's run_ids, computing only the sort key (same WHERE/HAVING, run_id tie-break)
+  phase 2: aggregate the summary columns for those run_ids only, returned in phase-1 order
+  default page (no filters, latest activity first): walk the new
+    idx_executions_run_activity ON executions ((COALESCE(updated_at, started_at)), run_id)
+    newest first and stop when the page is complete (bounded LIMIT window, widened if needed)

 control-plane: started_at stored in UTC on insert and update
-  exec.StartedAt                 (written in whatever zone the time carried)
+  exec.StartedAt.UTC()

 run page: VC chain
-  GET /api/ui/v1/workflows/:id/vc-chain                (one signed credential per step)
+  GET /api/ui/v1/workflows/:id/vc-chain?view=summary   (status, counts, verifiable, unresolved_dids, issuer; no component_vcs)
   the default response is unchanged, and the audit download still fetches the full chain

Notes:

  • The JSON returned by the runs list is the same. The one change is that ties in the sort key are now ordered by run_id. Before, tie order was undefined, so a run could show up on two pages or on neither. This matters most for sort_by=status, which has only three key values.
  • The index is created at startup on SQLite and PostgreSQL, next to the existing ones. If creating it fails, a warning is logged and startup continues: the list returns the same page without the index, just slower.
  • The UTC fix applies to new writes only. Rows already stored keep the text they were written with.

Type of change

  • Bug fix

Evidence

Measured on a copy of a busy local SQLite database: 4.2 GB, about 24,000 runs and 203,000 executions. Live traffic came from a real agent through /api/v1/execute/async: one 1,500-step run (six steps in parallel) plus a new 5-step run about every 0.66 s. The machine's load average was about 20 from unrelated work, so these are noisy-box numbers.

GET /api/ui/v2/workflow-runs?page=1&page_size=25&sort_by=updated_at&sort_order=desc

Before After
no traffic, p50 285 ms 25 ms
under traffic, p50 / p90 / max (20 samples) 441 / 509 / 549 ms 56 / 67 / 73 ms
QueryRunSummaries alone, default page 320 ms 20 ms (most of it the COUNT(DISTINCT run_id) total)
QueryRunSummaries, other sort columns ~320 ms 35–135 ms

The per-run lookups in the handler (run metadata and trigger) were measured at about 1–3 ms for a page of 25, so they were left as they are.

Network trace, 15 s on each page under traffic (headless Chrome, /api requests only)

Runs page:

before coalescing (measured earlier on the same data):
  ~30 x workflow-runs in 15 s, overlapping and cancelled, 0.3 s -> 2.7 s each
coalesced, old query:
  9 x workflow-runs   332-446 ms, 18 KB each
coalesced, new query:
  9 x workflow-runs    45-101 ms, 18 KB each
  plus dashboard/summary x1, nodes/summary x1, llm/health x3, queue/status x3, one event stream

Run page of the 1,500-step run while it runs:

before:
  dag?mode=lightweight   16 ms   143 KB
  vc-chain               31 ms  1220 KB      <- whole chain, fetched on every page open
  dag?mode=lightweight   x7 more in 15 s (one per coalesced flush), 11-18 ms, ~150-180 KB
after:
  dag?mode=lightweight   20 ms   135 KB
  vc-chain?view=summary  45 ms     1 KB
  dag?mode=lightweight   x6 more in 15 s, 11-23 ms, ~140-170 KB
  executions/:id/details x2, nodes/summary x1, llm/health x3, queue/status x3, one event stream

For a 322-step run, the full chain was 766 KB.

Test plan

  • go build ./...
  • go vet ./internal/storage ./internal/handlers/ui
  • go test ./internal/storage/ ./internal/handlers/ui/ ./internal/server/ -count=1
  • POSTGRES_TEST_URL=... go test ./internal/storage -run Postgres -count=1 against pgvector/pgvector:pg17, including the new PostgreSQL page comparison
  • New TestQueryRunSummariesPagesMatchTheSingleGroupedQuery compares every sort column, both directions, ten filters and five page windows against the previous single grouped query. It fails if either tie-breaker is reversed.
  • New TestRecentRunIDsByActivityWidensPastBusyRuns, TestRunActivityIndexServesTheRecentRunsScan
  • New TestExecutionStartedAtIsStoredInUTC fails on main (a non-UTC start drops out of a UTC since filter) and passes here
  • New TestUIWorkflowVCChainSummaryViewOmitsPerStepCredentials
  • web/client: npx tsc --noEmit -p tsconfig.app.json, npx vitest run (141 files, 840 tests), npm run build

Merge Danger

Door: two-way. Every change reverts cleanly. The new index is additive (CREATE INDEX IF NOT EXISTS) and can be dropped. view=summary is opt-in.

Blast Radius: moderate. This changes the query behind the Runs list and adds one index on executions. On a large PostgreSQL table, the first start after upgrading builds that index synchronously, the same way the existing startup indexes are built.

Every execution event invalidated the runs list, the dashboard summary, the
run DAG and the step detail at once, with cancelRefetch on, so under steady
traffic the Runs page restarted its list request dozens of times a minute and
each cancelled request still ran to completion on the server. Invalidations
are now coalesced (at most one flush per two seconds, the first immediate,
without cancelling an in-flight fetch), and a running run polls every ten
seconds as a safety net while the event stream is connected.
QueryRunSummaries grouped every execution in the table, computing every
summary column for every run, and then kept one page. It now chooses the
page's run_ids first, computing only the sort key, and aggregates just those
runs with the same filters. The default page (no filters, latest activity
first) is read from a new expression index on
COALESCE(updated_at, started_at) and stops as soon as the page is complete.
run_id breaks sort ties so page boundaries no longer move between requests.
Tests compare every sort column, direction, filter and page against the
previous single grouped query.
SQLite compares started_at as the text the driver wrote, and the driver writes
a time in the zone it carries. An execution whose start time was not UTC was
filtered and sorted as though it started at a different instant; a since
bound given in UTC dropped it. Insert and update now write the UTC instant.
Rows already stored are unchanged.
The run page fetched the whole workflow VC chain, one signed credential per
step, to show its verifiability warning and issuer DID: about 1.2 MB for a
1500-step run, on every page open. GET /api/ui/v1/workflows/:id/vc-chain
accepts view=summary, which returns the same status, step counts,
verifiability and workflow issuer without component_vcs, the workflow
credential's document or the DID resolution bundle. The default response is
unchanged, and the audit download still fetches the full chain.
@santoshkumarradha
santoshkumarradha requested review from a team and AbirAbbas as code owners November 9, 2026 14:44
@santoshkumarradha santoshkumarradha mentioned this pull request Nov 9, 2026
19 of 22 tasks
@santoshkumarradha

Copy link
Copy Markdown
Member Author

Folded into #1171 so all fixes get one review; commits unchanged.

}
defer rows.Close()

runIDs := make([]string, 0, limit)
@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 88.20% 87.40% ↑ +0.80 pp 🟡
sdk-go 93.80% 92.00% ↑ +1.80 pp 🟢
sdk-python 94.73% 93.73% ↑ +1.00 pp 🟢
sdk-typescript 92.44% 90.42% ↑ +2.02 pp 🟢
web-ui 84.91% 84.79% ↑ +0.12 pp 🟡
aggregate 86.12% 85.75% ↑ +0.37 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 165 84.00% ✅
sdk-go 0 — ➖ no changes
sdk-python 0 — ➖ no changes
sdk-typescript 0 — ➖ no changes
web-ui 36 88.00% ✅

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

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.

3 participants