Orleans clients

A client allows non-grain code to interact with an Orleans cluster. Clients allow application code to communicate with grains and streams hosted in a cluster. There are two ways to obtain a client, depending on where the client code is hosted: in the same process as a silo, or in a separate process. This article will discuss both options, starting with the recommended option: co-hosting the client code in the same process as the grain code.

Co-hosted clients

If the client code is hosted in the same process as the grain code, then the client can be directly obtained from the hosting application's dependency injection container. In this case, the client communicates directly with the silo it is attached to and can take advantage of the extra knowledge that the silo has about the cluster.

This provides several benefits, including reducing network and CPU overhead as well as decreasing latency and increasing throughput and reliability. The client utilizes the silo's knowledge of the cluster topology and state and does not need to use a separate gateway. This avoids a network hop and serialization/deserialization round trip. This therefore also increases reliability, since the number of required nodes in between the client and the grain is minimized. If the grain is a stateless worker grain or otherwise happens to be activated on the silo where the client is hosted, then no serialization or network communication needs to be performed at all and the client can reap the additional performance and reliability gains. Co-hosting client and grain code also simplifies deployment and application topology by eliminating the need for two distinct application binaries to be deployed and monitored.

There are also detractors to this approach, primarily that the grain code is no longer isolated from the client process. Therefore, issues in client code, such as blocking IO or lock contention causing thread starvation can affect the performance of grain code. Even without code defects like the aforementioned, noisy neighbor effects can result simply by having the client code execute on the same processor as grain code, putting additional strain on CPU cache and additional contention for local resources in general. Additionally, identifying the source of these issues is now more difficult because monitoring systems cannot distinguish what is logically client code from grain code.

Despite these detractors, co-hosting client code with grain code is a popular option and the recommended approach for most applications. To elaborate, the aforementioned detractors are minimal in practice for the following reasons:

  • Client code is often very thin, for example, translating incoming HTTP requests into grain calls, and therefore the noisy neighbor effects are minimal and comparable in cost to the otherwise required gateway.
  • If a performance issue arises, the typical workflow for a developer involves tools such as CPU profilers and debuggers, which are still effective in quickly identifying the source of the issue despite having both client and grain code executing in the same process. In other words, metrics become more coarse and less able to precisely identify the source of an issue, but more detailed tools are still effective.

Obtain a client from a host

If hosting using the .NET Generic Host, the client will be available in the host's dependency injection container automatically and can be injected into services such as ASP.NET controllers or IHostedService implementations.

Alternatively, a client interface such as IGrainFactory or IClusterClient can be obtained from ISiloHost:

var client = host.Services.GetService<IClusterClient>();
await client.GetGrain<IMyGrain>(0).Ping();

External clients

Client code can run outside of the Orleans cluster where grain code is hosted. Hence, an external client acts as a connector or conduit to the cluster and all grains of the application. Usually, clients are used on the frontend web servers to connect to an Orleans cluster that serves as a middle tier with grains executing business logic.

In a typical setup, a frontend webserver:

  • Receives a web request.
  • Performs necessary authentication and authorization validation.
  • Decides which grain(s) should process the request.
  • Uses the Microsoft.Orleans.Client NuGet package to make one or more method call to the grain(s).
  • Handles successful completion or failures of the grain calls and any returned values.
  • Sends a response to the web request.

Initialization of grain client

Before a grain client can be used for making calls to grains hosted in an Orleans cluster, it needs to be configured, initialized, and connected to the cluster.

Configuration is provided via UseOrleansClient and several supplemental option classes that contain a hierarchy of configuration properties for programmatically configuring a client. For more information, see Client configuration.

Consider the following example of a client configuration:

// Alternatively, call Host.CreateDefaultBuilder(args) if using the 
// Microsoft.Extensions.Hosting NuGet package.
using IHost host = new HostBuilder()
    .UseOrleansClient(clientBuilder =>
    {
        clientBuilder.Configure<ClusterOptions>(options =>
        {
            options.ClusterId = "my-first-cluster";
            options.ServiceId = "MyOrleansService";
        });

        clientBuilder.UseAzureStorageClustering(
            options => options.ConfigureTableServiceClient(connectionString))
    })
    .Build();

When the host is started, the client will be configured and available through its constructed service provider instance.

Configuration is provided via ClientBuilder and several supplemental option classes that contain a hierarchy of configuration properties for programmatically configuring a client. For more information, see Client configuration.

Example of a client configuration:

var client = new ClientBuilder()
    .Configure<ClusterOptions>(options =>
    {
        options.ClusterId = "my-first-cluster";
        options.ServiceId = "MyOrleansService";
    })
    .UseAzureStorageClustering(
        options => options.ConnectionString = connectionString)
    .ConfigureApplicationParts(
        parts => parts.AddApplicationPart(typeof(IValueGrain).Assembly))
    .Build();

