Skip to content

Commit b3f6b23

Browse files
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> Co-authored-by: nelson-parente <20144601+nelson-parente@users.noreply.github.com>
1 parent 6789b6d commit b3f6b23

13 files changed

Lines changed: 803 additions & 356 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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 from propagated 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 chunk.</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> struct in the Go SDK and the
28+
/// <c>ActivityResult</c> dataclass in the Python SDK so cross-language
29+
/// quickstarts and chain-of-custody patterns line up.
30+
/// </remarks>
31+
public sealed record ActivityResult(
32+
string Name,
33+
bool Started,
34+
bool Completed,
35+
bool Failed,
36+
string? Input,
37+
string? Output,
38+
WorkflowTaskFailureDetails? FailureDetails);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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 from propagated 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 chunk.</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.
27+
/// </remarks>
28+
public sealed record ChildWorkflowResult(
29+
string Name,
30+
bool Started,
31+
bool Completed,
32+
bool Failed,
33+
string? Output,
34+
WorkflowTaskFailureDetails? FailureDetails);

src/Dapr.Workflow.Abstractions/PropagatedHistoryEvent.cs renamed to src/Dapr.Workflow.Abstractions/Exceptions/PropagationNotFoundException.cs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,22 @@ namespace Dapr.Workflow;
1616
using System;
1717

1818
/// <summary>
19-
/// Represents a single event in a propagated workflow history segment.
19+
/// Thrown when a query against propagated workflow history finds no match.
2020
/// </summary>
21-
/// <param name="EventId">The unique event ID within the workflow instance history.</param>
22-
/// <param name="Kind">The kind of history event.</param>
23-
/// <param name="Timestamp">The UTC timestamp when the event occurred.</param>
24-
public sealed record PropagatedHistoryEvent(int EventId, HistoryEventKind Kind, DateTimeOffset Timestamp);
21+
/// <remarks>
22+
/// Raised by <see cref="PropagatedHistory.GetLastWorkflowByName"/>,
23+
/// <see cref="WorkflowResult.GetLastActivityByName"/>, and
24+
/// <see cref="WorkflowResult.GetLastChildWorkflowByName"/> when the requested
25+
/// name is not present in the propagated history chain. Use the plural
26+
/// <c>Get*sByName</c> variants if you want an empty-list result instead.
27+
/// </remarks>
28+
public sealed class PropagationNotFoundException : Exception
29+
{
30+
/// <summary>
31+
/// Initializes a new instance of <see cref="PropagationNotFoundException"/>.
32+
/// </summary>
33+
/// <param name="message">The exception message.</param>
34+
public PropagationNotFoundException(string message) : base(message)
35+
{
36+
}
37+
}

src/Dapr.Workflow.Abstractions/HistoryEventKind.cs

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

src/Dapr.Workflow.Abstractions/PropagatedHistory.cs

Lines changed: 80 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -18,68 +18,112 @@ namespace Dapr.Workflow;
1818
using System.Linq;
1919

