CK.PerfectEvent 19.0.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package CK.PerfectEvent --version 19.0.0
NuGet\Install-Package CK.PerfectEvent -Version 19.0.0
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="CK.PerfectEvent" Version="19.0.0" />
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add CK.PerfectEvent --version 19.0.0
#r "nuget: CK.PerfectEvent, 19.0.0"
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install CK.PerfectEvent as a Cake Addin
#addin nuget:?package=CK.PerfectEvent&version=19.0.0

// Install CK.PerfectEvent as a Cake Tool
#tool nuget:?package=CK.PerfectEvent&version=19.0.0

Perfect Event

These events mimics standard .Net events but offer the support of asynchronous handlers.

Be sure to understand the standard .Net event pattern before reading this.

Perfect events come in two flavors:

  • Events with a single event argument PerfectEvent<TEvent> that accepts the following callback signature:
public delegate void SequentialEventHandler<TEvent>( IActivityMonitor monitor, TEvent e );
  • Events with the sender and event argument (like the .Net standard one) PerfectEvent<TSender, TEvent>:
public delegate void SequentialEventHandler<TSender, TEvent>( IActivityMonitor monitor, TSender sender, TEvent e );

As it appears in the signatures above, a monitor is provided: the callee uses it so that its own actions naturally belong to the calling activity.

Subscribing and unsubscribing to a Perfect Event

Synchronous handlers

Perfect events looks like regular events and support += and -= operators. Given the fact that things can talk:

public interface IThing
{
  string Name { get; }
  PerfectEvent<string> Talk { get; }
}

This is a typical listener code:

void ListenTo( IThing o )
{
    o.Talk.Sync += ThingTalked;
}

void StopListeningTo( IThing o )
{
    o.Talk.Sync -= ThingTalked;
}

void ThingTalked( IActivityMonitor monitor, string e )
{
    monitor.Info( $"A thing said: '{e}'." );
}

Asynchronous handlers

Let's say that this very first version is not enough:

  • We now want to know who is talking: we'll use the PerfectEvent<TSender, TEvent> event that includes the sender. The IThing definition becomes:
public interface IThing
{
  string Name { get; }
  PerfectEvent<IThing,string> Talk { get; }
}
  • We want to persist the talk in a database: it's better to use an asynchronous API to interact with the database. The listener becomes:
void ListenTo( IThing o )
{
    o.Talk.Async += ThingTalkedAsync;
}

void StopListeningTo( IThing o )
{
    o.Talk.Async -= ThingTalkedAsync;
}

async Task ThingTalkedAsync( IActivityMonitor monitor, IThing thing, string e )
{
    monitor.Info( $"Thing {thing.Name} said: '{e}'." );
    await _database.RecordAsync( monitor, thing.Name, e );
}

Parallel handlers

Parallel handlers are a little bit more complex to implement and also more dangerous: as alway, concurrency must be handled carefully. The parallel handlers is not called with the origin monitor but with a ActivityMonitor.DependentToken that is a correlation identifier (actually a string that identifies its creation monitor and instant):

void ListenTo( IThing o )
{
    o.Talk.ParallelAsync += ThingTalkedAsync;
}

void StopListeningTo( IThing o )
{
    o.Talk.ParallelAsync -= ThingTalkedAsync;
}

async Task ThingTalkedAsync( ActivityMonitor.DependentToken token, IThing thing, string e )
{
    var monitor = new ActivityMonitor();
    using( TestHelper.Monitor.StartDependentActivity( token ) )
    {
      monitor.DependentActivity().Launch( token );
      //...
    }
    monitor.MonitorEnd();
}

A more realistic usage of Parallel handling would be to submit the event to an asynchronous worker (through a mailbox, typically a System.Threading.Channel) and wait for its handling, the worker having its own monitor. And if the handling of the event doesn't need to be awaited, then a Synchronous handler that synchronously pushes the event into the worker's mailbox is the best solution.

Implementing and raising a Perfect Event

A Perfect Event is implemented thanks to a PerfectEventSender<TEvent> or PerfectEventSender<TSender,TEvent>

