UFX.Orleans.SignalRBackplane 8.2.2

dotnet add package UFX.Orleans.SignalRBackplane --version 8.2.2                
NuGet\Install-Package UFX.Orleans.SignalRBackplane -Version 8.2.2                
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="UFX.Orleans.SignalRBackplane" Version="8.2.2" />                
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add UFX.Orleans.SignalRBackplane --version 8.2.2                
#r "nuget: UFX.Orleans.SignalRBackplane, 8.2.2"                
#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 UFX.Orleans.SignalRBackplane as a Cake Addin
#addin nuget:?package=UFX.Orleans.SignalRBackplane&version=8.2.2

// Install UFX.Orleans.SignalRBackplane as a Cake Tool
#tool nuget:?package=UFX.Orleans.SignalRBackplane&version=8.2.2                

CI Nuget Nuget

UFX.Orleans.SignalRBackplane

Overview

Orleans is a framework that provides a straightforward approach to building distributed high-scale computing applications without the need to learn and apply complex concurrency or other scaling patterns.

ASP.NET Core SignalR is a library for ASP.NET Core that makes it incredibly simple to add real-time web functionality to your applications. The ability to have your server-side code push content to the connected clients as it happens, in real-time with support for the following clients:

This library is inspired by SignalR.Orleans and Microsoft.AspNetCore.SignalR.StackExchangeRedis and provides a SignalR backplane on top of Orleans, allowing scale-out to multiple servers with optimal performance and minimal dependencies. This library supports Orleans V7 and uses Grain Observers as a PubSub mechanism.

Benefits

  • Ideal redundancy and scale-out compared to Redis due to co-hosting SignalR hubs with Orleans Silos.
  • Eliminates the requirement for additional 3rd-party components to learn/scale and manage (i.e., Redis).
  • Works with any supported Orleans storage provider: ADO.NET, Azure Storage, Amazon DynamoDB and MongoDb, among others.
  • Other than configuring the Orleans Silo, there is no requirement to interact with Orleans directly. You can use the SignalR IHubContext directly, and messages will be sent across multiple servers if required.
  • Minimal latency due to the direct server-to-server messaging using Grain Observers as a PubSub mechanism, as opposed to Orleans streams that work on a Store & Forward Queue.
  • It can be used instead of Azure SignalR Service scale-out, potentially saving thousands of dollars.

Usage

Adding the Backplane to an Orleans Silo

Install UFX.Orleans.SignalRBackplane on the silo.

This is the minimum setup required to use this backplane:

builder.Host
    .UseOrleans(siloBuilder => siloBuilder
        .AddMemoryGrainStorage(UFX.Orleans.SignalRBackplane.Constants.StorageName)
        .UseInMemoryReminderService()
        .AddSignalRBackplane()
    );

AddSignalRBackplane will register reminder support on the silo if not already registered. You must provide reminder persistence using the UseInMemoryReminderService() extension (unsuitable for production), or a persisted reminder storage provider.

You must also provide a named storage provider for the grains. We do not recommend memory storage for production. The name you must use is stored in the constant UFX.Orleans.SignalRBackplane.Constants.StorageName. This allows you to register a storage provider specific to the SignalR backplane, which can be a different storage provider to the rest of your application if preferred. You can see more detail on the persistence API here.

You can use any supported grain persistence. For example, if you want to store our grains in Azure Blob storage, you can install the Microsoft.Orleans.Persistence.AzureStorage package, and change the above code to

builder.Host
    .UseOrleans(siloBuilder => siloBuilder
        .AddAzureBlobGrainStorage(UFX.Orleans.SignalRBackplane.Constants.StorageName, options => options.ConfigureBlobServiceClient(<yourBlobStorageConnectionString>))
        .UseInMemoryReminderService()
        .AddSignalRBackplane()
    );

A complete example using MongoDB, includes the setup for named storage and reminders:

using Orleans.Providers.MongoDB.Configuration;
using UFX.Orleans.SignalRBackplane;

builder.Host
    .UseOrleans(siloBuilder => siloBuilder
        .UseMongoDBClient("MongoDBConnectionString")
        .AddMongoDBGrainStorageAsDefault(options =>
        {
            options.Configure(storage =>
            {
                storage.DatabaseName = "DefaultStorage";
            });
        })
        .AddMongoDBGrainStorage(Constants.StorageName, options =>
        {
            options.Configure(storage =>
            {
                storage.DatabaseName = "SignalRBackPlaneGrainStoreage";
            });
        })
        .UseMongoDBReminders(options =>
        {
            options.DatabaseName = "SignalRBackPlaneRemindersStoreage";
        })
        .Configure<Orleans.Configuration.ClusterOptions>(options =>
        {
            options.ClusterId = "OrleansClusterId";
            options.ServiceId = "OrleansServiceId";
        })
        .AddSignalRBackplane();
    );

