Ana içeriğe geç

Release v0.0.85

· 20 dakikalık okuma
vNext Team
Burgan Tech Engineering

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:

PolicyBatch succeeds whenEmpty batch
allevery item succeededsucceeds
allSettledalways (per-item outcomes are collected)succeeds
quorumat least minSuccess items succeededfails
firstSuccessat least one item succeededfails

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:

CodeMeaning
FanOut:ItemTimeoutThe item exceeded its own itemTimeoutSeconds — takes precedence over the reasons below
FanOut:BatchTimeoutThe item was cut short because the batch hit batchTimeoutSeconds
FanOut:ItemCancelledThe item was cancelled by the join policy's early stop
FanOut:ItemNotStartedThe item was cancelled while still queued for a concurrency slot
FanOut:ItemFailedFallback: 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 most TtlSeconds.
  • 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=false or TtlSeconds<=0 bypasses 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:

FieldRole
TraceParentPredecessor — attached as an ActivityLink
TraceRootLane anchor — the actual parent
ParentTraceRootThe 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:

MetricBeforeAfter
BackgroundJob.Schedule inside a held lock59%0/59
Lock hold p507.8 ms2.55 ms
Lock hold worst30.1 s48 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.

RequestBeforeAfter
{"key":"K1"} (envelope, no payload)400 on key400 naming the missing payload fields
{"Attributes":{…}}400200
key=K1&tags[]=a (form)400correct
{"key":"K1","tags":[…]} on a schema-less transitionenvelope persisted as instance datakey 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.

Reference: PR #906 — see also REST API.

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.

beforeafter
Identity compile path (16 KB source)12.5 µs / 98.8 KB1.67 µs / 8.67 KB, flat
Compile calls per instance3323
COW parallel branch1.29 ms / 1.7 MB102 ns / 992 B
Canonicalize + merge−66% time, −41% alloc
Cold start1.62 s1.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 to false: the row was persisted Pending and 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 passed directly: 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 resolve dapr-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's DiagnosticsHandler, so a traceparent must be passed as component metadata. DaprBindingTaskInvoker did 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 with mode: "durable", an HttpTask missing url, SubProcessTask, GetInstancesTask, …) surfaced an opaque internal error and discarded a message that already named the offending value and the supported one. ComponentValidatorProcessor now maps ArgumentException into the component-validation result; genuine infrastructure faults still surface as 500 (#905).
  • ScriptContext.CreateParallelBranch() silently retyped the transport dictionaries — it cloned Headers / RouteValues / QueryParameters through a JSON round trip, turning a Dictionary<string,string> into an ExpandoObject. The C# runtime binder cannot see ExpandoObject's explicitly-implemented ContainsKey, so the pervasive context.Headers.ContainsKey(...) idiom threw RuntimeBinderException on any branch context. This also affected any existing workflow with two tasks at the same order whose 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 from InstanceDataWriteService is extracted to InstanceWriteGate and shared by both writers (#905).
  • FanOut:ItemCancelled leaked a raw exception code — an item cancelled while already inside the inner task surfaced Task:Unknown:{taskKey}:TaskCanceledException instead 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).
  • ComponentGenerationProvider fabricated 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) to task-definition.schema.json (vnext-schema #135). Until you update @burgan-tech/vnext-schema in your domain project, npm run validate rejects 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): itemsPath XOR ItemSelector, four join policies with a deliberate empty-batch asymmetry, two-level concurrency bulkhead, IFanOutMapping, one write per batch, stable FanOut:* 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 / stage reserved; schema errors always name an instance path.
  • Script compiler and ScriptContext measured and optimized; opt-in PreserveNumericPrecision, LegacyAppendPipeline kill-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 correlation DbContext race, FanOut:ItemCancelled code leak.
  • Schema advances to 0.0.53; requires Aether 1.0.36.

vNext Runtime Platform Team Released August 24, 2026