class Thing : IThing
{
    // The sender must NOT be exposed: its PerfectEvent property is the external API. 
    readonly PerfectEventSender<IThing, string> _talk;

    public Thing( string name )
    {
        Name = name;
        _talk = new PerfectEventSender<IThing, string>();
    }

    public string Name { get; }

    public PerfectEvent<IThing, string> Talk => _talk.PerfectEvent;

    internal Task SaySomething( IActivityMonitor monitor, string something ) => _talk.RaiseAsync( monitor, this, something );
}

Calling RaiseAsync calls all the subscribed handlers and if any of them throws an exception, it is propagated to the caller. Sometimes, we want to isolate the caller from any error in the handlers (handlers are "client code", they can be buggy). SafeRaiseAsync protects the calls:

/// <summary>
/// Same as <see cref="RaiseAsync"/> except that if exceptions occurred they are caught and logged
/// and a gentle false is returned.
/// <para>
/// The returned task is resolved once the parallels, the synchronous and the asynchronous event handlers have finished their jobs.
/// </para>
/// <para>
/// If exceptions occurred, they are logged and false is returned.
/// </para>
/// </summary>
/// <param name="monitor">The monitor to use.</param>
/// <param name="sender">The sender of the event.</param>
/// <param name="e">The argument of the event.</param>
/// <param name="fileName">The source filename where this event is raised.</param>
/// <param name="lineNumber">The source line number in the filename where this event is raised.</param>
/// <returns>True on success, false if an exception occurred.</returns>
public async Task<bool> SafeRaiseAsync( IActivityMonitor monitor, TSender sender, TEvent e, [CallerFilePath] string? fileName = null, [CallerLineNumber] int lineNumber = 0 )

Adapting the event type

Covariance: Usafe.As when types are compatible

The signature of the PerfectEvent<TEvent> locks the type to invariantly be TEvent. However, a PerfectEvent<Dog> should be compatible with a PerfectEvent<Animal>: the event should be covariant, it should be specified as PerfectEvent<out TEvent>. Unfortunately this is not possible because PerfectEvent is a struct but even if we try to define an interface:

public interface IPerfectEvent<out TEvent>
{
    bool HasHandlers { get; }
    event SequentialEventHandler<TEvent> Sync;
    event SequentialEventHandlerAsync<TEvent> Async;
    event ParallelEventHandlerAsync<TEvent> ParallelAsync;
}

This is not possible because the delegate signatures prevent it:

CS1961 Invalid variance: The type parameter 'TEvent' must be invariantly valid on 'IPerfectEvent<TEvent>.Sync'. 'TEvent' is covariant.

Please note that we are talking of the PerfectEvent<TEvent> type here, this has nothing to do with the signature itself: at the signature level, the rules apply and a OnAnimal handler can perfectly be assigned to/combined with a Dog handler:

class Animal { }
class Dog : Animal { }

static void DemoVariance()
{
    SequentialEventHandler<Dog>? handlerOfDogs = null;
    SequentialEventHandler<Animal>? handlerOfAnimals = null;

    // Exact type match:
    handlerOfDogs += OnDog;
    handlerOfAnimals += OnAnimal;

    // This is possible: the delegate that accepts an Animal can be called with a Dog.
    handlerOfDogs += OnAnimal;

    // Of course, this is not possible: one cannot call a Dog handler with a Cat!
    // handlerOfAnimals += OnDog;
}

static void OnAnimal( IActivityMonitor monitor, Animal e ) { }

static void OnDog( IActivityMonitor monitor, Dog e ) { }

This is precisely what we would like to express at the type level: a PerfectEvent<Dog> is a PerfectEvent<Animal> just like a IEnumerable<Dog> is a IEnumerable<Animal>.

The workaround is to provide an explicit way to adapt the type. A typical usage is to use explicit implementation and/or the new masking operator to expose these adaptations:

PerfectEventSender<object, Dog> _dogKilled = new();

PerfectEvent<Animal> IAnimalGarden.Killed => _dogKilled.PerfectEvent.Adapt<Animal>();

PerfectEvent<Dog> Killed => _dogKilled.PerfectEvent;

This Adapt method allows the event type to be adapted. In a perfect world, it would be defined in the following way:

