# Fan-out with invoke and Task.WhenAll

> Build a weekly report from per-region figures by invoking one function per region in parallel, and finish the report even when a region fails.

Web page: https://damikun.github.io/inngest-dotnet/examples/fan-out-with-invoke/

When a function needs results back from many pieces of work, invoke a function per piece. Each invocation is a run
of its own with its own retries and concurrency, and the caller waits for all of them with `Task.WhenAll`.

```csharp title="RegionFigures.cs"
using Inngest;

public sealed record RegionRequest(string Region, string Week);

public sealed record RegionFigures(string Region, decimal Revenue, int Orders);

// Has no trigger of its own: other functions invoke it.
public sealed class ComputeRegionFigures(IWarehouseQueries queries) : InngestFunction<RegionRequest, RegionFigures>
{
    public override async Task<RegionFigures> ExecuteAsync(FunctionContext<RegionRequest> context, CancellationToken cancellationToken)
    {
        var (region, week) = context.Event.Data;
        var revenue = await context.Step.RunAsync("revenue", ct => queries.RevenueAsync(region, week, ct));
        var orders = await context.Step.RunAsync("orders", ct => queries.OrderCountAsync(region, week, ct));
        return new RegionFigures(region, revenue, orders);
    }
}

public interface IWarehouseQueries
{
    Task<decimal> RevenueAsync(string region, string week, CancellationToken cancellationToken);

    Task<int> OrderCountAsync(string region, string week, CancellationToken cancellationToken);
}
```

```csharp title="WeeklyReport.cs"
using Inngest;

public sealed record ReportRequested(string Week) : IInngestEventData
{
    public static string EventName => "reports/weekly.requested";
}

public sealed class WeeklyReport(IReportMailer mailer) : InngestFunction<ReportRequested>
{
    private static readonly string[] Regions = ["eu-west", "eu-central", "us-east", "us-west", "ap-south"];

    public override async Task<object?> ExecuteAsync(FunctionContext<ReportRequested> context, CancellationToken cancellationToken)
    {
        var week = context.Event.Data.Week;

        var results = await Task.WhenAll(Regions.Select(region => FiguresOrNullAsync(context, region, week)));
        var figures = results.OfType<RegionFigures>().ToList();
        var missing = Regions.Except(figures.Select(f => f.Region)).ToList();

        await context.Step.RunAsync("send-report", ct => mailer.SendAsync(week, figures, missing, ct));
        return new { week, regions = figures.Count, missing };
    }

    // A region that fails all its attempts, or does not finish within the timeout, is reported as missing
    // instead of failing the whole report.
    private static async Task<RegionFigures?> FiguresOrNullAsync(FunctionContext context, string region, string week)
    {
        try
        {
            return await context.Step.InvokeAsync<ComputeRegionFigures, RegionFigures>($"figures-{region}",
                new RegionRequest(region, week), timeout: TimeSpan.FromMinutes(30));
        }
        catch (StepFailedException exception)
        {
            context.Logger.LogWarning("No figures for {Region}: {Error}", region, exception.Error.Message);
            return null;
        }
    }
}

public interface IReportMailer
{
    Task SendAsync(string week, IReadOnlyList<RegionFigures> figures, IReadOnlyList<string> missing, CancellationToken cancellationToken);
}
```

The app registers both with their options as attributes on the classes, or with the options in code:

## Attributes

On `ComputeRegionFigures`, which has no trigger attribute:

```csharp
[InngestFunction("compute-region-figures", Retries = 3)]
[Concurrency(2)]
```

On `WeeklyReport`:

```csharp
[InngestFunction("weekly-report")]
[EventTrigger<ReportRequested>]
```

`AddFunction` reads the attributes of both classes once at startup:

```csharp title="Program.cs (registration)"
inngest
    .AddFunction<WeeklyReport>()
    .AddFunction<ComputeRegionFigures>();
```

## Code first

```csharp title="Program.cs (registration)"
inngest
    .AddFunction<WeeklyReport>(new FunctionOptions
    {
        Id = "weekly-report",
        Triggers = [Trigger.Event<ReportRequested>()],
    })
    .AddFunction<ComputeRegionFigures>(new FunctionOptions
    {
        Id = "compute-region-figures",
        Triggers = [],
        Retries = 3,
        Concurrency = [new ConcurrencyLimit(2)],
    });
```

## How it works

- **Parallel invocations.** All five `InvokeAsync` steps start before any is awaited, so the SDK reports them
  together and Inngest starts five runs of `compute-region-figures`. The concurrency limit lets two of them query the
  warehouse at once.
- **Results come back typed.** `ComputeRegionFigures` derives from `InngestFunction<RegionRequest, RegionFigures>`, so
  `InvokeAsync<ComputeRegionFigures, RegionFigures>` returns a `RegionFigures` and the compiler checks the result
  type. The step finds the function's id by its class, so the id is written once, where the function is registered
  or in its attribute. To invoke
  a function of another app, or a delegate function, pass `InvokeOptions` with its id instead.
- **Partial failure.** An invoke step fails when the invoked run fails for good or the timeout passes. Catching
  `StepFailedException` per region turns that into a gap in the report rather than a failed report. The helper takes
  the base `FunctionContext`, which has the step tools and the logger.
- **Retries stay where the work is.** The region function retries its own queries. The invoke step itself is not
  retried, so retries never multiply across the two levels.

[Invoking functions](https://damikun.github.io/inngest-dotnet/guides/invoking-functions.md) covers the options, and
[Parallel steps](https://damikun.github.io/inngest-dotnet/guides/parallel-steps.md) how parallel steps execute.
