1

I'm having issues with proper handling of exceptions thrown from pipeline steps created using Open.ChannelExtensions library. In some scenarios exception is being swallowed instead of being propagated to caller.

From my observations, it seems that it's somehow related to the .Batch() step, also moment of throwing exceptions may have some meaning.

Am I doing something wrong? How it should be properly handled to propagate exception up?

// See https://aka.ms/new-console-template for more information

using System.Threading.Channels;
using Open.ChannelExtensions;

var test = new Test();
try
{
    //await test.Scenario1();   //exception catched
    //await test.Scenario2();   //exception swallowed
    //await test.Scenario3();   //exception catched
    //await test.Scenario4();   //exception sometimes catched (~25% chance)
}
catch (Exception)
{
    Console.WriteLine("Got exception");
}


class Test
{
    public async Task Scenario1()
    {
        var channel = Channel.CreateBounded<int>(10000);

        for (int i = 0; i < 100; i++)
        {
            await channel.Writer.WriteAsync(i);
        }

        var task = channel.Reader.Pipe(1, (element) =>
            {
                throw new Exception();
                Console.WriteLine(element);
                return 1;
            })
            .Pipe(2, (evt) =>
            {
                Console.WriteLine("\t" + evt);
                return evt * 2;
            })
            //.Batch(20)
            .PipeAsync(1, async (evt) =>
            {
                Console.WriteLine("\t\t" + evt);
                return Task.FromResult(evt);

            })
            .ReadAll(task =>
            {
            });

        channel.Writer.TryComplete();
        await task;

        Console.WriteLine("end");
    }

    public async Task Scenario2()
    {
        var channel = Channel.CreateBounded<int>(10000);

        for (int i = 0; i < 100; i++)
        {
            await channel.Writer.WriteAsync(i);
        }

        var task = channel.Reader.Pipe(1, (element) =>
            {
                throw new Exception();
                Console.WriteLine(element);
                return 1;
            })
            .Pipe(2, (evt) =>
            {
                Console.WriteLine("\t" + evt);
                return evt * 2;
            })
            .Batch(20)
            .PipeAsync(1, async (evt) =>
            {
                Console.WriteLine("\t\t" + evt);
                return Task.FromResult(evt);

            })
            .ReadAll(task =>
            {
            });

        channel.Writer.TryComplete();
        await task;
    }

    public async Task Scenario3()
    {
        var channel = Channel.CreateBounded<int>(10000);

        for (int i = 0; i < 100; i++)
        {
            await channel.Writer.WriteAsync(i);
        }

        var task = channel.Reader.Pipe(1, (element) =>
            {
                if(element == 20)
                throw new Exception();
                Console.WriteLine(element);
                return 1;
            })
            .Pipe(2, (evt) =>
            {
                Console.WriteLine("\t" + evt);
                return evt * 2;
            })
            //.Batch(20)
            .PipeAsync(1, async (evt) =>
            {
                Console.WriteLine("\t\t" + evt);
                return Task.FromResult(evt);

            })
            .ReadAll(task =>
            {
            });

        channel.Writer.TryComplete();
        await task;
    }

    public async Task Scenario4()
    {
        var channel = Channel.CreateBounded<int>(10000);

        for (int i = 0; i < 100; i++)
        {
            await channel.Writer.WriteAsync(i);
        }

        var task = channel.Reader.Pipe(1, (element) =>
            {
                if (element == 20)
                    throw new Exception();
                Console.WriteLine(element);
                return 1;
            })
            .Pipe(2, (evt) =>
            {
                Console.WriteLine("\t" + evt);
                return evt * 2;
            })
            .Batch(20)
            .PipeAsync(1, async (evt) =>
            {
                Console.WriteLine("\t\t" + evt);
                return Task.FromResult(evt);

            })
            .ReadAll(task =>
            {
            });

        channel.Writer.TryComplete();
        await task;
    }
}

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
Marduk
  • 359
  • 4
  • 13
  • 1
    I am not familiar with the Open.ChannelExtensions library, so I can't answer your question. Nevertheless I would like to share my opinion that using the `ChannelReader` as the monad of a pipelining mechanism is unlikely to be as convenient as using the `IAsyncEnumerable` interface. That's because the former lacks the ability of being disposed, so cancelling a pipeline is not as trivial as breaking an `await foreach` loop. And apparently the Open.ChannelExtensions library was introduced at a time (2019) when the C# had no support for the `IAsyncEnumerable` interface (before C# 8). – Theodor Zoulias Aug 30 '22 at 20:31
  • IAsyncEnumerable is to Channels what IEnumerable is to List and all containers. It's not an either-or situation, nor is this about monads. In fact, channels are specifically built as building blocks for pipelines. They're used in ASP.NET Core and SignalR. You simply can't use the interface without *some* implementation behind it, and pipelines *need* buffering at one point or another – Panagiotis Kanavos Aug 31 '22 at 15:05
  • As it turned out, it was a bug in library itself, new version has fixed the issue. – Marduk Sep 04 '22 at 09:22

