diff --git a/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs index 798c75ea3..0142ddc81 100644 --- a/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs +++ b/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs @@ -21,36 +21,29 @@ namespace Dapr.Workflow; /// /// Workflow history propagated from one or more ancestor workflows to a child workflow or activity. /// +/// +/// Workflow history events in execution order (ancestor first, immediate parent last). +/// /// -/// A propagated history is an ordered list of values, +/// 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 for the full list, the FilterBy* methods to narrow by -/// app, instance, or workflow name, and for the most +/// 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 +public sealed class PropagatedHistory(IReadOnlyList events) { - private readonly IReadOnlyList _entries; + private readonly IReadOnlyList _events = + events ?? throw new ArgumentNullException(nameof(events)); /// - /// Initializes a new from the given workflow entries. + /// Returns every event in the propagated history, in execution + /// order (ancestor first, immediate parent last). /// - /// - /// Workflow entries in execution order (ancestor first, immediate parent last). - /// - public PropagatedHistory(IReadOnlyList entries) - { - _entries = entries ?? throw new ArgumentNullException(nameof(entries)); - } - - /// - /// Returns every entry in the propagated history, in execution order - /// (ancestor first, immediate parent last). - /// - public IReadOnlyList GetEntries() => _entries; + public IReadOnlyList Events => _events; /// /// Returns an ordered, deduplicated list of app IDs in this propagated history. @@ -58,14 +51,8 @@ public PropagatedHistory(IReadOnlyList entries) public IReadOnlyList GetAppIds() { var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - var result = new List(_entries.Count); - foreach (var entry in _entries) - { - if (seen.Add(entry.AppId)) - { - result.Add(entry.AppId); - } - } + var result = new List(_events.Count); + result.AddRange(from entry in _events where seen.Add(entry.AppId) select entry.AppId); return result; } @@ -76,10 +63,10 @@ public IReadOnlyList GetAppIds() /// /// The workflow name to filter by. /// An empty list when no match is found. - public IReadOnlyList FilterByWorkflowName(string name) + public IReadOnlyList GetEventsByWorkflowName(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - return _entries + return _events .Where(e => string.Equals(e.Name, name, StringComparison.OrdinalIgnoreCase)) .ToList(); } @@ -90,14 +77,14 @@ public IReadOnlyList FilterByWorkflowName(string name) /// The workflow name to look up. /// 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) + public bool TryGetLastWorkflowEventByName(string name, [NotNullWhen(true)] out PropagatedHistoryEvent? result) { 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; } } @@ -111,10 +98,10 @@ public bool TryGetLastWorkflowByName(string name, [NotNullWhen(true)] out Propag /// /// The Dapr App ID to filter by. /// An empty list when no match is found. - public IReadOnlyList FilterByAppId(string appId) + public IReadOnlyList GetByAppId(string appId) { ArgumentException.ThrowIfNullOrWhiteSpace(appId); - return _entries + return _events .Where(e => string.Equals(e.AppId, appId, StringComparison.OrdinalIgnoreCase)) .ToList(); } @@ -125,11 +112,11 @@ public IReadOnlyList FilterByAppId(string appId) /// /// The workflow instance ID to filter by. /// An empty list when no match is found. - public IReadOnlyList FilterByInstanceId(string instanceId) + public IReadOnlyList GetByInstanceId(string instanceId) { ArgumentException.ThrowIfNullOrWhiteSpace(instanceId); - return _entries + return _events .Where(e => string.Equals(e.InstanceId, instanceId, StringComparison.Ordinal)) - .ToList(); + .ToList(); } } diff --git a/src/Dapr.Workflow.Abstractions/PropagatedHistoryActivityResult.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistoryActivityResult.cs index 0f4133e81..0dc05ea78 100644 --- a/src/Dapr.Workflow.Abstractions/PropagatedHistoryActivityResult.cs +++ b/src/Dapr.Workflow.Abstractions/PropagatedHistoryActivityResult.cs @@ -17,34 +17,19 @@ namespace Dapr.Workflow; /// 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 history. -/// Whether the activity completed successfully. -/// Whether the activity failed. +/// The resolved lifecycle status of this activity. /// 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. +/// The failure details when is , otherwise null. /// -/// Mirrors the ActivityResult type in the Go and Python SDKs so cross-language -/// 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. +/// Every activity surfaced through propagated history was scheduled, so +/// reflects how far it progressed past scheduling: +/// means it was scheduled but has not yet completed or failed, +/// means it succeeded, and means it failed. /// public sealed record PropagatedHistoryActivityResult( string Name, - bool Started, - bool Completed, - bool Failed, + PropagatedHistoryStatus Status, string? Input, string? Output, - 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; -} + WorkflowTaskFailureDetails? FailureDetails); diff --git a/src/Dapr.Workflow.Abstractions/PropagatedHistoryChildWorkflowResult.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistoryChildWorkflowResult.cs deleted file mode 100644 index a4196c697..000000000 --- a/src/Dapr.Workflow.Abstractions/PropagatedHistoryChildWorkflowResult.cs +++ /dev/null @@ -1,48 +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; - -/// -/// 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 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. -/// The failure details when is true, otherwise null. -/// -/// 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, - bool Started, - bool Completed, - bool Failed, - string? Output, - 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/PropagatedHistoryEntry.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistoryEvent.cs similarity index 68% rename from src/Dapr.Workflow.Abstractions/PropagatedHistoryEntry.cs rename to src/Dapr.Workflow.Abstractions/PropagatedHistoryEvent.cs index abbc903ba..14a589d91 100644 --- a/src/Dapr.Workflow.Abstractions/PropagatedHistoryEntry.cs +++ b/src/Dapr.Workflow.Abstractions/PropagatedHistoryEvent.cs @@ -28,53 +28,50 @@ namespace Dapr.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 +/// One exists per ancestor workflow in a /// . Use and -/// to look up specific items in this entry; +/// to look up specific items in this entry; /// the plural Get*ByName variants return every occurrence in execution order. /// -public sealed class PropagatedHistoryEntry( +public sealed class PropagatedHistoryEvent( string instanceId, string appId, string name, IReadOnlyList activities, - IReadOnlyList childWorkflows) + 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. + /// The instance ID of the workflow this history event describes. public string InstanceId { get; } = instanceId ?? throw new ArgumentNullException(nameof(instanceId)); - /// The Dapr App ID that ran this ancestor workflow. + /// The Dapr App ID that ran this workflow. public string AppId { get; } = appId ?? throw new ArgumentNullException(nameof(appId)); - /// The name of this ancestor workflow. + /// The name of this 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 activities executed in this history event, in execution order. + public IReadOnlyList Activities { get; } = + activities ?? throw new ArgumentNullException(nameof(activities)); - /// All child workflows started in this entry, in execution order. - public IReadOnlyList ChildWorkflows => _childWorkflows; + /// All workflows started in this history event, in execution order. + public IReadOnlyList Workflows { get; } = + childWorkflows ?? throw new ArgumentNullException(nameof(childWorkflows)); /// - /// 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. /// /// The activity name to filter by. /// An empty list when no match is found. public IReadOnlyList GetActivitiesByName(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - return _activities + return Activities .Where(a => string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase)) .ToList(); } /// - /// 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. /// /// The activity name to look up. /// When this method returns , the last matching activity; otherwise . @@ -82,11 +79,11 @@ public IReadOnlyList GetActivitiesByName(string 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; } } @@ -96,32 +93,32 @@ public bool TryGetLastActivityByName(string name, [NotNullWhen(true)] out Propag } /// - /// 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. /// /// The child workflow name to filter by. /// An empty list when no match is found. - public IReadOnlyList GetChildWorkflowsByName(string name) + public IReadOnlyList GetWorkflowsByName(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - return _childWorkflows + return Workflows .Where(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase)) .ToList(); } /// - /// 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. /// /// 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) + 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; } } diff --git a/src/Dapr.Workflow.Abstractions/PropagatedHistoryTaskStatus.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistoryStatus.cs similarity index 79% rename from src/Dapr.Workflow.Abstractions/PropagatedHistoryTaskStatus.cs rename to src/Dapr.Workflow.Abstractions/PropagatedHistoryStatus.cs index 9e85954bb..9ad77e04f 100644 --- a/src/Dapr.Workflow.Abstractions/PropagatedHistoryTaskStatus.cs +++ b/src/Dapr.Workflow.Abstractions/PropagatedHistoryStatus.cs @@ -19,12 +19,10 @@ namespace Dapr.Workflow; /// /// /// 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. +/// far it progressed past scheduling. Use a switch expression on this value to branch +/// on the three possible states without needing to evaluate multiple boolean conditions. /// -public enum PropagatedHistoryTaskStatus +public enum PropagatedHistoryStatus { /// /// The task was scheduled but has not yet completed or failed in the propagated history. diff --git a/src/Dapr.Workflow.Abstractions/PropagatedHistoryWorkflowResult.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistoryWorkflowResult.cs new file mode 100644 index 000000000..cfa3b9a43 --- /dev/null +++ b/src/Dapr.Workflow.Abstractions/PropagatedHistoryWorkflowResult.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// 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 surfaced through propagated workflow history. +/// +/// The scheduled name of the child workflow. +/// The resolved lifecycle status of this child workflow. +/// The JSON-encoded output payload, or null when the child workflow has not completed. +/// The failure details when is , otherwise null. +/// +/// Every child workflow surfaced through propagated history was scheduled, so +/// reflects how far it progressed past scheduling: +/// means it was scheduled but has not yet completed or failed, +/// means it succeeded, and means it failed. +/// +public sealed record PropagatedHistoryWorkflowResult( + string Name, + PropagatedHistoryStatus Status, + string? Output, + WorkflowTaskFailureDetails? FailureDetails); diff --git a/src/Dapr.Workflow.Abstractions/WorkflowContext.cs b/src/Dapr.Workflow.Abstractions/WorkflowContext.cs index 91de5db65..2c3ddc70c 100644 --- a/src/Dapr.Workflow.Abstractions/WorkflowContext.cs +++ b/src/Dapr.Workflow.Abstractions/WorkflowContext.cs @@ -341,8 +341,8 @@ public virtual Task CallChildWorkflowAsync( /// specified a other than . /// /// - /// Use and - /// / + /// Use and + /// / /// to look up the most recent matching item; the plural FilterBy* / /// Get*ByName variants return every match. /// diff --git a/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs b/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs index 2aa18deec..6333a9412 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. /// /// @@ -1014,7 +1014,7 @@ private static WorkflowTaskFailedException CreateTaskFailedException(TaskFailedE /// Malformed event bytes are surfaced as exceptions — a runtime sending unparseable /// proto bytes is a contract violation we should not hide. /// - private static PropagatedHistoryEntry ConvertChunk(PropagatedHistoryChunk chunk) + private static PropagatedHistoryEvent ConvertChunk(PropagatedHistoryChunk chunk) { var events = new List(chunk.RawEvents.Count); foreach (var rawEvent in chunk.RawEvents) @@ -1048,7 +1048,7 @@ private static PropagatedHistoryEntry ConvertChunk(PropagatedHistoryChunk chunk) } var activities = new List(); - var childWorkflows = new List(); + var childWorkflows = new List(); foreach (var historyEvent in events) { switch (historyEvent.EventTypeCase) @@ -1062,7 +1062,7 @@ private static PropagatedHistoryEntry ConvertChunk(PropagatedHistoryChunk chunk) } } - return new PropagatedHistoryEntry( + return new PropagatedHistoryEvent( chunk.InstanceId, chunk.AppId, chunk.WorkflowName, @@ -1081,67 +1081,61 @@ private static PropagatedHistoryActivityResult ResolveActivity( { var scheduled = scheduleEvent.TaskScheduled; var scheduleId = scheduleEvent.EventId; - var completed = false; - var failed = false; + var status = PropagatedHistoryStatus.Pending; string? output = null; WorkflowTaskFailureDetails? failureDetails = null; if (completions.TryGetValue(scheduleId, out var completion)) { - completed = true; + status = PropagatedHistoryStatus.Completed; output = completion.TaskCompleted.Result; } if (failures.TryGetValue(scheduleId, out var failure)) { - failed = true; + status = PropagatedHistoryStatus.Failed; failureDetails = MapFailureDetails(failure.TaskFailed.FailureDetails); } return new PropagatedHistoryActivityResult( Name: scheduled.Name, - Started: true, - Completed: completed, - Failed: failed, + Status: status, Input: scheduled.Input, Output: output, FailureDetails: failureDetails); } /// - /// Builds a by matching the create event's + /// Builds a by matching the create event's /// EventId against subsequent ChildWorkflowInstanceCompleted / /// ChildWorkflowInstanceFailed events. /// - private static PropagatedHistoryChildWorkflowResult ResolveChildWorkflow( + private static PropagatedHistoryWorkflowResult ResolveChildWorkflow( HistoryEvent createEvent, IReadOnlyDictionary completions, IReadOnlyDictionary failures) { var created = createEvent.ChildWorkflowInstanceCreated; var creationId = createEvent.EventId; - var completed = false; - var failed = false; + var status = PropagatedHistoryStatus.Pending; string? output = null; WorkflowTaskFailureDetails? failureDetails = null; if (completions.TryGetValue(creationId, out var completion)) { - completed = true; + status = PropagatedHistoryStatus.Completed; output = completion.ChildWorkflowInstanceCompleted.Result; } if (failures.TryGetValue(creationId, out var failure)) { - failed = true; + status = PropagatedHistoryStatus.Failed; failureDetails = MapFailureDetails(failure.ChildWorkflowInstanceFailed.FailureDetails); } - return new PropagatedHistoryChildWorkflowResult( + return new PropagatedHistoryWorkflowResult( Name: created.Name, - Started: true, - Completed: completed, - Failed: failed, + Status: status, Output: output, FailureDetails: failureDetails); } diff --git a/test/Dapr.IntegrationTest.Workflow/HistoryPropagationWorkflowTests.cs b/test/Dapr.IntegrationTest.Workflow/HistoryPropagationWorkflowTests.cs index 046d7048c..9a26fce26 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?.GetEntries().Count ?? 0); + PropagatedEntryCount: propagated?.Events.Count ?? 0); } } diff --git a/test/Dapr.Workflow.Abstractions.Test/PropagatedHistoryTests.cs b/test/Dapr.Workflow.Abstractions.Test/PropagatedHistoryTests.cs new file mode 100644 index 000000000..a2d5c00a6 --- /dev/null +++ b/test/Dapr.Workflow.Abstractions.Test/PropagatedHistoryTests.cs @@ -0,0 +1,677 @@ +// ------------------------------------------------------------------------ +// 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.Abstractions.Test; + +public class PropagatedHistoryTests +{ + // ----- Helpers ----- + + static PropagatedHistoryEvent MakeEvent(string instanceId = "id1", string appId = "app1", string name = "WF1") => + new(instanceId, appId, name, [], []); + + // ----- Events property ----- + + [Fact] + public void Events_ReturnsEmpty_WhenConstructedWithEmptyList() + { + var history = new PropagatedHistory([]); + Assert.Empty(history.Events); + } + + [Fact] + public void Events_ReturnsProvidedEvents() + { + var e1 = MakeEvent("id1", "app1", "WF1"); + var e2 = MakeEvent("id2", "app2", "WF2"); + var history = new PropagatedHistory([e1, e2]); + Assert.Equal(2, history.Events.Count); + Assert.Same(e1, history.Events[0]); + Assert.Same(e2, history.Events[1]); + } + + // ----- GetAppIds ----- + + [Fact] + public void GetAppIds_ReturnsEmpty_WhenNoEvents() + { + var history = new PropagatedHistory([]); + Assert.Empty(history.GetAppIds()); + } + + [Fact] + public void GetAppIds_ReturnsOrderedDeduplicated_AppIds() + { + var events = new[] + { + MakeEvent("id1", "appA", "WF1"), + MakeEvent("id2", "appB", "WF2"), + MakeEvent("id3", "appA", "WF3"), + }; + var history = new PropagatedHistory(events); + var appIds = history.GetAppIds(); + Assert.Equal(2, appIds.Count); + Assert.Equal("appA", appIds[0]); + Assert.Equal("appB", appIds[1]); + } + + [Fact] + public void GetAppIds_IsCaseInsensitive_ForDeduplication() + { + var events = new[] + { + MakeEvent("id1", "AppA", "WF1"), + MakeEvent("id2", "appa", "WF2"), + }; + var history = new PropagatedHistory(events); + var appIds = history.GetAppIds(); + Assert.Single(appIds); + Assert.Equal("AppA", appIds[0]); + } + + [Fact] + public void GetAppIds_ReturnsAllDistinct_AppIds() + { + var events = new[] + { + MakeEvent("id1", "appX", "WF1"), + MakeEvent("id2", "appY", "WF2"), + MakeEvent("id3", "appZ", "WF3"), + }; + var history = new PropagatedHistory(events); + var appIds = history.GetAppIds(); + Assert.Equal(3, appIds.Count); + } + + // ----- GetEventsByWorkflowName ----- + + [Fact] + public void GetEventsByWorkflowName_ThrowsOnNull() + { + var history = new PropagatedHistory([]); + Assert.Throws(() => history.GetEventsByWorkflowName(null!)); + } + + [Fact] + public void GetEventsByWorkflowName_ThrowsOnWhitespace() + { + var history = new PropagatedHistory([]); + Assert.Throws(() => history.GetEventsByWorkflowName(" ")); + } + + [Fact] + public void GetEventsByWorkflowName_ReturnsEmpty_WhenNoMatch() + { + var history = new PropagatedHistory([MakeEvent("id1", "app1", "WF1")]); + Assert.Empty(history.GetEventsByWorkflowName("WF2")); + } + + [Fact] + public void GetEventsByWorkflowName_ReturnsMatching_InOrder() + { + var e1 = MakeEvent("id1", "app1", "WFMatch"); + var e2 = MakeEvent("id2", "app2", "WFOther"); + var e3 = MakeEvent("id3", "app3", "WFMatch"); + var history = new PropagatedHistory([e1, e2, e3]); + + var result = history.GetEventsByWorkflowName("WFMatch"); + Assert.Equal(2, result.Count); + Assert.Same(e1, result[0]); + Assert.Same(e3, result[1]); + } + + [Fact] + public void GetEventsByWorkflowName_IsCaseInsensitive() + { + var e = MakeEvent("id1", "app1", "MyWorkflow"); + var history = new PropagatedHistory([e]); + Assert.Single(history.GetEventsByWorkflowName("myworkflow")); + Assert.Single(history.GetEventsByWorkflowName("MYWORKFLOW")); + } + + // ----- TryGetLastWorkflowEventByName ----- + + [Fact] + public void TryGetLastWorkflowEventByName_ThrowsOnNull() + { + var history = new PropagatedHistory([]); + Assert.Throws(() => history.TryGetLastWorkflowEventByName(null!, out _)); + } + + [Fact] + public void TryGetLastWorkflowEventByName_ThrowsOnWhitespace() + { + var history = new PropagatedHistory([]); + Assert.Throws(() => history.TryGetLastWorkflowEventByName(" ", out _)); + } + + [Fact] + public void TryGetLastWorkflowEventByName_ReturnsFalse_WhenNoMatch() + { + var history = new PropagatedHistory([MakeEvent("id1", "app1", "WF1")]); + Assert.False(history.TryGetLastWorkflowEventByName("WF2", out var result)); + Assert.Null(result); + } + + [Fact] + public void TryGetLastWorkflowEventByName_ReturnsTrue_AndLastMatch() + { + var e1 = MakeEvent("id1", "app1", "WFRepeat"); + var e2 = MakeEvent("id2", "app2", "WFOther"); + var e3 = MakeEvent("id3", "app1", "WFRepeat"); + var history = new PropagatedHistory([e1, e2, e3]); + + Assert.True(history.TryGetLastWorkflowEventByName("WFRepeat", out var result)); + Assert.Same(e3, result); + } + + [Fact] + public void TryGetLastWorkflowEventByName_IsCaseInsensitive() + { + var e = MakeEvent("id1", "app1", "MyWorkflow"); + var history = new PropagatedHistory([e]); + Assert.True(history.TryGetLastWorkflowEventByName("myworkflow", out var result)); + Assert.Same(e, result); + } + + // ----- FilterByAppId ----- + + [Fact] + public void FilterByAppId_ThrowsOnNull() + { + var history = new PropagatedHistory([]); + Assert.Throws(() => history.GetByAppId(null!)); + } + + [Fact] + public void FilterByAppId_ThrowsOnWhitespace() + { + var history = new PropagatedHistory([]); + Assert.Throws(() => history.GetByAppId(" ")); + } + + [Fact] + public void FilterByAppId_ReturnsEmpty_WhenNoMatch() + { + var history = new PropagatedHistory([MakeEvent("id1", "appA", "WF1")]); + Assert.Empty(history.GetByAppId("appB")); + } + + [Fact] + public void FilterByAppId_ReturnsMatching_InOrder() + { + var e1 = MakeEvent("id1", "appA", "WF1"); + var e2 = MakeEvent("id2", "appB", "WF2"); + var e3 = MakeEvent("id3", "appA", "WF3"); + var history = new PropagatedHistory([e1, e2, e3]); + + var result = history.GetByAppId("appA"); + Assert.Equal(2, result.Count); + Assert.Same(e1, result[0]); + Assert.Same(e3, result[1]); + } + + [Fact] + public void FilterByAppId_IsCaseInsensitive() + { + var e = MakeEvent("id1", "AppA", "WF1"); + var history = new PropagatedHistory([e]); + Assert.Single(history.GetByAppId("appa")); + Assert.Single(history.GetByAppId("APPA")); + } + + // ----- FilterByInstanceId ----- + + [Fact] + public void FilterByInstanceId_ThrowsOnNull() + { + var history = new PropagatedHistory([]); + Assert.Throws(() => history.GetByInstanceId(null!)); + } + + [Fact] + public void FilterByInstanceId_ThrowsOnWhitespace() + { + var history = new PropagatedHistory([]); + Assert.Throws(() => history.GetByInstanceId(" ")); + } + + [Fact] + public void FilterByInstanceId_ReturnsEmpty_WhenNoMatch() + { + var history = new PropagatedHistory([MakeEvent("idA", "app1", "WF1")]); + Assert.Empty(history.GetByInstanceId("idB")); + } + + [Fact] + public void FilterByInstanceId_ReturnsMatching() + { + var e1 = MakeEvent("idA", "app1", "WF1"); + var e2 = MakeEvent("idB", "app2", "WF2"); + var e3 = MakeEvent("idA", "app3", "WF3"); + var history = new PropagatedHistory([e1, e2, e3]); + + var result = history.GetByInstanceId("idA"); + Assert.Equal(2, result.Count); + Assert.Same(e1, result[0]); + Assert.Same(e3, result[1]); + } + + [Fact] + public void FilterByInstanceId_IsCaseSensitive() + { + var e = MakeEvent("idA", "app1", "WF1"); + var history = new PropagatedHistory([e]); + Assert.Empty(history.GetByInstanceId("ida")); + Assert.Empty(history.GetByInstanceId("IDA")); + Assert.Single(history.GetByInstanceId("idA")); + } +} + +public class PropagatedHistoryEventTests +{ + // ----- Helpers ----- + + static PropagatedHistoryActivityResult MakeActivity(string name, PropagatedHistoryStatus status = PropagatedHistoryStatus.Completed) => + new(name, status, null, null, null); + + static PropagatedHistoryWorkflowResult MakeChildWorkflow(string name, PropagatedHistoryStatus status = PropagatedHistoryStatus.Completed) => + new(name, status, null, null); + + // ----- Constructor null guards ----- + + [Fact] + public void Constructor_ThrowsOnNull_InstanceId() + { + Assert.Throws(() => + new PropagatedHistoryEvent(null!, "appId", "name", [], [])); + } + + [Fact] + public void Constructor_ThrowsOnNull_AppId() + { + Assert.Throws(() => + new PropagatedHistoryEvent("instanceId", null!, "name", [], [])); + } + + [Fact] + public void Constructor_ThrowsOnNull_Name() + { + Assert.Throws(() => + new PropagatedHistoryEvent("instanceId", "appId", null!, [], [])); + } + + [Fact] + public void Constructor_ThrowsOnNull_Activities() + { + Assert.Throws(() => + new PropagatedHistoryEvent("instanceId", "appId", "name", null!, [])); + } + + [Fact] + public void Constructor_ThrowsOnNull_ChildWorkflows() + { + Assert.Throws(() => + new PropagatedHistoryEvent("instanceId", "appId", "name", [], null!)); + } + + [Fact] + public void Constructor_SetsProperties() + { + var activities = new[] { MakeActivity("Act1") }; + var workflows = new[] { MakeChildWorkflow("CW1") }; + var evt = new PropagatedHistoryEvent("inst-1", "my-app", "MyWorkflow", activities, workflows); + + Assert.Equal("inst-1", evt.InstanceId); + Assert.Equal("my-app", evt.AppId); + Assert.Equal("MyWorkflow", evt.Name); + Assert.Equal(activities, evt.Activities); + Assert.Equal(workflows, evt.Workflows); + } + + // ----- GetActivitiesByName ----- + + [Fact] + public void GetActivitiesByName_ThrowsOnNull() + { + var evt = new PropagatedHistoryEvent("id", "app", "wf", [], []); + Assert.Throws(() => evt.GetActivitiesByName(null!)); + } + + [Fact] + public void GetActivitiesByName_ThrowsOnWhitespace() + { + var evt = new PropagatedHistoryEvent("id", "app", "wf", [], []); + Assert.Throws(() => evt.GetActivitiesByName(" ")); + } + + [Fact] + public void GetActivitiesByName_ReturnsEmpty_WhenNoMatch() + { + var activities = new[] { MakeActivity("Act1") }; + var evt = new PropagatedHistoryEvent("id", "app", "wf", activities, []); + Assert.Empty(evt.GetActivitiesByName("Act2")); + } + + [Fact] + public void GetActivitiesByName_ReturnsMatching_InOrder() + { + var a1 = MakeActivity("ActMatch"); + var a2 = MakeActivity("ActOther"); + var a3 = MakeActivity("ActMatch"); + var evt = new PropagatedHistoryEvent("id", "app", "wf", [a1, a2, a3], []); + + var result = evt.GetActivitiesByName("ActMatch"); + Assert.Equal(2, result.Count); + Assert.Same(a1, result[0]); + Assert.Same(a3, result[1]); + } + + [Fact] + public void GetActivitiesByName_IsCaseInsensitive() + { + var a = MakeActivity("MyActivity"); + var evt = new PropagatedHistoryEvent("id", "app", "wf", [a], []); + Assert.Single(evt.GetActivitiesByName("myactivity")); + Assert.Single(evt.GetActivitiesByName("MYACTIVITY")); + } + + // ----- TryGetLastActivityByName ----- + + [Fact] + public void TryGetLastActivityByName_ThrowsOnNull() + { + var evt = new PropagatedHistoryEvent("id", "app", "wf", [], []); + Assert.Throws(() => evt.TryGetLastActivityByName(null!, out _)); + } + + [Fact] + public void TryGetLastActivityByName_ThrowsOnWhitespace() + { + var evt = new PropagatedHistoryEvent("id", "app", "wf", [], []); + Assert.Throws(() => evt.TryGetLastActivityByName(" ", out _)); + } + + [Fact] + public void TryGetLastActivityByName_ReturnsFalse_WhenNoMatch() + { + var evt = new PropagatedHistoryEvent("id", "app", "wf", [MakeActivity("Act1")], []); + Assert.False(evt.TryGetLastActivityByName("Act2", out var result)); + Assert.Null(result); + } + + [Fact] + public void TryGetLastActivityByName_ReturnsTrue_AndLastMatch() + { + var a1 = MakeActivity("ActRepeat", PropagatedHistoryStatus.Completed); + var a2 = MakeActivity("ActOther"); + var a3 = MakeActivity("ActRepeat", PropagatedHistoryStatus.Failed); + var evt = new PropagatedHistoryEvent("id", "app", "wf", [a1, a2, a3], []); + + Assert.True(evt.TryGetLastActivityByName("ActRepeat", out var result)); + Assert.Same(a3, result); + } + + [Fact] + public void TryGetLastActivityByName_IsCaseInsensitive() + { + var a = MakeActivity("MyActivity"); + var evt = new PropagatedHistoryEvent("id", "app", "wf", [a], []); + Assert.True(evt.TryGetLastActivityByName("myactivity", out var result)); + Assert.Same(a, result); + } + + // ----- GetWorkflowsByName ----- + + [Fact] + public void GetWorkflowsByName_ThrowsOnNull() + { + var evt = new PropagatedHistoryEvent("id", "app", "wf", [], []); + Assert.Throws(() => evt.GetWorkflowsByName(null!)); + } + + [Fact] + public void GetWorkflowsByName_ThrowsOnWhitespace() + { + var evt = new PropagatedHistoryEvent("id", "app", "wf", [], []); + Assert.Throws(() => evt.GetWorkflowsByName(" ")); + } + + [Fact] + public void GetWorkflowsByName_ReturnsEmpty_WhenNoMatch() + { + var workflows = new[] { MakeChildWorkflow("CW1") }; + var evt = new PropagatedHistoryEvent("id", "app", "wf", [], workflows); + Assert.Empty(evt.GetWorkflowsByName("CW2")); + } + + [Fact] + public void GetWorkflowsByName_ReturnsMatching_InOrder() + { + var cw1 = MakeChildWorkflow("CWMatch"); + var cw2 = MakeChildWorkflow("CWOther"); + var cw3 = MakeChildWorkflow("CWMatch"); + var evt = new PropagatedHistoryEvent("id", "app", "wf", [], [cw1, cw2, cw3]); + + var result = evt.GetWorkflowsByName("CWMatch"); + Assert.Equal(2, result.Count); + Assert.Same(cw1, result[0]); + Assert.Same(cw3, result[1]); + } + + [Fact] + public void GetWorkflowsByName_IsCaseInsensitive() + { + var cw = MakeChildWorkflow("MyChild"); + var evt = new PropagatedHistoryEvent("id", "app", "wf", [], [cw]); + Assert.Single(evt.GetWorkflowsByName("mychild")); + Assert.Single(evt.GetWorkflowsByName("MYCHILD")); + } + + // ----- TryGetLastWorkflowByName ----- + + [Fact] + public void TryGetLastWorkflowByName_ThrowsOnNull() + { + var evt = new PropagatedHistoryEvent("id", "app", "wf", [], []); + Assert.Throws(() => evt.TryGetLastWorkflowByName(null!, out _)); + } + + [Fact] + public void TryGetLastWorkflowByName_ThrowsOnWhitespace() + { + var evt = new PropagatedHistoryEvent("id", "app", "wf", [], []); + Assert.Throws(() => evt.TryGetLastWorkflowByName(" ", out _)); + } + + [Fact] + public void TryGetLastWorkflowByName_ReturnsFalse_WhenNoMatch() + { + var evt = new PropagatedHistoryEvent("id", "app", "wf", [], [MakeChildWorkflow("CW1")]); + Assert.False(evt.TryGetLastWorkflowByName("CW2", out var result)); + Assert.Null(result); + } + + [Fact] + public void TryGetLastWorkflowByName_ReturnsTrue_AndLastMatch() + { + var cw1 = MakeChildWorkflow("CWRepeat", PropagatedHistoryStatus.Completed); + var cw2 = MakeChildWorkflow("CWOther"); + var cw3 = MakeChildWorkflow("CWRepeat", PropagatedHistoryStatus.Failed); + var evt = new PropagatedHistoryEvent("id", "app", "wf", [], [cw1, cw2, cw3]); + + Assert.True(evt.TryGetLastWorkflowByName("CWRepeat", out var result)); + Assert.Same(cw3, result); + } + + [Fact] + public void TryGetLastWorkflowByName_IsCaseInsensitive() + { + var cw = MakeChildWorkflow("MyChild"); + var evt = new PropagatedHistoryEvent("id", "app", "wf", [], [cw]); + Assert.True(evt.TryGetLastWorkflowByName("mychild", out var result)); + Assert.Same(cw, result); + } +} + +public class PropagatedHistoryActivityResultTests +{ + [Fact] + public void Constructor_SetsProperties() + { + var failure = new WorkflowTaskFailureDetails(typeof(InvalidOperationException).FullName!, "err"); + var result = new PropagatedHistoryActivityResult("MyAct", PropagatedHistoryStatus.Failed, """{"x":1}""", null, failure); + + Assert.Equal("MyAct", result.Name); + Assert.Equal(PropagatedHistoryStatus.Failed, result.Status); + Assert.Equal("""{"x":1}""", result.Input); + Assert.Null(result.Output); + Assert.Same(failure, result.FailureDetails); + } + + [Fact] + public void Constructor_SetsNullableFields_ToNull() + { + var result = new PropagatedHistoryActivityResult("Act1", PropagatedHistoryStatus.Pending, null, null, null); + Assert.Null(result.Input); + Assert.Null(result.Output); + Assert.Null(result.FailureDetails); + } + + [Fact] + public void RecordEquality_SameValues_AreEqual() + { + var r1 = new PropagatedHistoryActivityResult("Act", PropagatedHistoryStatus.Completed, "in", "out", null); + var r2 = new PropagatedHistoryActivityResult("Act", PropagatedHistoryStatus.Completed, "in", "out", null); + Assert.Equal(r1, r2); + } + + [Fact] + public void RecordEquality_DifferentName_AreNotEqual() + { + var r1 = new PropagatedHistoryActivityResult("Act1", PropagatedHistoryStatus.Completed, null, null, null); + var r2 = new PropagatedHistoryActivityResult("Act2", PropagatedHistoryStatus.Completed, null, null, null); + Assert.NotEqual(r1, r2); + } + + [Fact] + public void RecordEquality_DifferentStatus_AreNotEqual() + { + var r1 = new PropagatedHistoryActivityResult("Act", PropagatedHistoryStatus.Completed, null, null, null); + var r2 = new PropagatedHistoryActivityResult("Act", PropagatedHistoryStatus.Failed, null, null, null); + Assert.NotEqual(r1, r2); + } + + [Fact] + public void CompletedActivity_HasOutputAndNoFailureDetails() + { + var result = new PropagatedHistoryActivityResult("Act", PropagatedHistoryStatus.Completed, """{"in":true}""", """{"out":42}""", null); + Assert.Equal(PropagatedHistoryStatus.Completed, result.Status); + Assert.NotNull(result.Output); + Assert.Null(result.FailureDetails); + } +} + +public class PropagatedHistoryWorkflowResultTests +{ + [Fact] + public void Constructor_SetsProperties() + { + var failure = new WorkflowTaskFailureDetails(typeof(Exception).FullName!, "err"); + var result = new PropagatedHistoryWorkflowResult("ChildWF", PropagatedHistoryStatus.Failed, null, failure); + + Assert.Equal("ChildWF", result.Name); + Assert.Equal(PropagatedHistoryStatus.Failed, result.Status); + Assert.Null(result.Output); + Assert.Same(failure, result.FailureDetails); + } + + [Fact] + public void Constructor_SetsNullableFields_ToNull() + { + var result = new PropagatedHistoryWorkflowResult("CW", PropagatedHistoryStatus.Pending, null, null); + Assert.Null(result.Output); + Assert.Null(result.FailureDetails); + } + + [Fact] + public void RecordEquality_SameValues_AreEqual() + { + var r1 = new PropagatedHistoryWorkflowResult("CW", PropagatedHistoryStatus.Completed, "out", null); + var r2 = new PropagatedHistoryWorkflowResult("CW", PropagatedHistoryStatus.Completed, "out", null); + Assert.Equal(r1, r2); + } + + [Fact] + public void RecordEquality_DifferentName_AreNotEqual() + { + var r1 = new PropagatedHistoryWorkflowResult("CW1", PropagatedHistoryStatus.Completed, null, null); + var r2 = new PropagatedHistoryWorkflowResult("CW2", PropagatedHistoryStatus.Completed, null, null); + Assert.NotEqual(r1, r2); + } + + [Fact] + public void RecordEquality_DifferentStatus_AreNotEqual() + { + var r1 = new PropagatedHistoryWorkflowResult("CW", PropagatedHistoryStatus.Completed, null, null); + var r2 = new PropagatedHistoryWorkflowResult("CW", PropagatedHistoryStatus.Pending, null, null); + Assert.NotEqual(r1, r2); + } + + [Fact] + public void CompletedWorkflow_HasOutputAndNoFailureDetails() + { + var result = new PropagatedHistoryWorkflowResult("CW", PropagatedHistoryStatus.Completed, """{"done":true}""", null); + Assert.Equal(PropagatedHistoryStatus.Completed, result.Status); + Assert.NotNull(result.Output); + Assert.Null(result.FailureDetails); + } +} + +public class PropagatedHistoryStatusTests +{ + [Fact] + public void Enum_HasExpectedValues() + { + Assert.Equal(0, (int)PropagatedHistoryStatus.Pending); + Assert.Equal(1, (int)PropagatedHistoryStatus.Completed); + Assert.Equal(2, (int)PropagatedHistoryStatus.Failed); + } + + [Fact] + public void Enum_HasExactlyThreeValues() + { + var values = Enum.GetValues(); + Assert.Equal(3, values.Length); + } + + [Theory] + [InlineData(PropagatedHistoryStatus.Pending)] + [InlineData(PropagatedHistoryStatus.Completed)] + [InlineData(PropagatedHistoryStatus.Failed)] + public void Status_IsStoredCorrectly_InActivityResult(PropagatedHistoryStatus status) + { + var result = new PropagatedHistoryActivityResult("Act", status, null, null, null); + Assert.Equal(status, result.Status); + } + + [Theory] + [InlineData(PropagatedHistoryStatus.Pending)] + [InlineData(PropagatedHistoryStatus.Completed)] + [InlineData(PropagatedHistoryStatus.Failed)] + public void Status_IsStoredCorrectly_InWorkflowResult(PropagatedHistoryStatus status) + { + var result = new PropagatedHistoryWorkflowResult("CW", status, null, null); + Assert.Equal(status, result.Status); + } +} diff --git a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs index 5c8588c44..b17d1ff8b 100644 --- a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs +++ b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs @@ -28,9 +28,9 @@ namespace Dapr.Workflow.Test.Worker.Internal; /// , propagating the scope to the /// outgoing / , /// and exposing inbound propagated history through -/// as +/// as /// values, each carrying typed / -/// records. +/// records. /// public class WorkflowHistoryPropagationTests { @@ -162,13 +162,13 @@ public void GetPropagatedHistory_ReturnsSingleWorkflow_WhenOneEntryPropagated() var history = context.GetPropagatedHistory(); Assert.NotNull(history); - var entries = history.GetEntries(); + var entries = history.Events; 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); + Assert.Empty(entries[0].Workflows); } [Fact] @@ -184,7 +184,7 @@ public void GetPropagatedHistory_PreservesEntryOrder() var history = context.GetPropagatedHistory(); Assert.NotNull(history); - var entries = history.GetEntries(); + var entries = history.Events; Assert.Equal(2, entries.Count); Assert.Equal("gp-inst", entries[0].InstanceId); Assert.Equal("p-inst", entries[1].InstanceId); @@ -218,14 +218,11 @@ public void Activity_ResolvedAs_CompletedWithInputAndOutput() TaskCompleted(eventId: 2, scheduledId: 1, result: "true")); var context = CreateContext(incomingPropagatedHistory: [chunk]); - Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowByName("Wf", out var workflow)); + Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowEventByName("Wf", out var workflow)); Assert.True(workflow.TryGetLastActivityByName("ValidateMerchant", out var activity)); Assert.Equal("ValidateMerchant", activity.Name); - Assert.True(activity.Started); - Assert.True(activity.Completed); - Assert.False(activity.Failed); - Assert.Equal(PropagatedHistoryTaskStatus.Completed, activity.Status); + Assert.Equal(PropagatedHistoryStatus.Completed, activity.Status); Assert.Equal("\"merchant-1\"", activity.Input); Assert.Equal("true", activity.Output); Assert.Null(activity.FailureDetails); @@ -239,12 +236,10 @@ public void Activity_ResolvedAs_FailedWithFailureDetails() TaskFailed(eventId: 2, scheduledId: 1, errorMessage: "card declined")); var context = CreateContext(incomingPropagatedHistory: [chunk]); - Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowByName("Wf", out var workflow)); + Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowEventByName("Wf", out var workflow)); Assert.True(workflow.TryGetLastActivityByName("ValidateCard", out var activity)); - Assert.False(activity.Completed); - Assert.True(activity.Failed); - Assert.Equal(PropagatedHistoryTaskStatus.Failed, activity.Status); + Assert.Equal(PropagatedHistoryStatus.Failed, activity.Status); Assert.NotNull(activity.FailureDetails); Assert.Equal("card declined", activity.FailureDetails.ErrorMessage); } @@ -256,13 +251,10 @@ public void Activity_ResolvedAs_StartedOnly_WhenNotYetCompleted() TaskScheduled(eventId: 1, name: "PendingCheck", input: "\"in\"")); var context = CreateContext(incomingPropagatedHistory: [chunk]); - Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowByName("Wf", out var workflow)); + Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowEventByName("Wf", out var workflow)); Assert.True(workflow.TryGetLastActivityByName("PendingCheck", out var activity)); - Assert.True(activity.Started); - Assert.False(activity.Completed); - Assert.False(activity.Failed); - Assert.Equal(PropagatedHistoryTaskStatus.Pending, activity.Status); + Assert.Equal(PropagatedHistoryStatus.Pending, activity.Status); Assert.Equal("\"in\"", activity.Input); Assert.Null(activity.Output); } @@ -278,15 +270,15 @@ public void TryGetLastActivityByName_ReturnsMostRecentInvocation_WhenRetried() TaskFailed(eventId: 4, scheduledId: 3, errorMessage: "card declined")); var context = CreateContext(incomingPropagatedHistory: [chunk]); - Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowByName("Wf", out var workflow)); + Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowEventByName("Wf", out var workflow)); var all = workflow.GetActivitiesByName("ValidateCard"); Assert.Equal(2, all.Count); - Assert.True(all[0].Completed); - Assert.True(all[1].Failed); + Assert.Equal(PropagatedHistoryStatus.Completed, all[0].Status); + Assert.Equal(PropagatedHistoryStatus.Failed, all[1].Status); Assert.True(workflow.TryGetLastActivityByName("ValidateCard", out var last)); - Assert.True(last.Failed); + Assert.Equal(PropagatedHistoryStatus.Failed, last.Status); Assert.Equal("card declined", last.FailureDetails!.ErrorMessage); } @@ -297,7 +289,7 @@ public void TryGetLastActivityByName_ReturnsFalse_WhenMissing() TaskScheduled(eventId: 1, name: "Real")); var context = CreateContext(incomingPropagatedHistory: [chunk]); - Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowByName("Wf", out var workflow)); + Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowEventByName("Wf", out var workflow)); Assert.False(workflow.TryGetLastActivityByName("Missing", out var missing)); Assert.Null(missing); @@ -315,13 +307,10 @@ public void ChildWorkflow_ResolvedAs_Completed() ChildCompleted(eventId: 2, creationId: 1, result: "\"paid\"")); var context = CreateContext(incomingPropagatedHistory: [chunk]); - Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowByName("Wf", out var workflow)); - Assert.True(workflow.TryGetLastChildWorkflowByName("ProcessPayment", out var child)); + Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowEventByName("Wf", out var workflow)); + Assert.True(workflow.TryGetLastWorkflowByName("ProcessPayment", out var child)); - Assert.True(child.Started); - Assert.True(child.Completed); - Assert.False(child.Failed); - Assert.Equal(PropagatedHistoryTaskStatus.Completed, child.Status); + Assert.Equal(PropagatedHistoryStatus.Completed, child.Status); Assert.Equal("\"paid\"", child.Output); } @@ -333,11 +322,10 @@ public void ChildWorkflow_ResolvedAs_Failed() ChildFailed(eventId: 2, creationId: 1, errorMessage: "boom")); var context = CreateContext(incomingPropagatedHistory: [chunk]); - Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowByName("Wf", out var workflow)); - Assert.True(workflow.TryGetLastChildWorkflowByName("ProcessPayment", out var child)); + Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowEventByName("Wf", out var workflow)); + Assert.True(workflow.TryGetLastWorkflowByName("ProcessPayment", out var child)); - Assert.True(child.Failed); - Assert.Equal(PropagatedHistoryTaskStatus.Failed, child.Status); + Assert.Equal(PropagatedHistoryStatus.Failed, child.Status); Assert.Equal("boom", child.FailureDetails!.ErrorMessage); } @@ -346,9 +334,9 @@ public void TryGetLastChildWorkflowByName_ReturnsFalse_WhenMissing() { var chunk = MakeChunk("app", "inst", "Wf"); var context = CreateContext(incomingPropagatedHistory: [chunk]); - Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowByName("Wf", out var workflow)); + Assert.True(context.GetPropagatedHistory()!.TryGetLastWorkflowEventByName("Wf", out var workflow)); - Assert.False(workflow.TryGetLastChildWorkflowByName("Missing", out var missing)); + Assert.False(workflow.TryGetLastWorkflowByName("Missing", out var missing)); Assert.Null(missing); } @@ -360,9 +348,9 @@ public void TryGetLastChildWorkflowByName_ReturnsFalse_WhenMissing() public void GetAppIds_ReturnsOrderedDeduplicatedList() { var history = new PropagatedHistory([ - new PropagatedHistoryEntry("i1", "appA", "WfA", [], []), - new PropagatedHistoryEntry("i2", "appB", "WfB", [], []), - new PropagatedHistoryEntry("i3", "appA", "WfA2", [], []), + new PropagatedHistoryEvent("i1", "appA", "WfA", [], []), + new PropagatedHistoryEvent("i2", "appB", "WfB", [], []), + new PropagatedHistoryEvent("i3", "appA", "WfA2", [], []), ]); Assert.Equal(["appA", "appB"], history.GetAppIds()); @@ -372,20 +360,20 @@ public void GetAppIds_ReturnsOrderedDeduplicatedList() public void TryGetLastWorkflowByName_ReturnsMostRecent_WhenNameRepeated() { var history = new PropagatedHistory([ - new PropagatedHistoryEntry("wf-1", "app", "Loop", [], []), - new PropagatedHistoryEntry("wf-2", "app", "Loop", [], []), + new PropagatedHistoryEvent("wf-1", "app", "Loop", [], []), + new PropagatedHistoryEvent("wf-2", "app", "Loop", [], []), ]); - Assert.True(history.TryGetLastWorkflowByName("Loop", out var last)); + Assert.True(history.TryGetLastWorkflowEventByName("Loop", out var last)); Assert.Equal("wf-2", last.InstanceId); - Assert.Equal(2, history.FilterByWorkflowName("Loop").Count); + Assert.Equal(2, history.GetEventsByWorkflowName("Loop").Count); } [Fact] public void TryGetLastWorkflowByName_ReturnsFalse_WhenMissing() { var history = new PropagatedHistory([]); - Assert.False(history.TryGetLastWorkflowByName("Missing", out var missing)); + Assert.False(history.TryGetLastWorkflowEventByName("Missing", out var missing)); Assert.Null(missing); } @@ -395,6 +383,78 @@ public void PropagatedHistory_Ctor_ThrowsOnNullEntries() Assert.Throws(() => new PropagatedHistory(null!)); } + [Fact] + public void GetByInstanceId_ReturnsMatchingEntries_WhenInstanceIdMatches() + { + var history = new PropagatedHistory([ + new PropagatedHistoryEvent("inst-a", "app", "WfA", [], []), + new PropagatedHistoryEvent("inst-b", "app", "WfB", [], []), + new PropagatedHistoryEvent("inst-c", "app", "WfC", [], []), + ]); + + var result = history.GetByInstanceId("inst-b"); + + Assert.Single(result); + Assert.Equal("inst-b", result[0].InstanceId); + } + + [Fact] + public void GetByInstanceId_ReturnsEmptyList_WhenNoMatch() + { + var history = new PropagatedHistory([ + new PropagatedHistoryEvent("inst-a", "app", "WfA", [], []), + ]); + + var result = history.GetByInstanceId("inst-z"); + + Assert.Empty(result); + } + + [Fact] + public void GetByInstanceId_ReturnsBothEntries_WhenSameInstanceAppearsMultipleTimes() + { + // ContinueAsNew or replay can produce multiple chunks for the same instance ID. + var history = new PropagatedHistory([ + new PropagatedHistoryEvent("inst-loop", "app", "LoopWf", [], []), + new PropagatedHistoryEvent("inst-other", "app", "OtherWf", [], []), + new PropagatedHistoryEvent("inst-loop", "app", "LoopWf", [], []), + ]); + + var result = history.GetByInstanceId("inst-loop"); + + Assert.Equal(2, result.Count); + Assert.All(result, e => Assert.Equal("inst-loop", e.InstanceId)); + } + + [Fact] + public void GetByInstanceId_IsCaseSensitive() + { + // Instance IDs use Ordinal comparison (unlike app/workflow name lookups which are OrdinalIgnoreCase). + var history = new PropagatedHistory([ + new PropagatedHistoryEvent("Instance-1", "app", "Wf", [], []), + ]); + + Assert.Empty(history.GetByInstanceId("instance-1")); + Assert.Single(history.GetByInstanceId("Instance-1")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void GetByInstanceId_ThrowsOnWhitespace(string? instanceId) + { + var history = new PropagatedHistory([]); + Assert.Throws(() => history.GetByInstanceId(instanceId!)); + } + + [Theory] + [InlineData(null)] + public void GetByInstanceId_ThrowsOnNull(string? instanceId) + { + var history = new PropagatedHistory([]); + Assert.Throws(() => history.GetByInstanceId(instanceId!)); + } + [Fact] public void PropagatedHistory_NameAndAppIdLookups_AreCaseInsensitive() { @@ -402,50 +462,47 @@ public void PropagatedHistory_NameAndAppIdLookups_AreCaseInsensitive() // 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, + Name: "ValidateMerchant", Status: PropagatedHistoryStatus.Completed, Input: null, Output: null, FailureDetails: null); - var child = new PropagatedHistoryChildWorkflowResult( - Name: "FraudDetection", Started: true, Completed: true, Failed: false, + var child = new PropagatedHistoryWorkflowResult( + Name: "FraudDetection", Status: PropagatedHistoryStatus.Completed, Output: null, FailureDetails: null); - var entry = new PropagatedHistoryEntry("inst-1", "AppA", "MerchantCheckout", [activity], [child]); + var entry = new PropagatedHistoryEvent("inst-1", "AppA", "MerchantCheckout", [activity], [child]); var history = new PropagatedHistory([ entry, - new PropagatedHistoryEntry("inst-2", "appa", "OtherWf", [], []), + new PropagatedHistoryEvent("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.Equal(2, history.FilterByAppId("APPA").Count); - Assert.Single(history.FilterByWorkflowName("merchantcheckout")); - Assert.True(history.TryGetLastWorkflowByName("MERCHANTCHECKOUT", out _)); + Assert.Equal(2, history.GetByAppId("APPA").Count); + Assert.Single(history.GetEventsByWorkflowName("merchantcheckout")); + Assert.True(history.TryGetLastWorkflowEventByName("MERCHANTCHECKOUT", out _)); Assert.Single(entry.GetActivitiesByName("validatemerchant")); Assert.True(entry.TryGetLastActivityByName("VALIDATEMERCHANT", out _)); - Assert.Single(entry.GetChildWorkflowsByName("frauddetection")); - Assert.True(entry.TryGetLastChildWorkflowByName("FRAUDDETECTION", out _)); + Assert.Single(entry.GetWorkflowsByName("frauddetection")); + Assert.True(entry.TryGetLastWorkflowByName("FRAUDDETECTION", out _)); } [Fact] - public void Status_ProjectsFlags_AndFailedTakesPrecedenceOverCompleted() + public void Status_IsStoredAndReadBackCorrectly() { var pending = new PropagatedHistoryActivityResult( - Name: "A", Started: true, Completed: false, Failed: false, + Name: "A", Status: PropagatedHistoryStatus.Pending, 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, + var completed = pending with { Status = PropagatedHistoryStatus.Completed }; + var failed = pending with { Status = PropagatedHistoryStatus.Failed }; + + Assert.Equal(PropagatedHistoryStatus.Pending, pending.Status); + Assert.Equal(PropagatedHistoryStatus.Completed, completed.Status); + Assert.Equal(PropagatedHistoryStatus.Failed, failed.Status); + + var child = new PropagatedHistoryWorkflowResult( + Name: "C", Status: PropagatedHistoryStatus.Completed, Output: null, FailureDetails: null); - Assert.Equal(PropagatedHistoryTaskStatus.Completed, child.Status); - Assert.Equal(PropagatedHistoryTaskStatus.Failed, (child with { Failed = true }).Status); + Assert.Equal(PropagatedHistoryStatus.Completed, child.Status); + Assert.Equal(PropagatedHistoryStatus.Failed, (child with { Status = PropagatedHistoryStatus.Failed }).Status); } // ------------------------------------------------------------------