2020
/// <summary>
21-
/// Contains the workflow history that was propagated from ancestor workflow instances.
22-
/// Each entry corresponds to a single ancestor's history.
21+
/// Workflow history propagated from a parent workflow to a child workflow or activity.
2322
/// </summary>
2423
/// <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.
24+
/// A propagated history is composed of one or more chunks, each owned by a distinct
25+
/// workflow instance. Chunks preserve execution order: index 0 is the oldest ancestor,
26+
/// the last chunk is the immediate parent.
27+
/// <para>
28+
/// Use the <c>Get*</c> methods to walk the chain by app, instance, or workflow name.
29+
/// Mirrors the <c>PropagatedHistory</c> type in the Go and Python SDKs.
30+
/// </para>
2931
/// </remarks>
3032
public sealed class PropagatedHistory
3133
{
32-
private readonly IReadOnlyList<PropagatedHistoryEntry> _entries;
34+
private readonly IReadOnlyList<WorkflowResult> _workflows;
3335

3436
/// <summary>
35-
/// Initializes a new instance of <see cref="PropagatedHistory"/> with the given entries.
37+
/// Initializes a new <see cref="PropagatedHistory"/> from the given workflow chunks.
3638
/// </summary>
37-
/// <param name="entries">The propagated history entries from ancestor workflows.</param>
38-
public PropagatedHistory(IReadOnlyList<PropagatedHistoryEntry> entries)
39+
/// <param name="workflows">
40+
/// Workflow chunks in execution order (ancestor first, immediate parent last).
41+
/// </param>
42+
public PropagatedHistory(IReadOnlyList<WorkflowResult> workflows)
3943
{
40-
_entries = entries ?? throw new ArgumentNullException(nameof(entries));
44+
_workflows = workflows ?? throw new ArgumentNullException(nameof(workflows));
4145
}
4246

4347
/// <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.
48+
/// Returns every workflow chunk in the chain, in execution order
49+
/// (ancestor first, immediate parent last).
4750
/// </summary>
48-
public IReadOnlyList<PropagatedHistoryEntry> Entries => _entries;
51+
public IReadOnlyList<WorkflowResult> GetWorkflows() => _workflows;
4952

5053
/// <summary>
51-
/// Returns a new <see cref="PropagatedHistory"/> containing only entries from the specified App ID.
54+
/// Returns an ordered, deduplicated list of app IDs in the propagated chain.
5255
/// </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)
56+
public IReadOnlyList<string> GetAppIds()
5657
{
57-
ArgumentException.ThrowIfNullOrWhiteSpace(appId);
58-
return new PropagatedHistory(
59-
_entries.Where(e => string.Equals(e.AppId, appId, StringComparison.OrdinalIgnoreCase)).ToList());
58+
var seen = new HashSet<string>(StringComparer.Ordinal);
59+
var result = new List<string>(_workflows.Count);
60+
foreach (var workflow in _workflows)
61+
{
62+
if (seen.Add(workflow.AppId))
63+
{
64+
result.Add(workflow.AppId);
65+
}
66+
}
67+
68+
return result;
6069
}
6170

6271
/// <summary>
63-
/// Returns a new <see cref="PropagatedHistory"/> containing only the entry with the specified instance ID.
72+
/// Returns every workflow whose name matches, in execution order. Useful when the
73+
/// chain contains the same name more than once (e.g. recursion or ContinueAsNew).
6474
/// </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)
75+
/// <param name="name">The workflow name to filter by.</param>
76+
/// <returns>An empty list when no match is found.</returns>
77+
public IReadOnlyList<WorkflowResult> GetWorkflowsByName(string name)
6878
{
69-
ArgumentException.ThrowIfNullOrWhiteSpace(instanceId);
70-
return new PropagatedHistory(
71-
_entries.Where(e => string.Equals(e.InstanceId, instanceId, StringComparison.Ordinal)).ToList());
79+
ArgumentException.ThrowIfNullOrWhiteSpace(name);
80+
return _workflows
81+
.Where(w => string.Equals(w.Name, name, StringComparison.Ordinal))
82+
.ToList();
83+
}
84+
85+
/// <summary>
86+
/// Returns the most recent workflow in the chain whose name matches.
87+
/// </summary>
88+
/// <param name="name">The workflow name to look up.</param>
89+
/// <returns>The last matching workflow chunk.</returns>
90+
/// <exception cref="PropagationNotFoundException">No workflow with the given name is present in the chain.</exception>
91+
public WorkflowResult GetLastWorkflowByName(string name)
92+
{
93+
ArgumentException.ThrowIfNullOrWhiteSpace(name);
94+
var matches = GetWorkflowsByName(name);
95+
if (matches.Count == 0)
96+
{
97+
throw new PropagationNotFoundException($"no workflow named '{name}' in propagated history");
98+
}
99+
100+
return matches[^1];
101+
}
102+
103+
/// <summary>
104+
/// Returns every workflow chunk produced by the given app, in execution order.
105+
/// </summary>
106+
/// <param name="appId">The Dapr App ID to filter by.</param>
107+
/// <returns>An empty list when no match is found.</returns>
108+
public IReadOnlyList<WorkflowResult> GetWorkflowsByAppId(string appId)
109+
{
110+
ArgumentException.ThrowIfNullOrWhiteSpace(appId);
111+
return _workflows
112+
.Where(w => string.Equals(w.AppId, appId, StringComparison.Ordinal))
113+
.ToList();
72114
}
73115

74116
/// <summary>
75-
/// Returns a new <see cref="PropagatedHistory"/> containing only entries for the specified workflow name.
117+
/// Returns every workflow chunk produced by the given instance, in execution order.
118+
/// Usually a single entry, except when the same instance reappears via ContinueAsNew.
76119
/// </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)
120+
/// <param name="instanceId">The workflow instance ID to filter by.</param>
121+
/// <returns>An empty list when no match is found.</returns>
122+
public IReadOnlyList<WorkflowResult> GetWorkflowsByInstanceId(string instanceId)
80123
{
81-
ArgumentException.ThrowIfNullOrWhiteSpace(workflowName);
82-
return new PropagatedHistory(
83-
_entries.Where(e => string.Equals(e.WorkflowName, workflowName, StringComparison.Ordinal)).ToList());
124+
ArgumentException.ThrowIfNullOrWhiteSpace(instanceId);
125+
return _workflows
126+
.Where(w => string.Equals(w.InstanceId, instanceId, StringComparison.Ordinal))
127+
.ToList();
84128
}
85129
}

src/Dapr.Workflow.Abstractions/PropagatedHistoryEntry.cs

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

0 commit comments

Comments
 (0)