Skip to content
63 changes: 25 additions & 38 deletions src/Dapr.Workflow.Abstractions/PropagatedHistory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,51 +21,38 @@ namespace Dapr.Workflow;
/// <summary>
/// Workflow history propagated from one or more ancestor workflows to a child workflow or activity.
/// </summary>
/// <param name="events">
/// Workflow history events in execution order (ancestor first, immediate parent last).
/// </param>
/// <remarks>
/// A propagated history is an ordered list of <see cref="PropagatedHistoryEntry"/> values,
/// A propagated history is an ordered list of <see cref="PropagatedHistoryEvent"/> values,
/// one per ancestor workflow. Order is execution order: index 0 is the oldest ancestor,
/// the last entry is the immediate parent.
/// <para>
/// Use <see cref="GetEntries"/> for the full list, the <c>FilterBy*</c> methods to narrow by
/// app, instance, or workflow name, and <see cref="TryGetLastWorkflowByName"/> for the most
/// Use <see cref="Events"/> for the full list, the <c>FilterBy*</c> methods to narrow by
/// app, instance, or workflow name, and <see cref="TryGetLastWorkflowEventByName"/> for the most
/// recent entry with a given name. Mirrors the <c>PropagatedHistory</c> type in the Go and Python SDKs.
/// </para>
/// </remarks>
public sealed class PropagatedHistory
public sealed class PropagatedHistory(IReadOnlyList<PropagatedHistoryEvent> events)
{
private readonly IReadOnlyList<PropagatedHistoryEntry> _entries;
private readonly IReadOnlyList<PropagatedHistoryEvent> _events =
events ?? throw new ArgumentNullException(nameof(events));

/// <summary>
/// Initializes a new <see cref="PropagatedHistory"/> from the given workflow entries.
/// Returns every event in the propagated history, in execution
/// order (ancestor first, immediate parent last).
/// </summary>
/// <param name="entries">
/// Workflow entries in execution order (ancestor first, immediate parent last).
/// </param>
public PropagatedHistory(IReadOnlyList<PropagatedHistoryEntry> entries)
{
_entries = entries ?? throw new ArgumentNullException(nameof(entries));
}

/// <summary>
/// Returns every entry in the propagated history, in execution order
/// (ancestor first, immediate parent last).
/// </summary>
public IReadOnlyList<PropagatedHistoryEntry> GetEntries() => _entries;
public IReadOnlyList<PropagatedHistoryEvent> Events => _events;

/// <summary>
/// Returns an ordered, deduplicated list of app IDs in this propagated history.
/// </summary>
public IReadOnlyList<string> GetAppIds()
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var result = new List<string>(_entries.Count);
foreach (var entry in _entries)
{
if (seen.Add(entry.AppId))
{
result.Add(entry.AppId);
}
}
var result = new List<string>(_events.Count);
result.AddRange(from entry in _events where seen.Add(entry.AppId) select entry.AppId);

return result;
}
Expand All @@ -76,10 +63,10 @@ public IReadOnlyList<string> GetAppIds()
/// </summary>
/// <param name="name">The workflow name to filter by.</param>
/// <returns>An empty list when no match is found.</returns>
public IReadOnlyList<PropagatedHistoryEntry> FilterByWorkflowName(string name)
public IReadOnlyList<PropagatedHistoryEvent> GetEventsByWorkflowName(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
return _entries
return _events
.Where(e => string.Equals(e.Name, name, StringComparison.OrdinalIgnoreCase))
.ToList();
}
Expand All @@ -90,14 +77,14 @@ public IReadOnlyList<PropagatedHistoryEntry> FilterByWorkflowName(string name)
/// <param name="name">The workflow name to look up.</param>
/// <param name="result">When this method returns <see langword="true"/>, the last matching workflow entry; otherwise <see langword="null"/>.</param>
/// <returns><see langword="true"/> if a matching entry was found; otherwise <see langword="false"/>.</returns>
public bool TryGetLastWorkflowByName(string name, [NotNullWhen(true)] out PropagatedHistoryEntry? result)
public bool TryGetLastWorkflowEventByName(string name, [NotNullWhen(true)] out PropagatedHistoryEvent? result)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why Try? All the other sdks have GetLastWorkflowEventByName without Try. Is this more of a dotnet consistency thing?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A null in a response can mean so many things. Taking a recent example from the state store - if you query a value from state and you get a null back, does that mean that the value didn't exist or that it did and it is null? A TryGet answers this definitively.

