From d1e40fe0d05cee1b3732d96b7151d52c01e2fb8c Mon Sep 17 00:00:00 2001 From: Nelson Parente Date: Wed, 20 May 2026 22:22:05 +0100 Subject: [PATCH 01/12] feat(workflow): align history propagation API with go-sdk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cassie (durabletask-go author) flagged the .NET surface for cross-SDK divergence post-merge of dotnet-sdk#1802 / #1818. This rewrites the public history-propagation API to match the go-sdk shape — same one the python-sdk just adopted (python-sdk#1047). Issue dotnet-sdk#1801 was closed before her review; this PR delivers what the issue originally described. Three concrete gaps closed: 1. Activity-level opt-in (was missing entirely) - PropagationScope moved from ChildWorkflowTaskOptions to base WorkflowTaskOptions; ChildWorkflowTaskOptions inherits it. - WithHistoryPropagation() extension method added on the base record. - scheduleTaskAction.HistoryPropagationScope is now wired in WorkflowOrchestrationContext.CallActivityInternalAsync so activities can opt into propagation, matching CallChildWorkflowInternalAsync. - Without this, the Go SDK's reference example (SettlePayment activity using PropagateOwnHistory) literally cannot be ported to .NET. 2. Read API rewritten as high-level resolvers (was lossy FilterBy* + a PropagatedHistoryEvent record that dropped input/output/failure payloads) - PropagatedHistory.FilterByAppId/InstanceId/WorkflowName removed. - PropagatedHistory now exposes GetWorkflows(), GetWorkflowsByName(), GetLastWorkflowByName(), GetAppIds(), GetWorkflowsByAppId(), GetWorkflowsByInstanceId(). - New WorkflowResult class with InstanceId/AppId/Name plus GetActivitiesByName(), GetLastActivityByName(), GetChildWorkflowsByName(), GetLastChildWorkflowByName() — mirrors durabletask-go's GetLastWorkflowByName / GetLastActivityByName / GetLastChildWorkflowByName renames from durabletask-go#105. - New ActivityResult record carries Name, Started, Completed, Failed, Input, Output, FailureDetails — matching the Go/Python equivalents so chain-of-custody patterns line up. - New ChildWorkflowResult record with the equivalent shape. 3. Event payload preserved internally (was discarded by ConvertChunk) - ConvertChunk in WorkflowOrchestrationContext now parses raw events, walks them to resolve TaskScheduled <-> TaskCompleted/Failed and ChildWorkflowInstanceCreated <-> ChildWorkflowInstanceCompleted/ Failed by scheduleId, and produces fully-populated ActivityResult / ChildWorkflowResult instances. SDK retries reuse TaskExecutionId so matching is on scheduleId (matching Go/Python semantics). - Public API does not leak the proto HistoryEvent type — resolution happens at construction time inside Dapr.Workflow. Additional surface additions: - PropagationNotFoundException for missing-name lookups (mirrors Python's PropagationNotFoundError / Go's error returns). - Static WorkflowHistory.PropagateLineage() / PropagateOwnHistory() factory helpers for go-sdk call-site parity. Removed (clean break — 1.18 unreleased): PropagatedHistoryEntry, PropagatedHistoryEvent, HistoryEventKind, FilterByAppId, FilterByInstanceId, FilterByWorkflowName. Tests: - WorkflowHistoryPropagationTests.cs rewritten end-to-end to cover the new resolvers, query helpers, factory helpers, activity-level scope wiring, and child-workflow-level scope wiring. - HistoryPropagationWorkflowTests.cs (integration) updated to use GetWorkflows().Count in place of Entries.Count. Refs: dapr/dotnet-sdk#1801, dapr/durabletask-go#105, dapr/go-sdk#823, dapr/python-sdk#1047 Signed-off-by: Nelson Parente --- .../ActivityResult.cs | 38 ++ .../ChildWorkflowResult.cs | 34 ++ .../PropagationNotFoundException.cs} | 23 +- .../HistoryEventKind.cs | 115 ----- .../PropagatedHistory.cs | 116 +++-- .../PropagatedHistoryEntry.cs | 29 -- .../WorkflowContext.cs | 7 +- .../WorkflowHistory.cs | 46 ++ .../WorkflowResult.cs | 134 ++++++ .../WorkflowTaskOptions.cs | 25 +- .../Internal/WorkflowOrchestrationContext.cs | 185 +++++--- .../HistoryPropagationWorkflowTests.cs | 2 +- .../WorkflowHistoryPropagationTests.cs | 405 +++++++++++++----- 13 files changed, 803 insertions(+), 356 deletions(-) create mode 100644 src/Dapr.Workflow.Abstractions/ActivityResult.cs create mode 100644 src/Dapr.Workflow.Abstractions/ChildWorkflowResult.cs rename src/Dapr.Workflow.Abstractions/{PropagatedHistoryEvent.cs => Exceptions/PropagationNotFoundException.cs} (50%) delete mode 100644 src/Dapr.Workflow.Abstractions/HistoryEventKind.cs delete mode 100644 src/Dapr.Workflow.Abstractions/PropagatedHistoryEntry.cs create mode 100644 src/Dapr.Workflow.Abstractions/WorkflowHistory.cs create mode 100644 src/Dapr.Workflow.Abstractions/WorkflowResult.cs diff --git a/src/Dapr.Workflow.Abstractions/ActivityResult.cs b/src/Dapr.Workflow.Abstractions/ActivityResult.cs new file mode 100644 index 000000000..d0109d758 --- /dev/null +++ b/src/Dapr.Workflow.Abstractions/ActivityResult.cs @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow; + +/// +/// A reconstructed view of a single activity invocation from propagated history. +/// +/// The scheduled name of the activity. +/// Whether the activity was scheduled in the propagated chunk. +/// Whether the activity completed successfully. +/// Whether the activity failed. +/// The JSON-encoded input payload, or null when unset. +/// The JSON-encoded output payload, or null when the activity has not completed. +/// The failure details when is true, otherwise null. +/// +/// Mirrors the ActivityResult struct in the Go SDK and the +/// ActivityResult dataclass in the Python SDK so cross-language +/// quickstarts and chain-of-custody patterns line up. +/// +public sealed record ActivityResult( + string Name, + bool Started, + bool Completed, + bool Failed, + string? Input, + string? Output, + WorkflowTaskFailureDetails? FailureDetails); diff --git a/src/Dapr.Workflow.Abstractions/ChildWorkflowResult.cs b/src/Dapr.Workflow.Abstractions/ChildWorkflowResult.cs new file mode 100644 index 000000000..a67b14035 --- /dev/null +++ b/src/Dapr.Workflow.Abstractions/ChildWorkflowResult.cs @@ -0,0 +1,34 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow; + +/// +/// A reconstructed view of a single child workflow invocation from propagated history. +/// +/// The scheduled name of the child workflow. +/// Whether the child workflow was scheduled in the propagated chunk. +/// Whether the child workflow completed successfully. +/// Whether the child workflow failed. +/// The JSON-encoded output payload, or null when the child workflow has not completed. +/// The failure details when is true, otherwise null. +/// +/// Mirrors the ChildWorkflowResult type in the Go and Python SDKs. +/// +public sealed record ChildWorkflowResult( + string Name, + bool Started, + bool Completed, + bool Failed, + string? Output, + WorkflowTaskFailureDetails? FailureDetails); diff --git a/src/Dapr.Workflow.Abstractions/PropagatedHistoryEvent.cs b/src/Dapr.Workflow.Abstractions/Exceptions/PropagationNotFoundException.cs similarity index 50% rename from src/Dapr.Workflow.Abstractions/PropagatedHistoryEvent.cs rename to src/Dapr.Workflow.Abstractions/Exceptions/PropagationNotFoundException.cs index 44e700c94..c723d58dc 100644 --- a/src/Dapr.Workflow.Abstractions/PropagatedHistoryEvent.cs +++ b/src/Dapr.Workflow.Abstractions/Exceptions/PropagationNotFoundException.cs @@ -16,9 +16,22 @@ namespace Dapr.Workflow; using System; /// -/// Represents a single event in a propagated workflow history segment. +/// Thrown when a query against propagated workflow history finds no match. /// -/// The unique event ID within the workflow instance history. -/// The kind of history event. -/// The UTC timestamp when the event occurred. -public sealed record PropagatedHistoryEvent(int EventId, HistoryEventKind Kind, DateTimeOffset Timestamp); +/// +/// Raised by , +/// , and +/// when the requested +/// name is not present in the propagated history chain. Use the plural +/// Get*sByName variants if you want an empty-list result instead. +/// +public sealed class PropagationNotFoundException : Exception +{ + /// + /// Initializes a new instance of . + /// + /// The exception message. + public PropagationNotFoundException(string message) : base(message) + { + } +} diff --git a/src/Dapr.Workflow.Abstractions/HistoryEventKind.cs b/src/Dapr.Workflow.Abstractions/HistoryEventKind.cs deleted file mode 100644 index ea31ed07f..000000000 --- a/src/Dapr.Workflow.Abstractions/HistoryEventKind.cs +++ /dev/null @@ -1,115 +0,0 @@ -// ------------------------------------------------------------------------ -// Copyright 2026 The Dapr Authors -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ------------------------------------------------------------------------ - -namespace Dapr.Workflow; - -/// -/// Identifies the kind of a workflow history event returned in propagated history. -/// -public enum HistoryEventKind -{ - /// - /// Unknown or unsupported event type. - /// - Unknown = 0, - - /// - /// The workflow execution started. - /// - ExecutionStarted, - - /// - /// The workflow execution completed. - /// - ExecutionCompleted, - - /// - /// The workflow execution was terminated. - /// - ExecutionTerminated, - - /// - /// An activity task was scheduled. - /// - TaskScheduled, - - /// - /// An activity task completed successfully. - /// - TaskCompleted, - - /// - /// An activity task failed. - /// - TaskFailed, - - /// - /// A child workflow instance was created. - /// - SubOrchestrationInstanceCreated, - - /// - /// A child workflow instance completed successfully. - /// - SubOrchestrationInstanceCompleted, - - /// - /// A child workflow instance failed. - /// - SubOrchestrationInstanceFailed, - - /// - /// A durable timer was created. - /// - TimerCreated, - - /// - /// A durable timer fired. - /// - TimerFired, - - /// - /// The orchestrator started a processing turn. - /// - OrchestratorStarted, - - /// - /// The orchestrator completed a processing turn. - /// - OrchestratorCompleted, - - /// - /// An event was sent to another workflow instance. - /// - EventSent, - - /// - /// An external event was raised for this workflow instance. - /// - EventRaised, - - /// - /// The workflow continued as new. - /// - ContinueAsNew, - - /// - /// The workflow execution was suspended. - /// - ExecutionSuspended, - - /// - /// The workflow execution was resumed. - /// - ExecutionResumed -} diff --git a/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs index eda643b7d..64910f7e8 100644 --- a/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs +++ b/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs @@ -18,68 +18,112 @@ namespace Dapr.Workflow; using System.Linq; /// -/// Contains the workflow history that was propagated from ancestor workflow instances. -/// Each entry corresponds to a single ancestor's history. +/// Workflow history propagated from a parent workflow to a child workflow or activity. /// /// -/// A workflow receives propagated history when it is scheduled with a -/// other than . -/// Use to retrieve the propagated history -/// inside a workflow implementation. +/// A propagated history is composed of one or more chunks, each owned by a distinct +/// workflow instance. Chunks preserve execution order: index 0 is the oldest ancestor, +/// the last chunk is the immediate parent. +/// +/// Use the Get* methods to walk the chain by app, instance, or workflow name. +/// Mirrors the PropagatedHistory type in the Go and Python SDKs. +/// /// public sealed class PropagatedHistory { - private readonly IReadOnlyList _entries; + private readonly IReadOnlyList _workflows; /// - /// Initializes a new instance of with the given entries. + /// Initializes a new from the given workflow chunks. /// - /// The propagated history entries from ancestor workflows. - public PropagatedHistory(IReadOnlyList entries) + /// + /// Workflow chunks in execution order (ancestor first, immediate parent last). + /// + public PropagatedHistory(IReadOnlyList workflows) { - _entries = entries ?? throw new ArgumentNullException(nameof(entries)); + _workflows = workflows ?? throw new ArgumentNullException(nameof(workflows)); } /// - /// Gets the ordered list of propagated history entries. - /// The first entry corresponds to the immediate parent workflow; subsequent entries - /// correspond to progressively older ancestors when is used. + /// Returns every workflow chunk in the chain, in execution order + /// (ancestor first, immediate parent last). /// - public IReadOnlyList Entries => _entries; + public IReadOnlyList GetWorkflows() => _workflows; /// - /// Returns a new containing only entries from the specified App ID. + /// Returns an ordered, deduplicated list of app IDs in the propagated chain. /// - /// The Dapr App ID to filter by. - /// A filtered instance. - public PropagatedHistory FilterByAppId(string appId) + public IReadOnlyList GetAppIds() { - ArgumentException.ThrowIfNullOrWhiteSpace(appId); - return new PropagatedHistory( - _entries.Where(e => string.Equals(e.AppId, appId, StringComparison.OrdinalIgnoreCase)).ToList()); + var seen = new HashSet(StringComparer.Ordinal); + var result = new List(_workflows.Count); + foreach (var workflow in _workflows) + { + if (seen.Add(workflow.AppId)) + { + result.Add(workflow.AppId); + } + } + + return result; } /// - /// Returns a new containing only the entry with the specified instance ID. + /// Returns every workflow whose name matches, in execution order. Useful when the + /// chain contains the same name more than once (e.g. recursion or ContinueAsNew). /// - /// The workflow instance ID to filter by. - /// A filtered instance. - public PropagatedHistory FilterByInstanceId(string instanceId) + /// The workflow name to filter by. + /// An empty list when no match is found. + public IReadOnlyList GetWorkflowsByName(string name) { - ArgumentException.ThrowIfNullOrWhiteSpace(instanceId); - return new PropagatedHistory( - _entries.Where(e => string.Equals(e.InstanceId, instanceId, StringComparison.Ordinal)).ToList()); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return _workflows + .Where(w => string.Equals(w.Name, name, StringComparison.Ordinal)) + .ToList(); + } + + /// + /// Returns the most recent workflow in the chain whose name matches. + /// + /// The workflow name to look up. + /// The last matching workflow chunk. + /// No workflow with the given name is present in the chain. + public WorkflowResult GetLastWorkflowByName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + var matches = GetWorkflowsByName(name); + if (matches.Count == 0) + { + throw new PropagationNotFoundException($"no workflow named '{name}' in propagated history"); + } + + return matches[^1]; + } + + /// + /// Returns every workflow chunk produced by the given app, in execution order. + /// + /// The Dapr App ID to filter by. + /// An empty list when no match is found. + public IReadOnlyList GetWorkflowsByAppId(string appId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(appId); + return _workflows + .Where(w => string.Equals(w.AppId, appId, StringComparison.Ordinal)) + .ToList(); } /// - /// Returns a new containing only entries for the specified workflow name. + /// Returns every workflow chunk produced by the given instance, in execution order. + /// Usually a single entry, except when the same instance reappears via ContinueAsNew. /// - /// The workflow name to filter by. - /// A filtered instance. - public PropagatedHistory FilterByWorkflowName(string workflowName) + /// The workflow instance ID to filter by. + /// An empty list when no match is found. + public IReadOnlyList GetWorkflowsByInstanceId(string instanceId) { - ArgumentException.ThrowIfNullOrWhiteSpace(workflowName); - return new PropagatedHistory( - _entries.Where(e => string.Equals(e.WorkflowName, workflowName, StringComparison.Ordinal)).ToList()); + ArgumentException.ThrowIfNullOrWhiteSpace(instanceId); + return _workflows + .Where(w => string.Equals(w.InstanceId, instanceId, StringComparison.Ordinal)) + .ToList(); } } diff --git a/src/Dapr.Workflow.Abstractions/PropagatedHistoryEntry.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistoryEntry.cs deleted file mode 100644 index 427f1b26f..000000000 --- a/src/Dapr.Workflow.Abstractions/PropagatedHistoryEntry.cs +++ /dev/null @@ -1,29 +0,0 @@ -// ------------------------------------------------------------------------ -// Copyright 2026 The Dapr Authors -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ------------------------------------------------------------------------ - -namespace Dapr.Workflow; - -using System.Collections.Generic; - -/// -/// Represents a segment of propagated workflow history originating from a single ancestor workflow instance. -/// -/// The Dapr App ID of the application that ran the ancestor workflow. -/// The instance ID of the ancestor workflow. -/// The name of the ancestor workflow. -/// The ordered list of history events from the ancestor workflow. -public sealed record PropagatedHistoryEntry( - string AppId, - string InstanceId, - string WorkflowName, - IReadOnlyList Events); diff --git a/src/Dapr.Workflow.Abstractions/WorkflowContext.cs b/src/Dapr.Workflow.Abstractions/WorkflowContext.cs index de355eaf5..cd8bbac54 100644 --- a/src/Dapr.Workflow.Abstractions/WorkflowContext.cs +++ b/src/Dapr.Workflow.Abstractions/WorkflowContext.cs @@ -341,15 +341,16 @@ public virtual Task CallChildWorkflowAsync( /// specified a other than . /// /// - /// Use , , - /// or to narrow down the returned entries. + /// Use and + /// / + /// to query specific items from the chain. The plural Get*sByName variants return every match. /// /// /// This method always returns the same value regardless of whether the workflow is replaying. /// /// /// - /// A containing entries from ancestor workflows, + /// A containing the chain of ancestor workflows, /// or null if no history was propagated to this workflow instance. /// public abstract PropagatedHistory? GetPropagatedHistory(); diff --git a/src/Dapr.Workflow.Abstractions/WorkflowHistory.cs b/src/Dapr.Workflow.Abstractions/WorkflowHistory.cs new file mode 100644 index 000000000..78d1597e3 --- /dev/null +++ b/src/Dapr.Workflow.Abstractions/WorkflowHistory.cs @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow; + +/// +/// Scheduling-side helpers for workflow history propagation, mirroring the +/// workflow.PropagateLineage() / workflow.PropagateOwnHistory() +/// factories in the Go SDK. +/// +/// +/// Both forms are equivalent: options.WithHistoryPropagation(WorkflowHistory.PropagateLineage()) +/// and options.WithHistoryPropagation(HistoryPropagationScope.Lineage) produce the same scope. +/// The factory helpers exist for cross-SDK call-site parity. +/// +public static class WorkflowHistory +{ + /// + /// Returns the that propagates the caller's own + /// events plus any ancestor events it received. + /// + /// + /// Use for chain-of-custody verification where downstream code needs visibility into + /// the full lineage of upstream workflows. + /// + public static HistoryPropagationScope PropagateLineage() => HistoryPropagationScope.Lineage; + + /// + /// Returns the that propagates the caller's events + /// only; ancestor history is dropped. + /// + /// + /// Use as a trust boundary, where downstream code should only see the immediate caller. + /// + public static HistoryPropagationScope PropagateOwnHistory() => HistoryPropagationScope.OwnHistory; +} diff --git a/src/Dapr.Workflow.Abstractions/WorkflowResult.cs b/src/Dapr.Workflow.Abstractions/WorkflowResult.cs new file mode 100644 index 000000000..74f9728db --- /dev/null +++ b/src/Dapr.Workflow.Abstractions/WorkflowResult.cs @@ -0,0 +1,134 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow; + +using System; +using System.Collections.Generic; +using System.Linq; + +/// +/// A scoped view of a single workflow's chunk in propagated history. +/// +/// +/// Mirrors the WorkflowResult type in the Go SDK. Use +/// / +/// to query specific items inside this chunk; the plural Get*sByName +/// variants return every occurrence in execution order. +/// +public sealed class WorkflowResult +{ + private readonly IReadOnlyList _activities; + private readonly IReadOnlyList _childWorkflows; + + /// + /// Initializes a new . + /// + /// The instance ID of the ancestor workflow. + /// The Dapr App ID that ran the ancestor workflow. + /// The name of the ancestor workflow. + /// Activities resolved from this chunk, in execution order. + /// Child workflows resolved from this chunk, in execution order. + public WorkflowResult( + string instanceId, + string appId, + string name, + IReadOnlyList activities, + IReadOnlyList childWorkflows) + { + InstanceId = instanceId ?? throw new ArgumentNullException(nameof(instanceId)); + AppId = appId ?? throw new ArgumentNullException(nameof(appId)); + Name = name ?? throw new ArgumentNullException(nameof(name)); + _activities = activities ?? throw new ArgumentNullException(nameof(activities)); + _childWorkflows = childWorkflows ?? throw new ArgumentNullException(nameof(childWorkflows)); + } + + /// The instance ID of this workflow chunk's ancestor. + public string InstanceId { get; } + + /// The Dapr App ID that ran this workflow chunk. + public string AppId { get; } + + /// The name of this workflow. + public string Name { get; } + + /// All activities executed in this chunk, in execution order. + public IReadOnlyList Activities => _activities; + + /// All child workflows started in this chunk, in execution order. + public IReadOnlyList ChildWorkflows => _childWorkflows; + + /// + /// Returns every activity in this chunk whose scheduled name matches, in execution order. + /// + /// The activity name to filter by. + /// An empty list when no match is found. + public IReadOnlyList GetActivitiesByName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return _activities + .Where(a => string.Equals(a.Name, name, StringComparison.Ordinal)) + .ToList(); + } + + /// + /// Returns the most recent activity in this chunk whose name matches. + /// + /// The activity name to look up. + /// The last matching activity. + /// No activity with the given name is present in this chunk. + public ActivityResult GetLastActivityByName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + var matches = GetActivitiesByName(name); + if (matches.Count == 0) + { + throw new PropagationNotFoundException( + $"no activity named '{name}' in propagated history for workflow '{Name}'"); + } + + return matches[^1]; + } + + /// + /// Returns every child workflow in this chunk whose name matches, in execution order. + /// + /// The child workflow name to filter by. + /// An empty list when no match is found. + public IReadOnlyList GetChildWorkflowsByName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return _childWorkflows + .Where(c => string.Equals(c.Name, name, StringComparison.Ordinal)) + .ToList(); + } + + /// + /// Returns the most recent child workflow in this chunk whose name matches. + /// + /// The child workflow name to look up. + /// The last matching child workflow. + /// No child workflow with the given name is present in this chunk. + public ChildWorkflowResult GetLastChildWorkflowByName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + var matches = GetChildWorkflowsByName(name); + if (matches.Count == 0) + { + throw new PropagationNotFoundException( + $"no child workflow named '{name}' in propagated history for workflow '{Name}'"); + } + + return matches[^1]; + } +} diff --git a/src/Dapr.Workflow.Abstractions/WorkflowTaskOptions.cs b/src/Dapr.Workflow.Abstractions/WorkflowTaskOptions.cs index 86b166912..b24c710dc 100644 --- a/src/Dapr.Workflow.Abstractions/WorkflowTaskOptions.cs +++ b/src/Dapr.Workflow.Abstractions/WorkflowTaskOptions.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------ +// ------------------------------------------------------------------------ // Copyright 2023 The Dapr Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -18,7 +18,23 @@ namespace Dapr.Workflow; /// /// The workflow retry policy. /// The App ID indicating the app in which to find the named activity to run. -public record WorkflowTaskOptions(WorkflowRetryPolicy? RetryPolicy = null, string? TargetAppId = null); +/// +/// Determines which ancestor history events are propagated to the scheduled activity or child workflow. +/// Defaults to , meaning no history is propagated. +/// +public record WorkflowTaskOptions( + WorkflowRetryPolicy? RetryPolicy = null, + string? TargetAppId = null, + HistoryPropagationScope PropagationScope = HistoryPropagationScope.None) +{ + /// + /// Returns a new with the specified history propagation scope. + /// + /// The propagation scope to apply. + /// A new options instance with the propagation scope set. + public WorkflowTaskOptions WithHistoryPropagation(HistoryPropagationScope scope) => + this with { PropagationScope = scope }; +} /// /// Options for controlling the behavior of child workflow execution. @@ -34,13 +50,14 @@ public record ChildWorkflowTaskOptions( string? InstanceId = null, WorkflowRetryPolicy? RetryPolicy = null, string? TargetAppId = null, - HistoryPropagationScope PropagationScope = HistoryPropagationScope.None) : WorkflowTaskOptions(RetryPolicy, TargetAppId) + HistoryPropagationScope PropagationScope = HistoryPropagationScope.None) + : WorkflowTaskOptions(RetryPolicy, TargetAppId, PropagationScope) { /// /// Returns a new with the specified history propagation scope. /// /// The propagation scope to apply. /// A new options instance with the propagation scope set. - public ChildWorkflowTaskOptions WithHistoryPropagation(HistoryPropagationScope scope) => + public new ChildWorkflowTaskOptions WithHistoryPropagation(HistoryPropagationScope scope) => this with { PropagationScope = scope }; } diff --git a/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs b/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs index cdbfbe3ab..442754f51 100644 --- a/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs +++ b/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs @@ -170,16 +170,29 @@ private async Task CallActivityInternalAsync(string name, object? input, W return await HandleHistoryMatch(name, earlyCompletion, taskId); } + var scheduleTaskAction = new ScheduleTaskAction + { + Name = name, + Input = _workflowSerializer.Serialize(input), + Router = router, + TaskExecutionId = taskExecutionId + }; + + var activityPropagationScope = options?.PropagationScope ?? HistoryPropagationScope.None; + if (activityPropagationScope != HistoryPropagationScope.None) + { + scheduleTaskAction.HistoryPropagationScope = activityPropagationScope switch + { + HistoryPropagationScope.OwnHistory => Dapr.DurableTask.Protobuf.HistoryPropagationScope.OwnHistory, + HistoryPropagationScope.Lineage => Dapr.DurableTask.Protobuf.HistoryPropagationScope.Lineage, + _ => Dapr.DurableTask.Protobuf.HistoryPropagationScope.None + }; + } + _pendingActions.Add(taskId, new WorkflowAction { Id = taskId, - ScheduleTask = new ScheduleTaskAction - { - Name = name, - Input = _workflowSerializer.Serialize(input), - Router = router, - TaskExecutionId = taskExecutionId - }, + ScheduleTask = scheduleTaskAction, Router = router }); @@ -989,72 +1002,144 @@ private static WorkflowTaskFailedException CreateTaskFailedException(TaskFailedE } /// - /// Converts a proto message to a domain . + /// Converts a proto message into a public-facing + /// by resolving each scheduled activity and child workflow + /// against its matching completion/failure event. /// /// - /// Each entry is the deterministic byte representation - /// of a single ; we parse the bytes here on demand rather than re-marshalling. - /// Malformed event bytes are skipped (mapped to nothing) so a single bad event cannot crash the workflow. + /// Each entry is the deterministic byte + /// representation of a single ; we parse the bytes here. + /// SDK retries reuse TaskExecutionId, so we match completions on + /// TaskScheduledId (the scheduling event ID) rather than execution ID. + /// Malformed event bytes are skipped — they cannot crash the workflow. /// - private static PropagatedHistoryEntry ConvertChunk(PropagatedHistoryChunk chunk) + private static WorkflowResult ConvertChunk(PropagatedHistoryChunk chunk) { - var events = new List(chunk.RawEvents.Count); + var events = new List(chunk.RawEvents.Count); foreach (var rawEvent in chunk.RawEvents) { - HistoryEvent? historyEvent; try { - historyEvent = HistoryEvent.Parser.ParseFrom(rawEvent); + events.Add(HistoryEvent.Parser.ParseFrom(rawEvent)); } catch (InvalidProtocolBufferException) { - continue; + // Skip malformed events; a single bad event cannot poison the chunk. } + } - events.Add(new PropagatedHistoryEvent( - historyEvent.EventId, - MapEventKind(historyEvent), - MapTimestamp(historyEvent.Timestamp))); + var activities = new List(); + var childWorkflows = new List(); + foreach (var historyEvent in events) + { + switch (historyEvent.EventTypeCase) + { + case HistoryEvent.EventTypeOneofCase.TaskScheduled: + activities.Add(ResolveActivity(events, historyEvent)); + break; + case HistoryEvent.EventTypeOneofCase.ChildWorkflowInstanceCreated: + childWorkflows.Add(ResolveChildWorkflow(events, historyEvent)); + break; + } } - return new PropagatedHistoryEntry( - chunk.AppId, + return new WorkflowResult( chunk.InstanceId, + chunk.AppId, chunk.WorkflowName, - events); + activities, + childWorkflows); } /// - /// Maps a proto to a . + /// Builds an by matching TaskCompleted / TaskFailed + /// against the scheduling event's EventId. /// - private static HistoryEventKind MapEventKind(HistoryEvent e) => e.EventTypeCase switch + private static ActivityResult ResolveActivity( + IReadOnlyList events, + HistoryEvent scheduleEvent) { - HistoryEvent.EventTypeOneofCase.ExecutionStarted => HistoryEventKind.ExecutionStarted, - HistoryEvent.EventTypeOneofCase.ExecutionCompleted => HistoryEventKind.ExecutionCompleted, - HistoryEvent.EventTypeOneofCase.ExecutionTerminated => HistoryEventKind.ExecutionTerminated, - HistoryEvent.EventTypeOneofCase.TaskScheduled => HistoryEventKind.TaskScheduled, - HistoryEvent.EventTypeOneofCase.TaskCompleted => HistoryEventKind.TaskCompleted, - HistoryEvent.EventTypeOneofCase.TaskFailed => HistoryEventKind.TaskFailed, - HistoryEvent.EventTypeOneofCase.ChildWorkflowInstanceCreated => HistoryEventKind.SubOrchestrationInstanceCreated, - HistoryEvent.EventTypeOneofCase.ChildWorkflowInstanceCompleted => HistoryEventKind.SubOrchestrationInstanceCompleted, - HistoryEvent.EventTypeOneofCase.ChildWorkflowInstanceFailed => HistoryEventKind.SubOrchestrationInstanceFailed, - HistoryEvent.EventTypeOneofCase.TimerCreated => HistoryEventKind.TimerCreated, - HistoryEvent.EventTypeOneofCase.TimerFired => HistoryEventKind.TimerFired, - HistoryEvent.EventTypeOneofCase.WorkflowStarted => HistoryEventKind.OrchestratorStarted, - HistoryEvent.EventTypeOneofCase.WorkflowCompleted => HistoryEventKind.OrchestratorCompleted, - HistoryEvent.EventTypeOneofCase.EventSent => HistoryEventKind.EventSent, - HistoryEvent.EventTypeOneofCase.EventRaised => HistoryEventKind.EventRaised, - HistoryEvent.EventTypeOneofCase.ContinueAsNew => HistoryEventKind.ContinueAsNew, - HistoryEvent.EventTypeOneofCase.ExecutionSuspended => HistoryEventKind.ExecutionSuspended, - HistoryEvent.EventTypeOneofCase.ExecutionResumed => HistoryEventKind.ExecutionResumed, - _ => HistoryEventKind.Unknown - }; + var scheduled = scheduleEvent.TaskScheduled; + var scheduleId = scheduleEvent.EventId; + var completed = false; + var failed = false; + string? output = null; + WorkflowTaskFailureDetails? failureDetails = null; + + foreach (var e in events) + { + if (e.EventTypeCase == HistoryEvent.EventTypeOneofCase.TaskCompleted && + e.TaskCompleted.TaskScheduledId == scheduleId) + { + completed = true; + output = StringValueOrNull(e.TaskCompleted.Result); + } + else if (e.EventTypeCase == HistoryEvent.EventTypeOneofCase.TaskFailed && + e.TaskFailed.TaskScheduledId == scheduleId) + { + failed = true; + failureDetails = MapFailureDetails(e.TaskFailed.FailureDetails); + } + } + + return new ActivityResult( + Name: scheduled.Name, + Started: true, + Completed: completed, + Failed: failed, + Input: StringValueOrNull(scheduled.Input), + Output: output, + FailureDetails: failureDetails); + } /// - /// Converts a proto Timestamp to a . + /// Builds a by matching the create event's EventId + /// against subsequent ChildWorkflowInstanceCompleted / ChildWorkflowInstanceFailed events. /// - private static DateTimeOffset MapTimestamp(Timestamp? timestamp) => - timestamp is not null - ? new DateTimeOffset(timestamp.ToDateTime(), TimeSpan.Zero) - : DateTimeOffset.MinValue; + private static ChildWorkflowResult ResolveChildWorkflow( + IReadOnlyList events, + HistoryEvent createEvent) + { + var created = createEvent.ChildWorkflowInstanceCreated; + var creationId = createEvent.EventId; + var completed = false; + var failed = false; + string? output = null; + WorkflowTaskFailureDetails? failureDetails = null; + + foreach (var e in events) + { + if (e.EventTypeCase == HistoryEvent.EventTypeOneofCase.ChildWorkflowInstanceCompleted && + e.ChildWorkflowInstanceCompleted.TaskScheduledId == creationId) + { + completed = true; + output = StringValueOrNull(e.ChildWorkflowInstanceCompleted.Result); + } + else if (e.EventTypeCase == HistoryEvent.EventTypeOneofCase.ChildWorkflowInstanceFailed && + e.ChildWorkflowInstanceFailed.TaskScheduledId == creationId) + { + failed = true; + failureDetails = MapFailureDetails(e.ChildWorkflowInstanceFailed.FailureDetails); + } + } + + return new ChildWorkflowResult( + Name: created.Name, + Started: true, + Completed: completed, + Failed: failed, + Output: output, + FailureDetails: failureDetails); + } + + private static string? StringValueOrNull(Google.Protobuf.WellKnownTypes.StringValue? value) => + value is null ? null : value.Value; + + private static WorkflowTaskFailureDetails? MapFailureDetails(TaskFailureDetails? failure) => + failure is null + ? null + : new WorkflowTaskFailureDetails( + errorType: failure.ErrorType ?? "Exception", + errorMessage: failure.ErrorMessage ?? string.Empty, + stackTrace: failure.StackTrace); } diff --git a/test/Dapr.IntegrationTest.Workflow/HistoryPropagationWorkflowTests.cs b/test/Dapr.IntegrationTest.Workflow/HistoryPropagationWorkflowTests.cs index c8c1655e9..b63360d9a 100644 --- a/test/Dapr.IntegrationTest.Workflow/HistoryPropagationWorkflowTests.cs +++ b/test/Dapr.IntegrationTest.Workflow/HistoryPropagationWorkflowTests.cs @@ -397,7 +397,7 @@ public override async Task RunAsync(WorkflowContext conte await context.CreateTimer(TimeSpan.Zero); return new PropagationTestResult( ChildReceivedPropagatedHistory: propagated is not null, - PropagatedEntryCount: propagated?.Entries.Count ?? 0); + PropagatedEntryCount: propagated?.GetWorkflows().Count ?? 0); } } diff --git a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs index f7b55f809..cdc45f9ce 100644 --- a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs +++ b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs @@ -24,9 +24,13 @@ namespace Dapr.Workflow.Test.Worker.Internal; /// /// Tests for workflow history propagation: the SDK API surface for declaring -/// a propagation scope on , propagating -/// the scope to the outgoing , and -/// exposing inbound propagated history through . +/// a propagation scope on / +/// , propagating the scope to the +/// outgoing / , +/// and exposing inbound propagated history through +/// as typed +/// / / +/// records. /// public class WorkflowHistoryPropagationTests { @@ -58,7 +62,7 @@ private static HistoryEvent MakeEvent(int eventId, Action configur var ev = new HistoryEvent { EventId = eventId, - Timestamp = Timestamp.FromDateTime(timestamp ?? StartTime) + Timestamp = Timestamp.FromDateTime(timestamp ?? StartTime), }; configure(ev); return ev; @@ -80,8 +84,57 @@ private static PropagatedHistoryChunk MakeChunk(string appId, string instanceId, return chunk; } + private static HistoryEvent TaskScheduled(int eventId, string name, string? input = null) => + MakeEvent(eventId, e => e.TaskScheduled = new TaskScheduledEvent + { + Name = name, + Input = input is null ? null : new StringValue { Value = input }, + }); + + private static HistoryEvent TaskCompleted(int eventId, int scheduledId, string? result = null) => + MakeEvent(eventId, e => e.TaskCompleted = new TaskCompletedEvent + { + TaskScheduledId = scheduledId, + Result = result is null ? null : new StringValue { Value = result }, + }); + + private static HistoryEvent TaskFailed(int eventId, int scheduledId, string errorMessage) => + MakeEvent(eventId, e => e.TaskFailed = new TaskFailedEvent + { + TaskScheduledId = scheduledId, + FailureDetails = new TaskFailureDetails + { + ErrorType = "TestException", + ErrorMessage = errorMessage, + }, + }); + + private static HistoryEvent ChildCreated(int eventId, string name) => + MakeEvent(eventId, e => e.ChildWorkflowInstanceCreated = new ChildWorkflowInstanceCreatedEvent + { + Name = name, + }); + + private static HistoryEvent ChildCompleted(int eventId, int creationId, string? result = null) => + MakeEvent(eventId, e => e.ChildWorkflowInstanceCompleted = new ChildWorkflowInstanceCompletedEvent + { + TaskScheduledId = creationId, + Result = result is null ? null : new StringValue { Value = result }, + }); + + private static HistoryEvent ChildFailed(int eventId, int creationId, string errorMessage) => + MakeEvent(eventId, e => e.ChildWorkflowInstanceFailed = new ChildWorkflowInstanceFailedEvent + { + TaskScheduledId = creationId, + FailureDetails = new TaskFailureDetails + { + ErrorType = "TestException", + ErrorMessage = errorMessage, + }, + }); + // ------------------------------------------------------------------ - // GetPropagatedHistory + // GetPropagatedHistory — chunk shape and ordering // ------------------------------------------------------------------ [Fact] @@ -99,7 +152,7 @@ public void GetPropagatedHistory_ReturnsNull_WhenEmptyChunksProvided() } [Fact] - public void GetPropagatedHistory_ReturnsSingleEntry_WhenOneChunkPropagated() + public void GetPropagatedHistory_ReturnsSingleWorkflow_WhenOneChunkPropagated() { var chunk = MakeChunk("parent-app", "parent-instance", "ParentWorkflow", MakeEvent(1, e => e.ExecutionStarted = new ExecutionStartedEvent { Name = "ParentWorkflow" })); @@ -109,12 +162,13 @@ public void GetPropagatedHistory_ReturnsSingleEntry_WhenOneChunkPropagated() var history = context.GetPropagatedHistory(); Assert.NotNull(history); - Assert.Single(history.Entries); - Assert.Equal("parent-app", history.Entries[0].AppId); - Assert.Equal("parent-instance", history.Entries[0].InstanceId); - Assert.Equal("ParentWorkflow", history.Entries[0].WorkflowName); - Assert.Single(history.Entries[0].Events); - Assert.Equal(HistoryEventKind.ExecutionStarted, history.Entries[0].Events[0].Kind); + var workflows = history.GetWorkflows(); + Assert.Single(workflows); + Assert.Equal("parent-app", workflows[0].AppId); + Assert.Equal("parent-instance", workflows[0].InstanceId); + Assert.Equal("ParentWorkflow", workflows[0].Name); + Assert.Empty(workflows[0].Activities); + Assert.Empty(workflows[0].ChildWorkflows); } [Fact] @@ -129,177 +183,246 @@ public void GetPropagatedHistory_PreservesChunkOrder() var history = context.GetPropagatedHistory(); Assert.NotNull(history); - Assert.Equal(2, history.Entries.Count); - Assert.Equal("p-inst", history.Entries[0].InstanceId); - Assert.Equal("gp-inst", history.Entries[1].InstanceId); + var workflows = history.GetWorkflows(); + Assert.Equal(2, workflows.Count); + Assert.Equal("p-inst", workflows[0].InstanceId); + Assert.Equal("gp-inst", workflows[1].InstanceId); } [Fact] - public void GetPropagatedHistory_MapsAllHistoryEventKinds() + public void GetPropagatedHistory_SkipsMalformedRawEvents() { - var mappings = new Dictionary + var chunk = new PropagatedHistoryChunk { - { MakeEvent(1, e => e.ExecutionStarted = new ExecutionStartedEvent()), HistoryEventKind.ExecutionStarted }, - { MakeEvent(2, e => e.ExecutionCompleted = new ExecutionCompletedEvent()), HistoryEventKind.ExecutionCompleted }, - { MakeEvent(3, e => e.ExecutionTerminated = new ExecutionTerminatedEvent()), HistoryEventKind.ExecutionTerminated }, - { MakeEvent(4, e => e.TaskScheduled = new TaskScheduledEvent { Name = "a" }), HistoryEventKind.TaskScheduled }, - { MakeEvent(5, e => e.TaskCompleted = new TaskCompletedEvent()), HistoryEventKind.TaskCompleted }, - { MakeEvent(6, e => e.TaskFailed = new TaskFailedEvent()), HistoryEventKind.TaskFailed }, - { MakeEvent(7, e => e.ChildWorkflowInstanceCreated = new ChildWorkflowInstanceCreatedEvent()), HistoryEventKind.SubOrchestrationInstanceCreated }, - { MakeEvent(8, e => e.ChildWorkflowInstanceCompleted = new ChildWorkflowInstanceCompletedEvent()), HistoryEventKind.SubOrchestrationInstanceCompleted }, - { MakeEvent(9, e => e.ChildWorkflowInstanceFailed = new ChildWorkflowInstanceFailedEvent()), HistoryEventKind.SubOrchestrationInstanceFailed }, - { MakeEvent(10, e => e.TimerCreated = new TimerCreatedEvent()), HistoryEventKind.TimerCreated }, - { MakeEvent(11, e => e.TimerFired = new TimerFiredEvent()), HistoryEventKind.TimerFired }, - { MakeEvent(12, e => e.WorkflowStarted = new WorkflowStartedEvent()), HistoryEventKind.OrchestratorStarted }, - { MakeEvent(13, e => e.WorkflowCompleted = new WorkflowCompletedEvent()), HistoryEventKind.OrchestratorCompleted }, - { MakeEvent(14, e => e.EventSent = new EventSentEvent()), HistoryEventKind.EventSent }, - { MakeEvent(15, e => e.EventRaised = new EventRaisedEvent()), HistoryEventKind.EventRaised }, - { MakeEvent(16, e => e.ContinueAsNew = new ContinueAsNewEvent()), HistoryEventKind.ContinueAsNew }, - { MakeEvent(17, e => e.ExecutionSuspended = new ExecutionSuspendedEvent()), HistoryEventKind.ExecutionSuspended }, - { MakeEvent(18, e => e.ExecutionResumed = new ExecutionResumedEvent()), HistoryEventKind.ExecutionResumed }, + AppId = "app", + InstanceId = "inst", + WorkflowName = "Wf", }; + chunk.RawEvents.Add(ByteString.CopyFrom([0xff, 0xff, 0xff, 0xff, 0xff])); + chunk.RawEvents.Add(TaskScheduled(eventId: 7, name: "Echo").ToByteString()); - var chunk = MakeChunk("app", "inst", "Wf", mappings.Keys.ToArray()); var context = CreateContext(incomingPropagatedHistory: [chunk]); - var entry = context.GetPropagatedHistory()!.Entries.Single(); + var workflow = context.GetPropagatedHistory()!.GetWorkflows().Single(); - Assert.Equal(mappings.Count, entry.Events.Count); - var expectedKinds = mappings.Values.ToList(); - for (var i = 0; i < entry.Events.Count; i++) - { - Assert.Equal(expectedKinds[i], entry.Events[i].Kind); - } + Assert.Single(workflow.Activities); + Assert.Equal("Echo", workflow.Activities[0].Name); + } + + // ------------------------------------------------------------------ + // Activity resolution from raw events + // ------------------------------------------------------------------ + + [Fact] + public void ActivityResult_ResolvedAs_CompletedWithInputAndOutput() + { + var chunk = MakeChunk("app", "inst", "Wf", + TaskScheduled(eventId: 1, name: "ValidateMerchant", input: "\"merchant-1\""), + TaskCompleted(eventId: 2, scheduledId: 1, result: "true")); + + var context = CreateContext(incomingPropagatedHistory: [chunk]); + var activity = context.GetPropagatedHistory()! + .GetLastWorkflowByName("Wf") + .GetLastActivityByName("ValidateMerchant"); + + Assert.Equal("ValidateMerchant", activity.Name); + Assert.True(activity.Started); + Assert.True(activity.Completed); + Assert.False(activity.Failed); + Assert.Equal("\"merchant-1\"", activity.Input); + Assert.Equal("true", activity.Output); + Assert.Null(activity.FailureDetails); } [Fact] - public void GetPropagatedHistory_MapsUnsetEventTypeToUnknown() + public void ActivityResult_ResolvedAs_FailedWithFailureDetails() { - // An event with no oneof case set should be mapped to Unknown rather than crashing. - var bareEvent = new HistoryEvent { EventId = 42, Timestamp = Timestamp.FromDateTime(StartTime) }; - var chunk = MakeChunk("app", "inst", "Wf", bareEvent); + var chunk = MakeChunk("app", "inst", "Wf", + TaskScheduled(eventId: 1, name: "ValidateCard"), + TaskFailed(eventId: 2, scheduledId: 1, errorMessage: "card declined")); var context = CreateContext(incomingPropagatedHistory: [chunk]); - var entry = context.GetPropagatedHistory()!.Entries.Single(); + var activity = context.GetPropagatedHistory()! + .GetLastWorkflowByName("Wf") + .GetLastActivityByName("ValidateCard"); + + Assert.False(activity.Completed); + Assert.True(activity.Failed); + Assert.NotNull(activity.FailureDetails); + Assert.Equal("card declined", activity.FailureDetails.ErrorMessage); + } + + [Fact] + public void ActivityResult_ResolvedAs_StartedOnly_WhenNotYetCompleted() + { + var chunk = MakeChunk("app", "inst", "Wf", + TaskScheduled(eventId: 1, name: "PendingCheck", input: "\"in\"")); - Assert.Single(entry.Events); - Assert.Equal(HistoryEventKind.Unknown, entry.Events[0].Kind); - Assert.Equal(42, entry.Events[0].EventId); + var context = CreateContext(incomingPropagatedHistory: [chunk]); + var activity = context.GetPropagatedHistory()! + .GetLastWorkflowByName("Wf") + .GetLastActivityByName("PendingCheck"); + + Assert.True(activity.Started); + Assert.False(activity.Completed); + Assert.False(activity.Failed); + Assert.Equal("\"in\"", activity.Input); + Assert.Null(activity.Output); } [Fact] - public void GetPropagatedHistory_SkipsMalformedRawEvents() + public void GetLastActivityByName_ReturnsMostRecentInvocation_WhenRetried() { - var chunk = new PropagatedHistoryChunk - { - AppId = "app", - InstanceId = "inst", - WorkflowName = "Wf", - }; - // Add a malformed event (not a valid serialized HistoryEvent). - chunk.RawEvents.Add(ByteString.CopyFrom(new byte[] { 0xff, 0xff, 0xff, 0xff, 0xff })); - // Followed by a well-formed event. - chunk.RawEvents.Add(MakeEvent(7, e => e.TaskCompleted = new TaskCompletedEvent()).ToByteString()); + // Same activity scheduled twice — first completes, second fails. GetLast returns the failed (most recent). + var chunk = MakeChunk("app", "inst", "Wf", + TaskScheduled(eventId: 1, name: "ValidateCard", input: "\"first\""), + TaskCompleted(eventId: 2, scheduledId: 1, result: "true"), + TaskScheduled(eventId: 3, name: "ValidateCard", input: "\"second\""), + TaskFailed(eventId: 4, scheduledId: 3, errorMessage: "card declined")); var context = CreateContext(incomingPropagatedHistory: [chunk]); - var entry = context.GetPropagatedHistory()!.Entries.Single(); + var workflow = context.GetPropagatedHistory()!.GetLastWorkflowByName("Wf"); - // Only the well-formed event survives. - Assert.Single(entry.Events); - Assert.Equal(7, entry.Events[0].EventId); - Assert.Equal(HistoryEventKind.TaskCompleted, entry.Events[0].Kind); + var all = workflow.GetActivitiesByName("ValidateCard"); + Assert.Equal(2, all.Count); + Assert.True(all[0].Completed); + Assert.True(all[1].Failed); + + var last = workflow.GetLastActivityByName("ValidateCard"); + Assert.True(last.Failed); + Assert.Equal("card declined", last.FailureDetails!.ErrorMessage); } [Fact] - public void GetPropagatedHistory_PreservesEventTimestamp() + public void GetLastActivityByName_Throws_WhenMissing() { - var when = new DateTime(2026, 06, 15, 12, 30, 45, DateTimeKind.Utc); var chunk = MakeChunk("app", "inst", "Wf", - MakeEvent(1, e => e.ExecutionStarted = new ExecutionStartedEvent(), timestamp: when)); + TaskScheduled(eventId: 1, name: "Real")); var context = CreateContext(incomingPropagatedHistory: [chunk]); - var entry = context.GetPropagatedHistory()!.Entries.Single(); + var workflow = context.GetPropagatedHistory()!.GetLastWorkflowByName("Wf"); - Assert.Equal(when, entry.Events[0].Timestamp.UtcDateTime); + Assert.Throws(() => workflow.GetLastActivityByName("Missing")); } // ------------------------------------------------------------------ - // PropagatedHistory filters + // Child workflow resolution // ------------------------------------------------------------------ [Fact] - public void FilterByAppId_ReturnsOnlyMatchingEntries_CaseInsensitive() + public void ChildWorkflowResult_ResolvedAs_Completed() { - var history = new PropagatedHistory(new[] - { - new PropagatedHistoryEntry("app-a", "i1", "WfA", []), - new PropagatedHistoryEntry("app-b", "i2", "WfB", []), - new PropagatedHistoryEntry("APP-A", "i3", "WfA2", []), - }); + var chunk = MakeChunk("app", "inst", "Wf", + ChildCreated(eventId: 1, name: "ProcessPayment"), + ChildCompleted(eventId: 2, creationId: 1, result: "\"paid\"")); + + var context = CreateContext(incomingPropagatedHistory: [chunk]); + var child = context.GetPropagatedHistory()! + .GetLastWorkflowByName("Wf") + .GetLastChildWorkflowByName("ProcessPayment"); + + Assert.True(child.Started); + Assert.True(child.Completed); + Assert.False(child.Failed); + Assert.Equal("\"paid\"", child.Output); + } + + [Fact] + public void ChildWorkflowResult_ResolvedAs_Failed() + { + var chunk = MakeChunk("app", "inst", "Wf", + ChildCreated(eventId: 1, name: "ProcessPayment"), + ChildFailed(eventId: 2, creationId: 1, errorMessage: "boom")); - var filtered = history.FilterByAppId("app-a"); + var context = CreateContext(incomingPropagatedHistory: [chunk]); + var child = context.GetPropagatedHistory()! + .GetLastWorkflowByName("Wf") + .GetLastChildWorkflowByName("ProcessPayment"); - Assert.Equal(2, filtered.Entries.Count); - Assert.All(filtered.Entries, e => Assert.Equal("app-a", e.AppId, StringComparer.OrdinalIgnoreCase)); + Assert.True(child.Failed); + Assert.Equal("boom", child.FailureDetails!.ErrorMessage); } [Fact] - public void FilterByInstanceId_ReturnsOnlyMatchingEntry_CaseSensitive() + public void GetLastChildWorkflowByName_Throws_WhenMissing() { - var history = new PropagatedHistory(new[] - { - new PropagatedHistoryEntry("app", "Instance-1", "Wf", []), - new PropagatedHistoryEntry("app", "instance-2", "Wf", []), - }); + var chunk = MakeChunk("app", "inst", "Wf"); + var context = CreateContext(incomingPropagatedHistory: [chunk]); + var workflow = context.GetPropagatedHistory()!.GetLastWorkflowByName("Wf"); - Assert.Single(history.FilterByInstanceId("Instance-1").Entries); - Assert.Empty(history.FilterByInstanceId("instance-1").Entries); + Assert.Throws(() => workflow.GetLastChildWorkflowByName("Missing")); } + // ------------------------------------------------------------------ + // Chain-level helpers + // ------------------------------------------------------------------ + [Fact] - public void FilterByWorkflowName_ReturnsOnlyMatchingEntries_CaseSensitive() + public void GetAppIds_ReturnsOrderedDeduplicatedList() { - var history = new PropagatedHistory(new[] - { - new PropagatedHistoryEntry("app", "i1", "PaymentWorkflow", []), - new PropagatedHistoryEntry("app", "i2", "OrderWorkflow", []), - new PropagatedHistoryEntry("app", "i3", "PaymentWorkflow", []), - new PropagatedHistoryEntry("app", "i4", "paymentworkflow", []), - }); + var history = new PropagatedHistory([ + new WorkflowResult("i1", "appA", "WfA", [], []), + new WorkflowResult("i2", "appB", "WfB", [], []), + new WorkflowResult("i3", "appA", "WfA2", [], []), + ]); + + Assert.Equal(["appA", "appB"], history.GetAppIds()); + } - var filtered = history.FilterByWorkflowName("PaymentWorkflow"); + [Fact] + public void GetLastWorkflowByName_ReturnsMostRecent_WhenNameRepeated() + { + var history = new PropagatedHistory([ + new WorkflowResult("wf-1", "app", "Loop", [], []), + new WorkflowResult("wf-2", "app", "Loop", [], []), + ]); - Assert.Equal(2, filtered.Entries.Count); - Assert.All(filtered.Entries, e => Assert.Equal("PaymentWorkflow", e.WorkflowName)); + Assert.Equal("wf-2", history.GetLastWorkflowByName("Loop").InstanceId); + Assert.Equal(2, history.GetWorkflowsByName("Loop").Count); } [Fact] - public void Filters_ThrowOnEmptyOrWhitespace() + public void GetLastWorkflowByName_Throws_WhenMissing() { var history = new PropagatedHistory([]); - Assert.Throws(() => history.FilterByAppId(string.Empty)); - Assert.Throws(() => history.FilterByInstanceId(" ")); - Assert.Throws(() => history.FilterByWorkflowName(string.Empty)); + Assert.Throws(() => history.GetLastWorkflowByName("Missing")); } [Fact] - public void PropagatedHistory_Ctor_ThrowsOnNullEntries() + public void PropagatedHistory_Ctor_ThrowsOnNullWorkflows() { Assert.Throws(() => new PropagatedHistory(null!)); } // ------------------------------------------------------------------ - // ChildWorkflowTaskOptions.WithHistoryPropagation + // Scheduling helpers — WithHistoryPropagation // ------------------------------------------------------------------ [Fact] - public void WithHistoryPropagation_SetsScope() + public void WorkflowTaskOptions_WithHistoryPropagation_SetsScope() + { + var options = new WorkflowTaskOptions().WithHistoryPropagation(HistoryPropagationScope.Lineage); + Assert.Equal(HistoryPropagationScope.Lineage, options.PropagationScope); + } + + [Fact] + public void WorkflowTaskOptions_WithHistoryPropagation_DoesNotMutateOriginal() + { + var original = new WorkflowTaskOptions(); + var updated = original.WithHistoryPropagation(HistoryPropagationScope.OwnHistory); + + Assert.Equal(HistoryPropagationScope.None, original.PropagationScope); + Assert.Equal(HistoryPropagationScope.OwnHistory, updated.PropagationScope); + } + + [Fact] + public void ChildWorkflowTaskOptions_WithHistoryPropagation_SetsScope() { var options = new ChildWorkflowTaskOptions().WithHistoryPropagation(HistoryPropagationScope.Lineage); Assert.Equal(HistoryPropagationScope.Lineage, options.PropagationScope); } [Fact] - public void WithHistoryPropagation_DoesNotMutateOriginal() + public void ChildWorkflowTaskOptions_WithHistoryPropagation_PreservesOtherFields() { var original = new ChildWorkflowTaskOptions(InstanceId: "id-1"); var updated = original.WithHistoryPropagation(HistoryPropagationScope.OwnHistory); @@ -309,8 +432,65 @@ public void WithHistoryPropagation_DoesNotMutateOriginal() Assert.Equal("id-1", updated.InstanceId); } + [Fact] + public void WorkflowHistory_PropagateLineage_ReturnsLineageScope() + { + Assert.Equal(HistoryPropagationScope.Lineage, WorkflowHistory.PropagateLineage()); + } + + [Fact] + public void WorkflowHistory_PropagateOwnHistory_ReturnsOwnHistoryScope() + { + Assert.Equal(HistoryPropagationScope.OwnHistory, WorkflowHistory.PropagateOwnHistory()); + } + + // ------------------------------------------------------------------ + // Outbound action scope — activity path + // ------------------------------------------------------------------ + + [Fact] + public void CallActivityAsync_DefaultScope_LeavesActionScopeUnset() + { + var context = CreateContext(instanceId: "parent", appId: "my-app"); + _ = context.CallActivityAsync("Echo"); + + var action = context.PendingActions + .Select(a => a.ScheduleTask) + .First(a => a is not null); + + Assert.Equal(Dapr.DurableTask.Protobuf.HistoryPropagationScope.None, action.HistoryPropagationScope); + } + + [Fact] + public void CallActivityAsync_WithOwnHistory_SetsScopeOnAction() + { + var context = CreateContext(instanceId: "parent", appId: "my-app"); + _ = context.CallActivityAsync("Echo", + options: new WorkflowTaskOptions(PropagationScope: HistoryPropagationScope.OwnHistory)); + + var action = context.PendingActions + .Select(a => a.ScheduleTask) + .First(a => a is not null); + + Assert.Equal(Dapr.DurableTask.Protobuf.HistoryPropagationScope.OwnHistory, action.HistoryPropagationScope); + } + + [Fact] + public void CallActivityAsync_WithLineage_SetsScopeOnAction() + { + var context = CreateContext(instanceId: "parent", appId: "my-app"); + _ = context.CallActivityAsync("Echo", + options: new WorkflowTaskOptions().WithHistoryPropagation(WorkflowHistory.PropagateLineage())); + + var action = context.PendingActions + .Select(a => a.ScheduleTask) + .First(a => a is not null); + + Assert.Equal(Dapr.DurableTask.Protobuf.HistoryPropagationScope.Lineage, action.HistoryPropagationScope); + } + // ------------------------------------------------------------------ - // CallChildWorkflowAsync — outbound HistoryPropagationScope on action + // Outbound action scope — child workflow path // ------------------------------------------------------------------ [Fact] @@ -323,7 +503,6 @@ public void CallChildWorkflowAsync_DefaultScope_LeavesActionScopeUnset() .Select(a => a.CreateChildWorkflow) .First(a => a is not null); - // Default = None => HistoryPropagationScope field is left at its proto default (None / unset). Assert.Equal(Dapr.DurableTask.Protobuf.HistoryPropagationScope.None, action.HistoryPropagationScope); } @@ -346,7 +525,7 @@ public void CallChildWorkflowAsync_WithLineage_SetsScopeOnAction() { var context = CreateContext(instanceId: "parent", appId: "my-app"); _ = context.CallChildWorkflowAsync("ChildWf", - options: new ChildWorkflowTaskOptions(PropagationScope: HistoryPropagationScope.Lineage)); + options: new ChildWorkflowTaskOptions().WithHistoryPropagation(WorkflowHistory.PropagateLineage())); var action = context.PendingActions .Select(a => a.CreateChildWorkflow) From b81c4ea87b55527a1c9a6d7b91fdbf8384e7d0cc Mon Sep 17 00:00:00 2001 From: Nelson Parente Date: Wed, 20 May 2026 22:27:12 +0100 Subject: [PATCH 02/12] fix(workflow): address code-review feedback on history-propagation alignment - Document the `new`-hiding contract on ChildWorkflowTaskOptions .WithHistoryPropagation and add a regression test that asserts the returned type is ChildWorkflowTaskOptions (not the base record), so InstanceId survives the with-expression. - Add the standard `()`, `(string)`, and `(string, Exception)` constructors on PropagationNotFoundException so callers can wrap inner exceptions. - Alias StringValue alongside the existing Timestamp alias in WorkflowOrchestrationContext so the propagation helper signature stays consistent with the rest of the file. Signed-off-by: Nelson Parente --- .../Exceptions/PropagationNotFoundException.cs | 17 +++++++++++++++++ .../WorkflowTaskOptions.cs | 6 ++++++ .../Internal/WorkflowOrchestrationContext.cs | 3 ++- .../Internal/WorkflowHistoryPropagationTests.cs | 13 +++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/Dapr.Workflow.Abstractions/Exceptions/PropagationNotFoundException.cs b/src/Dapr.Workflow.Abstractions/Exceptions/PropagationNotFoundException.cs index c723d58dc..c87fd6ce3 100644 --- a/src/Dapr.Workflow.Abstractions/Exceptions/PropagationNotFoundException.cs +++ b/src/Dapr.Workflow.Abstractions/Exceptions/PropagationNotFoundException.cs @@ -30,8 +30,25 @@ public sealed class PropagationNotFoundException : Exception /// /// Initializes a new instance of . /// + public PropagationNotFoundException() + { + } + + /// + /// Initializes a new instance of with a message. + /// /// The exception message. public PropagationNotFoundException(string message) : base(message) { } + + /// + /// Initializes a new instance of with a message + /// and inner exception. + /// + /// The exception message. + /// The underlying cause. + public PropagationNotFoundException(string message, Exception innerException) : base(message, innerException) + { + } } diff --git a/src/Dapr.Workflow.Abstractions/WorkflowTaskOptions.cs b/src/Dapr.Workflow.Abstractions/WorkflowTaskOptions.cs index b24c710dc..ede71b177 100644 --- a/src/Dapr.Workflow.Abstractions/WorkflowTaskOptions.cs +++ b/src/Dapr.Workflow.Abstractions/WorkflowTaskOptions.cs @@ -58,6 +58,12 @@ public record ChildWorkflowTaskOptions( /// /// The propagation scope to apply. /// A new options instance with the propagation scope set. + /// + /// Hides the base method (records cannot override) so the derived type is returned — + /// callers must hold a reference to preserve + /// . Calling through a reference + /// invokes the base method and returns a plain . + /// public new ChildWorkflowTaskOptions WithHistoryPropagation(HistoryPropagationScope scope) => this with { PropagationScope = scope }; } diff --git a/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs b/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs index 442754f51..f68818696 100644 --- a/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs +++ b/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs @@ -25,6 +25,7 @@ using Google.Protobuf; using Microsoft.Extensions.Logging; using static Dapr.Workflow.Worker.Internal.TimerOriginHelpers; +using StringValue = Google.Protobuf.WellKnownTypes.StringValue; using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp; namespace Dapr.Workflow.Worker.Internal; @@ -1132,7 +1133,7 @@ private static ChildWorkflowResult ResolveChildWorkflow( FailureDetails: failureDetails); } - private static string? StringValueOrNull(Google.Protobuf.WellKnownTypes.StringValue? value) => + private static string? StringValueOrNull(StringValue? value) => value is null ? null : value.Value; private static WorkflowTaskFailureDetails? MapFailureDetails(TaskFailureDetails? failure) => diff --git a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs index cdc45f9ce..78b83673a 100644 --- a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs +++ b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs @@ -432,6 +432,19 @@ public void ChildWorkflowTaskOptions_WithHistoryPropagation_PreservesOtherFields Assert.Equal("id-1", updated.InstanceId); } + [Fact] + public void ChildWorkflowTaskOptions_WithHistoryPropagation_ReturnsDerivedType() + { + // Locks in the `new`-hiding behavior on the derived record: invoked on a + // ChildWorkflowTaskOptions reference, WithHistoryPropagation must return + // ChildWorkflowTaskOptions (not the base WorkflowTaskOptions) so InstanceId + // and other derived fields survive the with-expression. + var original = new ChildWorkflowTaskOptions(InstanceId: "id-1"); + var updated = original.WithHistoryPropagation(HistoryPropagationScope.Lineage); + + Assert.IsType(updated); + } + [Fact] public void WorkflowHistory_PropagateLineage_ReturnsLineageScope() { From fcb19937f1f7dcc541e69dc67b2c9dc2c3a05769 Mon Sep 17 00:00:00 2001 From: Nelson Parente Date: Wed, 20 May 2026 22:47:52 +0100 Subject: [PATCH 03/12] test(workflow): clarify chunk-order test variable names Renames the test fixtures in GetPropagatedHistory_PreservesChunkOrder so the variable order matches the documented oldest-first chunk ordering (index 0 is the oldest ancestor, the last chunk is the immediate parent). No behavior change. Signed-off-by: Nelson Parente --- .../Internal/WorkflowHistoryPropagationTests.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs index 78b83673a..46ea0d971 100644 --- a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs +++ b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs @@ -174,19 +174,20 @@ public void GetPropagatedHistory_ReturnsSingleWorkflow_WhenOneChunkPropagated() [Fact] public void GetPropagatedHistory_PreservesChunkOrder() { - var parent = MakeChunk("p-app", "p-inst", "ParentWf", - MakeEvent(1, e => e.ExecutionStarted = new ExecutionStartedEvent { Name = "ParentWf" })); + // Chunks arrive oldest-first: grandparent at index 0, immediate parent last. var grandparent = MakeChunk("gp-app", "gp-inst", "GrandparentWf", MakeEvent(1, e => e.ExecutionStarted = new ExecutionStartedEvent { Name = "GrandparentWf" })); + var parent = MakeChunk("p-app", "p-inst", "ParentWf", + MakeEvent(1, e => e.ExecutionStarted = new ExecutionStartedEvent { Name = "ParentWf" })); - var context = CreateContext(incomingPropagatedHistory: [parent, grandparent]); + var context = CreateContext(incomingPropagatedHistory: [grandparent, parent]); var history = context.GetPropagatedHistory(); Assert.NotNull(history); var workflows = history.GetWorkflows(); Assert.Equal(2, workflows.Count); - Assert.Equal("p-inst", workflows[0].InstanceId); - Assert.Equal("gp-inst", workflows[1].InstanceId); + Assert.Equal("gp-inst", workflows[0].InstanceId); + Assert.Equal("p-inst", workflows[1].InstanceId); } [Fact] From 34a2c98ea09839a9864b75b2285ddb92021632c2 Mon Sep 17 00:00:00 2001 From: Nelson Parente Date: Thu, 21 May 2026 11:04:29 +0100 Subject: [PATCH 04/12] fix(workflow): pass StringValue-wrapped fields directly as strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit protoc unwraps google.protobuf.StringValue to a plain string in the generated C# (only the wire codec uses the wrapper). The StringValueOrNull(StringValue?) helper added in this branch expected the wrapper type, breaking the build with CS1503 at the three call sites in ResolveActivity / ResolveChildWorkflow. Drop the helper and pass the generated string fields straight through — they are already nullable at runtime and ActivityResult/ChildWorkflowResult accept string? for Input/Output. Signed-off-by: Nelson Parente --- .../Worker/Internal/WorkflowOrchestrationContext.cs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs b/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs index f68818696..24c2eaf80 100644 --- a/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs +++ b/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs @@ -25,7 +25,6 @@ using Google.Protobuf; using Microsoft.Extensions.Logging; using static Dapr.Workflow.Worker.Internal.TimerOriginHelpers; -using StringValue = Google.Protobuf.WellKnownTypes.StringValue; using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp; namespace Dapr.Workflow.Worker.Internal; @@ -1073,7 +1072,7 @@ private static ActivityResult ResolveActivity( e.TaskCompleted.TaskScheduledId == scheduleId) { completed = true; - output = StringValueOrNull(e.TaskCompleted.Result); + output = e.TaskCompleted.Result; } else if (e.EventTypeCase == HistoryEvent.EventTypeOneofCase.TaskFailed && e.TaskFailed.TaskScheduledId == scheduleId) @@ -1088,7 +1087,7 @@ private static ActivityResult ResolveActivity( Started: true, Completed: completed, Failed: failed, - Input: StringValueOrNull(scheduled.Input), + Input: scheduled.Input, Output: output, FailureDetails: failureDetails); } @@ -1114,7 +1113,7 @@ private static ChildWorkflowResult ResolveChildWorkflow( e.ChildWorkflowInstanceCompleted.TaskScheduledId == creationId) { completed = true; - output = StringValueOrNull(e.ChildWorkflowInstanceCompleted.Result); + output = e.ChildWorkflowInstanceCompleted.Result; } else if (e.EventTypeCase == HistoryEvent.EventTypeOneofCase.ChildWorkflowInstanceFailed && e.ChildWorkflowInstanceFailed.TaskScheduledId == creationId) @@ -1133,9 +1132,6 @@ private static ChildWorkflowResult ResolveChildWorkflow( FailureDetails: failureDetails); } - private static string? StringValueOrNull(StringValue? value) => - value is null ? null : value.Value; - private static WorkflowTaskFailureDetails? MapFailureDetails(TaskFailureDetails? failure) => failure is null ? null From 57c69da8b1c2d1aaf8cd54d0af6c1d4a5b6737ba Mon Sep 17 00:00:00 2001 From: Nelson Parente Date: Thu, 21 May 2026 11:23:29 +0100 Subject: [PATCH 05/12] test(workflow): assign string directly to wrapper-typed fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same StringValue mismatch as the production fix — protoc-generated properties for google.protobuf.StringValue fields are plain string, not the wrapper. Drop the new StringValue { Value = ... } wrappers in the test helpers. Signed-off-by: Nelson Parente --- .../Worker/Internal/WorkflowHistoryPropagationTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs index 46ea0d971..70cba3341 100644 --- a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs +++ b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs @@ -88,14 +88,14 @@ private static HistoryEvent TaskScheduled(int eventId, string name, string? inpu MakeEvent(eventId, e => e.TaskScheduled = new TaskScheduledEvent { Name = name, - Input = input is null ? null : new StringValue { Value = input }, + Input = input, }); private static HistoryEvent TaskCompleted(int eventId, int scheduledId, string? result = null) => MakeEvent(eventId, e => e.TaskCompleted = new TaskCompletedEvent { TaskScheduledId = scheduledId, - Result = result is null ? null : new StringValue { Value = result }, + Result = result, }); private static HistoryEvent TaskFailed(int eventId, int scheduledId, string errorMessage) => @@ -119,7 +119,7 @@ private static HistoryEvent ChildCompleted(int eventId, int creationId, string? MakeEvent(eventId, e => e.ChildWorkflowInstanceCompleted = new ChildWorkflowInstanceCompletedEvent { TaskScheduledId = creationId, - Result = result is null ? null : new StringValue { Value = result }, + Result = result, }); private static HistoryEvent ChildFailed(int eventId, int creationId, string errorMessage) => From b6042200919625e7bf5385a333baea0ab5e35701 Mon Sep 17 00:00:00 2001 From: Nelson Parente Date: Fri, 22 May 2026 11:01:29 +0100 Subject: [PATCH 06/12] refactor(workflow): rename propagation types and adopt TryGet pattern Addresses Whit's review on #1825: - Rename ActivityResult -> PropagatedHistoryActivityResult - Rename ChildWorkflowResult -> PropagatedHistoryChildWorkflowResult - Rename WorkflowResult -> PropagatedHistoryEntry (primary constructor) - Drop WorkflowHistory static class; callers pass HistoryPropagationScope directly - Switch GetLast*ByName to TryGet*ByName + drop PropagationNotFoundException - Drop chunk/chain terminology from public XML docs - Surface malformed propagated event bytes via InvalidProtocolBufferException instead of silently skipping - Update unit tests to new names and TryGet asserts Signed-off-by: Nelson Parente --- .../PropagationNotFoundException.cs | 54 ------- .../PropagatedHistory.cs | 59 ++++---- ....cs => PropagatedHistoryActivityResult.cs} | 11 +- ...> PropagatedHistoryChildWorkflowResult.cs} | 6 +- .../PropagatedHistoryEntry.cs | 132 +++++++++++++++++ .../WorkflowContext.cs | 9 +- .../WorkflowHistory.cs | 46 ------ .../WorkflowResult.cs | 134 ------------------ .../Internal/WorkflowOrchestrationContext.cs | 39 +++-- .../WorkflowHistoryPropagationTests.cs | 108 ++++++-------- 10 files changed, 240 insertions(+), 358 deletions(-) delete mode 100644 src/Dapr.Workflow.Abstractions/Exceptions/PropagationNotFoundException.cs rename src/Dapr.Workflow.Abstractions/{ActivityResult.cs => PropagatedHistoryActivityResult.cs} (82%) rename src/Dapr.Workflow.Abstractions/{ChildWorkflowResult.cs => PropagatedHistoryChildWorkflowResult.cs} (88%) create mode 100644 src/Dapr.Workflow.Abstractions/PropagatedHistoryEntry.cs delete mode 100644 src/Dapr.Workflow.Abstractions/WorkflowHistory.cs delete mode 100644 src/Dapr.Workflow.Abstractions/WorkflowResult.cs diff --git a/src/Dapr.Workflow.Abstractions/Exceptions/PropagationNotFoundException.cs b/src/Dapr.Workflow.Abstractions/Exceptions/PropagationNotFoundException.cs deleted file mode 100644 index c87fd6ce3..000000000 --- a/src/Dapr.Workflow.Abstractions/Exceptions/PropagationNotFoundException.cs +++ /dev/null @@ -1,54 +0,0 @@ -// ------------------------------------------------------------------------ -// Copyright 2026 The Dapr Authors -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ------------------------------------------------------------------------ - -namespace Dapr.Workflow; - -using System; - -/// -/// Thrown when a query against propagated workflow history finds no match. -/// -/// -/// Raised by , -/// , and -/// when the requested -/// name is not present in the propagated history chain. Use the plural -/// Get*sByName variants if you want an empty-list result instead. -/// -public sealed class PropagationNotFoundException : Exception -{ - /// - /// Initializes a new instance of . - /// - public PropagationNotFoundException() - { - } - - /// - /// Initializes a new instance of with a message. - /// - /// The exception message. - public PropagationNotFoundException(string message) : base(message) - { - } - - /// - /// Initializes a new instance of with a message - /// and inner exception. - /// - /// The exception message. - /// The underlying cause. - public PropagationNotFoundException(string message, Exception innerException) : base(message, innerException) - { - } -} diff --git a/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs index 64910f7e8..b011bb10a 100644 --- a/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs +++ b/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs @@ -15,43 +15,44 @@ namespace Dapr.Workflow; using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; /// -/// Workflow history propagated from a parent workflow to a child workflow or activity. +/// Workflow history propagated from one or more ancestor workflows to a child workflow or activity. /// /// -/// A propagated history is composed of one or more chunks, each owned by a distinct -/// workflow instance. Chunks preserve execution order: index 0 is the oldest ancestor, -/// the last chunk is the immediate parent. +/// A propagated history is an ordered list of values, +/// one per ancestor workflow. Order is execution order: index 0 is the oldest ancestor, +/// the last entry is the immediate parent. /// -/// Use the Get* methods to walk the chain by app, instance, or workflow name. +/// Use the Get* / TryGet* methods to walk the list by app, instance, or workflow name. /// Mirrors the PropagatedHistory type in the Go and Python SDKs. /// /// public sealed class PropagatedHistory { - private readonly IReadOnlyList _workflows; + private readonly IReadOnlyList _workflows; /// - /// Initializes a new from the given workflow chunks. + /// Initializes a new from the given workflow entries. /// /// - /// Workflow chunks in execution order (ancestor first, immediate parent last). + /// Workflow entries in execution order (ancestor first, immediate parent last). /// - public PropagatedHistory(IReadOnlyList workflows) + public PropagatedHistory(IReadOnlyList workflows) { _workflows = workflows ?? throw new ArgumentNullException(nameof(workflows)); } /// - /// Returns every workflow chunk in the chain, in execution order + /// Returns every workflow entry in the propagated history, in execution order /// (ancestor first, immediate parent last). /// - public IReadOnlyList GetWorkflows() => _workflows; + public IReadOnlyList GetWorkflows() => _workflows; /// - /// Returns an ordered, deduplicated list of app IDs in the propagated chain. + /// Returns an ordered, deduplicated list of app IDs in this propagated history. /// public IReadOnlyList GetAppIds() { @@ -69,12 +70,12 @@ public IReadOnlyList GetAppIds() } /// - /// Returns every workflow whose name matches, in execution order. Useful when the - /// chain contains the same name more than once (e.g. recursion or ContinueAsNew). + /// Returns every workflow entry whose name matches, in execution order. Useful when the + /// list contains the same name more than once (e.g. recursion or ContinueAsNew). /// /// The workflow name to filter by. /// An empty list when no match is found. - public IReadOnlyList GetWorkflowsByName(string name) + public IReadOnlyList GetWorkflowsByName(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); return _workflows @@ -83,29 +84,33 @@ public IReadOnlyList GetWorkflowsByName(string name) } /// - /// Returns the most recent workflow in the chain whose name matches. + /// Tries to return the most recent workflow entry whose name matches. /// /// The workflow name to look up. - /// The last matching workflow chunk. - /// No workflow with the given name is present in the chain. - public WorkflowResult GetLastWorkflowByName(string name) + /// When this method returns , the last matching workflow entry; otherwise . + /// if a matching entry was found; otherwise . + public bool TryGetLastWorkflowByName(string name, [NotNullWhen(true)] out PropagatedHistoryEntry? result) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - var matches = GetWorkflowsByName(name); - if (matches.Count == 0) + for (var i = _workflows.Count - 1; i >= 0; i--) { - throw new PropagationNotFoundException($"no workflow named '{name}' in propagated history"); + if (string.Equals(_workflows[i].Name, name, StringComparison.Ordinal)) + { + result = _workflows[i]; + return true; + } } - return matches[^1]; + result = null; + return false; } /// - /// Returns every workflow chunk produced by the given app, in execution order. + /// Returns every workflow entry produced by the given app, in execution order. /// /// The Dapr App ID to filter by. /// An empty list when no match is found. - public IReadOnlyList GetWorkflowsByAppId(string appId) + public IReadOnlyList GetWorkflowsByAppId(string appId) { ArgumentException.ThrowIfNullOrWhiteSpace(appId); return _workflows @@ -114,12 +119,12 @@ public IReadOnlyList GetWorkflowsByAppId(string appId) } /// - /// Returns every workflow chunk produced by the given instance, in execution order. + /// Returns every workflow entry produced by the given instance, in execution order. /// Usually a single entry, except when the same instance reappears via ContinueAsNew. /// /// The workflow instance ID to filter by. /// An empty list when no match is found. - public IReadOnlyList GetWorkflowsByInstanceId(string instanceId) + public IReadOnlyList GetWorkflowsByInstanceId(string instanceId) { ArgumentException.ThrowIfNullOrWhiteSpace(instanceId); return _workflows diff --git a/src/Dapr.Workflow.Abstractions/ActivityResult.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistoryActivityResult.cs similarity index 82% rename from src/Dapr.Workflow.Abstractions/ActivityResult.cs rename to src/Dapr.Workflow.Abstractions/PropagatedHistoryActivityResult.cs index d0109d758..9ed27c855 100644 --- a/src/Dapr.Workflow.Abstractions/ActivityResult.cs +++ b/src/Dapr.Workflow.Abstractions/PropagatedHistoryActivityResult.cs @@ -14,21 +14,20 @@ namespace Dapr.Workflow; /// -/// A reconstructed view of a single activity invocation from propagated history. +/// A reconstructed view of a single activity invocation surfaced through propagated workflow history. /// /// The scheduled name of the activity. -/// Whether the activity was scheduled in the propagated chunk. +/// Whether the activity was scheduled in the propagated history. /// Whether the activity completed successfully. /// Whether the activity failed. /// The JSON-encoded input payload, or null when unset. /// The JSON-encoded output payload, or null when the activity has not completed. /// The failure details when is true, otherwise null. /// -/// Mirrors the ActivityResult struct in the Go SDK and the -/// ActivityResult dataclass in the Python SDK so cross-language -/// quickstarts and chain-of-custody patterns line up. +/// Mirrors the ActivityResult type in the Go and Python SDKs so cross-language +/// quickstarts and audit patterns line up. /// -public sealed record ActivityResult( +public sealed record PropagatedHistoryActivityResult( string Name, bool Started, bool Completed, diff --git a/src/Dapr.Workflow.Abstractions/ChildWorkflowResult.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistoryChildWorkflowResult.cs similarity index 88% rename from src/Dapr.Workflow.Abstractions/ChildWorkflowResult.cs rename to src/Dapr.Workflow.Abstractions/PropagatedHistoryChildWorkflowResult.cs index a67b14035..fb7ca74b6 100644 --- a/src/Dapr.Workflow.Abstractions/ChildWorkflowResult.cs +++ b/src/Dapr.Workflow.Abstractions/PropagatedHistoryChildWorkflowResult.cs @@ -14,10 +14,10 @@ namespace Dapr.Workflow; /// -/// A reconstructed view of a single child workflow invocation from propagated history. +/// A reconstructed view of a single child workflow invocation surfaced through propagated workflow history. /// /// The scheduled name of the child workflow. -/// Whether the child workflow was scheduled in the propagated chunk. +/// Whether the child workflow was scheduled in the propagated history. /// Whether the child workflow completed successfully. /// Whether the child workflow failed. /// The JSON-encoded output payload, or null when the child workflow has not completed. @@ -25,7 +25,7 @@ namespace Dapr.Workflow; /// /// Mirrors the ChildWorkflowResult type in the Go and Python SDKs. /// -public sealed record ChildWorkflowResult( +public sealed record PropagatedHistoryChildWorkflowResult( string Name, bool Started, bool Completed, diff --git a/src/Dapr.Workflow.Abstractions/PropagatedHistoryEntry.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistoryEntry.cs new file mode 100644 index 000000000..51f00c9e0 --- /dev/null +++ b/src/Dapr.Workflow.Abstractions/PropagatedHistoryEntry.cs @@ -0,0 +1,132 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow; + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +/// +/// A single workflow's contribution to a propagated history: the ancestor workflow's identity, +/// plus the activities and child workflows it executed. +/// +/// The instance ID of the ancestor workflow. +/// The Dapr App ID that ran the ancestor workflow. +/// The name of the ancestor workflow. +/// Activities resolved from this entry, in execution order. +/// Child workflows resolved from this entry, in execution order. +/// +/// One exists per ancestor workflow in a +/// . Use and +/// to look up specific items in this entry; +/// the plural Get*ByName variants return every occurrence in execution order. +/// +public sealed class PropagatedHistoryEntry( + string instanceId, + string appId, + string name, + IReadOnlyList activities, + IReadOnlyList childWorkflows) +{ + private readonly IReadOnlyList _activities = + activities ?? throw new ArgumentNullException(nameof(activities)); + private readonly IReadOnlyList _childWorkflows = + childWorkflows ?? throw new ArgumentNullException(nameof(childWorkflows)); + + /// The instance ID of the ancestor workflow this entry describes. + public string InstanceId { get; } = instanceId ?? throw new ArgumentNullException(nameof(instanceId)); + + /// The Dapr App ID that ran this ancestor workflow. + public string AppId { get; } = appId ?? throw new ArgumentNullException(nameof(appId)); + + /// The name of this ancestor workflow. + public string Name { get; } = name ?? throw new ArgumentNullException(nameof(name)); + + /// All activities executed in this entry, in execution order. + public IReadOnlyList Activities => _activities; + + /// All child workflows started in this entry, in execution order. + public IReadOnlyList ChildWorkflows => _childWorkflows; + + /// + /// Returns every activity in this entry whose scheduled name matches, in execution order. + /// + /// The activity name to filter by. + /// An empty list when no match is found. + public IReadOnlyList GetActivitiesByName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return _activities + .Where(a => string.Equals(a.Name, name, StringComparison.Ordinal)) + .ToList(); + } + + /// + /// Tries to return the most recent activity in this entry whose name matches. + /// + /// The activity name to look up. + /// When this method returns , the last matching activity; otherwise . + /// if a matching activity was found; otherwise . + public bool TryGetLastActivityByName(string name, [NotNullWhen(true)] out PropagatedHistoryActivityResult? result) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + for (var i = _activities.Count - 1; i >= 0; i--) + { + if (string.Equals(_activities[i].Name, name, StringComparison.Ordinal)) + { + result = _activities[i]; + return true; + } + } + + result = null; + return false; + } + + /// + /// Returns every child workflow in this entry whose name matches, in execution order. + /// + /// The child workflow name to filter by. + /// An empty list when no match is found. + public IReadOnlyList GetChildWorkflowsByName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return _childWorkflows + .Where(c => string.Equals(c.Name, name, StringComparison.Ordinal)) + .ToList(); + } + + /// + /// Tries to return the most recent child workflow in this entry whose name matches. + /// + /// The child workflow name to look up. + /// When this method returns , the last matching child workflow; otherwise . + /// if a matching child workflow was found; otherwise . + public bool TryGetLastChildWorkflowByName(string name, [NotNullWhen(true)] out PropagatedHistoryChildWorkflowResult? result) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + for (var i = _childWorkflows.Count - 1; i >= 0; i--) + { + if (string.Equals(_childWorkflows[i].Name, name, StringComparison.Ordinal)) + { + result = _childWorkflows[i]; + return true; + } + } + + result = null; + return false; + } +} diff --git a/src/Dapr.Workflow.Abstractions/WorkflowContext.cs b/src/Dapr.Workflow.Abstractions/WorkflowContext.cs index cd8bbac54..2ed3b2852 100644 --- a/src/Dapr.Workflow.Abstractions/WorkflowContext.cs +++ b/src/Dapr.Workflow.Abstractions/WorkflowContext.cs @@ -341,16 +341,17 @@ public virtual Task CallChildWorkflowAsync( /// specified a other than . /// /// - /// Use and - /// / - /// to query specific items from the chain. The plural Get*sByName variants return every match. + /// Use and + /// / + /// to look up specific items in the propagated history. The plural Get*ByName variants + /// return every match. /// /// /// This method always returns the same value regardless of whether the workflow is replaying. /// /// /// - /// A containing the chain of ancestor workflows, + /// A containing the list of ancestor workflow entries, /// or null if no history was propagated to this workflow instance. /// public abstract PropagatedHistory? GetPropagatedHistory(); diff --git a/src/Dapr.Workflow.Abstractions/WorkflowHistory.cs b/src/Dapr.Workflow.Abstractions/WorkflowHistory.cs deleted file mode 100644 index 78d1597e3..000000000 --- a/src/Dapr.Workflow.Abstractions/WorkflowHistory.cs +++ /dev/null @@ -1,46 +0,0 @@ -// ------------------------------------------------------------------------ -// Copyright 2026 The Dapr Authors -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ------------------------------------------------------------------------ - -namespace Dapr.Workflow; - -/// -/// Scheduling-side helpers for workflow history propagation, mirroring the -/// workflow.PropagateLineage() / workflow.PropagateOwnHistory() -/// factories in the Go SDK. -/// -/// -/// Both forms are equivalent: options.WithHistoryPropagation(WorkflowHistory.PropagateLineage()) -/// and options.WithHistoryPropagation(HistoryPropagationScope.Lineage) produce the same scope. -/// The factory helpers exist for cross-SDK call-site parity. -/// -public static class WorkflowHistory -{ - /// - /// Returns the that propagates the caller's own - /// events plus any ancestor events it received. - /// - /// - /// Use for chain-of-custody verification where downstream code needs visibility into - /// the full lineage of upstream workflows. - /// - public static HistoryPropagationScope PropagateLineage() => HistoryPropagationScope.Lineage; - - /// - /// Returns the that propagates the caller's events - /// only; ancestor history is dropped. - /// - /// - /// Use as a trust boundary, where downstream code should only see the immediate caller. - /// - public static HistoryPropagationScope PropagateOwnHistory() => HistoryPropagationScope.OwnHistory; -} diff --git a/src/Dapr.Workflow.Abstractions/WorkflowResult.cs b/src/Dapr.Workflow.Abstractions/WorkflowResult.cs deleted file mode 100644 index 74f9728db..000000000 --- a/src/Dapr.Workflow.Abstractions/WorkflowResult.cs +++ /dev/null @@ -1,134 +0,0 @@ -// ------------------------------------------------------------------------ -// Copyright 2026 The Dapr Authors -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ------------------------------------------------------------------------ - -namespace Dapr.Workflow; - -using System; -using System.Collections.Generic; -using System.Linq; - -/// -/// A scoped view of a single workflow's chunk in propagated history. -/// -/// -/// Mirrors the WorkflowResult type in the Go SDK. Use -/// / -/// to query specific items inside this chunk; the plural Get*sByName -/// variants return every occurrence in execution order. -/// -public sealed class WorkflowResult -{ - private readonly IReadOnlyList _activities; - private readonly IReadOnlyList _childWorkflows; - - /// - /// Initializes a new . - /// - /// The instance ID of the ancestor workflow. - /// The Dapr App ID that ran the ancestor workflow. - /// The name of the ancestor workflow. - /// Activities resolved from this chunk, in execution order. - /// Child workflows resolved from this chunk, in execution order. - public WorkflowResult( - string instanceId, - string appId, - string name, - IReadOnlyList activities, - IReadOnlyList childWorkflows) - { - InstanceId = instanceId ?? throw new ArgumentNullException(nameof(instanceId)); - AppId = appId ?? throw new ArgumentNullException(nameof(appId)); - Name = name ?? throw new ArgumentNullException(nameof(name)); - _activities = activities ?? throw new ArgumentNullException(nameof(activities)); - _childWorkflows = childWorkflows ?? throw new ArgumentNullException(nameof(childWorkflows)); - } - - /// The instance ID of this workflow chunk's ancestor. - public string InstanceId { get; } - - /// The Dapr App ID that ran this workflow chunk. - public string AppId { get; } - - /// The name of this workflow. - public string Name { get; } - - /// All activities executed in this chunk, in execution order. - public IReadOnlyList Activities => _activities; - - /// All child workflows started in this chunk, in execution order. - public IReadOnlyList ChildWorkflows => _childWorkflows; - - /// - /// Returns every activity in this chunk whose scheduled name matches, in execution order. - /// - /// The activity name to filter by. - /// An empty list when no match is found. - public IReadOnlyList GetActivitiesByName(string name) - { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - return _activities - .Where(a => string.Equals(a.Name, name, StringComparison.Ordinal)) - .ToList(); - } - - /// - /// Returns the most recent activity in this chunk whose name matches. - /// - /// The activity name to look up. - /// The last matching activity. - /// No activity with the given name is present in this chunk. - public ActivityResult GetLastActivityByName(string name) - { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - var matches = GetActivitiesByName(name); - if (matches.Count == 0) - { - throw new PropagationNotFoundException( - $"no activity named '{name}' in propagated history for workflow '{Name}'"); - } - - return matches[^1]; - } - - /// - /// Returns every child workflow in this chunk whose name matches, in execution order. - /// - /// The child workflow name to filter by. - /// An empty list when no match is found. - public IReadOnlyList GetChildWorkflowsByName(string name) - { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - return _childWorkflows - .Where(c => string.Equals(c.Name, name, StringComparison.Ordinal)) - .ToList(); - } - - /// - /// Returns the most recent child workflow in this chunk whose name matches. - /// - /// The child workflow name to look up. - /// The last matching child workflow. - /// No child workflow with the given name is present in this chunk. - public ChildWorkflowResult GetLastChildWorkflowByName(string name) - { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - var matches = GetChildWorkflowsByName(name); - if (matches.Count == 0) - { - throw new PropagationNotFoundException( - $"no child workflow named '{name}' in propagated history for workflow '{Name}'"); - } - - return matches[^1]; - } -} diff --git a/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs b/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs index 24c2eaf80..bbfaa3782 100644 --- a/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs +++ b/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs @@ -1003,7 +1003,7 @@ private static WorkflowTaskFailedException CreateTaskFailedException(TaskFailedE /// /// Converts a proto message into a public-facing - /// by resolving each scheduled activity and child workflow + /// by resolving each scheduled activity and child workflow /// against its matching completion/failure event. /// /// @@ -1011,25 +1011,19 @@ private static WorkflowTaskFailedException CreateTaskFailedException(TaskFailedE /// representation of a single ; we parse the bytes here. /// SDK retries reuse TaskExecutionId, so we match completions on /// TaskScheduledId (the scheduling event ID) rather than execution ID. - /// Malformed event bytes are skipped — they cannot crash the workflow. + /// Malformed event bytes are surfaced as exceptions — a runtime sending unparseable + /// proto bytes is a contract violation we should not hide. /// - private static WorkflowResult ConvertChunk(PropagatedHistoryChunk chunk) + private static PropagatedHistoryEntry ConvertChunk(PropagatedHistoryChunk chunk) { var events = new List(chunk.RawEvents.Count); foreach (var rawEvent in chunk.RawEvents) { - try - { - events.Add(HistoryEvent.Parser.ParseFrom(rawEvent)); - } - catch (InvalidProtocolBufferException) - { - // Skip malformed events; a single bad event cannot poison the chunk. - } + events.Add(HistoryEvent.Parser.ParseFrom(rawEvent)); } - var activities = new List(); - var childWorkflows = new List(); + var activities = new List(); + var childWorkflows = new List(); foreach (var historyEvent in events) { switch (historyEvent.EventTypeCase) @@ -1043,7 +1037,7 @@ private static WorkflowResult ConvertChunk(PropagatedHistoryChunk chunk) } } - return new WorkflowResult( + return new PropagatedHistoryEntry( chunk.InstanceId, chunk.AppId, chunk.WorkflowName, @@ -1052,10 +1046,10 @@ private static WorkflowResult ConvertChunk(PropagatedHistoryChunk chunk) } /// - /// Builds an by matching TaskCompleted / TaskFailed - /// against the scheduling event's EventId. + /// Builds a by matching TaskCompleted / + /// TaskFailed against the scheduling event's EventId. /// - private static ActivityResult ResolveActivity( + private static PropagatedHistoryActivityResult ResolveActivity( IReadOnlyList events, HistoryEvent scheduleEvent) { @@ -1082,7 +1076,7 @@ private static ActivityResult ResolveActivity( } } - return new ActivityResult( + return new PropagatedHistoryActivityResult( Name: scheduled.Name, Started: true, Completed: completed, @@ -1093,10 +1087,11 @@ private static ActivityResult ResolveActivity( } /// - /// Builds a by matching the create event's EventId - /// against subsequent ChildWorkflowInstanceCompleted / ChildWorkflowInstanceFailed events. + /// Builds a by matching the create event's + /// EventId against subsequent ChildWorkflowInstanceCompleted / + /// ChildWorkflowInstanceFailed events. /// - private static ChildWorkflowResult ResolveChildWorkflow( + private static PropagatedHistoryChildWorkflowResult ResolveChildWorkflow( IReadOnlyList events, HistoryEvent createEvent) { @@ -1123,7 +1118,7 @@ private static ChildWorkflowResult ResolveChildWorkflow( } } - return new ChildWorkflowResult( + return new PropagatedHistoryChildWorkflowResult( Name: created.Name, Started: true, Completed: completed, diff --git a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs index 70cba3341..0a883f26c 100644 --- a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs +++ b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs @@ -29,8 +29,8 @@ namespace Dapr.Workflow.Test.Worker.Internal; /// outgoing / , /// and exposing inbound propagated history through /// as typed -/// / / -/// records. +/// / / +/// records. /// public class WorkflowHistoryPropagationTests { @@ -134,7 +134,7 @@ private static HistoryEvent ChildFailed(int eventId, int creationId, string erro }); // ------------------------------------------------------------------ - // GetPropagatedHistory — chunk shape and ordering + // GetPropagatedHistory — entry shape and ordering // ------------------------------------------------------------------ [Fact] @@ -152,7 +152,7 @@ public void GetPropagatedHistory_ReturnsNull_WhenEmptyChunksProvided() } [Fact] - public void GetPropagatedHistory_ReturnsSingleWorkflow_WhenOneChunkPropagated() + public void GetPropagatedHistory_ReturnsSingleWorkflow_WhenOneEntryPropagated() { var chunk = MakeChunk("parent-app", "parent-instance", "ParentWorkflow", MakeEvent(1, e => e.ExecutionStarted = new ExecutionStartedEvent { Name = "ParentWorkflow" })); @@ -172,9 +172,9 @@ public void GetPropagatedHistory_ReturnsSingleWorkflow_WhenOneChunkPropagated() } [Fact] - public void GetPropagatedHistory_PreservesChunkOrder() + public void GetPropagatedHistory_PreservesEntryOrder() { - // Chunks arrive oldest-first: grandparent at index 0, immediate parent last. + // Entries arrive oldest-first: grandparent at index 0, immediate parent last. var grandparent = MakeChunk("gp-app", "gp-inst", "GrandparentWf", MakeEvent(1, e => e.ExecutionStarted = new ExecutionStartedEvent { Name = "GrandparentWf" })); var parent = MakeChunk("p-app", "p-inst", "ParentWf", @@ -191,8 +191,10 @@ public void GetPropagatedHistory_PreservesChunkOrder() } [Fact] - public void GetPropagatedHistory_SkipsMalformedRawEvents() + public void GetPropagatedHistory_ThrowsOnMalformedRawEvents() { + // A runtime sending unparseable proto bytes is a contract violation; surface it + // instead of silently masking the bug. var chunk = new PropagatedHistoryChunk { AppId = "app", @@ -200,13 +202,8 @@ public void GetPropagatedHistory_SkipsMalformedRawEvents() WorkflowName = "Wf", }; chunk.RawEvents.Add(ByteString.CopyFrom([0xff, 0xff, 0xff, 0xff, 0xff])); - chunk.RawEvents.Add(TaskScheduled(eventId: 7, name: "Echo").ToByteString()); - var context = CreateContext(incomingPropagatedHistory: [chunk]); - var workflow = context.GetPropagatedHistory()!.GetWorkflows().Single(); - - Assert.Single(workflow.Activities); - Assert.Equal("Echo", workflow.Activities[0].Name); + Assert.Throws(() => CreateContext(incomingPropagatedHistory: [chunk])); } // ------------------------------------------------------------------ @@ -221,9 +218,8 @@ public void ActivityResult_ResolvedAs_CompletedWithInputAndOutput() TaskCompleted(eventId: 2, scheduledId: 1, result: "true")); var context = CreateContext(incomingPropagatedHistory: [chunk]); - var activity = context.GetPropagatedHistory()! - .GetLastWorkflowByName("Wf") - .GetLastActivityByName("ValidateMerchant"); + Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowByName("Wf", out var workflow)); + Assert.True(workflow.TryGetLastActivityByName("ValidateMerchant", out var activity)); Assert.Equal("ValidateMerchant", activity.Name); Assert.True(activity.Started); @@ -242,9 +238,8 @@ public void ActivityResult_ResolvedAs_FailedWithFailureDetails() TaskFailed(eventId: 2, scheduledId: 1, errorMessage: "card declined")); var context = CreateContext(incomingPropagatedHistory: [chunk]); - var activity = context.GetPropagatedHistory()! - .GetLastWorkflowByName("Wf") - .GetLastActivityByName("ValidateCard"); + Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowByName("Wf", out var workflow)); + Assert.True(workflow.TryGetLastActivityByName("ValidateCard", out var activity)); Assert.False(activity.Completed); Assert.True(activity.Failed); @@ -259,9 +254,8 @@ public void ActivityResult_ResolvedAs_StartedOnly_WhenNotYetCompleted() TaskScheduled(eventId: 1, name: "PendingCheck", input: "\"in\"")); var context = CreateContext(incomingPropagatedHistory: [chunk]); - var activity = context.GetPropagatedHistory()! - .GetLastWorkflowByName("Wf") - .GetLastActivityByName("PendingCheck"); + Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowByName("Wf", out var workflow)); + Assert.True(workflow.TryGetLastActivityByName("PendingCheck", out var activity)); Assert.True(activity.Started); Assert.False(activity.Completed); @@ -271,9 +265,9 @@ public void ActivityResult_ResolvedAs_StartedOnly_WhenNotYetCompleted() } [Fact] - public void GetLastActivityByName_ReturnsMostRecentInvocation_WhenRetried() + public void TryGetLastActivityByName_ReturnsMostRecentInvocation_WhenRetried() { - // Same activity scheduled twice — first completes, second fails. GetLast returns the failed (most recent). + // Same activity scheduled twice — first completes, second fails. TryGet returns the failed (most recent). var chunk = MakeChunk("app", "inst", "Wf", TaskScheduled(eventId: 1, name: "ValidateCard", input: "\"first\""), TaskCompleted(eventId: 2, scheduledId: 1, result: "true"), @@ -281,28 +275,29 @@ public void GetLastActivityByName_ReturnsMostRecentInvocation_WhenRetried() TaskFailed(eventId: 4, scheduledId: 3, errorMessage: "card declined")); var context = CreateContext(incomingPropagatedHistory: [chunk]); - var workflow = context.GetPropagatedHistory()!.GetLastWorkflowByName("Wf"); + Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowByName("Wf", out var workflow)); var all = workflow.GetActivitiesByName("ValidateCard"); Assert.Equal(2, all.Count); Assert.True(all[0].Completed); Assert.True(all[1].Failed); - var last = workflow.GetLastActivityByName("ValidateCard"); + Assert.True(workflow.TryGetLastActivityByName("ValidateCard", out var last)); Assert.True(last.Failed); Assert.Equal("card declined", last.FailureDetails!.ErrorMessage); } [Fact] - public void GetLastActivityByName_Throws_WhenMissing() + public void TryGetLastActivityByName_ReturnsFalse_WhenMissing() { var chunk = MakeChunk("app", "inst", "Wf", TaskScheduled(eventId: 1, name: "Real")); var context = CreateContext(incomingPropagatedHistory: [chunk]); - var workflow = context.GetPropagatedHistory()!.GetLastWorkflowByName("Wf"); + Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowByName("Wf", out var workflow)); - Assert.Throws(() => workflow.GetLastActivityByName("Missing")); + Assert.False(workflow.TryGetLastActivityByName("Missing", out var missing)); + Assert.Null(missing); } // ------------------------------------------------------------------ @@ -317,9 +312,8 @@ public void ChildWorkflowResult_ResolvedAs_Completed() ChildCompleted(eventId: 2, creationId: 1, result: "\"paid\"")); var context = CreateContext(incomingPropagatedHistory: [chunk]); - var child = context.GetPropagatedHistory()! - .GetLastWorkflowByName("Wf") - .GetLastChildWorkflowByName("ProcessPayment"); + Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowByName("Wf", out var workflow)); + Assert.True(workflow.TryGetLastChildWorkflowByName("ProcessPayment", out var child)); Assert.True(child.Started); Assert.True(child.Completed); @@ -335,57 +329,59 @@ public void ChildWorkflowResult_ResolvedAs_Failed() ChildFailed(eventId: 2, creationId: 1, errorMessage: "boom")); var context = CreateContext(incomingPropagatedHistory: [chunk]); - var child = context.GetPropagatedHistory()! - .GetLastWorkflowByName("Wf") - .GetLastChildWorkflowByName("ProcessPayment"); + Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowByName("Wf", out var workflow)); + Assert.True(workflow.TryGetLastChildWorkflowByName("ProcessPayment", out var child)); Assert.True(child.Failed); Assert.Equal("boom", child.FailureDetails!.ErrorMessage); } [Fact] - public void GetLastChildWorkflowByName_Throws_WhenMissing() + public void TryGetLastChildWorkflowByName_ReturnsFalse_WhenMissing() { var chunk = MakeChunk("app", "inst", "Wf"); var context = CreateContext(incomingPropagatedHistory: [chunk]); - var workflow = context.GetPropagatedHistory()!.GetLastWorkflowByName("Wf"); + Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowByName("Wf", out var workflow)); - Assert.Throws(() => workflow.GetLastChildWorkflowByName("Missing")); + Assert.False(workflow.TryGetLastChildWorkflowByName("Missing", out var missing)); + Assert.Null(missing); } // ------------------------------------------------------------------ - // Chain-level helpers + // PropagatedHistory-level helpers // ------------------------------------------------------------------ [Fact] public void GetAppIds_ReturnsOrderedDeduplicatedList() { var history = new PropagatedHistory([ - new WorkflowResult("i1", "appA", "WfA", [], []), - new WorkflowResult("i2", "appB", "WfB", [], []), - new WorkflowResult("i3", "appA", "WfA2", [], []), + new PropagatedHistoryEntry("i1", "appA", "WfA", [], []), + new PropagatedHistoryEntry("i2", "appB", "WfB", [], []), + new PropagatedHistoryEntry("i3", "appA", "WfA2", [], []), ]); Assert.Equal(["appA", "appB"], history.GetAppIds()); } [Fact] - public void GetLastWorkflowByName_ReturnsMostRecent_WhenNameRepeated() + public void TryGetLastWorkflowByName_ReturnsMostRecent_WhenNameRepeated() { var history = new PropagatedHistory([ - new WorkflowResult("wf-1", "app", "Loop", [], []), - new WorkflowResult("wf-2", "app", "Loop", [], []), + new PropagatedHistoryEntry("wf-1", "app", "Loop", [], []), + new PropagatedHistoryEntry("wf-2", "app", "Loop", [], []), ]); - Assert.Equal("wf-2", history.GetLastWorkflowByName("Loop").InstanceId); + Assert.True(history.TryGetLastWorkflowByName("Loop", out var last)); + Assert.Equal("wf-2", last.InstanceId); Assert.Equal(2, history.GetWorkflowsByName("Loop").Count); } [Fact] - public void GetLastWorkflowByName_Throws_WhenMissing() + public void TryGetLastWorkflowByName_ReturnsFalse_WhenMissing() { var history = new PropagatedHistory([]); - Assert.Throws(() => history.GetLastWorkflowByName("Missing")); + Assert.False(history.TryGetLastWorkflowByName("Missing", out var missing)); + Assert.Null(missing); } [Fact] @@ -446,18 +442,6 @@ public void ChildWorkflowTaskOptions_WithHistoryPropagation_ReturnsDerivedType() Assert.IsType(updated); } - [Fact] - public void WorkflowHistory_PropagateLineage_ReturnsLineageScope() - { - Assert.Equal(HistoryPropagationScope.Lineage, WorkflowHistory.PropagateLineage()); - } - - [Fact] - public void WorkflowHistory_PropagateOwnHistory_ReturnsOwnHistoryScope() - { - Assert.Equal(HistoryPropagationScope.OwnHistory, WorkflowHistory.PropagateOwnHistory()); - } - // ------------------------------------------------------------------ // Outbound action scope — activity path // ------------------------------------------------------------------ @@ -494,7 +478,7 @@ public void CallActivityAsync_WithLineage_SetsScopeOnAction() { var context = CreateContext(instanceId: "parent", appId: "my-app"); _ = context.CallActivityAsync("Echo", - options: new WorkflowTaskOptions().WithHistoryPropagation(WorkflowHistory.PropagateLineage())); + options: new WorkflowTaskOptions().WithHistoryPropagation(HistoryPropagationScope.Lineage)); var action = context.PendingActions .Select(a => a.ScheduleTask) @@ -539,7 +523,7 @@ public void CallChildWorkflowAsync_WithLineage_SetsScopeOnAction() { var context = CreateContext(instanceId: "parent", appId: "my-app"); _ = context.CallChildWorkflowAsync("ChildWf", - options: new ChildWorkflowTaskOptions().WithHistoryPropagation(WorkflowHistory.PropagateLineage())); + options: new ChildWorkflowTaskOptions().WithHistoryPropagation(HistoryPropagationScope.Lineage)); var action = context.PendingActions .Select(a => a.CreateChildWorkflow) From 36c2788a5d066b2fe3648d3b1e4834ac9f09ec26 Mon Sep 17 00:00:00 2001 From: Nelson Parente Date: Fri, 22 May 2026 11:54:22 +0100 Subject: [PATCH 07/12] test(workflow): rename propagation test cases to match renamed types Test names previously embedded the old type names (ActivityResult, ChildWorkflowResult); rename to the more general Activity_/ChildWorkflow_ prefix to avoid confusion with the renamed public types. Signed-off-by: Nelson Parente --- .../Worker/Internal/WorkflowHistoryPropagationTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs index 0a883f26c..13c49f919 100644 --- a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs +++ b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs @@ -211,7 +211,7 @@ public void GetPropagatedHistory_ThrowsOnMalformedRawEvents() // ------------------------------------------------------------------ [Fact] - public void ActivityResult_ResolvedAs_CompletedWithInputAndOutput() + public void Activity_ResolvedAs_CompletedWithInputAndOutput() { var chunk = MakeChunk("app", "inst", "Wf", TaskScheduled(eventId: 1, name: "ValidateMerchant", input: "\"merchant-1\""), @@ -231,7 +231,7 @@ public void ActivityResult_ResolvedAs_CompletedWithInputAndOutput() } [Fact] - public void ActivityResult_ResolvedAs_FailedWithFailureDetails() + public void Activity_ResolvedAs_FailedWithFailureDetails() { var chunk = MakeChunk("app", "inst", "Wf", TaskScheduled(eventId: 1, name: "ValidateCard"), @@ -248,7 +248,7 @@ public void ActivityResult_ResolvedAs_FailedWithFailureDetails() } [Fact] - public void ActivityResult_ResolvedAs_StartedOnly_WhenNotYetCompleted() + public void Activity_ResolvedAs_StartedOnly_WhenNotYetCompleted() { var chunk = MakeChunk("app", "inst", "Wf", TaskScheduled(eventId: 1, name: "PendingCheck", input: "\"in\"")); @@ -305,7 +305,7 @@ public void TryGetLastActivityByName_ReturnsFalse_WhenMissing() // ------------------------------------------------------------------ [Fact] - public void ChildWorkflowResult_ResolvedAs_Completed() + public void ChildWorkflow_ResolvedAs_Completed() { var chunk = MakeChunk("app", "inst", "Wf", ChildCreated(eventId: 1, name: "ProcessPayment"), @@ -322,7 +322,7 @@ public void ChildWorkflowResult_ResolvedAs_Completed() } [Fact] - public void ChildWorkflowResult_ResolvedAs_Failed() + public void ChildWorkflow_ResolvedAs_Failed() { var chunk = MakeChunk("app", "inst", "Wf", ChildCreated(eventId: 1, name: "ProcessPayment"), From 25fe1d75fb41bd7cdecbb3e5d87a17342320c189 Mon Sep 17 00:00:00 2001 From: Nelson Parente Date: Fri, 22 May 2026 16:46:18 +0100 Subject: [PATCH 08/12] fix(workflow): match propagated-history names and app IDs case-insensitively Workflow / activity names register through WorkflowsFactory with StringComparer.OrdinalIgnoreCase, and app IDs are matched case-insensitively on the invocation path. Align the propagated history lookups (GetAppIds, GetWorkflowsByName, TryGetLastWorkflowByName, GetWorkflowsByAppId, GetActivitiesByName, TryGetLastActivityByName, GetChildWorkflowsByName, TryGetLastChildWorkflowByName) with that contract so callers don't get surprising misses or duplicate logical IDs that only differ by casing. Signed-off-by: Nelson Parente --- .../PropagatedHistory.cs | 8 ++--- .../PropagatedHistoryEntry.cs | 8 ++--- .../WorkflowHistoryPropagationTests.cs | 32 +++++++++++++++++-- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs index b011bb10a..7544e77dd 100644 --- a/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs +++ b/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs @@ -56,7 +56,7 @@ public PropagatedHistory(IReadOnlyList workflows) /// public IReadOnlyList GetAppIds() { - var seen = new HashSet(StringComparer.Ordinal); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var result = new List(_workflows.Count); foreach (var workflow in _workflows) { @@ -79,7 +79,7 @@ public IReadOnlyList GetWorkflowsByName(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); return _workflows - .Where(w => string.Equals(w.Name, name, StringComparison.Ordinal)) + .Where(w => string.Equals(w.Name, name, StringComparison.OrdinalIgnoreCase)) .ToList(); } @@ -94,7 +94,7 @@ public bool TryGetLastWorkflowByName(string name, [NotNullWhen(true)] out Propag ArgumentException.ThrowIfNullOrWhiteSpace(name); for (var i = _workflows.Count - 1; i >= 0; i--) { - if (string.Equals(_workflows[i].Name, name, StringComparison.Ordinal)) + if (string.Equals(_workflows[i].Name, name, StringComparison.OrdinalIgnoreCase)) { result = _workflows[i]; return true; @@ -114,7 +114,7 @@ public IReadOnlyList GetWorkflowsByAppId(string appId) { ArgumentException.ThrowIfNullOrWhiteSpace(appId); return _workflows - .Where(w => string.Equals(w.AppId, appId, StringComparison.Ordinal)) + .Where(w => string.Equals(w.AppId, appId, StringComparison.OrdinalIgnoreCase)) .ToList(); } diff --git a/src/Dapr.Workflow.Abstractions/PropagatedHistoryEntry.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistoryEntry.cs index 51f00c9e0..abbc903ba 100644 --- a/src/Dapr.Workflow.Abstractions/PropagatedHistoryEntry.cs +++ b/src/Dapr.Workflow.Abstractions/PropagatedHistoryEntry.cs @@ -69,7 +69,7 @@ public IReadOnlyList GetActivitiesByName(string { ArgumentException.ThrowIfNullOrWhiteSpace(name); return _activities - .Where(a => string.Equals(a.Name, name, StringComparison.Ordinal)) + .Where(a => string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase)) .ToList(); } @@ -84,7 +84,7 @@ public bool TryGetLastActivityByName(string name, [NotNullWhen(true)] out Propag ArgumentException.ThrowIfNullOrWhiteSpace(name); for (var i = _activities.Count - 1; i >= 0; i--) { - if (string.Equals(_activities[i].Name, name, StringComparison.Ordinal)) + if (string.Equals(_activities[i].Name, name, StringComparison.OrdinalIgnoreCase)) { result = _activities[i]; return true; @@ -104,7 +104,7 @@ public IReadOnlyList GetChildWorkflowsByNa { ArgumentException.ThrowIfNullOrWhiteSpace(name); return _childWorkflows - .Where(c => string.Equals(c.Name, name, StringComparison.Ordinal)) + .Where(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase)) .ToList(); } @@ -119,7 +119,7 @@ public bool TryGetLastChildWorkflowByName(string name, [NotNullWhen(true)] out P ArgumentException.ThrowIfNullOrWhiteSpace(name); for (var i = _childWorkflows.Count - 1; i >= 0; i--) { - if (string.Equals(_childWorkflows[i].Name, name, StringComparison.Ordinal)) + if (string.Equals(_childWorkflows[i].Name, name, StringComparison.OrdinalIgnoreCase)) { result = _childWorkflows[i]; return true; diff --git a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs index 13c49f919..e1148d910 100644 --- a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs +++ b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs @@ -28,8 +28,8 @@ namespace Dapr.Workflow.Test.Worker.Internal; /// , propagating the scope to the /// outgoing / , /// and exposing inbound propagated history through -/// as typed -/// / / +/// as +/// values, each carrying typed / /// records. /// public class WorkflowHistoryPropagationTests @@ -390,6 +390,34 @@ public void PropagatedHistory_Ctor_ThrowsOnNullWorkflows() Assert.Throws(() => new PropagatedHistory(null!)); } + [Fact] + public void PropagatedHistory_NameAndAppIdLookups_AreCaseInsensitive() + { + // Workflow / activity names register case-insensitively in WorkflowsFactory, + // and AppIds are matched case-insensitively elsewhere in the SDK. The propagated + // history lookups must follow the same contract. + var activity = new PropagatedHistoryActivityResult( + Name: "ValidateMerchant", Started: true, Completed: true, Failed: false, + Input: null, Output: null, FailureDetails: null); + var child = new PropagatedHistoryChildWorkflowResult( + Name: "FraudDetection", Started: true, Completed: true, Failed: false, + Output: null, FailureDetails: null); + var entry = new PropagatedHistoryEntry("inst-1", "AppA", "MerchantCheckout", [activity], [child]); + var history = new PropagatedHistory([ + entry, + new PropagatedHistoryEntry("inst-2", "appa", "OtherWf", [], []), + ]); + + Assert.Single(history.GetAppIds()); + Assert.Single(history.GetWorkflowsByAppId("APPA")); + Assert.Single(history.GetWorkflowsByName("merchantcheckout")); + Assert.True(history.TryGetLastWorkflowByName("MERCHANTCHECKOUT", out _)); + Assert.Single(entry.GetActivitiesByName("validatemerchant")); + Assert.True(entry.TryGetLastActivityByName("VALIDATEMERCHANT", out _)); + Assert.Single(entry.GetChildWorkflowsByName("frauddetection")); + Assert.True(entry.TryGetLastChildWorkflowByName("FRAUDDETECTION", out _)); + } + // ------------------------------------------------------------------ // Scheduling helpers — WithHistoryPropagation // ------------------------------------------------------------------ From 6909ac21756ab777653243e5f466ea54d3f0fc1e Mon Sep 17 00:00:00 2001 From: Nelson Parente Date: Fri, 22 May 2026 16:47:24 +0100 Subject: [PATCH 09/12] perf(workflow): pre-index completion events in ConvertChunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConvertChunk previously rescanned the full event list inside ResolveActivity and ResolveChildWorkflow, making conversion O(n²) in the number of history events. Pre-index TaskCompleted / TaskFailed by TaskScheduledId (and the ChildWorkflowInstance counterparts) up front so each scheduled item resolves in O(1). Signed-off-by: Nelson Parente --- .../Internal/WorkflowOrchestrationContext.cs | 83 ++++++++++++------- 1 file changed, 51 insertions(+), 32 deletions(-) diff --git a/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs b/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs index bbfaa3782..2aa18deec 100644 --- a/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs +++ b/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs @@ -1022,6 +1022,31 @@ private static PropagatedHistoryEntry ConvertChunk(PropagatedHistoryChunk chunk) events.Add(HistoryEvent.Parser.ParseFrom(rawEvent)); } + // Pre-index completion / failure events by TaskScheduledId so each scheduled + // activity or child workflow resolves in O(1) instead of rescanning the list. + var taskCompletions = new Dictionary(); + var taskFailures = new Dictionary(); + var childCompletions = new Dictionary(); + var childFailures = new Dictionary(); + foreach (var e in events) + { + switch (e.EventTypeCase) + { + case HistoryEvent.EventTypeOneofCase.TaskCompleted: + taskCompletions[e.TaskCompleted.TaskScheduledId] = e; + break; + case HistoryEvent.EventTypeOneofCase.TaskFailed: + taskFailures[e.TaskFailed.TaskScheduledId] = e; + break; + case HistoryEvent.EventTypeOneofCase.ChildWorkflowInstanceCompleted: + childCompletions[e.ChildWorkflowInstanceCompleted.TaskScheduledId] = e; + break; + case HistoryEvent.EventTypeOneofCase.ChildWorkflowInstanceFailed: + childFailures[e.ChildWorkflowInstanceFailed.TaskScheduledId] = e; + break; + } + } + var activities = new List(); var childWorkflows = new List(); foreach (var historyEvent in events) @@ -1029,10 +1054,10 @@ private static PropagatedHistoryEntry ConvertChunk(PropagatedHistoryChunk chunk) switch (historyEvent.EventTypeCase) { case HistoryEvent.EventTypeOneofCase.TaskScheduled: - activities.Add(ResolveActivity(events, historyEvent)); + activities.Add(ResolveActivity(historyEvent, taskCompletions, taskFailures)); break; case HistoryEvent.EventTypeOneofCase.ChildWorkflowInstanceCreated: - childWorkflows.Add(ResolveChildWorkflow(events, historyEvent)); + childWorkflows.Add(ResolveChildWorkflow(historyEvent, childCompletions, childFailures)); break; } } @@ -1050,8 +1075,9 @@ private static PropagatedHistoryEntry ConvertChunk(PropagatedHistoryChunk chunk) /// TaskFailed against the scheduling event's EventId. /// private static PropagatedHistoryActivityResult ResolveActivity( - IReadOnlyList events, - HistoryEvent scheduleEvent) + HistoryEvent scheduleEvent, + IReadOnlyDictionary completions, + IReadOnlyDictionary failures) { var scheduled = scheduleEvent.TaskScheduled; var scheduleId = scheduleEvent.EventId; @@ -1060,20 +1086,16 @@ private static PropagatedHistoryActivityResult ResolveActivity( string? output = null; WorkflowTaskFailureDetails? failureDetails = null; - foreach (var e in events) + if (completions.TryGetValue(scheduleId, out var completion)) { - if (e.EventTypeCase == HistoryEvent.EventTypeOneofCase.TaskCompleted && - e.TaskCompleted.TaskScheduledId == scheduleId) - { - completed = true; - output = e.TaskCompleted.Result; - } - else if (e.EventTypeCase == HistoryEvent.EventTypeOneofCase.TaskFailed && - e.TaskFailed.TaskScheduledId == scheduleId) - { - failed = true; - failureDetails = MapFailureDetails(e.TaskFailed.FailureDetails); - } + completed = true; + output = completion.TaskCompleted.Result; + } + + if (failures.TryGetValue(scheduleId, out var failure)) + { + failed = true; + failureDetails = MapFailureDetails(failure.TaskFailed.FailureDetails); } return new PropagatedHistoryActivityResult( @@ -1092,8 +1114,9 @@ private static PropagatedHistoryActivityResult ResolveActivity( /// ChildWorkflowInstanceFailed events. /// private static PropagatedHistoryChildWorkflowResult ResolveChildWorkflow( - IReadOnlyList events, - HistoryEvent createEvent) + HistoryEvent createEvent, + IReadOnlyDictionary completions, + IReadOnlyDictionary failures) { var created = createEvent.ChildWorkflowInstanceCreated; var creationId = createEvent.EventId; @@ -1102,20 +1125,16 @@ private static PropagatedHistoryChildWorkflowResult ResolveChildWorkflow( string? output = null; WorkflowTaskFailureDetails? failureDetails = null; - foreach (var e in events) + if (completions.TryGetValue(creationId, out var completion)) { - if (e.EventTypeCase == HistoryEvent.EventTypeOneofCase.ChildWorkflowInstanceCompleted && - e.ChildWorkflowInstanceCompleted.TaskScheduledId == creationId) - { - completed = true; - output = e.ChildWorkflowInstanceCompleted.Result; - } - else if (e.EventTypeCase == HistoryEvent.EventTypeOneofCase.ChildWorkflowInstanceFailed && - e.ChildWorkflowInstanceFailed.TaskScheduledId == creationId) - { - failed = true; - failureDetails = MapFailureDetails(e.ChildWorkflowInstanceFailed.FailureDetails); - } + completed = true; + output = completion.ChildWorkflowInstanceCompleted.Result; + } + + if (failures.TryGetValue(creationId, out var failure)) + { + failed = true; + failureDetails = MapFailureDetails(failure.ChildWorkflowInstanceFailed.FailureDetails); } return new PropagatedHistoryChildWorkflowResult( From da2f380a9c7b39b7a28a9418c74ad3c3977f1ad7 Mon Sep 17 00:00:00 2001 From: Nelson Parente Date: Mon, 25 May 2026 11:14:45 +0100 Subject: [PATCH 10/12] refactor(workflow): rename PropagatedHistory backing field to _entries The private field and ctor parameter on PropagatedHistory are now named after the value type they hold (PropagatedHistoryEntry) rather than the 'workflows' role those entries play today. Public API surface is unchanged. Signed-off-by: Nelson Parente --- .../PropagatedHistory.cs | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs index 7544e77dd..a1c905c0c 100644 --- a/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs +++ b/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs @@ -32,24 +32,24 @@ namespace Dapr.Workflow; /// public sealed class PropagatedHistory { - private readonly IReadOnlyList _workflows; + private readonly IReadOnlyList _entries; /// /// Initializes a new from the given workflow entries. /// - /// + /// /// Workflow entries in execution order (ancestor first, immediate parent last). /// - public PropagatedHistory(IReadOnlyList workflows) + public PropagatedHistory(IReadOnlyList entries) { - _workflows = workflows ?? throw new ArgumentNullException(nameof(workflows)); + _entries = entries ?? throw new ArgumentNullException(nameof(entries)); } /// /// Returns every workflow entry in the propagated history, in execution order /// (ancestor first, immediate parent last). /// - public IReadOnlyList GetWorkflows() => _workflows; + public IReadOnlyList GetWorkflows() => _entries; /// /// Returns an ordered, deduplicated list of app IDs in this propagated history. @@ -57,12 +57,12 @@ public PropagatedHistory(IReadOnlyList workflows) public IReadOnlyList GetAppIds() { var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - var result = new List(_workflows.Count); - foreach (var workflow in _workflows) + var result = new List(_entries.Count); + foreach (var entry in _entries) { - if (seen.Add(workflow.AppId)) + if (seen.Add(entry.AppId)) { - result.Add(workflow.AppId); + result.Add(entry.AppId); } } @@ -78,8 +78,8 @@ public IReadOnlyList GetAppIds() public IReadOnlyList GetWorkflowsByName(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - return _workflows - .Where(w => string.Equals(w.Name, name, StringComparison.OrdinalIgnoreCase)) + return _entries + .Where(e => string.Equals(e.Name, name, StringComparison.OrdinalIgnoreCase)) .ToList(); } @@ -92,11 +92,11 @@ public IReadOnlyList GetWorkflowsByName(string name) public bool TryGetLastWorkflowByName(string name, [NotNullWhen(true)] out PropagatedHistoryEntry? result) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - for (var i = _workflows.Count - 1; i >= 0; i--) + for (var i = _entries.Count - 1; i >= 0; i--) { - if (string.Equals(_workflows[i].Name, name, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(_entries[i].Name, name, StringComparison.OrdinalIgnoreCase)) { - result = _workflows[i]; + result = _entries[i]; return true; } } @@ -113,8 +113,8 @@ public bool TryGetLastWorkflowByName(string name, [NotNullWhen(true)] out Propag public IReadOnlyList GetWorkflowsByAppId(string appId) { ArgumentException.ThrowIfNullOrWhiteSpace(appId); - return _workflows - .Where(w => string.Equals(w.AppId, appId, StringComparison.OrdinalIgnoreCase)) + return _entries + .Where(e => string.Equals(e.AppId, appId, StringComparison.OrdinalIgnoreCase)) .ToList(); } @@ -127,8 +127,8 @@ public IReadOnlyList GetWorkflowsByAppId(string appId) public IReadOnlyList GetWorkflowsByInstanceId(string instanceId) { ArgumentException.ThrowIfNullOrWhiteSpace(instanceId); - return _workflows - .Where(w => string.Equals(w.InstanceId, instanceId, StringComparison.Ordinal)) + return _entries + .Where(e => string.Equals(e.InstanceId, instanceId, StringComparison.Ordinal)) .ToList(); } } From 5987d9c44c2ca93824f88a8c529229913090d7c6 Mon Sep 17 00:00:00 2001 From: Nelson Parente Date: Wed, 27 May 2026 15:46:11 +0100 Subject: [PATCH 11/12] refactor(workflow): use generic propagated-history method names; add Status enum Addresses Whit's 2026-05-24 review. Rename the PropagatedHistory query family to scope-neutral names so the public surface need not change if propagated history ever carries non-workflow entries: GetWorkflows() -> GetEntries() GetWorkflowsByName() -> FilterByWorkflowName() GetWorkflowsByAppId() -> FilterByAppId() GetWorkflowsByInstanceId() -> FilterByInstanceId() Add PropagatedHistoryTaskStatus (Pending/Completed/Failed) and a computed Status property on PropagatedHistoryActivityResult and PropagatedHistoryChildWorkflowResult so callers can switch on a single value. The Started/Completed/Failed flags are retained for go-sdk/python-sdk parity; Status is a projection of them, with Failed taking precedence over Completed. Signed-off-by: Nelson Parente --- .../PropagatedHistory.cs | 21 +++---- .../PropagatedHistoryActivityResult.cs | 17 +++++- .../PropagatedHistoryChildWorkflowResult.cs | 18 +++++- .../PropagatedHistoryTaskStatus.cs | 43 ++++++++++++++ .../WorkflowContext.cs | 4 +- .../HistoryPropagationWorkflowTests.cs | 2 +- .../WorkflowHistoryPropagationTests.cs | 58 ++++++++++++++----- 7 files changed, 131 insertions(+), 32 deletions(-) create mode 100644 src/Dapr.Workflow.Abstractions/PropagatedHistoryTaskStatus.cs diff --git a/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs index a1c905c0c..798c75ea3 100644 --- a/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs +++ b/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs @@ -26,8 +26,9 @@ namespace Dapr.Workflow; /// one per ancestor workflow. Order is execution order: index 0 is the oldest ancestor, /// the last entry is the immediate parent. /// -/// Use the Get* / TryGet* methods to walk the list by app, instance, or workflow name. -/// Mirrors the PropagatedHistory type in the Go and Python SDKs. +/// Use for the full list, the FilterBy* methods to narrow by +/// app, instance, or workflow name, and for the most +/// recent entry with a given name. Mirrors the PropagatedHistory type in the Go and Python SDKs. /// /// public sealed class PropagatedHistory @@ -46,10 +47,10 @@ public PropagatedHistory(IReadOnlyList entries) } /// - /// Returns every workflow entry in the propagated history, in execution order + /// Returns every entry in the propagated history, in execution order /// (ancestor first, immediate parent last). /// - public IReadOnlyList GetWorkflows() => _entries; + public IReadOnlyList GetEntries() => _entries; /// /// Returns an ordered, deduplicated list of app IDs in this propagated history. @@ -70,12 +71,12 @@ public IReadOnlyList GetAppIds() } /// - /// Returns every workflow entry whose name matches, in execution order. Useful when the + /// Returns every entry whose workflow name matches, in execution order. Useful when the /// list contains the same name more than once (e.g. recursion or ContinueAsNew). /// /// The workflow name to filter by. /// An empty list when no match is found. - public IReadOnlyList GetWorkflowsByName(string name) + public IReadOnlyList FilterByWorkflowName(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); return _entries @@ -106,11 +107,11 @@ public bool TryGetLastWorkflowByName(string name, [NotNullWhen(true)] out Propag } /// - /// Returns every workflow entry produced by the given app, in execution order. + /// Returns every entry produced by the given app, in execution order. /// /// The Dapr App ID to filter by. /// An empty list when no match is found. - public IReadOnlyList GetWorkflowsByAppId(string appId) + public IReadOnlyList FilterByAppId(string appId) { ArgumentException.ThrowIfNullOrWhiteSpace(appId); return _entries @@ -119,12 +120,12 @@ public IReadOnlyList GetWorkflowsByAppId(string appId) } /// - /// Returns every workflow entry produced by the given instance, in execution order. + /// Returns every entry produced by the given instance, in execution order. /// Usually a single entry, except when the same instance reappears via ContinueAsNew. /// /// The workflow instance ID to filter by. /// An empty list when no match is found. - public IReadOnlyList GetWorkflowsByInstanceId(string instanceId) + public IReadOnlyList FilterByInstanceId(string instanceId) { ArgumentException.ThrowIfNullOrWhiteSpace(instanceId); return _entries diff --git a/src/Dapr.Workflow.Abstractions/PropagatedHistoryActivityResult.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistoryActivityResult.cs index 9ed27c855..0f4133e81 100644 --- a/src/Dapr.Workflow.Abstractions/PropagatedHistoryActivityResult.cs +++ b/src/Dapr.Workflow.Abstractions/PropagatedHistoryActivityResult.cs @@ -25,7 +25,9 @@ namespace Dapr.Workflow; /// The failure details when is true, otherwise null. /// /// Mirrors the ActivityResult type in the Go and Python SDKs so cross-language -/// quickstarts and audit patterns line up. +/// quickstarts and audit patterns line up. The / +/// / flags carry that parity; projects them onto a +/// single value for callers that prefer to switch on the lifecycle. /// public sealed record PropagatedHistoryActivityResult( string Name, @@ -34,4 +36,15 @@ public sealed record PropagatedHistoryActivityResult( bool Failed, string? Input, string? Output, - WorkflowTaskFailureDetails? FailureDetails); + WorkflowTaskFailureDetails? FailureDetails) +{ + /// + /// The resolved lifecycle status of this activity, derived from the + /// and flags. takes + /// precedence over . + /// + public PropagatedHistoryTaskStatus Status => + Failed ? PropagatedHistoryTaskStatus.Failed + : Completed ? PropagatedHistoryTaskStatus.Completed + : PropagatedHistoryTaskStatus.Pending; +} diff --git a/src/Dapr.Workflow.Abstractions/PropagatedHistoryChildWorkflowResult.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistoryChildWorkflowResult.cs index fb7ca74b6..a4196c697 100644 --- a/src/Dapr.Workflow.Abstractions/PropagatedHistoryChildWorkflowResult.cs +++ b/src/Dapr.Workflow.Abstractions/PropagatedHistoryChildWorkflowResult.cs @@ -23,7 +23,10 @@ namespace Dapr.Workflow; /// The JSON-encoded output payload, or null when the child workflow has not completed. /// The failure details when is true, otherwise null. /// -/// Mirrors the ChildWorkflowResult type in the Go and Python SDKs. +/// Mirrors the ChildWorkflowResult type in the Go and Python SDKs. The +/// / / flags carry that +/// parity; projects them onto a single value for callers that prefer to +/// switch on the lifecycle. /// public sealed record PropagatedHistoryChildWorkflowResult( string Name, @@ -31,4 +34,15 @@ public sealed record PropagatedHistoryChildWorkflowResult( bool Completed, bool Failed, string? Output, - WorkflowTaskFailureDetails? FailureDetails); + WorkflowTaskFailureDetails? FailureDetails) +{ + /// + /// The resolved lifecycle status of this child workflow, derived from the + /// and flags. takes + /// precedence over . + /// + public PropagatedHistoryTaskStatus Status => + Failed ? PropagatedHistoryTaskStatus.Failed + : Completed ? PropagatedHistoryTaskStatus.Completed + : PropagatedHistoryTaskStatus.Pending; +} diff --git a/src/Dapr.Workflow.Abstractions/PropagatedHistoryTaskStatus.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistoryTaskStatus.cs new file mode 100644 index 000000000..9e85954bb --- /dev/null +++ b/src/Dapr.Workflow.Abstractions/PropagatedHistoryTaskStatus.cs @@ -0,0 +1,43 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow; + +/// +/// The resolved lifecycle status of a task (activity or child workflow) reconstructed from +/// propagated workflow history. +/// +/// +/// Every task surfaced through propagated history was scheduled, so the status reflects how +/// far it progressed past scheduling. It is a projection of the Completed and +/// Failed flags on / +/// , provided so callers can switch +/// on a single value instead of evaluating the flags by hand. +/// +public enum PropagatedHistoryTaskStatus +{ + /// + /// The task was scheduled but has not yet completed or failed in the propagated history. + /// + Pending = 0, + + /// + /// The task completed successfully. + /// + Completed = 1, + + /// + /// The task failed. + /// + Failed = 2, +} diff --git a/src/Dapr.Workflow.Abstractions/WorkflowContext.cs b/src/Dapr.Workflow.Abstractions/WorkflowContext.cs index 2ed3b2852..91de5db65 100644 --- a/src/Dapr.Workflow.Abstractions/WorkflowContext.cs +++ b/src/Dapr.Workflow.Abstractions/WorkflowContext.cs @@ -343,8 +343,8 @@ public virtual Task CallChildWorkflowAsync( /// /// Use and /// / - /// to look up specific items in the propagated history. The plural Get*ByName variants - /// return every match. + /// to look up the most recent matching item; the plural FilterBy* / + /// Get*ByName variants return every match. /// /// /// This method always returns the same value regardless of whether the workflow is replaying. diff --git a/test/Dapr.IntegrationTest.Workflow/HistoryPropagationWorkflowTests.cs b/test/Dapr.IntegrationTest.Workflow/HistoryPropagationWorkflowTests.cs index b63360d9a..046d7048c 100644 --- a/test/Dapr.IntegrationTest.Workflow/HistoryPropagationWorkflowTests.cs +++ b/test/Dapr.IntegrationTest.Workflow/HistoryPropagationWorkflowTests.cs @@ -397,7 +397,7 @@ public override async Task RunAsync(WorkflowContext conte await context.CreateTimer(TimeSpan.Zero); return new PropagationTestResult( ChildReceivedPropagatedHistory: propagated is not null, - PropagatedEntryCount: propagated?.GetWorkflows().Count ?? 0); + PropagatedEntryCount: propagated?.GetEntries().Count ?? 0); } } diff --git a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs index e1148d910..7d0f1b934 100644 --- a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs +++ b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs @@ -162,13 +162,13 @@ public void GetPropagatedHistory_ReturnsSingleWorkflow_WhenOneEntryPropagated() var history = context.GetPropagatedHistory(); Assert.NotNull(history); - var workflows = history.GetWorkflows(); - Assert.Single(workflows); - Assert.Equal("parent-app", workflows[0].AppId); - Assert.Equal("parent-instance", workflows[0].InstanceId); - Assert.Equal("ParentWorkflow", workflows[0].Name); - Assert.Empty(workflows[0].Activities); - Assert.Empty(workflows[0].ChildWorkflows); + var entries = history.GetEntries(); + Assert.Single(entries); + Assert.Equal("parent-app", entries[0].AppId); + Assert.Equal("parent-instance", entries[0].InstanceId); + Assert.Equal("ParentWorkflow", entries[0].Name); + Assert.Empty(entries[0].Activities); + Assert.Empty(entries[0].ChildWorkflows); } [Fact] @@ -184,10 +184,10 @@ public void GetPropagatedHistory_PreservesEntryOrder() var history = context.GetPropagatedHistory(); Assert.NotNull(history); - var workflows = history.GetWorkflows(); - Assert.Equal(2, workflows.Count); - Assert.Equal("gp-inst", workflows[0].InstanceId); - Assert.Equal("p-inst", workflows[1].InstanceId); + var entries = history.GetEntries(); + Assert.Equal(2, entries.Count); + Assert.Equal("gp-inst", entries[0].InstanceId); + Assert.Equal("p-inst", entries[1].InstanceId); } [Fact] @@ -225,6 +225,7 @@ public void Activity_ResolvedAs_CompletedWithInputAndOutput() Assert.True(activity.Started); Assert.True(activity.Completed); Assert.False(activity.Failed); + Assert.Equal(PropagatedHistoryTaskStatus.Completed, activity.Status); Assert.Equal("\"merchant-1\"", activity.Input); Assert.Equal("true", activity.Output); Assert.Null(activity.FailureDetails); @@ -243,6 +244,7 @@ public void Activity_ResolvedAs_FailedWithFailureDetails() Assert.False(activity.Completed); Assert.True(activity.Failed); + Assert.Equal(PropagatedHistoryTaskStatus.Failed, activity.Status); Assert.NotNull(activity.FailureDetails); Assert.Equal("card declined", activity.FailureDetails.ErrorMessage); } @@ -260,6 +262,7 @@ public void Activity_ResolvedAs_StartedOnly_WhenNotYetCompleted() Assert.True(activity.Started); Assert.False(activity.Completed); Assert.False(activity.Failed); + Assert.Equal(PropagatedHistoryTaskStatus.Pending, activity.Status); Assert.Equal("\"in\"", activity.Input); Assert.Null(activity.Output); } @@ -318,6 +321,7 @@ public void ChildWorkflow_ResolvedAs_Completed() Assert.True(child.Started); Assert.True(child.Completed); Assert.False(child.Failed); + Assert.Equal(PropagatedHistoryTaskStatus.Completed, child.Status); Assert.Equal("\"paid\"", child.Output); } @@ -333,6 +337,7 @@ public void ChildWorkflow_ResolvedAs_Failed() Assert.True(workflow.TryGetLastChildWorkflowByName("ProcessPayment", out var child)); Assert.True(child.Failed); + Assert.Equal(PropagatedHistoryTaskStatus.Failed, child.Status); Assert.Equal("boom", child.FailureDetails!.ErrorMessage); } @@ -373,7 +378,7 @@ public void TryGetLastWorkflowByName_ReturnsMostRecent_WhenNameRepeated() Assert.True(history.TryGetLastWorkflowByName("Loop", out var last)); Assert.Equal("wf-2", last.InstanceId); - Assert.Equal(2, history.GetWorkflowsByName("Loop").Count); + Assert.Equal(2, history.FilterByWorkflowName("Loop").Count); } [Fact] @@ -385,7 +390,7 @@ public void TryGetLastWorkflowByName_ReturnsFalse_WhenMissing() } [Fact] - public void PropagatedHistory_Ctor_ThrowsOnNullWorkflows() + public void PropagatedHistory_Ctor_ThrowsOnNullEntries() { Assert.Throws(() => new PropagatedHistory(null!)); } @@ -409,8 +414,8 @@ public void PropagatedHistory_NameAndAppIdLookups_AreCaseInsensitive() ]); Assert.Single(history.GetAppIds()); - Assert.Single(history.GetWorkflowsByAppId("APPA")); - Assert.Single(history.GetWorkflowsByName("merchantcheckout")); + Assert.Single(history.FilterByAppId("APPA")); + Assert.Single(history.FilterByWorkflowName("merchantcheckout")); Assert.True(history.TryGetLastWorkflowByName("MERCHANTCHECKOUT", out _)); Assert.Single(entry.GetActivitiesByName("validatemerchant")); Assert.True(entry.TryGetLastActivityByName("VALIDATEMERCHANT", out _)); @@ -418,6 +423,29 @@ public void PropagatedHistory_NameAndAppIdLookups_AreCaseInsensitive() Assert.True(entry.TryGetLastChildWorkflowByName("FRAUDDETECTION", out _)); } + [Fact] + public void Status_ProjectsFlags_AndFailedTakesPrecedenceOverCompleted() + { + var pending = new PropagatedHistoryActivityResult( + Name: "A", Started: true, Completed: false, Failed: false, + Input: null, Output: null, FailureDetails: null); + var completed = pending with { Completed = true }; + var failed = pending with { Failed = true }; + // Defensive: if both flags are ever set, Failed wins. + var both = pending with { Completed = true, Failed = true }; + + Assert.Equal(PropagatedHistoryTaskStatus.Pending, pending.Status); + Assert.Equal(PropagatedHistoryTaskStatus.Completed, completed.Status); + Assert.Equal(PropagatedHistoryTaskStatus.Failed, failed.Status); + Assert.Equal(PropagatedHistoryTaskStatus.Failed, both.Status); + + var child = new PropagatedHistoryChildWorkflowResult( + Name: "C", Started: true, Completed: true, Failed: false, + Output: null, FailureDetails: null); + Assert.Equal(PropagatedHistoryTaskStatus.Completed, child.Status); + Assert.Equal(PropagatedHistoryTaskStatus.Failed, (child with { Failed = true }).Status); + } + // ------------------------------------------------------------------ // Scheduling helpers — WithHistoryPropagation // ------------------------------------------------------------------ From a5a24cd05d17f48a28bd7be2a1b2526fa30b1d76 Mon Sep 17 00:00:00 2001 From: Nelson Parente Date: Wed, 27 May 2026 16:26:41 +0100 Subject: [PATCH 12/12] test(workflow): fix case-insensitive AppId filter expectation FilterByAppId matches case-insensitively, so two entries whose app IDs differ only in casing ("AppA" / "appa") both match a query for "APPA". The de-duped GetAppIds list collapses to one, but the filter returns both; assert two matches instead of one. Signed-off-by: Nelson Parente --- .../Worker/Internal/WorkflowHistoryPropagationTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs index 7d0f1b934..5c8588c44 100644 --- a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs +++ b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs @@ -413,8 +413,10 @@ public void PropagatedHistory_NameAndAppIdLookups_AreCaseInsensitive() new PropagatedHistoryEntry("inst-2", "appa", "OtherWf", [], []), ]); + // Both entries belong to the same app (differing only in casing), so the de-duped + // app-id list collapses to one, while a case-insensitive filter matches both entries. Assert.Single(history.GetAppIds()); - Assert.Single(history.FilterByAppId("APPA")); + Assert.Equal(2, history.FilterByAppId("APPA").Count); Assert.Single(history.FilterByWorkflowName("merchantcheckout")); Assert.True(history.TryGetLastWorkflowByName("MERCHANTCHECKOUT", out _)); Assert.Single(entry.GetActivitiesByName("validatemerchant"));