Relationships
#3968 serve: workflow.history.get rescans every behavior definition (incl. auto-definitions) per request to authorize step outputs; latency grows with catalog size, multiplies under overlap, and cancel does not stop it
Opened by zack · 10/26/2026· Shipped 10/26/2026
Summary
On an authenticated serve, workflow.history.get re-reads and YAML-parses every behavior definition, including every auto-created one, on every request. The first output-ownership check triggers this scan (dataReadPolicy → CanonicalResources.dataOwners → #definitionsById → YamlDefinitionRepository.findAllIncludingAutoGlobal()). The index is memoized only for that one request. Nothing shares it across requests, nothing caches it across requests, and cancelling the request does not stop it.
As a result, output-resolution time grows linearly with the size of the definition catalog. It does not depend on the run's resource count. Overlapping requests each run their own scan on the same event loop, so their latency multiplies. @swamp/ci-uat/run makes the catalog grow without bound: every default run auto-creates 73 behaviors (uat-vms-<run>, ur-<run>-<slot>, ut-<run>-<shard>).
This is distinct from #3959, which covers behavior output history rescanning definitions for each missing ID. Related, but not duplicates: #3741 (added the current spans), #3742 (client poll stacking, shipped), and #3779 (the same per-request full-scan pattern inside an authorization filter, but for extension workflows in workflow.search).
Affected revisions
origin/mainat commit 4c4327b42 (primary reproduction)- commit b4dd513d, ops build
20261025.114930.0-sha.b4dd513d. Reproduced with the same numbers. Between b4dd513d and main,history_get.ts,run.ts,resource_resolution.ts,workflow_handlers.tsandyaml_definition_repository.tsare byte-identical.step_output_resolver.tsonly drops an unused vault option. - The code path predates both. The historical ops data below is from 9a065cc6.
Present-day ops observation (b4dd513d, 2026-10-26 07:58 CDT)
Trace 917dcd9b39a2924ed6498f4ec1a132ce: completed @swamp/ci-uat/run run 95d70d16-…, filesystem datastore, engine v2, user principal, 2 active requests, event-loop lag 2.2 ms.
| span | ms |
|---|---|
| request total | 6958 |
swamp.workflow.step_outputs.resolve (28 resources read, 0 denied) |
6273 (90%) |
swamp.serve.authorize (workflow) |
467 |
swamp.workflow.run_reference.resolve |
74 |
swamp.serve.redact |
129 |
swamp.serve.response.send |
11 |
Ops has no child spans inside output resolution. Its per-phase split is therefore not observed directly. The split below comes from the local reproduction.
Historical ops observations (9a065cc6, Friday 2026-10-23). Kept separate from today's case.
- 579 output-resolution spans: median 32.6 s, max 137.2 s. Output resolution was a median 95% of the request.
- The same completed run (ee54cbd7-…, 76 resources) took 3.24 s with 1 active request (trace cf871398…) and 136 s with 35 active requests (trace 6fa43d9a…). The peak was 45 overlapping output resolutions on one connection.
- 87 of 579 requests replied
cancelledalthough their output-resolution spans completed OK.
Local reproduction
Fixture
The fixture is synthetic, contains no secrets and uses no sleeps. It is a local extension with @repro/uat/workers and @repro/uat/runner plus a workflow @repro/uat/run that copies @swamp/ci-uat/run's jobs, steps, forEach fan-out and per-run model names (uat-vms-${{ run.id }}, ur-${{ run.id }}-${{ self.slot }}, ut-${{ run.id }}-${{ self.shard }}). Each step writes one JSON resource, so:
- the default inputs (23 shards + 13 side shards) give 4 + 36 ready + 36 shards = 76 resources
- 12 shards and no side shards give 4 + 2×12 = 28 resources
These match ops's counters exactly. Shard resources carry a 150-line log tail (about 16 KB each; the 76-resource run has 660 KB of output content).
The datastore is the default filesystem one (v2 engine). Serve ran with --auth-mode token, a non-admin user:repro-reader granted read on workflow:*, data:* and model:*, plus an admin token for comparison. A local OTLP collector captured serve's own spans. A temporary instrumented copy added timers inside output resolution and a span around findAllIncludingAutoGlobal. 101 real workflow runs produced 7,404 definitions; the 22k and 74k levels add clones of those real auto-definition files with fresh IDs and names.
Commands
These run from the reproduction directory: the sw wrapper runs main.ts from the chosen source tree with -A --unstable-bundle, and the tools/*.ts scripts sit beside it.
./sw repo init repo --tool none
# extensions/models/uat.ts, extensions/models/uat_runner.ts, workflows/workflow-uat-run.yaml (attached in the repro dir)
./sw workflow validate '@repro/uat/run' --repo-dir repo
./sw workflow run '@repro/uat/run' --repo-dir repo # 76 resources
./sw workflow run '@repro/uat/run' --repo-dir repo \
--input 'shards=["cli-1","cli-2","cli-3","cli-4","cli-5","cli-6","cli-7","serve-1","serve-2","serve-3","quality","club"]' \
--input 'sideShards=[]' # 28 resources
./seed-runs.sh repo 100 # +73 auto-definitions per run
./sw access token mint repro-reader --principal user:repro-reader --repo-dir repo
# grants/repro.yaml: allow read on workflow:*, data:*, model:* for user:repro-reader
deno run -A tools/collector.ts 14318 results/spans.jsonl & # OTLP/HTTP JSON sink
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:14318 OTEL_BSP_USE=1 \
./sw serve --repo-dir repo --host 127.0.0.1 --port 19191 --no-schedule \
--auth-mode token --admins user:repro-admin &
deno run -A tools/client.ts --url ws://127.0.0.1:19191 --token-file token-reader.txt \
--run <run-id> --n 45 --concurrency 45 [--cancel-after-ms 100]
python3 -I tools/summarize.py results/spans.jsonl
python3 -I tools/clone_definitions.py repo-scaled 74000 # scaling control
deno run -A tools/bench_definitions.ts <src> repo-scaled 5 1 # scan aloneThe machine was a 32-core Linux host shared with other test suites, with load average 3–12 throughout. The scan's user+system CPU time roughly equals its wall time, so the scan is CPU-bound here (warm page cache).
Results: one request at a time, completed runs, non-admin user
| definitions | resources | request total | output resolution | of which definition-index load | content reads (getContent) |
JSON parse |
|---|---|---|---|---|---|---|
| 98 | 28 / 76 | 12–36 ms | 5–28 ms | n/a (not instrumented) | – | – |
| 7,404 | 28 | 169–208 ms | 162–193 ms | 155–182 ms | 3–5 ms | 0.2–0.3 ms |
| 7,404 | 76 | 190–196 ms | 171–178 ms | 158–167 ms | 7–9 ms | 0.4–0.5 ms |
| 74,000 | 28 | 1.61–1.65 s | 1.60–1.64 s | 1.57–1.61 s | 3–10 ms | 0.2–0.3 ms |
| 74,000 | 76 | 1.66–1.74 s | 1.64–1.72 s | 1.60–1.69 s | 7–16 ms | 0.5 ms |
- The scan alone (
findAllIncludingAutoGlobal, outside serve): 152 ms at 7,404 definitions, 456 ms at 22,000 and 1,533 ms at 74,000. That is linear, about 20.7 µs per definition on this host. - Resource count is not the driver. 28 and 76 resources cost the same within noise. Reading 660 KB of content for 76 resources takes under 20 ms.
- The scan comes from read authorization. With
--auth-mode none(wherefilterByResourcesreturns early) the same 76-resource request at 7,404 definitions takes a median of 30 ms (68 ms for the first request) instead of 175 ms. - Admin vs non-admin: the same (159–178 ms at 7,404). An admin still pays for owner resolution.
- main vs b4dd513d at 7,404: 167/178 ms vs 176/188 ms (28/76 resources). Same within noise.
- No nested-run reads (
findChildRun0 calls). Ops's counter showed 0 denied, which matches.
Results: overlapping requests on one connection (76-resource run, uninstrumented main)
| definitions | concurrent | per-request latency (min / median / max) | wall time for the batch |
|---|---|---|---|
| 7,404 | 1 (45 in series) | 168 / 175 / 202 ms | 7.9 s |
| 7,404 | 10 | 619 / 1233 / 1303 ms | 5.7 s |
| 7,404 | 45 | 4960 / 5829 / 5851 ms | 5.9 s |
| 74,000 | 10 (instrumented) | 11.55 / 11.98 / 11.99 s | 12.0 s |
Throughput stays the same while latency multiplies: each request runs its own full scan, and the scans take turns on serve's event loop. In the 45-way batch, output resolution was 4.94–5.14 s per request, active requests rose to 45, and the recorded event-loop lag peaked at 454 ms. Ops's slow historical trace showed 543 ms lag with 33 active. Workflow authorization and redaction stayed small, while run lookup rose to about 610 ms because it waits behind the same event loop.
Results: cancellation
Ten concurrent requests (7,404 definitions) each sent cancel 100 ms after the request. All ten replied cancelled, but only after 1.33–1.38 s. Inside each one, the definition-index load ran to completion (1.08–1.24 s) and the output-resolution span ended OK. handleWorkflowHistoryGet checks controller.signal.aborted only after consumeStream finishes, and output resolution never receives the signal. This reproduces the ops pattern of cancelled roots with completed OK output spans: cancelled requests keep competing for the event loop.
Running vs completed
Nothing in output resolution branches on run status: the same per-request scan happens either way. A running run hurts more only because the run page re-reads it while it is live. A running run was not measured separately.
Causal mechanism (source, main 4c4327b42)
handleWorkflowHistoryGet(src/serve/handlers/workflow_handlers.ts) always passesincludeOutputs: true, and passesdataReadPolicy(socket, ctx, principal). That builds a freshcanonicalResources(ctx)per request.StepOutputResolver.resolveResources(src/domain/workflows/step_output_resolver.ts) callscanRead(ref)before each read. The first call goes throughfilterByResources→canonical.dataOwners(ref)→modelOwners→#definitionsById(src/serve/handlers/resource_resolution.ts). Because the repository hasfindAllIncludingAutoGlobal, this builds an id → definitions index by reading every file undermodels/and the datastore'sauto-definitions/. It awaits eachDeno.readTextFile, then runsparseYamlandDefinition.fromData(collectAllDefinitionsinsrc/infrastructure/persistence/yaml_definition_repository.ts).- The index lives on the per-request
CanonicalResources. The next request, including one sent concurrently for the same run, builds it again. @swamp/ci-uat/runnames its behaviors after the run ID, so each run leaves 73 new auto-definitions (default inputs), and the scan keeps growing.
What matches ops, and what remains uncertain
Matches:
- filesystem datastore with the v2 engine
- an authenticated user principal, with 0 denied
- a completed
@swamp/ci-uat/run-shaped run with exactly 28 and 76 output resources - output resolution as 90–97% of the request
- no nested-run reads
- identical code at b4dd513d
- overlap amplification with high
active_requestsand event-loop lag cancelledreplies whose output spans completed OK
Not established:
- The ops definition count is unknown. At this host's 20.7 µs per definition, 6.27 s would take about 300k definitions. Ops's other spans run slower than this host: run lookup 74 ms vs 5–17 ms locally. On ops's VM disk, the serial per-file reads may also cost more than here. So ops plausibly needs far fewer, but how many is unmeasured. The isolated same-workflow latency also rose from 3.24 s (Friday) to 6.27 s (Monday). That is consistent with a growing catalog but does not prove it: the revisions and other load differ.
- Ops's own phase split is not observed. Ops may also pay other costs that this fixture lacks, such as extension-workflow walks (#3779). Those would explain ops's 467 ms authorization and 129 ms redaction, versus about 1 ms and 0.1 ms here.
- To confirm on ops (read-only): count
*.yamlfiles under the datastore'sauto-definitions/and undermodels/, and time onefindAllIncludingAutoGlobal. Or deploy a span around the index load, with the definition count as an attribute.
Suggested fix direction
- Stop scanning the whole catalog per request to find owners. Look up definitions by ID without a full scan, or keep one serve-level id → owners index. Invalidate it when definitions are written, deleted, or pulled from the datastore, and share it single-flight between concurrent requests. Ownership must still be decided on current definitions: keep #2675's guarantee that owners are judged now, by ID.
- Observe cancellation in output resolution. Pass the request's
AbortSignalintoresolveStepOutputsand the resolver loop. Check it before the index load and between resources, and reply as soon as the request is cancelled. - Consider coalescing identical in-flight
history.getrequests per connection and principal, or caching the resolved outputs of a terminal run per authorization snapshot. - Telemetry: add a span for the definition-index load with a definition-count attribute, and attributes for content reads, bytes and child-run reads under
swamp.workflow.step_outputs.resolve. - Separately, outside this fix: per-run auto-created behavior names in ci-uat grow the catalog without bound. That is worth a lifecycle or retention decision, but serve should not be quadratic in it.
Regression criteria
On the fixture above, with a non-admin token:
history.getfor the 76-resource completed run at 74,000 definitions stays within 2× of its latency at 98 definitions. Today it is about 1.7 s vs about 30 ms.- 45 concurrent
history.getrequests for the same run at 7,404 definitions: median per-request latency stays under 3× the serial latency. Today it is 5.8 s vs 0.175 s. At most one definition scan runs for the whole batch, or none if the index is kept warm. - A cancel sent 100 ms after the request gets its
cancelledreply within 100 ms of the cancel, and no definition scan starts after the cancel. - A unit or integration test counts
findAllIncludingAutoGlobal(or the replacement lookup) calls across Nhistory.getrequests and asserts that the count does not grow with N. - Grants that deny on definition tags or name are still honoured for outputs: a denied resource stays out of the outputs, and denied counts still reach the span.
Reproduction artifacts (local): fixture repos, harness scripts, collector JSONL, per-trace span summaries and the instrumentation patch are kept in the reporter's worktree under .repro/run-detail-outputs/.
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.