microsoft / microsoft/aspire

Provide an API to wait for multiple resources at once in tests

Open
#14,127 1 comment 1 reaction 0 assignees View on GitHub
area-app-testing
Dominant language
C#
Stars
6.3k
Forks
991
Avg merge
2d 15h
Merged PRs (30d)
196

Description

### Is there an existing issue for this?

- [x] I have searched the existing issues

### Is your feature request related to a problem? Please describe the problem.

In tests, you often want to wait for multiple resources to reach a state, so the first thing you'll try is

```cs
await rns.WaitForResourcesToBeHealthy("one", ct),
await rns.WaitForResourcesToBeHealthy("two", ct),
await rns.WaitForResourcesToBeHealthy("three", ct)
```

This works fine if your resources have a particular start order, but if they don't you can end up in situations where you can deadlock because a later wait has failed, but the first one has not yet fialed, but never will fail.

e.g. if your api doesn't wait on your Db, but it's health check requires a db, a `FailureToStart` of the db, does not cause an immediate failure as the api's health check will hang for all eternity.

The obvious way to try and fix this is to wrap everything up in a `Task.WhenAll()`.

```cs
await Task.WhenAll(
rns.WaitForResourcesToBeHealthy("one", ct),
rns.WaitForResourcesToBeHealthy("two", ct),
rns.WaitForResourcesToBeHealthy("three", ct)
)
```

However this is actually even worse - the entire WhenAll task will not complete until all it's children have completed. So if a single one of these hangs, the entire WhenAll task will hang. Compared to the sequential awaits you may get lucky depending on whether your hanging health check is before or after the failure.

A solution to this is to use `Task.WhenEach()` instead of `Task.WhenAll()`, so you can fail as soon as the first one fails.

### Describe the solution you'd like

I'd like to see some apis to help streamline this, or at least simplify the common use cases.

The most common need I've seen for this is testing scenarios where you're starting up your app host once for an entire test suite, and want to ensure every resource is healthy before proceeding with the test suite.

### Additional context

Below is an implementation I've used for this myself

```cs
///
/// Starts and waits for all resources to become healthy
///
///
/// This method fails fast upon the first startup failure.
///
/// The app to start.
/// The token to trigger shutdown.
///
public static async Task StartAndWaitForResourcesToGoHealthyAsync(this DistributedApplication app, CancellationToken cancellationToken)
{
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

Task[] tasks = [
app.StartAsync(cts.Token),
app.WaitForResourcesToBeHealthy(cts.Token)
];

try
{
await WhenAllFailFast(tasks);
}
catch
{
cts.Cancel();
throw;
}
}

///
/// Waits for all resources in the distributed application to become healthy.
///
///
/// This method fails for the first resource that fails to become healthy.
///
/// The
/// A
///
public static async Task WaitForResourcesToBeHealthy(this DistributedApplication distributedApplication, CancellationToken cancellationToken)
{
var resourcesToWaitFor = distributedApplication.Services.GetRequiredService()
.Resources
.Where(x => !x.TryGetAnnotationsOfType(out _));

// wait for all tasks to complete, failing fast if any fail
// Task.WhenAll does NOT work here as it will not complete until all tasks complete
// Which can delay showing what failed.
await distributedApplication.WaitForResourcesToBeHealthy(resourcesToWaitFor, cancellationToken);
}

///
/// Waits for to become healthy.
///
/// The
/// A list of resources to wait for.
/// A
/// A task that will complete once are all healthy.
public static async Task WaitForResourcesToBeHealthy(this DistributedApplication distributedApplication, IEnumerable resourcesToWaitFor, CancellationToken cancellationToken)
{
var waitTasks = resourcesToWaitFor.Select(x => distributedApplication.ResourceNotifications.WaitForResourceHealthyAsync(x.Name, cancellationToken));

// wait for all tasks to complete, failing fast if any fail
// Task.WhenAll does NOT work here as it will not complete until all tasks complete
// Which can delay showing what failed.
await WhenAllFailFast(waitTasks);
}

static async Task WhenAllFailFast(IEnumerable source)
{
await foreach (var task in WhenEach(source))
{
await task;
}
}

#if NET8_0
//TODO: Replace with `Task.WhenEach` in .Net 9
static async IAsyncEnumerable WhenEach(IEnumerable source)
where T : Task
{
var tasks = source.ToList();

while (tasks.Count > 0)
{
var task = await Task.WhenAny(tasks);
tasks.Remove((T)task);
yield return (T)task;
}
}
#endif
```

Contributor guide

Open the contributing guide

Research direction

Start by reading the existing WaitForResourcesToBeHealthy APIs and the DistributedApplication startup and resource-notification entry points described in the issue. Compare the proposed fail-fast behavior with Task.WhenAll and Task.WhenEach, including the .NET 8 fallback. Done means the selected API supports waiting for multiple resources and reports the first failure without hanging on other waits.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
backend-api-design, testing-qa
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.