Skip to main content

Release v0.0.80

Β· 14 min read
vNext Team
Burgan Tech Engineering

Overview​

This release makes a client request observable from end to end and finishes the Busy-as-mutex work started in v0.0.79. Armed timers are now visible to clients β€” the state response carries a scheduledTransitions list built from active scheduled-transition jobs, each with a persisted UTC executeAtUtc taken from the new InstanceJob.ExecuteAt column, and the scheduled job set folds into the fingerprint ETag so a parked long-poller no longer misses a re-armed timer (response shape v6) (#876). Correlation becomes a four-carrier contract β€” W3C trace context, X-Request-Id, X-Correlation-Id and X-Workflow-Instance-Id each do exactly one job, propagate across the orchestration β†’ execution β†’ outbound-task boundary, and land in Elastic under stable label names, so one query returns a whole flow (#879, #882). Busy-as-mutex is refined in three places: a $self transition no longer re-runs the state's lifecycle, the accept path takes one lock instead of two, and an async accept on a parent holding an open SubFlow correlation now reserves the whole chain down to the leaf before answering 202 (#883, #884). This release runs on component schema 0.0.52.


Features​

Scheduled transitions in the state response (#876)​

An instance parked in a state with armed timers gave clients no way to learn when those timers would fire. The state (long-poll) response now carries a scheduledTransitions array:

{
"scheduledTransitions": [
{ "name": "escalate-to-supervisor", "kind": "scheduled", "executeAtUtc": "2026-08-17T14:32:05Z" }
]
}
  • Entries are populated from active scheduled-transition jobs and ordered by execution time.
  • executeAtUtc is the exact UTC instant the scheduler was armed for. It is persisted on a new InstanceJob.ExecuteAt column (migration AddInstanceJobExecuteAt) rather than recomputed at read time, so the value a client sees is the value the scheduler holds. TimerSchedule.ResolveExecuteAt computes it deterministically from a single clock reference and is reused by both the workflow-timeout and state-transition scheduling paths.
  • A scheduled entry is not an invitation to call anything β€” execution stays System-actor gated.
  • InstanceStateFingerprint and its projection now aggregate over active scheduled-transition jobs (count plus newest CreatedAt), and those aggregates go into both the ETag material and the state-function cache key. Arming, cancelling or re-arming a timer therefore moves the ETag instead of leaving a long-poller on a stale 304.
  • The state-function cache response shape version advances to v6, so cached bodies from the previous shape are not served.

Reference: PR #876 β€” see also Built-in Functions β†’ State and Event-driven workflows.

End-to-end correlation and X-Request-Id tracing (#879, #882)​

Two problems were solved with one contract. Async processing runs every transition as a Dapr job, and a job callback is a fresh HTTP request β€” so an APM trace fell apart at that hop and orchestration, the job, execution and the remote task each became their own tree. Separately, function calls made before an instance exists (a start request) could not be queried at all, because there was no instance id yet to query by.

After this change one client request is one trace tree, and every log line in Orchestration, Execution, Inbox and Outbox carries x_request_id. Four carriers, four jobs β€” keeping them apart is the point:

CarrierWireFieldScope
W3C trace contexttraceparent / tracestateβ€”The trace tree
Request idX-Request-Idx_request_idOne client request, across all services
Business correlationX-Correlation-Idcorrelation.idStable for a whole workflow chain
Instance idX-Workflow-Instance-Idworkflow.instance.idThe instance, vendor-neutral

Mechanics worth knowing:

  • Immediate jobs continue the trace. BackgroundJobActivityHelper.StartActivityContinuingTrace() makes the payload's TraceParent the span's real parent for flow.transition and state.notify, and attaches the Dapr callback span as an ActivityLink (vnext.dapr.callback). Deferred jobs β€” timers, workflow timeout, long-poll ack β€” deliberately keep the old new-trace-plus-link shape: they fire minutes to days later and must not resurrect an expired trace.
  • Events restore the publisher's context. A new EventTraceScope on the Inbox side rebuilds the publishing context across the outbox β†’ pub/sub β†’ inbox hop, driven by ITraceableDistributedEvent, which HookedDistributedEventBus stamps centrally at publish time.
  • Pipeline step spans are created only in Verbose. The new PipelineStepActivityHelper establishes the rule: a span that Business mode would drop must never be created in the first place. Previously they were created always and dropped at export, which orphaned their children and pushed TaskCoordinator.Execute to the trace root.
  • Reserved headers. traceparent, tracestate, baggage, x-request-id, X-Correlation-Id and X-Workflow-Instance-Id cannot be set by a task binding (IsReservedTraceHeader) β€” a stale traceparent or a forged correlation copied into a definition would detach or spoof the chain. Workflow context is removed and re-stamped from trusted baggage by InvokerHelpers.ApplyTrustedCorrelationHeaders(), applied by every HTTP-shaped invoker (http, soap, daprservice, daprhttpendpoint, trigger). Identity claims sub / act_sub are fill-if-absent and deliberately not reserved: they are token-derived, but a binding may legitimately set them and that value wins.
  • Identity is normalized, not forwarded wholesale. Only sub and act.sub propagate; the complete act claim is never propagated or logged. Values are accepted only if they match A-Z a-z 0-9 _ -, are at most 128 characters, and come from the vNext trace context rather than a task mapping header.
  • Correlation fallback is the current W3C trace id when correlation baggage is unavailable, with an opaque generated GUID as the last resort. Instance ids are emitted as canonical lowercase UUIDs; correlation ids in the non-zero 32-character N format.

In Elastic the attributes land as:

OpenTelemetry attributeElastic field
workflow.instance.idlabels.workflow_instance_id
correlation.idlabels.correlation_id
sublabels.sub
act.sublabels.act_sub

The W3C trace identifier stays available as the native trace.id. Useful Kibana Discover queries:

labels.workflow_instance_id : "<workflow-instance-id>"

labels.correlation_id : "<correlation-id>"
and service.name : ("vnext-app-<domain>" or "vnext-execution-app-<domain>")

trace.id : "<trace-id>"

Two limits are deliberate: system-triggered jobs (timer transitions, workflow timeout, long-poll ack) are separate traces by design and carry no request headers β€” correlate them by vnext.instance.id / correlation.id or follow the ActivityLink; and the Outbox publish loop runs in a BackgroundService with no request context, so its own publish lines carry no request id, though the id still travels inside the event and reappears on the Inbox side.

Reference: PRs #879, #882 β€” see also Observability and Telemetry configuration.

Busy-as-mutex refinements β€” $self, one lock per accept, subflow chain reserve (#883, #884)​

Three corrections to the execution model introduced in v0.0.79.

1. A $self transition no longer re-runs the state's lifecycle. A transition targeting $self changes no state, but it still ran the current state's OnExit, the target state's OnEntry a second time, and cancelled and re-armed the state's scheduled transitions β€” silently restarting every timeout from zero. Most visible on updateData, whose target is fixed to $self by WorkflowValidator.

The fix is a self-target profile composed on top of the trigger's profile (Manual+Self, AutoChain+Self, …), not a sixth profile you select instead. It adds four exclusions:

Excluded stepWhy
CancelScheduledJobs (39)The state isn't left; tearing its timers down loses them
OnExit (40)No state is left
OnEntry (60)No state is entered; hooks already ran on first arrival
ScheduleRe-arming restarts every timeout

ChangeState (50) and OnExecute (30) are deliberately kept in. ChangeState is the only step that sets context.Target, which the auto-transition step reads β€” exclude it and the auto step returns at its first guard and the transition advances nothing. OnExecute is the transition's own work, not the state's lifecycle.

Two details matter for authors:

  • Only the authored $self keyword counts. A literal target that happens to equal the current state is not treated as $self, because target == currentState is a coincidence produced by three unrelated mechanisms and means "no state change" in only one of them: a start pre-positions a new instance into its initial state before dispatching the start transition (the state still needs entering); a retry after a partial commit finds the instance already committed in the target state (the retry exists to redo exactly that step); and a genuine self-loop from: A, target: A. An earlier revision using the literal comparison killed the initial state's OnEntry outright and turned retry into a no-op. Authors who want no-state-change semantics must write $self.
  • The profile applies to updateData only (#884). A $self shared transition was silently losing its state's OnExit/OnEntry and its timer re-arm; SkipsStateLifecycle() now composes the target check with the updateData check, so every other $self transition keeps the base profile and runs the full lifecycle.

2. One lock per accept. The Busy flag is the mutex, so a lock belongs only at the status check-and-set β€” but the async path took a second lock ({LockKey}:enqueue) and nested the status lock inside it, paying two Postgres-backed round trips per accept. ITransitionAdmissionService.AcceptAsync now takes a single lock on ctx.LockKey and runs the kind's status flip, the duplicate-active-job guard and the durable enqueue under it. The guard has to stay in that critical section: no DB constraint backs its check-then-insert, and a partial unique index cannot replace it, because a $self auto loop and a re-armed scheduled transition both legitimately hold two active rows for the same (InstanceId, JobType, SourceState, TransitionKey) tuple. cancel / exit / timeout stay exempt from the Busy 409 but now flip Busy at the accept rather than in the pipeline β€” so the moment a client observes Busy moves earlier.

3. The subflow chain is reserved at accept. An async transition accepted on a parent holding an open SubFlow correlation answered 202 without marking anything Busy. Because the state function reports the deepest active subflow, a client long-polling the parent still saw the leaf Active, concluded nothing was in progress, and stopped driving the flow. AsyncTransitionStrategy now calls ReserveSubflowChainAsync on the subflow-forward branch, marking the chain down to the leaf before the 202 commits; the relay then claims that reserve so the leaf does not reject it with Instance:100031 for the Busy the accept just set. The claim is threaded accept β†’ TransitionJobPayload.SubflowChainReserved β†’ ForwardToActiveSubflowStep β†’ ForwardToSubflowJob β†’ handler, and is deliberately narrower than IsPreReserved so a sync-origin or cancel/exit/timeout relay cannot barge past a leaf that is Busy for its own reasons. Cross-domain hops use a new internal-only POST .../internal/subflow-forward (with a matching busy-release) rather than the public transition endpoint, which copies caller headers unfiltered and would make the claim forgeable. The sync path deliberately does not chain-reserve: a blocking caller cannot observe a stale Active, so reserving would only widen stranded-Busy.

Reference: PRs #883, #884 β€” see also Transition pipeline and Workflow component.


Behavior Changes​

Two items in this release change observable behavior. A $self transition (in practice, updateData) no longer re-runs OnExit / OnEntry and no longer re-arms the state's scheduled transitions β€” domains relying on that refresh must move the logic into the transition's own onExecutionTasks, and domains relying on the timer reset must model the reset explicitly. And cancel / exit / timeout now flip Busy at accept time rather than in the pipeline, so the moment a client observes Busy moves earlier. Both items, their impact direction and the migration steps are documented in the v0.0.80 breaking changes announcement. Grep your domain definitions for "target": "$self" before upgrading.


Fixes​

  • A state change from a state to itself no longer reports as a change β€” ChangeStateStep stopped emitting its state-change metric, log and span event, and Instance.ChangeState stopped publishing sub:state-changed, when previous == new. Reporting a change from a state to itself was a false signal that reached dashboards and subflow parents alike (#883).
  • Three stale JobName.ForScheduledTransition call sites fixed β€” the Domain test project did not compile on master and took the Application tests down with it, so the suite was unbuildable (#883).
  • Compensation on a failed enqueue releases only what it flipped β€” the accept path's compensation no longer releases Busy an unrelated holder set (#884).

Configuration Updates​

Configuration for v0.0.80:

{
"runtimeVersion": "0.0.80",
"schemaVersion": "0.0.52"
}

Note: Schema version is unchanged at 0.0.52 β€” nothing in this release adds or changes an authored component field.

Aether 1.0.35 is required. sub / act_sub were surfacing as requestheader_act_sub, because Aether hard-coded a RequestHeader. prefix and backends that cannot store dots (OpenObserve, Elasticsearch) lowercase and flatten it. LoggingEnricherOptions.RequestHeaderKeyPrefix is now configurable and every host sets it to the empty string, so the fields land as sub, act_sub, jti, role, x_parent_instance_id, user_agent:

{ "Telemetry": { "Logging": { "Enrichers": { "RequestHeaderKeyPrefix": "" } } } }

The response prefix keeps its default, so request and response values cannot collapse onto one field. The setting does nothing on Aether 1.0.34.

Gateway (APISIX): the request-id plugin must send X-Request-Id and the opentelemetry plugin must send traceparent, or the client hop is missing from both the log query and the trace tree. X-Request-Id is deliberately kept out of Telemetry:Logging:Enrichers:Headers and Telemetry:Tracing:Headers: on platform-originated requests Aether's middleware generates an id from HttpContext.TraceIdentifier and writes it back into the request headers, and the enricher would then emit that fabricated value under the same key and suppress the real one. RequestIdLogProcessor and RequestIdSpanProcessor are the single source for the field.

Dashboards: enricher fields lose their RequestHeader. prefix (requestheader_act_sub β†’ act_sub); saved queries and dashboard panels need updating.

CI: NuGet publishing moved to trusted publishing β€” NuGet/login@v1 exchanges the job's OIDC token for a one-hour API key, and no long-lived publishing secret remains in the workflow.

Container images: published at tag 0.0.80 under ghcr.io/burgan-tech/vnext/*, Cosign-signed (keyless OIDC) with SBOM + provenance. Immutable digests are listed in the GitHub release.


Issues Referenced​

  • vnext #876 β€” Expose scheduled transitions with a persisted UTC execution time and fold the job set into the ETag.
  • vnext #879 β€” Propagate workflow correlation context across the remote task execution boundary.
  • vnext #882 β€” End-to-end correlation and distributed tracing across the platform.
  • vnext #883 β€” Skip state lifecycle steps for $self transitions.
  • vnext #884 β€” Reserve the subflow chain at accept with one lock, and scope the $self profile to updateData.

Summary​

  • scheduledTransitions in the state response: name, kind: "scheduled", persisted executeAtUtc, ordered by execution time, folded into the fingerprint ETag; response shape v6; migration AddInstanceJobExecuteAt.
  • Four correlation carriers β€” trace context, X-Request-Id, X-Correlation-Id, X-Workflow-Instance-Id β€” propagated through jobs, events and outbound tasks; reserved headers cannot be set by a task binding; identity claims are fill-if-absent.
  • One trace tree per client request; pipeline step spans are Verbose-only by construction.
  • $self skips the state lifecycle β€” only for updateData, and only for the authored keyword, never a literal same-state target.
  • One lock per accept; cancel / exit / timeout flip Busy at accept.
  • Subflow chain reserved at accept on the async forward path, with an internal-only subflow-forward / busy-release pair for cross-domain hops.
  • Requires Aether 1.0.35 and Telemetry:Logging:Enrichers:RequestHeaderKeyPrefix: ""; dashboards lose the RequestHeader. prefix.
  • Schema stays at 0.0.52.

vNext Runtime Platform Team Released August 17, 2026