Orleans Serialization

Orleans v7 introduced a version-tolerant serializer. The new serializer requires you to be explicit about which types and members are serialized. All types passed between grains and therefore the SignalR Backplane must be serializable. Therefore, sending types via SignalR Backplane that are not marked with the [GenerateSerializer] attribute will result in an exception. If you are unable to use the [GenerateSerializer] attribute on your SignalR types you can use set the JsonSerializerFallback option to true to allow any type to be sent via SignalR BackPlane without the need to mark them with the [GenerateSerializer] attribute.

AddSignalRBackplane(options => options.JsonSerializerFallback = true);

When JsonSerializerFallback is set to true any types that are not marked with the [GenerateSerializer] attribute will be serialized using the default JSON serializer using the following logic:

services.AddSerializer(serializerBuilder =>
{
    var assemblies = AppDomain.CurrentDomain.GetAssemblies();
    var types = new HashSet<Type>(assemblies.
        SelectMany(a => a.GetTypes()).
        Where(t => t.CustomAttributes.Any(a =>a.AttributeType == typeof(GenerateSerializerAttribute))));
    serializerBuilder.AddJsonSerializer(type => !types.Contains(type));
});

Using Fully Qualified Grain Types

Versions greater than v7.2.1 of this library fully qualify the grain type names to avoid grain type conflicts with grains from your own application.

Existing deployments with v7.2.1 and below

Up to v7.2.1 of this library, if your own project contained any grain type names that are the same as a grain created by this library, such as UserGrain, then your silo would fail to start, because Orleans cannot differentiate between the two grain types.

If you have an existing deployment with v7.2.1 or earlier, and upgrade this library, Fully Qualified Grain Types are considered a breaking change, and you may experience issues as the grain type names will no longer be backwards compatible. If this is the case, you should set UseFullyQualifiedGrainTypes to false to keep the v7.2.1 behaviour of this library.

.AddSignalRBackplane(options => options.UseFullyQualifiedGrainTypes = false)

If you disable fully qualified grain types, you must ensure that your grain type names do not conflict with the grain types created by this library. This can be done by decorating your own types.

Adding SignalR to the server

Adding this backplane does not register the SignalR services that are required to make real-time client-to-server and server-to-client possible. We leave this to you as there are a number of configurations you may want to make when doing this. For out-of-the-box configuration, you can call the AddSignalR extension on the IServiceCollection:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSignalR();

You can then further configure SignalR as required.

Sending messages to clients

Sending from the within the Orleans cluster

If you are sending a message from within the Orleans cluster, such as in a co-hosting scenario, you can use the IHubContext directly.

Once the services have been registered by following the section above, you can access an instance of IHubContext<MyHub> via dependency injection. Use the instance to send messages to clients. You can see more detail on the API here.

public class MyService
{
    private readonly IHubContext<MyHub> _hubContext;

    public MyService(IHubContext<MyHub> hubContext)
      => _hubContext = hubContext;

    public Task SendMessage(string message)
       => await _hubContext.Clients.All.SendAsync("ReceiveMessage", message);
}

Sending from an external client

If you need to send a message from an external client, you can use the UFX.Orleans.SignalRBackplane.Client package. This package provides an extension point to allow you to use an Orleans Client to send messages to the SignalR backplane.

To add the external signalr hub service, you can call AddSignalRHubContexts on your silo client builder.

.UseOrleansClient(clientBuilder => clientBuilder  
  .AddSignalRHubContexts()
)

This will register both an IExternalSignalrHubContextFactory and a service resolver for typed contexts. This pattern will be familiar to anyone who has used Microsoft's ILogger and ILoggerFactory.

If you have access to the hub type from your client project, you can create an instance of the hub context either by resolving the hub context factory:

public MyService(IExternalSignalrHubContextFactory hubContextFactory)
  => _hubContext = hubContextFactory.CreateHubContext<MyHub>();

or simply injecting the typed context directly

public MyService(IExternalSignalrHubContext<MyHub> hubContext)
  => _hubContext = hubContext;

If you do not have access to the hub type from your client project, you must provide the hub name directly to the factory. Note that the name must be the FullName of the hub type, all in lowercase. You can can use the following to get an instance of the external hub context:

public MyService(IExternalSignalrHubContextFactory hubContextFactory)
  => _hubContext = hubContextFactory.CreateHubContext("myotherassembly.myhub");

Once you have an instance of the hub context, you can use it to send messages to clients. The API is very similar to an IHubContext.