Lastly, you need to call Connect() method on the constructed client object to make it connect to the Orleans cluster. It's an asynchronous method that returns a Task. So you need to wait for its completion with an await or .Wait().

await client.Connect();

Make calls to grains

Making calls to grain from a client is no different from making such calls from within grain code. The same IGrainFactory.GetGrain<TGrainInterface>(Type, Guid) method, where T is the target grain interface, is used in both cases to obtain grain references. The difference is in what factory object is invoked IGrainFactory.GetGrain. In client code, you do that through the connected client object as the following example shows:

IPlayerGrain player = client.GetGrain<IPlayerGrain>(playerId);
Task joinGameTask = player.JoinGame(game)

await joinGameTask;

A call to a grain method returns a Task or a Task<TResult> as required by the grain interface rules. The client can use the await keyword to asynchronously await the returned Task without blocking the thread or in some cases the Wait() method to block the current thread of execution.

The major difference between making calls to grains from client code and from within another grain is the single-threaded execution model of grains. Grains are constrained to be single-threaded by the Orleans runtime, while clients may be multi-threaded. Orleans does not provide any such guarantee on the client-side, and so it is up to the client to manage its concurrency using whatever synchronization constructs are appropriate for its environment—locks, events, and Tasks.

Receive notifications

There are situations in which a simple request-response pattern is not enough, and the client needs to receive asynchronous notifications. For example, a user might want to be notified when a new message has been published by someone that she is following.

The use of Observers is one such mechanism that enables exposing client-side objects as grain-like targets to get invoked by grains. Calls to observers do not provide any indication of success or failure, as they are sent as a one-way best effort message. So it is the responsibility of the application code to build a higher-level reliability mechanism on top of observers where necessary.

Another mechanism that can be used for delivering asynchronous messages to clients is Streams. Streams expose indications of success or failure of delivery of individual messages, and hence enable reliable communication back to the client.

Client connectivity

There are two scenarios in which a cluster client can experience connectivity issues:

  • When the client attempts to connect to a silo.
  • When making calls on grain references that were obtained from a connected cluster client.

In the first case, the client will attempt to connect to a silo. If the client is unable to connect to any silo, it will throw an exception to indicate what went wrong. You can register an IClientConnectionRetryFilter to handle the exception and decide whether to retry or not. If no retry filter is provided, or if the retry filter returns false, the client gives up for good.

using Orleans.Runtime;

internal sealed class ClientConnectRetryFilter : IClientConnectionRetryFilter
{
    private int _retryCount = 0;
    private const int MaxRetry = 5;
    private const int Delay = 1_500;

    public async Task<bool> ShouldRetryConnectionAttempt(
        Exception exception,
        CancellationToken cancellationToken)
    {
        if (_retryCount >= MaxRetry)
        {
            return false;
        }

        if (!cancellationToken.IsCancellationRequested &&
            exception is SiloUnavailableException siloUnavailableException)
        {
            await Task.Delay(++ _retryCount * Delay, cancellationToken);
            return true;
        }

        return false;
    }
}

There are two scenarios in which a cluster client can experience connectivity issues:

  • When the IClusterClient.Connect() method is called initially.
  • When making calls on grain references that were obtained from a connected cluster client.

In the first case, the Connect method will throw an exception to indicate what went wrong. This is typically (but not necessarily) a SiloUnavailableException. If this happens, the cluster client instance is unusable and should be disposed of. A retry filter function can optionally be provided to the Connect method which could, for instance, wait for a specified duration before making another attempt. If no retry filter is provided, or if the retry filter returns false, the client gives up for good.

If Connect returns successfully, the cluster client is guaranteed to be usable until it is disposed of. This means that even if the client experiences connection issues, it will attempt to recover indefinitely. The exact recovery behavior can be configured on a GatewayOptions object provided by the ClientBuilder, e.g.:

var client = new ClientBuilder()
    // ...
    .Configure<GatewayOptions>(
        options =>                         // Default is 1 min.
        options.GatewayListRefreshPeriod = TimeSpan.FromMinutes(10))
    .Build();

In the second case, where a connection issue occurs during a grain call, a SiloUnavailableException will be thrown on the client-side. This could be handled like so:

IPlayerGrain player = client.GetGrain<IPlayerGrain>(playerId);

try
{
    await player.JoinGame(game);
}
catch (SiloUnavailableException)
{
    // Lost connection to the cluster...
}

The grain reference is not invalidated in this situation; the call could be retried on the same reference later when a connection might have been re-established.

Dependency injection

The recommended way to create an external client in a program that uses the .NET Generic Host is to inject an IClusterClient singleton instance via dependency injection, which can then be accepted as a constructor parameter in hosted services, ASP.NET controllers, and so on.

Note

When co-hosting an Orleans silo in the same process that will be connecting to it, it is not necessary to manually create a client; Orleans will automatically provide one and manage its lifetime appropriately.

When connecting to a cluster in a different process (on a different machine), a common pattern is to create a hosted service like this:

using Microsoft.Extensions.Hosting;

