-1

I am writing a service that combines data from various internet sources, and generates a response on the fly. Speed is more important than completeness, so I would like to generate my response as soon as some (not all) of the internet sources have responded. Typically my service creates 10 concurrent web requests, and should stop waiting and start processing after 5 of them have completed. Neither the .NET Framework, nor any of the third-party libraries I am aware of are offering this functionality, so I 'll probably have to write it myself. The method I am trying to implement has the following signature:

public static Task<TResult[]> WhenSome<TResult>(int atLeast, params Task<TResult>[] tasks)
{
    // TODO
}

Contrary to how Task.WhenAny works, exceptions should be swallowed, provided that the required number of results have been acquired. If however, after completion of all tasks, there are not enough gathered results, then an AggregateException should be thrown propagating all exceptions.

Usage example:

var tasks = new Task<int>[]
{
    Task.Delay(100).ContinueWith<int>(_ => throw new ApplicationException("Oops!")),
    Task.Delay(200).ContinueWith(_ => 10),
    Task.Delay(Timeout.Infinite).ContinueWith(_ => 0,
        new CancellationTokenSource(300).Token),
    Task.Delay(400).ContinueWith(_ => 20),
    Task.Delay(500).ContinueWith(_ => 30),
};
var results = await WhenSome(2, tasks);
Console.WriteLine($"Results: {String.Join(", ", results)}");

Expected output:

Results: 10, 20

In this example the last task returning the value 30 should be ignored (not even awaited), because we have already acquired the number of results we want (2 results). The faulted and cancelled tasks should also be ignored, for the same reason.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • 1
    There are multiple ways actually. At the lowest level, a [semaphore](https://learn.microsoft.com/en-us/dotnet/api/system.threading.semaphore?view=netframework-4.8) can be used to wait until a certain number of evens are recorded. [This Latch implementation](https://stackoverflow.com/questions/14314223/an-asynchronous-counter-which-can-be-awaited-on) works in a similar way but is awaitable. – Panagiotis Kanavos May 10 '19 at 13:46
  • What you describe though can be seen as processing events. This means you can use Reactive Extensions' `Take(5)` to asynchronously await until 5 items in a task sequence complete. Tasks can be converted to Observables with `ToObservable()` – Panagiotis Kanavos May 10 '19 at 13:50
  • @Panagiotis Kanavos AFAIK semaphores are used to limit concurrency, and in my case I certainly don't want to limit concurrency! I'll try your suggestion with Reactive Extensions though to see if it goes me anywhere, although my experience with RX is practically non-existent. – Theodor Zoulias May 10 '19 at 13:57
  • @TheodorZoulias you don't have to *block* on it, you can await for that semaphore to fire from another thread – Panagiotis Kanavos May 10 '19 at 14:03
  • Somewhat related: [Asynchronous Task.WhenAll with timeout](https://stackoverflow.com/questions/9846615/asynchronous-task-whenall-with-timeout). That question is about setting a time limit to the awaiting of the tasks, and any number of completed tasks is acceptable (even zero completed tasks). It is even acceptable if the some of the completed tasks have not completed successfully. On the contrary this question is about *successfully completed* tasks, and the limit is on the number of them, not on their duration. – Theodor Zoulias Sep 26 '21 at 15:46

2 Answers2

0

This is some clunky code which I think achieves your requirements. It may be a starting point.

It may also be a bad way of handling tasks and/or not threadsafe, and/or just a terrible idea. But I expect if so someone will point that out.

async Task<TResult[]> WhenSome<TResult>(int atLeast, List<Task<TResult>> tasks)
{
    List<Task<TResult>> completedTasks = new List<System.Threading.Tasks.Task<TResult>>();
    int completed = 0;
    List<Exception> exceptions = new List<Exception>();

    while (completed < atLeast && tasks.Any()) {
        var completedTask = await Task.WhenAny(tasks);
        tasks.Remove(completedTask);

        if (completedTask.IsCanceled)
        {
            continue;
        }

        if (completedTask.IsFaulted)
        {
            exceptions.Add(completedTask.Exception);
            continue;
        }

        completed++;
        completedTasks.Add(completedTask);
    }

    if (completed >= atLeast)
    {
        return completedTasks.Select(t => t.Result).ToArray();
    }

    throw new AggregateException(exceptions).Flatten();
}
stuartd
  • 70,509
  • 14
  • 132
  • 163
  • Thanks @stuartd, this is great! Calling `Task.WhenAny` in a loop is creative, and probably sufficient for my needs. I can't see any thread-safety issues, since the logic is linear. Small improvement: I added `.Flatten()` in the line that throws the `AggregateException`, to avoid dealing with a hierarchy. I'll wait some hours before marking your answer as accepted. – Theodor Zoulias May 10 '19 at 13:48
0

I am adding one more solution to this problem, not because stuartd's answer in not sufficient, but just for the sake of variety. This implementation uses the Unwrap technique in order to return a Task that contains all the exceptions, in exactly the same way that the built-in Task.WhenAll method propagates all the exceptions.

public static Task<TResult[]> WhenSome<TResult>(int atLeast, params Task<TResult>[] tasks)
{
    if (tasks == null) throw new ArgumentNullException(nameof(tasks));
    if (atLeast < 1 || atLeast > tasks.Length)
        throw new ArgumentOutOfRangeException(nameof(atLeast));

    var cts = new CancellationTokenSource();
    int successfulCount = 0;
    var continuationAction = new Action<Task<TResult>>(task =>
    {
        if (task.IsCompletedSuccessfully)
            if (Interlocked.Increment(ref successfulCount) == atLeast) cts.Cancel();
    });
    var continuations = tasks.Select(task => task.ContinueWith(continuationAction,
        cts.Token, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default));

    return Task.WhenAll(continuations).ContinueWith(_ =>
    {
        cts.Dispose();
        if (successfulCount >= atLeast) // Success
            return Task.WhenAll(tasks.Where(task => task.IsCompletedSuccessfully));
        else
            return Task.WhenAll(tasks); // Failure
    }, TaskScheduler.Default).Unwrap();
}

The continuations do not propagate the results or the exceptions of the tasks. These are cancelable continuations, and they are canceled en masse when the specified number of successful tasks has been reached.

Note: This implementation might propagate more than atLeast results. If you want exactly this number of results, you can chain a .Take(atLeast) after the .Where LINQ operatgor.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104