Logging

All grains implement the IIncomingGrainCallFilter interface, which allows a log of all incoming calls to the grains. This is useful for debugging, as the grain type, method name called, address and id of the grain are all logged. This can be enabled by making sure the debug log level is active for the UFX.Orleans.SignalRBackplane namespace. One way to do this is to add the following to your appsettings.json:

{
  "Logging": {
    "LogLevel": {
      "UFX.Orleans.SignalRBackplane": "Debug"
    }
  }
}

You can see other ways to configure the log level here.

Sample Client and Server

A sample SignalR client, Orleans client and Orleans silo are supplied in the samples folder.

  • The Server is a co-hosted ASP.NET Core app that uses the SignalR backplane and provides a single SignalR hub in the same process as the Silo.
  • The SignalRClient is a console application that connects to the server via SignalR and allows sending messages via command line input. On receipt of a message, the ChatHub on the server will add the caller to a group and then echo the message to the sender, to the group, and all connected clients.
  • The OrleansClient is a console application that connects to the Silo and requests that a message is sent to all connections every 3 seconds.

Server

The server runs on a random port each time to allow you to run multiple instances locally for multi-silo testing. The port can be found in the console output of the server.

info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://127.0.0.1:62351

If you wish to run on the same port each time, you can change the applicationUrl in Properties\launchSettings.json to a fixed port rather than :0. You can then run the server via

\samples\Server> dotnet run

SignalR Client

To run the client, use the following command. You must provide the port number that the server is running on. You can also give an optional number of SignalR connections to create from the client, which defaults to 1.

\samples\SignalRClient> dotnet run <server port number> [connection count]

Orleans Client

To run the client, use the following command. There is no need to provide the port that the server is running on, as both the silo and client use localhost clustering.

\samples\OrleansClient> dotnet run

Design

Each connection, user, and group is represented by their own grain. When a new SignalR connection is made to a hub on a specific server, a connection grain is created, which can live on any silo. The hub then subscribes to the connection grain, which acts as a point of pub-sub communication. When a message is sent to a connection via the IHubContext on any server, the connection grain is called, which notifies the hub on the correct server that it should send the message to its local connection.

If the connection has a user identifier associated with it, then the hub will also subscribe to the user grain. When a message is sent to a user via the IHubContext, the user grain is called, which in turn notifies all of the hubs that have connections for that user, and they send out the message. The same mechanism is used for groups.

There is also a single HubGrain per hub type, that all hubs of that type subscribe to. This allows the sending of messages to all connections on all hubs of that type.

This design reduces the number of network requests between silos, using the Orleans grain directory to locate grains and the observer pattern to notify the correct hub of messages.

Graceful Disconnection

Hubs track their connections and which users and groups these connections are members of. When a connection is disconnected, the hub unsubscribes from the connection grain. All grains have an in-built mechanism whereby they delete their state and deactivate when their last observer unsubscribes. This allows group and user subscriptions to be removed when the last connection for that user or group is removed.

When a silo is shutdown gracefully, all of the connections on that silo are deactivated, and the same mechanism is used to remove the subscriptions.

Silo Crash

If a silo is stopped before it has a chance to gracefully shutdown, then the grains will remain active. In this scenario, there may be grains that will never be invoked again, because they represent connections/groups/users that no longer exist. To cater for this, every grain will periodically ping its subscribers to check they are still alive. Any defunct observers are removed from the grain's state, and the grain will clear all its state and deactivate if it has no more observers.

This ping period is configurable, and is one day by default. There is a trade-off here between persistence storage and cpu/network/memory usage. A short period will mean grains clean up their state quickly, reducing the amount of persistent storage used bvy defunct grains. However, it will also mean that more network requests are made to check the status of observers and more cpu and memory used to reactivate the grains. A longer period will mean fewer network requests made and grains reactivated less frequently, but more persistent storage may be used by defunct grains.

If you would like to customise the cleanup period, use the GrainCleanupPeriod property on the SignalrOrleansOptions class.

.AddSignalRBackplane(x => x.GrainCleanupPeriod = TimeSpan.FromHours(1))

Architecture

Sending from the within the Orleans cluster

Diagram

Sending from an external client

Diagram

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  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

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
8.2.2 149 10/14/2024
8.2.0 90 10/12/2024
8.0.1 87 10/12/2024
8.0.0 602 2/12/2024
7.2.1 796 8/14/2023
7.0.1 1,421 2/8/2023
7.0.0 716 2/8/2023
0.1.5-alpha 613 2/7/2023
0.1.4-alpha 626 2/7/2023
0.1.3-alpha 624 2/6/2023