Skip to content

Commit 5ad31e6

Browse files
feat(workflow): align history propagation API with go-sdk (#1825)
* feat(workflow): align history propagation API with go-sdk 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: #1801, dapr/durabletask-go#105, dapr/go-sdk#823, dapr/python-sdk#1047 Signed-off-by: Nelson Parente <nelson_parente@live.com.pt> * 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 <nelson_parente@live.com.pt> * 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 <nelson_parente@live.com.pt> * fix(workflow): pass StringValue-wrapped fields directly as strings 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 <nelson_parente@live.com.pt> * test(workflow): assign string directly to wrapper-typed fields 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 <nelson_parente@live.com.pt> * 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 <nelson_parente@live.com.pt> * 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 <nelson_parente@live.com.pt> * 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 <nelson_parente@live.com.pt> * perf(workflow): pre-index completion events in ConvertChunk 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 <nelson_parente@live.com.pt> * 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 <nelson_parente@live.com.pt> * 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 <nelson_parente@live.com.pt> * 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 <nelson_parente@live.com.pt> --------- Signed-off-by: Nelson Parente <nelson_parente@live.com.pt> Co-authored-by: Whit Waldo <whit.waldo@innovian.net>
1 parent 29eb639 commit 5ad31e6

12 files changed

Lines changed: 869 additions & 358 deletions

src/Dapr.Workflow.Abstractions/HistoryEventKind.cs

Lines changed: 0 additions & 115 deletions
This file was deleted.

src/Dapr.Workflow.Abstractions/PropagatedHistory.cs

Lines changed: 83 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -15,71 +15,121 @@ namespace Dapr.Workflow;
1515

1616
using System;
1717
using System.Collections.Generic;
18+
using System.Diagnostics.CodeAnalysis;
1819
using System.Linq;
1920

2021
/// <summary>
21-
/// Contains the workflow history that was propagated from ancestor workflow instances.
22-
/// Each entry corresponds to a single ancestor's history.
22+
/// Workflow history propagated from one or more ancestor workflows to a child workflow or activity.
2323
/// </summary>
2424
/// <remarks>
25-
/// A workflow receives propagated history when it is scheduled with a
26-
/// <see cref="HistoryPropagationScope"/> other than <see cref="HistoryPropagationScope.None"/>.
27-
/// Use <see cref="WorkflowContext.GetPropagatedHistory"/> to retrieve the propagated history
28-
/// inside a workflow implementation.
25+
/// A propagated history is an ordered list of <see cref="PropagatedHistoryEntry"/> values,
26+
/// one per ancestor workflow. Order is execution order: index 0 is the oldest ancestor,
27+
/// the last entry is the immediate parent.
28+
/// <para>
29+
/// Use <see cref="GetEntries"/> for the full list, the <c>FilterBy*</c> methods to narrow by
30+
/// app, instance, or workflow name, and <see cref="TryGetLastWorkflowByName"/> for the most
31+
/// recent entry with a given name. Mirrors the <c>PropagatedHistory</c> type in the Go and Python SDKs.
32+
/// </para>
2933
/// </remarks>
3034
public sealed class PropagatedHistory
3135
{
3236
private readonly IReadOnlyList<PropagatedHistoryEntry> _entries;
3337

3438
/// <summary>
35-
/// Initializes a new instance of <see cref="PropagatedHistory"/> with the given entries.
39+
/// Initializes a new <see cref="PropagatedHistory"/> from the given workflow entries.
3640
/// </summary>
37-
/// <param name="entries">The propagated history entries from ancestor workflows.</param>
41+
/// <param name="entries">
42+
/// Workflow entries in execution order (ancestor first, immediate parent last).
43+
/// </param>
3844
public PropagatedHistory(IReadOnlyList<PropagatedHistoryEntry> entries)
3945
{
4046
_entries = entries ?? throw new ArgumentNullException(nameof(entries));
4147
}
4248

4349
/// <summary>
44-
/// Gets the ordered list of propagated history entries.
45-
/// The first entry corresponds to the immediate parent workflow; subsequent entries
46-
/// correspond to progressively older ancestors when <see cref="HistoryPropagationScope.Lineage"/> is used.
50+
/// Returns every entry in the propagated history, in execution order
51+
/// (ancestor first, immediate parent last).
4752
/// </summary>
48-
public IReadOnlyList<PropagatedHistoryEntry> Entries => _entries;
53+
public IReadOnlyList<PropagatedHistoryEntry> GetEntries() => _entries;
4954

5055
/// <summary>
51-
/// Returns a new <see cref="PropagatedHistory"/> containing only entries from the specified App ID.
56+
/// Returns an ordered, deduplicated list of app IDs in this propagated history.
5257
/// </summary>
53-
/// <param name="appId">The Dapr App ID to filter by.</param>
54-
/// <returns>A filtered <see cref="PropagatedHistory"/> instance.</returns>
55-
public PropagatedHistory FilterByAppId(string appId)
58+
public IReadOnlyList<string> GetAppIds()
5659
{
57-
ArgumentException.ThrowIfNullOrWhiteSpace(appId);
58-
return new PropagatedHistory(
59-
_entries.Where(e => string.Equals(e.AppId, appId, StringComparison.OrdinalIgnoreCase)).ToList());
60+
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
61+
var result = new List<string>(_entries.Count);
62+
foreach (var entry in _entries)
63+
{
64+
if (seen.Add(entry.AppId))
65+
{
66+
result.Add(entry.AppId);
67+
}
68+
}
69+
70+
return result;
6071
}
6172

6273
/// <summary>
63-
/// Returns a new <see cref="PropagatedHistory"/> containing only the entry with the specified instance ID.
74+
/// Returns every entry whose workflow name matches, in execution order. Useful when the
75+
/// list contains the same name more than once (e.g. recursion or ContinueAsNew).
6476
/// </summary>
65-
/// <param name="instanceId">The workflow instance ID to filter by.</param>
66-
/// <returns>A filtered <see cref="PropagatedHistory"/> instance.</returns>
67-
public PropagatedHistory FilterByInstanceId(string instanceId)
77+
/// <param name="name">The workflow name to filter by.</param>
78+
/// <returns>An empty list when no match is found.</returns>
79+
public IReadOnlyList<PropagatedHistoryEntry> FilterByWorkflowName(string name)
6880
{
69-
ArgumentException.ThrowIfNullOrWhiteSpace(instanceId);
70-
return new PropagatedHistory(
71-
_entries.Where(e => string.Equals(e.InstanceId, instanceId, StringComparison.Ordinal)).ToList());
81+
ArgumentException.ThrowIfNullOrWhiteSpace(name);
82+
return _entries
83+
.Where(e => string.Equals(e.Name, name, StringComparison.OrdinalIgnoreCase))
84+
.ToList();
85+
}
86+
87+
/// <summary>
88+
/// Tries to return the most recent workflow entry whose name matches.
89+
/// </summary>
90+
/// <param name="name">The workflow name to look up.</param>
91+
/// <param name="result">When this method returns <see langword="true"/>, the last matching workflow entry; otherwise <see langword="null"/>.</param>
92+
/// <returns><see langword="true"/> if a matching entry was found; otherwise <see langword="false"/>.</returns>
93+
public bool TryGetLastWorkflowByName(string name, [NotNullWhen(true)] out PropagatedHistoryEntry? result)
94+
{
95+
ArgumentException.ThrowIfNullOrWhiteSpace(name);
96+
for (var i = _entries.Count - 1; i >= 0; i--)
97+
{
98+
if (string.Equals(_entries[i].Name, name, StringComparison.OrdinalIgnoreCase))
99+
{
100+
result = _entries[i];
101+
return true;
102+
}
103+
}
104+
105+
result = null;
106+
return false;
107+
}
108+
109+
/// <summary>
110+
/// Returns every entry produced by the given app, in execution order.
111+
/// </summary>
112+
/// <param name="appId">The Dapr App ID to filter by.</param>
113+
/// <returns>An empty list when no match is found.</returns>
114+
public IReadOnlyList<PropagatedHistoryEntry> FilterByAppId(string appId)
115+
{
116+
ArgumentException.ThrowIfNullOrWhiteSpace(appId);
117+
return _entries
118+
.Where(e => string.Equals(e.AppId, appId, StringComparison.OrdinalIgnoreCase))
119+
.ToList();
72120
}
73121

74122
/// <summary>
75-
/// Returns a new <see cref="PropagatedHistory"/> containing only entries for the specified workflow name.
123+
/// Returns every entry produced by the given instance, in execution order.
124+
/// Usually a single entry, except when the same instance reappears via ContinueAsNew.
76125
/// </summary>
77-
/// <param name="workflowName">The workflow name to filter by.</param>
78-
/// <returns>A filtered <see cref="PropagatedHistory"/> instance.</returns>
79-
public PropagatedHistory FilterByWorkflowName(string workflowName)
126+
/// <param name="instanceId">The workflow instance ID to filter by.</param>
127+
/// <returns>An empty list when no match is found.</returns>
128+
public IReadOnlyList<PropagatedHistoryEntry> FilterByInstanceId(string instanceId)
80129
{
81-
ArgumentException.ThrowIfNullOrWhiteSpace(workflowName);
82-
return new PropagatedHistory(
83-
_entries.Where(e => string.Equals(e.WorkflowName, workflowName, StringComparison.Ordinal)).ToList());
130+
ArgumentException.ThrowIfNullOrWhiteSpace(instanceId);
131+
return _entries
132+
.Where(e => string.Equals(e.InstanceId, instanceId, StringComparison.Ordinal))
133+
.ToList();
84134
}
85135
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// ------------------------------------------------------------------------
2+
// Copyright 2026 The Dapr Authors
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
// Unless required by applicable law or agreed to in writing, software
8+
// distributed under the License is distributed on an "AS IS" BASIS,
9+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
// See the License for the specific language governing permissions and
11+
// limitations under the License.
12+
// ------------------------------------------------------------------------
13+
14+
namespace Dapr.Workflow;
15+
16+
/// <summary>
17+
/// A reconstructed view of a single activity invocation surfaced through propagated workflow history.
18+
/// </summary>
19+
/// <param name="Name">The scheduled name of the activity.</param>
20+
/// <param name="Started">Whether the activity was scheduled in the propagated history.</param>
21+
/// <param name="Completed">Whether the activity completed successfully.</param>
22+
/// <param name="Failed">Whether the activity failed.</param>
23+
/// <param name="Input">The JSON-encoded input payload, or <c>null</c> when unset.</param>
24+
/// <param name="Output">The JSON-encoded output payload, or <c>null</c> when the activity has not completed.</param>
25+
/// <param name="FailureDetails">The failure details when <paramref name="Failed"/> is true, otherwise <c>null</c>.</param>
26+
/// <remarks>
27+
/// Mirrors the <c>ActivityResult</c> type in the Go and Python SDKs so cross-language
28+
/// quickstarts and audit patterns line up. The <see cref="Started"/> / <see cref="Completed"/>
29+
/// / <see cref="Failed"/> flags carry that parity; <see cref="Status"/> projects them onto a
30+
/// single value for callers that prefer to <c>switch</c> on the lifecycle.
31+
/// </remarks>
32+
public sealed record PropagatedHistoryActivityResult(
33+
string Name,
34+
bool Started,
35+
bool Completed,
36+
bool Failed,
37+
string? Input,
38+
string? Output,
39+
WorkflowTaskFailureDetails? FailureDetails)
40+
{
41+
/// <summary>
42+
/// The resolved lifecycle status of this activity, derived from the
43+
/// <see cref="Completed"/> and <see cref="Failed"/> flags. <see cref="Failed"/> takes
44+
/// precedence over <see cref="Completed"/>.
45+
/// </summary>
46+
public PropagatedHistoryTaskStatus Status =>
47+
Failed ? PropagatedHistoryTaskStatus.Failed
48+
: Completed ? PropagatedHistoryTaskStatus.Completed
49+
: PropagatedHistoryTaskStatus.Pending;
50+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// ------------------------------------------------------------------------
2+
// Copyright 2026 The Dapr Authors
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
// Unless required by applicable law or agreed to in writing, software
8+
// distributed under the License is distributed on an "AS IS" BASIS,
9+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
// See the License for the specific language governing permissions and
11+
// limitations under the License.
12+
// ------------------------------------------------------------------------
13+
14+
namespace Dapr.Workflow;
15+
16+
/// <summary>
17+
/// A reconstructed view of a single child workflow invocation surfaced through propagated workflow history.
18+
/// </summary>
19+
/// <param name="Name">The scheduled name of the child workflow.</param>
20+
/// <param name="Started">Whether the child workflow was scheduled in the propagated history.</param>
21+
/// <param name="Completed">Whether the child workflow completed successfully.</param>
22+
/// <param name="Failed">Whether the child workflow failed.</param>
23+
/// <param name="Output">The JSON-encoded output payload, or <c>null</c> when the child workflow has not completed.</param>
24+
/// <param name="FailureDetails">The failure details when <paramref name="Failed"/> is true, otherwise <c>null</c>.</param>
25+
/// <remarks>
26+
/// Mirrors the <c>ChildWorkflowResult</c> type in the Go and Python SDKs. The
27+
/// <see cref="Started"/> / <see cref="Completed"/> / <see cref="Failed"/> flags carry that
28+
/// parity; <see cref="Status"/> projects them onto a single value for callers that prefer to
29+
/// <c>switch</c> on the lifecycle.
30+
/// </remarks>
31+
public sealed record PropagatedHistoryChildWorkflowResult(
32+
string Name,
33+
bool Started,
34+
bool Completed,
35+
bool Failed,
36+
string? Output,
37+
WorkflowTaskFailureDetails? FailureDetails)
38+
{
39+
/// <summary>
40+
/// The resolved lifecycle status of this child workflow, derived from the
41+
/// <see cref="Completed"/> and <see cref="Failed"/> flags. <see cref="Failed"/> takes
42+
/// precedence over <see cref="Completed"/>.
43+
/// </summary>
44+
public PropagatedHistoryTaskStatus Status =>
45+
Failed ? PropagatedHistoryTaskStatus.Failed
46+
: Completed ? PropagatedHistoryTaskStatus.Completed
47+
: PropagatedHistoryTaskStatus.Pending;
48+
}

0 commit comments

Comments
 (0)