diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowRunner.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowRunner.cs index 832407f024..b210d8b7e2 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowRunner.cs @@ -356,23 +356,9 @@ internal class DurableWorkflowRunner /// Runs the workflow execution loop using superstep-based processing. /// /// - /// - /// This method implements a Bulk Synchronous Parallel (BSP) style execution where each "superstep" - /// processes all pending messages for all active executors. The workflow terminates when: - /// - /// - /// No more messages are pending (natural completion) - /// An executor calls RequestHaltAsync (explicit halt) - /// Maximum superstep limit is reached (safety limit) - /// - /// - /// Fan-In Handling: - /// - /// - /// For executors with multiple predecessors (Fan-In), the implementation waits for all predecessor - /// results before invoking the executor. If the executor accepts an array type, all messages are - /// aggregated into a JSON array. - /// + /// Implements Bulk Synchronous Parallel (BSP) style execution where each superstep + /// processes all pending messages for active executors in parallel. Terminates when + /// no messages remain, halt is requested, or max supersteps reached. /// [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")] [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")] @@ -385,152 +371,152 @@ internal class DurableWorkflowRunner { const int MaxSupersteps = 100; - // Message queues per executor - stores (message, inputTypeName) tuples - Dictionary> messageQueues = []; + SuperstepState state = new(workflow, plan); + EnqueueMessage(state.MessageQueues, plan.StartExecutorId, initialInput, typeof(string).FullName); - // Last result from each executor (for edge condition evaluation and final result) - Dictionary lastResults = []; - - // Track accumulated events and shared state - DurableWorkflowCustomStatus customStatus = new(); - Dictionary sharedState = []; - - // Get executor bindings for creating WorkflowExecutorInfo - Dictionary executorBindings = workflow.ReflectExecutors(); - - // Initialize: queue input to start executor (initial input is a string) - EnqueueMessage(messageQueues, plan.StartExecutorId, initialInput, typeof(string).FullName); - - int superstep = 0; - bool haltRequested = false; - string? finalOutput = null; - - while (superstep < MaxSupersteps && !haltRequested) + for (int superstep = 1; superstep <= MaxSupersteps; superstep++) { - superstep++; - - // Collect all executors with pending messages - List activeExecutors = messageQueues - .Where(kv => kv.Value.Count > 0) - .Select(kv => kv.Key) - .ToList(); - - if (activeExecutors.Count == 0) + List inputs = PrepareExecutorInputs(state, logger); + if (inputs.Count == 0) { - break; // No more work + break; } - logger.LogDebug( - "Superstep {Step}: {Count} active executor(s): {Executors}", - superstep, - activeExecutors.Count, - string.Join(", ", activeExecutors)); + logger.LogDebug("Superstep {Step}: {Count} active executor(s)", superstep, inputs.Count); - // Prepare execution tasks for all active executors (for parallel dispatch) - List<(string ExecutorId, string Input, string? InputTypeName, WorkflowExecutorInfo Info)> executorInputs = []; + // Dispatch all executors in parallel + Task[] tasks = inputs + .Select(e => this.DispatchExecutorAsync(context, e.Info, e.Input, e.InputTypeName, logger, state.CustomStatus, state.SharedState)) + .ToArray(); - foreach (string executorId in activeExecutors) + string[] results = await Task.WhenAll(tasks).ConfigureAwait(true); + + // Process results and route to successors + string? haltOutput = ProcessSuperstepResults(inputs, results, state, logger); + if (haltOutput is not null) { - Queue<(string Message, string? InputTypeName)> queue = messageQueues[executorId]; - - // Check if this is a Fan-In executor that expects aggregated input - bool isFanIn = plan.Predecessors.TryGetValue(executorId, out List? predecessors) && predecessors.Count > 1; - - string input; - string? inputTypeName; - - if (isFanIn && queue.Count > 1) - { - // Fan-In: Aggregate all messages into a JSON array - List messages = []; - while (queue.Count > 0) - { - (string msg, _) = queue.Dequeue(); - messages.Add(msg); - } - - input = AggregateMessagesToJsonArray(messages); - inputTypeName = typeof(string[]).FullName; - - logger.LogDebug("Fan-In executor {ExecutorId}: aggregated {Count} messages", executorId, messages.Count); - } - else - { - // Normal case: process single message - (input, inputTypeName) = queue.Dequeue(); - } - - WorkflowExecutorInfo executorInfo = CreateExecutorInfo(executorId, executorBindings); - executorInputs.Add((executorId, input, inputTypeName, executorInfo)); + return haltOutput; } - // Dispatch all executors in parallel by starting all tasks first, then awaiting together - List> executorTasks = []; - foreach ((string executorId, string input, string? inputTypeName, WorkflowExecutorInfo executorInfo) in executorInputs) - { - // Start the task without awaiting - this enables parallel dispatch - Task task = this.DispatchExecutorAsync( - context, executorInfo, input, inputTypeName, logger, customStatus, sharedState); - executorTasks.Add(task); - } - - // Wait for all executors to complete in parallel - string[] rawResults = await Task.WhenAll(executorTasks).ConfigureAwait(true); - - // Process results and route messages - for (int i = 0; i < executorInputs.Count; i++) - { - string executorId = executorInputs[i].ExecutorId; - string rawResult = rawResults[i]; - - (string result, List sentMessages) = UnwrapActivityResult(rawResult, customStatus, sharedState); - lastResults[executorId] = result; - - // Check for explicit halt request (via RequestHaltAsync) - if (HasHaltBeenRequested(customStatus, executorId)) - { - haltRequested = true; - finalOutput = result; - logger.LogDebug("Halt requested by executor {ExecutorId}", executorId); - break; - } - - // Route messages sent via SendMessageAsync (takes priority for void-returning executors) - if (sentMessages.Count > 0) - { - foreach (SentMessageInfo sentMessage in sentMessages) - { - if (!string.IsNullOrEmpty(sentMessage.Message)) - { - // Route to successors with the sent message's type - RouteMessageToSuccessors( - executorId, sentMessage.Message, sentMessage.TypeName, plan, messageQueues, logger); - } - } - } - else if (!string.IsNullOrEmpty(result)) - { - // Route executor's return value to successor executors via edges (for non-void executors) - RouteMessageToSuccessors( - executorId, result, plan, messageQueues, logger); - } - - if (haltRequested) - { - break; - } - } - - UpdateCustomStatus(context, customStatus); + UpdateCustomStatus(context, state.CustomStatus); } - if (superstep >= MaxSupersteps) + return DetermineFinalResult(workflow, state.LastResults, state.CustomStatus); + } + + /// + /// Holds mutable state for superstep-based workflow execution. + /// + private sealed class SuperstepState(Workflow workflow, WorkflowExecutionPlan plan) + { + public WorkflowExecutionPlan Plan { get; } = plan; + public Dictionary ExecutorBindings { get; } = workflow.ReflectExecutors(); + public Dictionary> MessageQueues { get; } = []; + public Dictionary LastResults { get; } = []; + public Dictionary SharedState { get; } = []; + public DurableWorkflowCustomStatus CustomStatus { get; } = new(); + } + + /// + /// Represents prepared input for an executor ready for dispatch. + /// + private sealed record ExecutorInput(string ExecutorId, string Input, string? InputTypeName, WorkflowExecutorInfo Info); + + /// + /// Prepares inputs for all active executors, handling Fan-In aggregation. + /// + private static List PrepareExecutorInputs(SuperstepState state, ILogger logger) + { + List inputs = []; + + foreach ((string executorId, Queue<(string Message, string? InputTypeName)> queue) in state.MessageQueues) { - logger.LogWarning("Workflow reached maximum superstep limit ({MaxSteps})", MaxSupersteps); + if (queue.Count == 0) + { + continue; + } + + bool isFanIn = state.Plan.Predecessors.TryGetValue(executorId, out List? predecessors) && predecessors.Count > 1; + (string input, string? inputTypeName) = isFanIn && queue.Count > 1 + ? AggregateQueueMessages(queue, executorId, logger) + : queue.Dequeue(); + + inputs.Add(new ExecutorInput(executorId, input, inputTypeName, CreateExecutorInfo(executorId, state.ExecutorBindings))); } - // Return final output or last result from output executors - return finalOutput ?? DetermineFinalResult(workflow, lastResults, customStatus); + return inputs; + } + + /// + /// Aggregates all messages in a queue into a JSON array for Fan-In executors. + /// + private static (string Input, string? TypeName) AggregateQueueMessages( + Queue<(string Message, string? InputTypeName)> queue, + string executorId, + ILogger logger) + { + List messages = []; + while (queue.Count > 0) + { + messages.Add(queue.Dequeue().Message); + } + + logger.LogDebug("Fan-In executor {ExecutorId}: aggregated {Count} messages", executorId, messages.Count); + return (AggregateMessagesToJsonArray(messages), typeof(string[]).FullName); + } + + /// + /// Processes results from a superstep, updating state and routing messages. + /// + /// The halt output if halt was requested; otherwise null. + private static string? ProcessSuperstepResults( + List inputs, + string[] rawResults, + SuperstepState state, + ILogger logger) + { + for (int i = 0; i < inputs.Count; i++) + { + string executorId = inputs[i].ExecutorId; + (string result, List sentMessages) = UnwrapActivityResult(rawResults[i], state.CustomStatus, state.SharedState); + state.LastResults[executorId] = result; + + if (HasHaltBeenRequested(state.CustomStatus, executorId)) + { + logger.LogDebug("Halt requested by executor {ExecutorId}", executorId); + return result; + } + + RouteExecutorOutput(executorId, result, sentMessages, state, logger); + } + + return null; + } + + /// + /// Routes executor output (explicit messages or return value) to successors. + /// + private static void RouteExecutorOutput( + string executorId, + string result, + List sentMessages, + SuperstepState state, + ILogger logger) + { + if (sentMessages.Count > 0) + { + foreach (SentMessageInfo msg in sentMessages) + { + if (!string.IsNullOrEmpty(msg.Message)) + { + RouteMessageToSuccessors(executorId, msg.Message, msg.TypeName, state.Plan, state.MessageQueues, logger); + } + } + } + else if (!string.IsNullOrEmpty(result)) + { + RouteMessageToSuccessors(executorId, result, state.Plan, state.MessageQueues, logger); + } } ///