Relationships
#3874 Workflow run-start evaluation is quadratic in behavior count: provenance map construction and data-hydrating scans in spliceOrigin (~100+ s for @swamp/ci-uat/run)
Opened by zack · 10/23/2026· Shipped 10/23/2026
Summary
Run-start evaluation of @swamp/ci-uat/run takes 102–177 s in production (median 118 s, 10 runs on 2026-10-23, swamp 20261023.090009.0-sha.9a065cc6). swamp.workflow.build_context takes ~66 ms and CEL itself takes ~50–150 ms. Almost all the remaining time is provenance bookkeeping in src/domain/expressions/splice_origin.ts. Cost grows with the square of the number of behaviors in the repo, and the work blocks the serve event loop. Typed secrets does not change it: typed and legacy runs take the same path.
Root cause
For a computed expression whose free roots include model (the UAT workflow has 7 of the form model.uat-vms.input.globalArguments.?x.orValue(...), each repeated in 4 steps), spliceOrigin does two things.
- Quadratic map construction.
bindingMapAt(context, "model", [])→modelNamespaceMapadds onewithWholeData(path)per behavior field (resource,file,execution). Each call goes throughgraft→ProvenanceMap.of, which copies and re-canonicalizes the whole accumulated map, so building one map is O(n²). The model namespace holds every behavior twice, under its name and its UUID (model_resolver.ts,context.model[definition.id] = modelData), so n ≈ 4 × behaviors. The map is built twice per computed occurrence: 7 × 4 × 2 = 56 builds per evaluation. - The scan loads and walks every behavior's data.
authoredRawsIn(ctx.model, …)runsvalueContainsExpression/extractExpressionsover the wholemodelroot. Those read the lazyresource/filegetters, so the first scan synchronously loads every behavior's records (getContentSync+JSON.parse, plus onefindAllGlobalSyncorphan scan). The next scans (28 per evaluation) then walk all of that data, even though every path under it is whole data and gets filtered out afterwards. A side effect: the run-start context, which later steps reuse, ends up holding run-start copies of every behavior's data, but only in workflows that happen to contain a computedmodelexpression.
Also: WorkflowExpressionEvaluator.evaluate evaluates every occurrence (83 here) and calls spliceOrigin for each, but the workflow.expressions_evaluated span attribute reports distinct raw text (24), so telemetry undercounts the work done.
Local measurements
Probe setup: the real workflow, the real WorkflowExpressionEvaluator and typed CelEvaluator, and a context shaped like buildContext (name+UUID aliases, self-replacing loaders that parse stored JSON). These are local timings, not production CPU.
| Behaviors (model keys) | Current | Bulk map only | Prototype (all three fixes) |
|---|---|---|---|
| 500 (1,000) | 8.1 s | 0.43 s | 0.36 s |
| 1,000 (2,000) | 30.3 s (map 29.5 s, CEL 0.05 s) | 0.93 s | 0.68 s |
| 2,000 (4,000) | 140.6 s (map 138.7 s) | 1.98 s | 1.27 s |
- With heavier data (20 records × 8 KB per behavior), fixing only the map still takes 33 s at 2,000 behaviors (36,000 records parsed, +576 MB heap). With the data-skipping scan it takes 1.4 s, parses nothing, and adds +97 MB.
- In every run the longest event-loop timer gap equalled the total evaluation time, so the pass never yields.
- All runs produced identical evaluated-workflow and provenance-map digests.
- Not yet established for production: the actual behavior count and data volume, so how production time splits between map building and the data scan.
Proposed fix (ranked)
- Build the model namespace map in one pass with
ProvenanceMap.fromEntries. The output is the same, because the paths are distinct two-segment paths and none nests under another. - Make
authoredRawsInmap-aware. Skip whole-data subtrees by key before reading the value, and check leaves withscanExpressions+overlapsData. This gives the same authored-raw set, never reads aresource/filegetter, and loads nothing. - Reuse each root's map within one
spliceOrigincall (56 → 28 builds). - Instrumentation on
swamp.workflow.evaluate: expression occurrences, actual CEL evaluations, distinct expressions, model key count, CEL ms and provenance ms. Ship this with 1–3 so production confirms the effect. - Follow-up, only if 4 shows it matters: scans now make up ~85% of the remaining time. Either memoize root maps and root authored raws for the duration of one evaluation pass (never keyed on the context object: a step adding
model.X.executionwould make a cached map read data as authored), or evaluate each distinct raw once (83 → 24) while still applying the per-path skip and foreign-text error rules to every occurrence. - Out of scope, needs design and security review: treating
.?x.orValue(literal)on a static path as a direct read. That would flip those results from data to authored, matching the plain read, which is a visible change to masking and persisted maps.
A prototype of 1–3 with tests exists on local branch investigate-expression-provenance-perf (base 268af5537, uncommitted):
- tests in
splice_origin_test.ts:- the new map equals the old path-by-path map, including aliases and quoted names;
- zero getter reads during the scan;
- zero
graftcalls when building the map;
- a new
splice_origin_property_test.ts, which compares the new scan with the old extract-then-filter result over adversarial values and catches over-skipping mutants; - related unit and integration suites: 1,998 passed, 0 failed.
Does typed secrets remove the need for provenance?
No. Typed secrets controls what a secret value can do in CEL and where its plaintext goes. Provenance decides whether text is author-written code.
Without provenance, ${{ vault.get(...) }} arriving in a webhook payload or a stored resource would be evaluated by a later pass under the run's authority and loaded into a method argument, a trusted destination. Typed secrets also doesn't cover env.*, cross-behavior data.* reads, file.contents, or foreign text such as ${{ github.sha }} that must stay literal.
All of these still have to hold:
- the raw-text rule;
- position ranges, because data can equal an authored raw;
- the partial-overlap rule for fragments;
- parent maps carried into child workflows;
- persisted maps for resume.
Section 8.1 of the typed-secrets design adds provenance work rather than removing it. Once the untyped branch is deleted (typed-secrets 4.6), the separate raw and sanitized copies, each with its own map, could merge into one. That is a representation simplification and doesn't affect this perf issue.
Acceptance
- Evaluation of the UAT workflow stays roughly linear in behavior count, with no
resource/fileloads during the provenance scan. - Provenance output is unchanged: the equivalence and property tests pass, and the provenance/cache/resume integration suites stay green.
swamp.workflow.evaluatereports occurrences, evaluations, model keys and subphase timings.- The readiness-step pre-dispatch gaps (110–161 s) look like a separate problem: no correlation with evaluation time (r = 0.02) and no overlap with other runs' evaluations. Track them separately.
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.