I observed a weird behavior while experimenting with a PLINQ query. Here is the scenario:
- There is a source
IEnumerable<int>
sequence that contains the two items 1 and 2. - A Parallel LINQ
Select
operation is applied on this sequence, projecting each item to itself (x => x
). - The resulting
ParallelQuery<int>
query is consumed immediately with aforeach
loop. - The
selector
lambda of theSelect
projects successfully the item 1. - The consuming
foreach
loop throws an exception for the item 1. - The
selector
lambda throws an exception for the item 2, after a small delay.
What happens next is that the consuming exception is lost! Apparently it is shadowed by the exception thrown afterwards in the Select
. Here is a minimal demonstration of this behavior:
ParallelQuery<int> query = Enumerable.Range(1, 2)
.AsParallel()
.Select(x =>
{
if (x == 2) { Thread.Sleep(500); throw new Exception($"Oops!"); }
return x;
});
try
{
foreach (int item in query)
{
Console.WriteLine($"Consuming item #{item} started");
throw new Exception($"Consuming item #{item} failed");
}
}
catch (AggregateException aex)
{
Console.WriteLine($"AggregateException ({aex.InnerExceptions.Count})");
foreach (Exception ex in aex.InnerExceptions)
Console.WriteLine($"- {ex.GetType().Name}: {ex.Message}");
}
catch (Exception ex)
{
Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
Output:
Consuming item #1 started
AggregateException (1)
- Exception: Oops!
Chronologically the consuming exception happens first, and the PLINQ exception happens later. So my understanding is that the consuming exception is more important, and it should be propagated with priority. Nevertheless the only exception that is surfaced is the one that occurs inside the PLINQ code.
My question is: why is the consuming exception lost, and is there any way that I can fix the query so that the consuming exception is propagated with priority?
The desirable output is this:
Consuming item #1 started
Exception: Consuming item #1 failed
foreach
could throw even on the very first execution). – Louellaselector
of the item 2. I could have configured the query withWithMergeOptions(ParallelMergeOptions.NotBuffered)
to make this behavior even more likely, but apparently it is not required in this case. – Labeforeach
. If you add a write forx
in theSelect
, you will see that the parallel query is completely evaluated before the first consuming occurs - sometimes in the order 2, 1.NotBuffered
makes this obvious, but the defaultAutoBuffered
can cause the order to swap all around and you might Select 2,3,1 then consume 3 first... there is no order guarantee once you useAsParallel
. – Breadselector
s are completing execution, unless I have requested ordered emission with theAsOrdered
operator. But my question is not based on this expectation. In the demo the item 1 has been emitted, the consumingforeach
code failed with an exception, and this exception has been lost. That's the weird thing. Don't you agree that the exception should be surfaced? – Labeforeach
into awhile (MoveNext())
with atry
/finally
to dispose of the enumerator. When the inner exception is thrown, it is caught by thefinally
and theDispose
of the enumerator causes all theSelect
threads to finish, which causes an exception inside thefinally
block, which throws away the initial exception as discussed here. You need to use your own loop and aCancellationTokenSource
if you want to prevent this. – BreadCancellationTokenSource
-based solution? Currently I am not able to visualize it. – LabeParallelQuery<int>
will complete immediately after an exception happens in the parallelSelect
. But I do expect that an exception thrown in the consumingforeach
will not be lost! – Labefinally
block explains how an exception duringfinally
processing will throw away the first exception. – Breadfinally
in general. But there is no hint in the other two links that the PLINQ propagates exceptions via theDispose
route, that can cause consuming exceptions to be lost. Not even the smartest person that ever lived (Archimedes!) could deduct this behavior by just reading these two documentation pages, I think. – Labeforeach
would yield an explanation :) – Bread