Relationships
#3779 Dashboard Workflows page takes ~19s on a serve with many extension workflows: workflow.search resolves each workflow's owner with a full extension-workflow scan
Opened by zack · 10/22/2026· Shipped 10/22/2026
Summary
The dashboard Workflows page takes ~19s to load on a serve whose workflows come mostly from pulled extensions. The time is spent in workflow.search's authorization filter: it resolves each listed workflow's owner with a separate repository lookup, and every lookup of an extension workflow re-walks and re-parses every pulled extension's YAML. The cost is quadratic in the number of extension workflows.
Observed (ops.swamp-club.com, serve bfde0792)
Timed over the serve WebSocket with a user token:
| Request | Alone | Sent together, as the page does |
|---|---|---|
workflow.search (141 workflows) |
13–15s | 19s |
workflow.run.summary (since: 7d, recentLimit: 20) |
2s | 7s |
The page waits on both. The run summary slows when sent together because search's synchronous YAML parsing blocks the event loop. The repo has 29 workflows in workflows/; the other ~112 come from pulled extensions.
Cause
handleWorkflowSearch (src/serve/handlers/workflow_handlers.ts) lists every workflow, then runs filterByResources with canonical.workflowOwners(item.id, item.name) for each one. That path is recordedWorkflowOwners → resolveRecordedWorkflow → findWorkflowById(repo, id, name) → CompositeWorkflowRepository.findByName(name):
- A workflow in
workflows/is found byYamlWorkflowRepository.findByNamefrom its cached listings. This is cheap. - An extension workflow misses the primary repository and reaches
ExtensionWorkflowRepository.findByName, which callsfindAll().findAll()walks every extension workflows directory and parses every*.yaml/*.ymlin it.
CanonicalResources memoizes per (id, name), but every extension workflow has a distinct key, so one search request does about 112 full extension walks. CanonicalResources already avoids this pattern for model definitions with a single per-request #index ("a scan per id would make a collection of N items cost N full scans"). Workflows have no equivalent.
Reproduction (in-process)
A temp repo built from a real workflow YAML template, with file mtimes backdated so YamlWorkflowRepository listings are cached as in production. Times are for CanonicalResources.workflowOwners over every workflow from repo.findAll(), which is what one workflow.search request does:
| Primary workflows | Extension workflows | workflowOwners total |
|---|---|---|
| 141 | 0 | 239ms |
| 29 | 112 | 1,624ms (1,593ms in extension lookups; one extension findAll = 12ms) |
| 29 | 400 | 18,530ms |
On the ops serve one extension walk costs about 100ms (larger pulled-extension trees), which gives the observed 13s+.
Proposed fix
Read once per request: CanonicalResources builds a single per-request workflow index on first use, the same way it already does for definitions, and workflowOwners resolves from that index instead of issuing a repository lookup per workflow. The index must keep resolveRecordedWorkflow's current semantics exactly:
- Prefer the workflow with both the recorded name and id. A primary workflow shadows an extension workflow of the same name.
- Otherwise fall back to lookup by id alone (
CompositeWorkflowRepository.findByIdorder: primary first, then extension). - Otherwise the workflow is "missing" and is judged on its recorded name.
- A read failure still fails the request.
A request then costs one primary listing plus one extension walk, however many workflows it authorizes. Add a regression test that counts extension findAll calls (one per request, not one per workflow) rather than asserting on wall-clock time.
Main (983e2929) has the same behavior; the only change to this path since bfde0792 is swamp-club#3741's tracing.
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.