1 Answers1

0

First, there's nothing wrong with using Channels for pipelines. That's what they were created for.

Error and exception handling in pipelines is harder than plain old calls though, whether you build them using Actors, Dataflow, Channels, IAsyncEnumerable, Observable and Reactive Extensions or function chains:

  • The code that receives the results and any possible exceptions is the next step in the pipeline
  • The only way such an exception can be handled is to nuke the pipeline. Unless you use an Actor model in which case a supervisor may decide to just restart that actor.
  • But since only downstream steps accept results and exceptions, there's no way to abort/nuke the upstream steps.

Especially for IAsyncEnumerable and Channels, since they use a pull model, downstream steps won't notice that something is wrong until they try to read from the previous step.

On top of that the Batch implementation doesn't seem to have explicit code to detect a failed source.

For this reason, exceptions should not escape from steps. A common pattern is to use a Result<TResult,TError> class to wrap the data passed among steps and either discard or forward error results. You'll often see this described as Railway Oriented Programming because error messages are moved to a "parallel" track that gets ignored by steps.

A lazy implementation could be:

record Result<T>(T? Message,Exception? Error)
{
    public bool HasError=>Error!=null;

    public Result<U> ToError()=>new Record<U>(null,Error);

    public static Result<T> Ok(T value)=>new Result<T>(value,null);

    public static Result<T> Fail(Exception exc)=>new Result<T>(null,exc);
}

...
channel.Reader.Pipe(1, (element) =>
            {
                try
                {
                    Console.WriteLine(element);
                    return Result.Ok(1);
                }
                catch(Exception exc)
                {
                    return Result<int>.Fail(exc);
                }
            })
            .Pipe(2, (msg) =>
            {
                if (msg.HasError)
                {
                    return msg.ToError<string>();
                }
                try
                {
                ...
                }
                ...
            })

Such code can easily be bundled in a generic method. The best place to put it would be inside the Pipe and Transform methods. Unfortunately, Open.ChannelExtensions doesn't follow this model.

Such a method could be :

public static Result<U> Call<T,U>(Result<T> msg,Func<T,U> func)
{
    if (msg.HasError)
    {
        return msg.ToError<U>();
    }
    try
    {
        return Result<U>.Ok(func(msg.Message));
    }
    catch(Exception exc)
    {
        return Result<U>.Fail(exc);
    }
}

Which can be used like this

channel.Reader.Pipe(1, (element) =>
{
    return Call(elt=>{
        Console.WriteLine(elt);
        return 1;
    },element);
).Pipe(2,...)

Call could also be an extension method on Func<T,U> allowing cleaner code:

public static Result<U> Call<T,U>(Result<T> msg,Func<T,U> func)
...
channel.Reader.Pipe(1, (element) =>
        myFunction.Call(element)
    )

To abort the entire pipeline, a CancellationTokenSource should be used to signal cancellation to all steps in the pipeline, both up- and down-stream. In case of critical exceptions, the exception handler inside the should signal the CTS to abort the entire pipeline.

It's worth noting that Pipe etc all accept a CancellationToken for this reason.

In less critical situations a step could simply stop producing results. This would allow downstream steps to process any remaining good data

Panagiotis Kanavos
  • 120,703
  • 13
  • 188
  • 236
  • Assuming that the only way to use successfully the Open.ChannelExtensions library is to patch it like this (railway style), then IMHO it's not a mature project. – Theodor Zoulias Aug 31 '22 at 18:09
  • @TheodorZoulias in that case Dataflow and IAsyncEnumerable are immature as well. I wouldn't compare an open source project built by two guys with a commercial library or even a company-backed project like eg pETL. That wasn't the question though. – Panagiotis Kanavos Sep 01 '22 at 09:04