Release v0.0.85
Overview
This release adds the first genuinely parallel task type and cuts the read path's cost in half. FanOutTask (type 21) resolves a collection at runtime and runs a referenced inner task once per item concurrently, joining the outcomes by policy and writing one output to instance data — replacing the $self auto loop that cost N full pipeline hops with a single transition (#905). Component definitions gain an in-process L1 cache in front of the distributed store, keyed by the existing Redis keys so a publish makes stale entries unreachable by construction, plus a five-second generation-token memo that removes the token read from the hot path entirely (#898); script secret bundles are cached in-process with a short TTL and single-flight stampede protection, taking a cold ~8.5 ms vault read down to ~1 µs warm (#899). Traces get flat lanes — one lane per instance instead of nesting equal to chain depth — and job arming moves outside the instance status lock, taking the worst observed lock hold from 30.1 s to 48 ms (#900). Payload envelope detection is resolved by field set rather than a single property, and a rejected payload can no longer come back naming no field at all (#906). Finally, the script compiler and ScriptContext get a measurement-first optimization pass with new metrics, an opt-in PreserveNumericPrecision flag and a LegacyAppendPipeline kill-switch (#907). This release runs on component schema 0.0.53.
Features
FanOutTask (type 21) — dynamic parallel task execution (#905)
Before this, the only way to process N runtime-known items was a $self auto loop: N full pipeline hops, each with a rule evaluation, a transition record, a remote call and its own instance-data write. A fan-out replaces that with a single transition.
{
"key": "launch-documents",
"type": "21",
"config": {
"mode": "inline",
"itemsPath": "order.documents",
"task": { "key": "launch-document", "domain": "core", "flow": "sys-tasks", "version": "1.0.0" },
"execution": {
"joinPolicy": "allSettled",
"maxDegreeOfParallelism": 4,
"itemTimeoutSeconds": 30,
"batchTimeoutSeconds": 120
}
}
}
The governing principle is that parallelism is the executor's business; writing is the single writer's business. N items execute concurrently, but instance data is written exactly once, by one OutputHandler call at the end. Item executions run collect-only — the engine's per-item retry, error boundary, journal and metrics all apply, but SuppressDataApply keeps them from each appending a data version, and item branch contexts are discarded rather than merged. That is not only about speed: the serial loop it replaces wrote its tracking list once per launch, so two writes could interleave and lose an id. With one writer there is no window for that race.
Item source is itemsPath (a dot-path subset of JSONPath over instance data) XOR the mapping's ItemSelector — exactly one, never both.
Join policies:
| Policy | Batch succeeds when | Empty batch |
|---|---|---|
all | every item succeeded | succeeds |
allSettled | always (per-item outcomes are collected) | succeeds |
quorum | at least minSuccess items succeeded | fails |
firstSuccess | at least one item succeeded | fails |
The empty-batch asymmetry is deliberate: quorum and firstSuccess are threshold policies and firstSuccess is definitionally quorum(1), so the two must not disagree on the same input.
Concurrency is bounded at two levels: per-task maxDegreeOfParallelism (default 4) and a process-wide bulkhead Workflow:FanOut:MaxConcurrentItems (default 64). One batch's cap cannot see another's — 100 instances at maxDop 5 would otherwise be 500 concurrent downstream calls.
Error codes are public contract that authors branch on:
| Code | Meaning |
|---|---|
FanOut:ItemTimeout | The item exceeded its own itemTimeoutSeconds — takes precedence over the reasons below |
FanOut:BatchTimeout | The item was cut short because the batch hit batchTimeoutSeconds |
FanOut:ItemCancelled | The item was cancelled by the join policy's early stop |
FanOut:ItemNotStarted | The item was cancelled while still queued for a concurrency slot |
FanOut:ItemFailed | Fallback: the inner task failed with no more specific fan-out cause (an inner task's own error code passes through unchanged) |
Script contract IFanOutMapping: ItemSelector and OutputHandler are optional (default interface implementations returning null, so the runtime does its own packaging); only ItemInputHandler is abstract — deliberately, because a default would silently fire N unbound requests on a typo.
Nested fan-out is rejected at depth 1: outer items would hold every bulkhead slot while inner items wait for one. The inner task type is otherwise unrestricted, including SubProcess and DirectTrigger.
Inline mode only. mode is in the schema from day one (default "inline", "durable" reserved and rejected at parse time) so adding the durable phase later is not a breaking change.
Reference: PR #905 — see also Fan-out task.
L1 component cache and generation-token memoization (#898)
Load testing put Dapr state on 9.7 s of the critical path: 580 GetState/SaveState calls, ~18.7 per transition, at up to 41 ms each (p95 202 ms on large workflow bodies). Two phases address it.
Phase 1 — a generation-keyed L1 envelope cache. IComponentL1Cache is a singleton, size-bounded in-process cache in front of the distributed store, holding serialized envelope bytes and deserializing per read, so callers keep getting fresh instances exactly as an L2 read gives them today. CacheSet<T> consults L1 before every L2 envelope read and writes through on every L2 write; InvalidateAsync also evicts the full-version body from L1.
Publish visibility is unchanged by construction. L1 keys are the existing Redis keys, and resolution keys embed the generation token, which is still fetched from L2 on every resolution. A publish bumps the token, so stale L1 entries simply become unreachable — no invalidation protocol, no staleness window. Effect: full-version bodies (pinned instance reads) cost zero Dapr calls after first touch, and range/latest resolutions drop from two calls (token + body) to one small token read.
Phase 2 — generation-token memoization. The memo mechanism already existed with a code default of 0; this release activates it in the orchestration host at 5 seconds. Measured on a local stack, one account-opening start performed 22 generation reads — typical read 1–2 ms, but the first read of each execution hop pays an idle-wake tax of 24–34 ms. Post-change: 22 reads → 8 (memo on, idle) → 0 within the window.
{
"ComponentCache": {
"L1Enabled": true,
"L1SizeLimitMb": 64,
"GenerationMemoSeconds": 5
}
}
The memo introduces a ≤5 s cross-pod publish-visibility window: the publishing pod is fresh immediately, other pods within 5 s. That is a CI/CD contract, not just a tuning knob — wait N + margin after the last publish before smoke tests or cutover, and use the same window in rollback runbooks. A bump never leaves a pre-bump token memoized, even when the bump write itself fails. Pinned full versions and running instances are unaffected; setting GenerationMemoSeconds to 0 restores instant cluster-wide visibility. A new cache.l1.hit span tag makes the hit path visible in traces.
Reference: PR #898 — see also Caching configuration.
Script secret cache (#899)
Script secret functions (GetSecret / GetSecretAsync / GetSecrets / GetSecretsAsync on ScriptBase) hit the vault on every call, which turns the vault into a bottleneck under script-heavy load.
ScriptSecretCache is a process-wide singleton caching whole secret bundles keyed by (storeName, secretStore) with a 30-second default TTL, single-flight stampede protection, immediate eviction of faulted fetches (no negative caching) and lazy TTL expiry. The synchronous GetSecret / GetSecrets wrappers first take a lock-free L1 probe (TryGetCachedSecret / TryGetCachedBundle) that never blocks and never fetches, dropping to the blocking async path only on a cold, in-flight, faulted or expired entry — so hits are structurally free of sync-over-async.
{ "Scripting": { "SecretCache": { "Enabled": true, "TtlSeconds": 30 } } }
Two things are deliberate and worth stating plainly:
- In-process, never
IDistributedCache. Secret material must not transit Redis or any other shared cache store. The cost is that each replica keeps its own TTL window, so after a rotation full fleet convergence takes at mostTtlSeconds. - Staleness is the contract, not a bug. A rotated secret keeps returning the old value until the bundle's TTL expires — bounded, tunable, and switchable off entirely (
Enabled=falseorTtlSeconds<=0bypasses the cache).
Measured end to end against a real Dapr → HashiCorp Vault at TTL 30 s: cold read ~8.5 ms, warm read ~1 µs; three reads in one transition cost exactly one vault round trip; a second transition inside the TTL cost zero. Misses still block on the synchronous API by nature, so miss-heavy scripts should prefer GetSecretAsync.
Reference: PR #899 — see also Scripting configuration.
Flat trace lanes and arming outside the status lock (#900)
Flat lanes. A chained request produced a deeply nested trace: each auto-chained hop's TransitionJob.Execute was parented to the previous hop, so nesting depth equalled chain depth — on a 22-hop trace the deepest hop sat at depth 53, and with subflows the waterfall was unusable. One field was doing two jobs. The payload's TraceParent is the previous hop — correct as a link, wrong as a parent:
| Field | Role |
|---|---|
TraceParent | Predecessor — attached as an ActivityLink |
TraceRoot | Lane anchor — the actual parent |
ParentTraceRoot | The lane to return to, set only inside a subflow |
One lane per instance. A new lane opens only at a subflow handoff, so a subflow's hops render flat underneath the PostCommit span that forwarded to them, and depth grows with subflow nesting rather than chain length:
PATCH .../transitions/{key}
├── TransitionJob.Execute (hop 1)
├── TransitionJob.Execute (hop 2) ← sibling, not a child
├── PostCommit.ForwardToSubflowJob ← anchors the subflow's lane
│ ├── TransitionJob.Execute (subflow hop 1)
│ └── TransitionJob.Execute (subflow hop 2)
├── SubFlow.Resume/{domain}/{flow} ← back in the parent's lane
└── TransitionJob.Execute (parent resume)
Measured: the deepest hop of a 23-hop trace moves from depth 53 to depth 1 (median 22 → 1). All policy lives in FlatLaneActivity; an anchor from another trace is linked, never trusted as a parent, so a stale AsyncLocal or a relayed payload cannot teleport a span. An absent anchor behaves exactly as before, which makes a rolling deploy safe in both directions, and there is no migration — job payloads live in the Dapr scheduler store and outbox events in a serialized blob.
Arming outside the status lock. The accept path held the instance status lock across the Dapr scheduler round trip, and under load that call was essentially the entire lock hold — arming p50 214 ms against a 198 ms median hold, p90 571 ms, worst 3.1 s. Every other request on the same instance queued behind an external call, which breaks the millisecond-scale check-and-set premise Busy-as-mutex rests on. Only the row must commit under the lock, because the duplicate-job guard is a check-then-insert with no DB constraint; telling Dapr does not. The accept now persists under the lock and arms after releasing it, via a deferred-arm handle — one scheduler call, no job-row read, no extra status write:
| Metric | Before | After |
|---|---|---|
BackgroundJob.Schedule inside a held lock | 59% | 0/59 |
| Lock hold p50 | 7.8 ms | 2.55 ms |
| Lock hold worst | 30.1 s | 48 ms |
The auto-chain is untouched — it runs in the pipeline's ambient unit of work and holds no status lock, so its arming was already deferred to post-commit.
Reference: PR #900 — see also Observability.
Payload envelope detection and schema error details (#906)
A transition or startTransition carrying a schema rejected a valid request depending on how the client wrapped its payload. Payload-mode detection keyed on a single case-sensitive attributes property, but the vNext envelope is a set of independently optional fields — key, tags, stage, attributes — so an envelope without attributes was classified free-form and wrapped whole, and the schema then validated key/tags instead of the business payload. A single PayloadEnvelope vocabulary with IsStandardShape now backs both the JSON and form-url-encoded paths, which had carried duplicated and divergent copies of the rule.
| Request | Before | After |
|---|---|---|
{"key":"K1"} (envelope, no payload) | 400 on key | 400 naming the missing payload fields |
{"Attributes":{…}} | 400 | 200 |
key=K1&tags[]=a (form) | 400 | correct |
{"key":"K1","tags":[…]} on a schema-less transition | envelope persisted as instance data | key consumed as the instance key |
Chasing that surfaced an independent defect: a rejected payload could come back naming no field at all ("errors":{}), because flattening the evaluation tree discarded a node's own errors whenever it had child details. A failing node now contributes its own errors and recurses, so an invalid subtree never flattens to an empty list, and a root error and a child error report together:
members=['root'] Required properties ["customer"] are not present
members=['customer'] Required properties ["ownerUserId"] are not present
members=['rogue'] All values fail against the false schema
members=['session'] Value is "integer" but should be "string"
Two contract notes: auto-detection now reserves key / tags / stage at the top level, so a free-form payload whose own fields are only those names must send x-vnext-payload-mode: raw; and member names change from keyword-ish values ("required") to instance paths (root, customer.ownerUserId), which is the documented intent but visible to any consumer that matched on the old value.
Script compiler and ScriptContext performance (#907)
vNext runs every piece of authored logic through the script compiler, and ScriptContext is the data carrier for all mappings — so allocation and IO on those two paths set the ceiling for the whole runtime under load. This work is deliberately measurement-first: it makes the hot paths observable, then optimizes what the numbers point at.
Measurement (additive only). Cache hit/miss counters, a script-types gauge, script_compilations_total, per-phase execution duration and runtime-error metrics wired at the funnel sites, plus Grafana panels for hit ratio and p95. Nothing was renamed: the old script_executions_total still flows and is marked deprecated. A BenchmarkDotNet project ships with committed baselines.
Compiler hit path. The cache key splits into a content hash plus a reusable profile; profiles and helper-set resolution are memoized (generation-token guarded, so a component hotfix is never masked); an expression-compiled activator replaces reflection; one compile per task execution via context-scoped mapping factories; helper token reads run in parallel.
Serialization / ScriptContext. Copy-on-write parallel branches with structural cloning (plus a depth guard so a cyclic script graph throws instead of killing the process), memoized JsonData parse and instance-data attribute reads, and a single-pass merge + canonicalize + hash JsonCanonicalizer behind the LegacyAppendPipeline kill-switch, with byte-parity proven against the old path.
| before | after | |
|---|---|---|
| Identity compile path (16 KB source) | 12.5 µs / 98.8 KB | 1.67 µs / 8.67 KB, flat |
| Compile calls per instance | 33 | 23 |
| COW parallel branch | 1.29 ms / 1.7 MB | 102 ns / 992 B |
| Canonicalize + merge | — | −66% time, −41% alloc |
| Cold start | 1.62 s | 1.08 s |
| Orchestration allocation / LOH / GC pause | — | −10.4% / −15% / −35% |
Opt-in numeric precision. WorkflowExecution:InstanceDataWrite:PreserveNumericPrecision makes append canonicalize numbers losslessly — int64 exact, decimal in plain trailing-zero-free form. Default false, because flipping it changes an affected instance's content hash once. Verified end to end both ways on the same flow and payload: 1234567890123456.78 with the flag on, 1234567890123456.8 with it off.
Several script-context behaviors are pinned as deliberate rather than accidental in this release: instance-data mutation is visible within the transition and branch response values are shared by reference (persistence is unchanged); SetBody preserves expando keys; parallel-merge conflict resolution is order-insensitive; a cyclic expando now throws instead of being silently pruned; and the transition record's request payload carries a task reference ({Key, Version, Domain, Flow, Type}) rather than the full task definition, which already lives in the component store.
Reference: PR #907 — see also Workflow execution configuration.
Behavior Changes
Several items here change observable behavior: payload envelope auto-detection now reserves key / tags / stage at the top level and schema-error member names become instance paths (#906); a cyclic expando in a script body throws instead of being silently pruned, parallel-merge conflict order becomes insensitive, and the transition record's request payload carries a task reference rather than the full definition (#907); and the five-second GenerationMemoSeconds window makes another pod's publish invisible for up to 5 s (#898). Each item, its impact direction and the migration steps are documented in the v0.0.85 breaking changes announcement.
Fixes
- Workflow timeouts were never armed — the timeout enqueue never passed
directly, so it defaulted tofalse: the row was persistedPendingand the scheduler was never called. Timeouts therefore depended on the arming poller, which is disabled, so they never fired. No exception, no log — the enqueue reported success. Four of the five enqueue sites already passeddirectly: true(#900). - The Dapr scheduler could not survive a restart — its etcd data directory was a 64 MB tmpfs, but etcd preallocates a 64 MB WAL segment plus snapshots, so the store could not fit and the container exited with
no space left on device. Once gone, sidecars could not resolvedapr-scheduler, every arm failed and transitions stopped running entirely. Raised to 512 MB in all five compose files (#900). - Notification bindings dropped trace context — Dapr output bindings bypass
HttpClient'sDiagnosticsHandler, so atraceparentmust be passed as component metadata.DaprBindingTaskInvokerdid this; the two notification dispatchers did not, so every notification left the trace at the task boundary (#900). Configure-time authoring errors returned 500 instead of 400 — a task refused at configure time (a type-21 task withmode: "durable", anHttpTaskmissingurl,SubProcessTask,GetInstancesTask, …) surfaced an opaque internal error and discarded a message that already named the offending value and the supported one.ComponentValidatorProcessornow mapsArgumentExceptioninto the component-validation result; genuine infrastructure faults still surface as 500 (#905).ScriptContext.CreateParallelBranch()silently retyped the transport dictionaries — it clonedHeaders/RouteValues/QueryParametersthrough a JSON round trip, turning aDictionary<string,string>into anExpandoObject. The C# runtime binder cannot seeExpandoObject's explicitly-implementedContainsKey, so the pervasivecontext.Headers.ContainsKey(...)idiom threwRuntimeBinderExceptionon any branch context. This also affected any existing workflow with two tasks at the sameorderwhose mapping reads headers. Those three are now cloned as dictionaries with the comparer preserved (#905).- SubProcess correlation writes raced on a shared
DbContext— the ambient (AsyncLocal) unit of work flows into parallel branches and hands them all the same schema-bound context, so per-item DI scopes were not enough. N concurrent items produced "A second operation was started on this context instance", losing a document's correlation, which then never got finalized. The striped per-instance gate fromInstanceDataWriteServiceis extracted toInstanceWriteGateand shared by both writers (#905). FanOut:ItemCancelledleaked a raw exception code — an item cancelled while already inside the inner task surfacedTask:Unknown:{taskKey}:TaskCanceledExceptioninstead of its fan-out cause, so a single batch carried two different codes for the same event, and the leaked string embedded the task key so it was not even stable to match on (#905).ComponentGenerationProviderfabricated a generation token on cancellation instead of propagating the caller's cancellation (#907).
Configuration Updates
Configuration for v0.0.85:
{
"runtimeVersion": "0.0.85",
"schemaVersion": "0.0.53"
}
Note: Schema version advances to 0.0.53 (from
0.0.52). The bump adds task type 21 (FanOutTask) totask-definition.schema.json(vnext-schema #135). Until you update@burgan-tech/vnext-schemain your domain project,npm run validaterejects a fan-out task; publishing and running it are unaffected.
New and changed settings:
{
"Workflow": { "FanOut": { "MaxConcurrentItems": 64 } },
"ComponentCache": { "L1Enabled": true, "L1SizeLimitMb": 64, "GenerationMemoSeconds": 5 },
"Scripting": { "SecretCache": { "Enabled": true, "TtlSeconds": 30 } },
"WorkflowExecution": { "InstanceDataWrite": { "PreserveNumericPrecision": false } }
}
Aether 1.0.36 is required — the flat-lane and deferred-arm work depends on it. The outbox/inbox idle poll cap drops from 60 s to 10 s: a 60 s ceiling put a measured 23 s of pure waiting into one observed trace before the outbox even leased the message. Measured idle cost of the change is small (commits/s 0.22 → 0.83 per replica, blks_read at zero — the extra polls return nothing, so they cost a transaction and an index probe, not data or disk). IdlePollingInterval and BusyPollingInterval are deliberately untouched. The Dapr scheduler's etcd tmpfs must be at least 512 MB; 64 MB cannot hold etcd's preallocated WAL segment.
Container images: published at tag 0.0.85 under ghcr.io/burgan-tech/vnext/*, Cosign-signed (keyless OIDC) with SBOM + provenance. Immutable digests are listed in the GitHub release.
Issues Referenced
- vnext #905 — FanOutTask (type 21): dynamic parallel task execution with a single-write join.
- vnext #898 — In-process L1 component cache and generation-token memoization.
- vnext #899 — Cache Dapr secret bundles in-process with a short TTL.
- vnext #900 — Flat trace lanes, job arming outside the status lock, and four defects found on the way.
- vnext #906 — Resolve the payload envelope by its field set and stop losing schema error details.
- vnext #907 — Script compiler and ScriptContext performance, metrics and opt-in numeric precision.
Summary
FanOutTask(type 21):itemsPathXORItemSelector, four join policies with a deliberate empty-batch asymmetry, two-level concurrency bulkhead,IFanOutMapping, one write per batch, stableFanOut:*error codes; inline mode only.- L1 component cache (
ComponentCache:L1Enabled,L1SizeLimitMb) plus a 5 s generation memo — pinned reads cost zero Dapr calls after first touch, at the price of a ≤5 s cross-pod publish-visibility window. - Script secret cache — in-process only, 30 s default TTL, single-flight, lock-free probe; ~8.5 ms cold → ~1 µs warm; staleness bounded by
TtlSeconds. - Flat trace lanes — one lane per instance, depth 53 → 1 on a 23-hop trace — and arming outside the status lock — worst lock hold 30.1 s → 48 ms.
- Payload envelopes resolved by field set;
key/tags/stagereserved; schema errors always name an instance path. - Script compiler and
ScriptContextmeasured and optimized; opt-inPreserveNumericPrecision,LegacyAppendPipelinekill-switch. - Fixes: workflow timeouts were never armed, Dapr scheduler etcd tmpfs 64 → 512 MB, notification bindings dropped trace context,
Configure-time authoring errors 500 → 400, parallel-branch dictionary retyping, SubProcess correlationDbContextrace,FanOut:ItemCancelledcode leak. - Schema advances to 0.0.53; requires Aether 1.0.36.
vNext Runtime Platform Team Released August 24, 2026