namespace Client;

public sealed class ClusterClientHostedService : IHostedService
{
    private readonly IClusterClient _client;

    public ClusterClientHostedService(IClusterClient client)
    {
        _client = client;
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        // Use the _client to consume grains...

        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken)
        => Task.CompletedTask;
}
public class ClusterClientHostedService : IHostedService
{
    private readonly IClusterClient _client;

    public ClusterClientHostedService(IClusterClient client)
    {
        _client = client;
    }

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        // A retry filter could be provided here.
        await _client.Connect();
    }

    public async Task StopAsync(CancellationToken cancellationToken)
    {
        await _client.Close();

        _client.Dispose();
    }
}

The service is then registered like this:

await Host.CreateDefaultBuilder(args)
    .UseOrleansClient(builder =>
    {
        builder.UseLocalhostClustering();
    })
    .ConfigureServices(services => 
    {
        services.AddHostedService<ClusterClientHostedService>();
    })
    .RunConsoleAsync();

Example

Here is an extended version of the example given above of a client application that connects to Orleans, finds the player account, subscribes for updates to the game session the player is part of with an observer, and prints out notifications until the program is manually terminated.

try
{
    using IHost host = Host.CreateDefaultBuilder(args)
        .UseOrleansClient((context, client) =>
        {
            client.Configure<ClusterOptions>(options =>
            {
                options.ClusterId = "my-first-cluster";
                options.ServiceId = "MyOrleansService";
            })
            .UseAzureStorageClustering(
                options => options.ConfigureTableServiceClient(
                    context.Configuration["ORLEANS_AZURE_STORAGE_CONNECTION_STRING"]));
        })
        .UseConsoleLifetime()
        .Build();

    await host.StartAsync();

    IGrainFactory client = host.Services.GetRequiredService<IGrainFactory>();

    // Hardcoded player ID
    Guid playerId = new("{2349992C-860A-4EDA-9590-000000000006}");
    IPlayerGrain player = client.GetGrain<IPlayerGrain>(playerId);
    IGameGrain? game = null;
    while (game is null)
    {
        Console.WriteLine(
            $"Getting current game for player {playerId}...");

        try
        {
            game = await player.GetCurrentGame();
            if (game is null) // Wait until the player joins a game
            {
                await Task.Delay(TimeSpan.FromMilliseconds(5_000));
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Exception: {ex.GetBaseException()}");
        }
    }

    Console.WriteLine(
        $"Subscribing to updates for game {game.GetPrimaryKey()}...");

    // Subscribe for updates
    var watcher = new GameObserver();
    await game.ObserveGameUpdates(
        client.CreateObjectReference<IGameObserver>(watcher));

    Console.WriteLine(
        "Subscribed successfully. Press <Enter> to stop.");
}
catch (Exception e)
{
    Console.WriteLine(
        $"Unexpected Error: {e.GetBaseException()}");
}
await RunWatcherAsync();

// Block the main thread so that the process doesn't exit.
// Updates arrive on thread pool threads.
Console.ReadLine();

static async Task RunWatcherAsync()
{
    try
    {
        var client = new ClientBuilder()
            .Configure<ClusterOptions>(options =>
            {
                options.ClusterId = "my-first-cluster";
                options.ServiceId = "MyOrleansService";
            })
            .UseAzureStorageClustering(
                options => options.ConnectionString = connectionString)
            .ConfigureApplicationParts(
                parts => parts.AddApplicationPart(typeof(IValueGrain).Assembly))
            .Build();

            // Hardcoded player ID
            Guid playerId = new("{2349992C-860A-4EDA-9590-000000000006}");
            IPlayerGrain player = client.GetGrain<IPlayerGrain>(playerId);
            IGameGrain game = null;
            while (game is null)
            {
                Console.WriteLine(
                    $"Getting current game for player {playerId}...");

                try
                {
                    game = await player.GetCurrentGame();
                    if (game is null) // Wait until the player joins a game
                    {
                        await Task.Delay(TimeSpan.FromMilliseconds(5_000));
                    }
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"Exception: {ex.GetBaseException()}");
                }
            }

            Console.WriteLine(
                $"Subscribing to updates for game {game.GetPrimaryKey()}...");

            // Subscribe for updates
            var watcher = new GameObserver();
            await game.SubscribeForGameUpdates(
                await client.CreateObjectReference<IGameObserver>(watcher));

            Console.WriteLine(
                "Subscribed successfully. Press <Enter> to stop.");
        }
        catch (Exception e)
        {
            Console.WriteLine(
                $"Unexpected Error: {e.GetBaseException()}");
        }
    }
}

/// <summary>
/// Observer class that implements the observer interface.
/// Need to pass a grain reference to an instance of
/// this class to subscribe for updates.
/// </summary>
class GameObserver : IGameObserver
{
    public void UpdateGameScore(string score)
    {
        Console.WriteLine("New game score: {0}", score);
    }
}