Provide an API to wait for multiple resources at once in tests
- 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
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