public readonly struct PerfectEvent<TEvent>
{
  ...

  public PerfectEvent<TNewEvent> Adapt<TNewEvent>() where TEvent : TNewEvent
  {
    ...
  }
}

The constraint TEvent : TNewEvent aims to restrict the adapted type to be a base class of the actual event type. Unfortunately (again), generic constraints don't support this (and anyway this 'base class constraint' should be extended to have the IsAssignableFrom semantics).

We cannot constrain the type this way, we cannot constrain it in any manner except the fact that the adapted type must be a reference type (the class constraint).

So, the bad news is that there is as of today no compile time check for this Adapt method, but the good (or not so bad) news is that safety is nevertheless checked at runtime when Adapt is called: adapters are forbidden when the event is a value type (boxing is not handled) and the adapted type must be a reference type that is assignable from the event type.

When this is the case, an explicit conversion and another PerfectEventSender are required.

Bridges: when types are not compatible

The Adapt method uses Unsafe.As. For this to work the types must be compliant, "true covariance" is required: IsAssignableFrom, no conversion, no implicit boxing (precisely what is checked at runtime by Adapt).

Unfortunately sometimes we need to express a more "logical" covariance, typically to expose read only facade like a Dictionary<string,List<int>> exposed as a IReadOnlyDictionary<string,IReadOnlyList<int>>.

This is not valid in .Net because the dictionary value is not defined as covariant: IDictionary<TKey,TValue> should be IDictionary<TKey,out TValue> but it's not: the out parameter of bool TryGetValue( TKey k, out TValue v) ironically "locks" the type of the value (under the hood, out is just a ref).

Creating and controlling bridges

To handle this and any other projections, a converter function must be used. PerfectEventSender can be bridged to other ones:

PerfectEventSender<Dictionary<string, List<string>>> mutableEvent = new();
PerfectEventSender<IReadOnlyDictionary<string, IReadOnlyList<string>>> readonlyEvent = new();
PerfectEventSender<int> stringCountEvent = new();

var bReadOnly = mutableEvent.CreateBridge( readonlyEvent, e => e.AsIReadOnlyDictionary<string, List<string>, IList<string>>() );
var bCount = mutableEvent.CreateBridge( stringCountEvent, e => e.Values.Select( l => l.Count ).Sum() );

Note: AsIReadOnlyDictionary is a helper available in CK.Core (here).

CreateBridge returns a IBridge : IDisposable: if needed a bridge can be activated or deactivated and definitely removed at any time.

The cool side effect of this "logical covariance" solution is that conversion can be done between any kind of types (string to int, User to DomainEvent, etc.).

Bridges are safe

An important aspect of this feature is that bridge underlying implementation guaranties that:

  • Existing bridges has no impact on the source HasHandlers property as long as their targets don't have handlers. This property can then be confidently used on a potential source of multiple events to totally skip raising events (typically avoiding the event object instantiation).
  • When raising an event, the converter is called once and only if the target has registered handlers or is itself bridged to other senders that have handlers.
  • A dependent activity token is obtained once and only if at least one parallel handler exists on the source or in any subsequent targets. This token is then shared by all the parallel events across all targets.
  • Parallel, sequential and then asynchronous sequential handlers are called uniformly and in deterministic order (breadth-first traversal) across the source and all its bridged targets.
  • Bridges can safely create cycles: bridges are triggered only once by their first occurrence in the breadth-first traversal of the bridges.
  • All this stuff (raising events, adding/removing handlers, bridging and disposing bridges) is thread-safe and can be safely called concurrently.

There is no way to obtain these capabilities "from the outside": such bridges must be implemented "behind" the senders.

The "cycle safe" capability is crucial: bridges can be freely established between senders without any knowledge of existing bridges. However, cycles are hard to figure out (even if logically sound) and the fact that more than one event can be raised for a single call to RaisAsync or SafeRaisAsync can be annoying. By default, a sender raises only one event per RaisAsync (the first it receives). This can be changed thanks to the bool AllowMultipleEvents { get; set; } exposed by senders.

On the bridge side, the bool OnlyFromSource { get; set; } can restrict a bridge to its Source: only events raised on the Source will be considered, events coming from other bridges are ignored.

