Durable Functions orchestrations can execute work in parallel, and the most common way to do this is by using fan-out/fan-in patterns.
Let’s see how this works with an example. Imagine you have a list of customer IDs and you need to process each one independently.
[FunctionName("ProcessCustomers")]
public static async Task RunOrchestrator(
[OrchestrationTrigger] IDurableOrchestrationContext context)
{
var customerIds = new List<string> { "cust1", "cust2", "cust3", "cust4", "cust5" };
var tasks = new List<Task>();
foreach (var customerId in customerIds)
{
tasks.Add(context.CallActivityAsync("ProcessCustomerActivity", customerId));
}
await Task.WhenAll(tasks);
// Now all customer processing is complete
}
[FunctionName("ProcessCustomerActivity")]
public static async Task ProcessCustomerActivity([ActivityTrigger] string customerId)
{
// Simulate work for each customer
await Task.Delay(TimeSpan.FromSeconds(1));
Console.WriteLine($"Processed customer: {customerId}");
}
In this example, the orchestrator ProcessCustomers takes a list of customerIds. For each customerId, it calls an activity function ProcessCustomerActivity. Crucially, these activity calls are made concurrently using Task.WhenAll. This means that multiple ProcessCustomerActivity functions can be running at the same time, each processing a different customer.
The "fan-out" part is the act of creating all these parallel tasks. The "fan-in" part is waiting for all of them to complete using Task.WhenAll. The result of Task.WhenAll is a collection of results from each activity function, which you can then aggregate or process further if needed.
The real power here is that Durable Functions is durable. If the orchestrator or any of the activity functions crash, they will be automatically replayed from where they left off. This makes your parallel processing resilient to failures. The state of which activities have completed, which are running, and which might need to be retried is all managed by the Durable Functions extension.
When you run this, you’ll see output indicating that customers are being processed concurrently. The order of the "Processed customer" logs will likely be mixed, demonstrating that they’re not executing sequentially.
The orchestrator function itself is stateless in terms of its execution context. When it reaches a point where it needs to wait for activities (like await Task.WhenAll(tasks)), it checkpoints its state. If the orchestrator process restarts, Durable Functions will load the orchestrator’s history and replay it until it reaches the point where it was waiting, then resume execution. The CallActivityAsync methods are designed to be replayable; if an activity fails, Durable Functions will automatically retry it according to its retry policy.
When you’re calling multiple activities concurrently like this, you’re not just limited to calling the same activity function. You can call different activity functions for different tasks.
[FunctionName("ProcessOrder")]
public static async Task RunOrchestrator(
[OrchestrationTrigger] IDurableOrchestrationContext context)
{
var orderDetails = await context.CallActivityAsync<OrderDetails>("GetOrderDetailsActivity", context.InstanceId);
var customerTask = context.CallActivityAsync("NotifyCustomerActivity", orderDetails.CustomerId);
var inventoryTask = context.CallActivityAsync("UpdateInventoryActivity", orderDetails.Items);
var shippingTask = context.CallActivityAsync("ScheduleShippingActivity", orderDetails.ShippingInfo);
await Task.WhenAll(customerTask, inventoryTask, shippingTask);
await context.CallActivityAsync("CompleteOrderActivity", context.InstanceId);
}
Here, after fetching order details, the orchestrator fans out three distinct tasks: notifying the customer, updating inventory, and scheduling shipping. Each of these tasks can run in parallel. This is a common pattern for processing complex workflows where multiple independent steps need to happen before finalization.
The key insight to grasp is that the IDurableOrchestrationContext is not a true C# Task object that you’re awaiting in the traditional sense for completion. Instead, context.CallActivityAsync returns a representation of a scheduled work item. When you use Task.WhenAll on these representations, you’re telling the Durable Functions runtime, "I have several independent pieces of work I want to schedule, and I will wait here until all of them have reported completion." The runtime then manages the execution of these activities in parallel and records their completion in the orchestrator’s history.
One subtle aspect of fan-out/fan-in is managing the results. If your activities return values, Task.WhenAll will give you an array of those results. The order of the results in the array corresponds to the order of the Task objects passed to Task.WhenAll.
var tasks = new List<Task<string>>(); // Activities return strings
foreach (var customerId in customerIds)
{
tasks.Add(context.CallActivityAsync<string>("ProcessCustomerActivity", customerId));
}
var results = await Task.WhenAll(tasks); // results is a string[]
for (int i = 0; i < results.Length; i++)
{
Console.WriteLine($"Result for customer {customerIds[i]}: {results[i]}");
}
This allows you to correlate the results back to the original inputs, which is vital for many processing scenarios. If you don’t need the results, you can simply use Task instead of Task<TResult>.
The next step in orchestrating complex parallel operations is to consider dynamic fan-out, where the number of parallel tasks isn’t known until runtime, often based on external data.