Training
Module
Build your first Orleans app with ASP.NET Core 8.0 - Training
Learn how to build cloud-native, distributed apps with Orleans.
This browser is no longer supported.
Upgrade to Microsoft Edge to take advantage of the latest features, security updates, and technical support.
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.
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:
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();
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:
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();
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
.
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.
There are two scenarios in which a cluster client can experience connectivity issues:
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:
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.
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();
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.");
Console.ReadLine();
}
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);
}
}
.NET feedback
.NET is an open source project. Select a link to provide feedback:
Training
Module
Build your first Orleans app with ASP.NET Core 8.0 - Training
Learn how to build cloud-native, distributed apps with Orleans.
Documentation
Orleans silo lifecycles - .NET
Learn about .NET Orleans silo lifecycles.
Learn about the grain directory in .NET Orleans.
Heterogeneous silos overview - .NET
Learn an overview of the supported heterogeneous silos in .NET Orleans.