Playing with "graph of senders" is not easy. Bridges should be used primarily as type adapters but bridges are always safe and can be used freely.

Bridges can also filter the events

The CreateFilteredBridge methods available on the PerfectEventSender and on the PerfectEvent enables event filtering:

/// <summary>
/// Creates a bridge from this event to another sender that can filter the event before
/// adapting the event type and raising the event on the target.
/// </summary>
/// <typeparam name="T">The target's event type.</typeparam>
/// <param name="target">The target that will receive converted events.</param>
/// <param name="filter">The filter that must be satisfied for the event to be raised on the target.</param>
/// <param name="converter">The conversion function.</param>
/// <param name="isActive">By default the new bridge is active.</param>
/// <returns>A new bridge.</returns>
public IBridge CreateFilteredBridge<T>( PerfectEventSender<T> target,
                                        Func<TEvent, bool> filter,
                                        Func<TEvent, T> converter,
                                        bool isActive = true );

/// <summary>
/// Creates a bridge from this event to another sender with a function that filters and converts at once
/// (think <see cref="int.TryParse(string?, out int)"/>).
/// </summary>
/// <typeparam name="T">The target's event type.</typeparam>
/// <param name="target">The target that will receive converted events.</param>
/// <param name="filterConverter">The filter and conversion function.</param>
/// <param name="isActive">By default the new bridge is active.</param>
/// <returns>A new bridge.</returns>
public IBridge CreateFilteredBridge<T>( PerfectEventSender<T> target,
                                        FilterConverter<TEvent,T> filterConverter,
                                        bool isActive = true );

This is easy and powerful:

var strings = new PerfectEventSender<string>();
var integers = new PerfectEventSender<int>();

// This can also be written like this:
// var sToI = strings.CreateFilteredBridge( integers, ( string s, out int i ) => int.TryParse( s, out i ) );
var sToI = strings.CreateFilteredBridge( integers, int.TryParse );

var intReceived = new List<int>();
integers.PerfectEvent.Sync += ( monitor, i ) => intReceived.Add( i );

await strings.RaiseAsync( TestHelper.Monitor, "not an int" );
intReceived.Should().BeEmpty( "integers didn't receive the not parsable string." );

// We now raise a valid int string.
await strings.RaiseAsync( TestHelper.Monitor, "3712" );
intReceived.Should().BeEquivalentTo( new[] { 3712 }, "string -> int done." );
Product Compatible and additional computed target framework versions.
.NET net6.0 is compatible.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (7)

Showing the top 5 NuGet packages that depend on CK.PerfectEvent:

Package Downloads
CK.MQTT.Client

Package Description

CK.Cris.Executor

Package Description

CK.Globalization

Package Description

CKli.AllCode

Package Description

CK.Observable.Domain

Package Description

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
21.0.0 1,274 12/10/2023
20.0.1 3,301 10/20/2023
20.0.0 728 10/8/2023
19.0.0 499 2/23/2023
18.1.0 445 2/7/2023
18.0.1 521 1/10/2023
18.0.0 565 12/21/2022
17.1.0 385 10/10/2022
17.0.0 352 10/3/2022
16.0.0 825 9/22/2022
15.1.0 973 7/16/2022
15.0.0 1,516 6/21/2022
15.0.0-r08 1,437 4/28/2022
15.0.0-r07 141 4/26/2022
15.0.0-r06-02 161 2/1/2022
15.0.0-r06-01 144 1/25/2022
15.0.0-r06 152 1/23/2022
15.0.0-r05 148 1/13/2022
15.0.0-r04 150 1/10/2022
15.0.0-r03 146 1/5/2022
15.0.0-r02 146 12/27/2021
15.0.0-r01 154 12/24/2021
15.0.0-r 169 12/7/2021
14.2.2 1,062 1/25/2022
14.2.1 361 10/11/2021
14.2.0 411 9/20/2021
14.1.0 336 4/15/2021
14.0.1-r02 177 4/13/2021
14.0.1-r01 191 4/12/2021
14.0.1-r 208 4/11/2021
14.0.0 309 2/24/2021
13.3.2 390 1/31/2021