Especially as these are not asynchronous operations, I'd rather return a clear boolean indicating if getting the value(s) worked or not and include a payload with the corresponding result if it did.

{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
for (var i = _entries.Count - 1; i >= 0; i--)
for (var i = _events.Count - 1; i >= 0; i--)
{
if (string.Equals(_entries[i].Name, name, StringComparison.OrdinalIgnoreCase))
if (string.Equals(_events[i].Name, name, StringComparison.OrdinalIgnoreCase))
{
result = _entries[i];
result = _events[i];
return true;
}
}
Expand All @@ -111,10 +98,10 @@ public bool TryGetLastWorkflowByName(string name, [NotNullWhen(true)] out Propag
/// </summary>
/// <param name="appId">The Dapr App ID to filter by.</param>
/// <returns>An empty list when no match is found.</returns>
public IReadOnlyList<PropagatedHistoryEntry> FilterByAppId(string appId)
public IReadOnlyList<PropagatedHistoryEvent> GetByAppId(string appId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(appId);
return _entries
return _events
.Where(e => string.Equals(e.AppId, appId, StringComparison.OrdinalIgnoreCase))
.ToList();
}
Expand All @@ -125,11 +112,11 @@ public IReadOnlyList<PropagatedHistoryEntry> FilterByAppId(string appId)
/// </summary>
/// <param name="instanceId">The workflow instance ID to filter by.</param>
/// <returns>An empty list when no match is found.</returns>
public IReadOnlyList<PropagatedHistoryEntry> FilterByInstanceId(string instanceId)
public IReadOnlyList<PropagatedHistoryEvent> GetByInstanceId(string instanceId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(instanceId);
return _entries
return _events
.Where(e => string.Equals(e.InstanceId, instanceId, StringComparison.Ordinal))
.ToList();
.ToList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,34 +17,19 @@ namespace Dapr.Workflow;
/// A reconstructed view of a single activity invocation surfaced through propagated workflow history.
/// </summary>
/// <param name="Name">The scheduled name of the activity.</param>
/// <param name="Started">Whether the activity was scheduled in the propagated history.</param>
/// <param name="Completed">Whether the activity completed successfully.</param>
/// <param name="Failed">Whether the activity failed.</param>
/// <param name="Status">The resolved lifecycle status of this activity.</param>
/// <param name="Input">The JSON-encoded input payload, or <c>null</c> when unset.</param>
/// <param name="Output">The JSON-encoded output payload, or <c>null</c> when the activity has not completed.</param>
/// <param name="FailureDetails">The failure details when <paramref name="Failed"/> is true, otherwise <c>null</c>.</param>
/// <param name="FailureDetails">The failure details when <paramref name="Status"/> is <see cref="PropagatedHistoryStatus.Failed"/>, otherwise <c>null</c>.</param>
/// <remarks>
/// Mirrors the <c>ActivityResult</c> type in the Go and Python SDKs so cross-language
/// quickstarts and audit patterns line up. The <see cref="Started"/> / <see cref="Completed"/>
/// / <see cref="Failed"/> flags carry that parity; <see cref="Status"/> projects them onto a
/// single value for callers that prefer to <c>switch</c> on the lifecycle.
/// Every activity surfaced through propagated history was scheduled, so <see cref="Status"/>
/// reflects how far it progressed past scheduling: <see cref="PropagatedHistoryStatus.Pending"/>
/// means it was scheduled but has not yet completed or failed, <see cref="PropagatedHistoryStatus.Completed"/>
/// means it succeeded, and <see cref="PropagatedHistoryStatus.Failed"/> means it failed.
/// </remarks>
public sealed record PropagatedHistoryActivityResult(
string Name,
bool Started,
bool Completed,
bool Failed,
PropagatedHistoryStatus Status,
string? Input,
string? Output,
WorkflowTaskFailureDetails? FailureDetails)
{
/// <summary>
/// The resolved lifecycle status of this activity, derived from the
/// <see cref="Completed"/> and <see cref="Failed"/> flags. <see cref="Failed"/> takes
/// precedence over <see cref="Completed"/>.
/// </summary>
public PropagatedHistoryTaskStatus Status =>
Failed ? PropagatedHistoryTaskStatus.Failed
: Completed ? PropagatedHistoryTaskStatus.Completed
: PropagatedHistoryTaskStatus.Pending;
}
WorkflowTaskFailureDetails? FailureDetails);

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -28,65 +28,62 @@ namespace Dapr.Workflow;
/// <param name="activities">Activities resolved from this entry, in execution order.</param>
/// <param name="childWorkflows">Child workflows resolved from this entry, in execution order.</param>
/// <remarks>
/// One <see cref="PropagatedHistoryEntry"/> exists per ancestor workflow in a
/// One <see cref="PropagatedHistoryEvent"/> exists per ancestor workflow in a
/// <see cref="PropagatedHistory"/>. Use <see cref="TryGetLastActivityByName"/> and
/// <see cref="TryGetLastChildWorkflowByName"/> to look up specific items in this entry;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I missed this in our DMs, we do support a top level GetLastWorkflowByName/GetWorkflowsByName in the go-sdk. This returns a WorkflowResult. In dotnet you have TryGetLastWorkflowEventByName/GetEventsByWorkflowName returning PropagatedHistoryEvent.

In go, off of WorkflowResult.GetLastChildWorkflowByName/GetChildWorkflowsByName I return the ChildWorkflowResult. In dotnet child is dropped. Can we re-add that on PropagatedHistoryEvent?

@WhitWaldo WhitWaldo May 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not super worried about having a bunch of variations of the filters. I expose all the events in a property, so if there's some special filter a developer wishes to apply, they can trivially add it themselves.

Also, yes, .NET has filters on the list of all propagated history events to return a single PropagatedHistoryEvent as you noted. But this also has filters within it for the specific workflows and activities you cite, so I think the bases are probably already covered here.

/// <see cref="TryGetLastWorkflowByName"/> to look up specific items in this entry;
/// the plural <c>Get*ByName</c> variants return every occurrence in execution order.
/// </remarks>
public sealed class PropagatedHistoryEntry(
public sealed class PropagatedHistoryEvent(
string instanceId,
string appId,
string name,
IReadOnlyList<PropagatedHistoryActivityResult> activities,
IReadOnlyList<PropagatedHistoryChildWorkflowResult> childWorkflows)
IReadOnlyList<PropagatedHistoryWorkflowResult> childWorkflows)
{
private readonly IReadOnlyList<PropagatedHistoryActivityResult> _activities =
activities ?? throw new ArgumentNullException(nameof(activities));
private readonly IReadOnlyList<PropagatedHistoryChildWorkflowResult> _childWorkflows =
childWorkflows ?? throw new ArgumentNullException(nameof(childWorkflows));

/// <summary>The instance ID of the ancestor workflow this entry describes.</summary>
/// <summary>The instance ID of the workflow this history event describes.</summary>
public string InstanceId { get; } = instanceId ?? throw new ArgumentNullException(nameof(instanceId));

/// <summary>The Dapr App ID that ran this ancestor workflow.</summary>
/// <summary>The Dapr App ID that ran this workflow.</summary>
public string AppId { get; } = appId ?? throw new ArgumentNullException(nameof(appId));

/// <summary>The name of this ancestor workflow.</summary>
/// <summary>The name of this workflow.</summary>
public string Name { get; } = name ?? throw new ArgumentNullException(nameof(name));

/// <summary>All activities executed in this entry, in execution order.</summary>
public IReadOnlyList<PropagatedHistoryActivityResult> Activities => _activities;
/// <summary>All activities executed in this history event, in execution order.</summary>
public IReadOnlyList<PropagatedHistoryActivityResult> Activities { get; } =
activities ?? throw new ArgumentNullException(nameof(activities));

/// <summary>All child workflows started in this entry, in execution order.</summary>
public IReadOnlyList<PropagatedHistoryChildWorkflowResult> ChildWorkflows => _childWorkflows;
/// <summary>All workflows started in this history event, in execution order.</summary>
public IReadOnlyList<PropagatedHistoryWorkflowResult> Workflows { get; } =
childWorkflows ?? throw new ArgumentNullException(nameof(childWorkflows));

/// <summary>
/// Returns every activity in this entry whose scheduled name matches, in execution order.
/// Returns every activity in this history event whose scheduled name matches, in execution order.
/// </summary>
/// <param name="name">The activity name to filter by.</param>
/// <returns>An empty list when no match is found.</returns>
public IReadOnlyList<PropagatedHistoryActivityResult> GetActivitiesByName(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
return _activities
return Activities
.Where(a => string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase))
.ToList();
}

/// <summary>
/// Tries to return the most recent activity in this entry whose name matches.
/// Tries to return the most recent activity in this history event whose name matches.
/// </summary>
/// <param name="name">The activity name to look up.</param>
/// <param name="result">When this method returns <see langword="true"/>, the last matching activity; otherwise <see langword="null"/>.</param>
/// <returns><see langword="true"/> if a matching activity was found; otherwise <see langword="false"/>.</returns>
public bool TryGetLastActivityByName(string name, [NotNullWhen(true)] out PropagatedHistoryActivityResult? result)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
for (var i = _activities.Count - 1; i >= 0; i--)
for (var i = Activities.Count - 1; i >= 0; i--)
{
if (string.Equals(_activities[i].Name, name, StringComparison.OrdinalIgnoreCase))
if (string.Equals(Activities[i].Name, name, StringComparison.OrdinalIgnoreCase))
{
result = _activities[i];
result = Activities[i];
return true;
}
}
Expand All @@ -96,32 +93,32 @@ public bool TryGetLastActivityByName(string name, [NotNullWhen(true)] out Propag
}

/// <summary>
/// Returns every child workflow in this entry whose name matches, in execution order.
/// Returns every child workflow in this history event whose name matches, in execution order.
/// </summary>
/// <param name="name">The child workflow name to filter by.</param>
/// <returns>An empty list when no match is found.</returns>
public IReadOnlyList<PropagatedHistoryChildWorkflowResult> GetChildWorkflowsByName(string name)
public IReadOnlyList<PropagatedHistoryWorkflowResult> GetWorkflowsByName(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
return _childWorkflows
return Workflows
.Where(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase))
.ToList();
}

/// <summary>
/// Tries to return the most recent child workflow in this entry whose name matches.
/// Tries to return the most recent child workflow in this history event whose name matches.
/// </summary>
/// <param name="name">The child workflow name to look up.</param>
/// <param name="result">When this method returns <see langword="true"/>, the last matching child workflow; otherwise <see langword="null"/>.</param>
/// <returns><see langword="true"/> if a matching child workflow was found; otherwise <see langword="false"/>.</returns>
public bool TryGetLastChildWorkflowByName(string name, [NotNullWhen(true)] out PropagatedHistoryChildWorkflowResult? result)
public bool TryGetLastWorkflowByName(string name, [NotNullWhen(true)] out PropagatedHistoryWorkflowResult? result)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
for (var i = _childWorkflows.Count - 1; i >= 0; i--)
for (var i = Workflows.Count - 1; i >= 0; i--)
{
if (string.Equals(_childWorkflows[i].Name, name, StringComparison.OrdinalIgnoreCase))
if (string.Equals(Workflows[i].Name, name, StringComparison.OrdinalIgnoreCase))
{
result = _childWorkflows[i];
result = Workflows[i];
return true;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,10 @@ namespace Dapr.Workflow;
/// </summary>
/// <remarks>
/// Every task surfaced through propagated history was scheduled, so the status reflects how
/// far it progressed past scheduling. It is a projection of the <c>Completed</c> and
/// <c>Failed</c> flags on <see cref="PropagatedHistoryActivityResult"/> /
/// <see cref="PropagatedHistoryChildWorkflowResult"/>, provided so callers can <c>switch</c>
/// on a single value instead of evaluating the flags by hand.
/// far it progressed past scheduling. Use a <c>switch</c> expression on this value to branch
/// on the three possible states without needing to evaluate multiple boolean conditions.
/// </remarks>
public enum PropagatedHistoryTaskStatus
public enum PropagatedHistoryStatus
{
/// <summary>
/// The task was scheduled but has not yet completed or failed in the propagated history.
Expand Down
Loading
Loading