dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
https://docs.microsoft.com/dotnet/core/
MIT License
15.29k stars 4.74k forks source link

[API Proposal]: BroadcastChannelWriter #100443

Open stephentoub opened 7 months ago

stephentoub commented 7 months ago

I've had a few discussions lately with folks that have wanted something akin to a broadcasting ChannelWriter, where you could supply multiple channel writers and multiplex across them, with a single writer writing the same value to all of them. There's also been a desire for participants to come and go, such that targets could be added/removed dynamically. Any targets there when the write is issued would receive it, just forwarding along the write.

Effectively, it would look like this:

public sealed class BroadcastChannelWriter<T> : ChannelWriter<T>
{
    public void Add(ChannelWriter<T> target);
    public void Remove(ChannelWriter<T> target);
    public override bool TryWrite(T item);
    public override ValueTask<bool> WaitToWriteAsync(CancellationToken cancellationToken = default);
    public override ValueTask WriteAsync(T item, CancellationToken cancellationToken = default);
    public override bool TryComplete(Exception? error = null);
}

Unfortunately, I don't know how to define the semantics for this in a way that makes sense for an arbitrary consumer of the type via the abstraction. WriteAsync is straightforward, e.g. the equivalent of:

public override async ValueTask WriteAsync(T item, CancellationToken cancellationToken = default)
{
    foreach (ChannelWriter<T> writer in _participants)
    {
        await writer.WriteAsync(item, cancellationToken);
    }
}

But... what, for example, should the semantics of TryWrite be? If TryWrite returns true for one participant but then false for another, what should the method return? If it returns true, that indicates to the consumer that the data was written, but it wasn't to all targets. If it returns false, that indicates to the consumer that they might try again, in which case they could end up writing the same value multiple times to some of the participants.

I'm opening this issue in case folks who are interested have suggestions for how to rationalize this, since at present I'm not comfortable adding something like this. The best I've come up with is to have the type not actually be a ChannelWriter, e.g.

public sealed class BroadcastChannelWriter<T>
{
    public void Add(ChannelWriter<T> target);
    public void Remove(ChannelWriter<T> target);
    public ValueTask WriteAsync(T item, CancellationToken cancellationToken = default);
    public override bool TryComplete(Exception? error = null);
}

such that you can't use it polymorphically and use the problematic operations. But that then also loses meaningful functionality.

gfoidl commented 7 months ago

If it returns false, that indicates to the consumer that they might try again, in which case they could end up writing the same value multiple times to some of the participants.

This would adhere "at least once" semantics, and I think that's the way to go here. "At most once" is bad, as data may be missing, and "exactly once" is almost impossible to have in such scenarios.

When that behavior is documented, then the consumers need to be aware of this, and ideally the operations should be idempotent so "at least once" isn't a real problem.


Another option would be to throw a NotSuppotedException for the problematic methods, but IMO that would be bad and violates the principle of least astonishment. The option w/o deriving from ChannelWriter<T> would be better, but my vote is for the "at least once" semantics (see above).

stephentoub commented 7 months ago

so "at least once" isn't a real problem.

I'm missing why duplicated data would be any less of a problem than missing data. If this was just about logging, then sure. But for many other situations, either way you get a wrong answer: if the data was numbers and a consumer was summing the data, missing or duplication both show up as a wrong answer.

gfoidl commented 7 months ago

You quoted only a piece of the information 😉 A better quote would be

When that behavior is documented, then the consumers need to be aware of this, and ideally the operations should be idempotent so "at least once" isn't a real problem.

So just in that context (documented, push consumers towards idempotent implementation) "at least once" isn't a problem. Otherwise you're right.

But that's a problem that many (all?) messaging systems have in common.

stephentoub commented 7 months ago

So just in that context (documented, push consumers towards idempotent implementation) "at least once" isn't a problem.

Sure, but I could just as easily say we document that data might be missing, at which point when someone codes to that, "at most once" isn't a problem. :smile:

gfoidl commented 7 months ago

True 😄

My PoV is motivated by other messaging systems (MQTT, message queues, and so on), and that duplicated detection is easier (e.g. by also having an identifier sent with the data) than detection of missing data.