Getting started with Orleans

Microsoft Orleans is a cross platform framework for distributed applications. It’s based upon the Actor model which represents a lightweight, concurrent, immutable objects encapsulating state.

Basic Concepts

A Grain is a virtual actor and one of several Orleans primitives. A Grain is an entity which comprises of

identity + behaviour + state

where an identity is a user-defined key.

Grains are automatically instantiated on demand by the Orleans runtime and have a managed lifecycle with the runtime activating/deactivating grains as well as placing/locating grains as required.

A Silo is a primitive which hosts one or more Grains. A group of silos run as a cluster and in this mode coordinates and distributes work.

Orleans can handle persistence, timers and reminders along with flexible grain placement and versioning.

Use cases

Whilst grains can be stateless, the “sweet spot” for using Orleans is where you require distributed, durable and concurrent state management without locks etc. Long running stateful process such as event driven workflows are very much in the Orleans world.

Where Orleans is not the best solution include stateless, computer heavy tasks, these are better suited to Azure functions (for example). In such situations Orleans just adds complexity.

Lifecycle

Orleans automatically manages the lifecycle of grains. A grain may be in one of the following states activating, active, deactivating and persisted. Persisted maybe wasn’t the state you first thought of when looking at the progress through other states. Let’s look at the states in a little more depth (although probably fairly self explanatory)

  • Activation occurs when a request for a grain is received and the grain current state is not active. Hence the grain will be initialized and when active, can accept requests. An important point is that the grain will stay active based upon the fulfilment of requests.
  • When a grain is active in memory it will accept requests but if it’s busy messages will be stored in a queue until the grain is ready to receive them. Whilst We can call the grain concurrently, due to the Actor model design, only one execution is permitted on the grains thread, ensuring thread safety.
  • Deactivation takes place once the grain stops receiving requests for a period of time. This state is not yet persisted, however once we reach the persisted state it will be removed from memory.
  • Persisted state is when a grain has been deactivated and it’s final state is stored in a database or other datastore.

The framework takes care of the life cycle and allows the developer to just concentrate on using grains.

The Silo lifecycle

The silo’s lifecycle goes something like the following

  • When created the silo initializes the runtime environment etc.
  • Runtime services are started and the silo initializes agents and networking
  • Runtime storage is initialized
  • Runtime services for grains is started, includes grain type management, membership services and the grain directory
  • Application layer services started
  • The silo joins the cluster
  • Once active the silo is ready to accept workload

Concurrency

Grains are virtual actors and hence based upon the Actor model which essentially has a single thread that accepts request/messages. Hence when a grain is processing a request it’s in a busy state and in the default, turn based concurrency, other requests are queued. It’s possible to override turn-based concurrency to handle multiple messages on the same thread but this does come with potential risks around sharing a threads.

Orleans maintains a relative small thread pool which is determined by the number of CPU cores in the system, therefore we must still be careful around any potential blocking of threads. Grains can use the .NET thread pool but this should be fairly rare and using async/await should be used.

Mesage flow

Requests for a grain are passed from client to silo and then the request is passed onto the grain. When the grain has completed it’s work and if a response is required this will pass back to the silo and onto the client.

Let’s write some code

Let’s get to writing the equivalent of “Hello World” by creating two Console projects, mine are named Client and Silo. However I also want some interface and implementation of a HelloGrain, so you’ll need to create two libraries. I’ve named mine GrainInterfaces and Grains (not very imaginative I admit).

In the Client project add the following nuget package Microsoft.Orleans.Client, so my packages are as follows in the .csproj

<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.10" />
<PackageReference Include="Microsoft.Orleans.Client" Version="9.2.1" />

Now in the Silot project add Microsoft.Orleans.Hosting.Server, I’m also wanting to host in Kubernetes so added Microsoft.Orleans.Hosting.Kuberneres and Microsoft.Clustering.Kuberneres. Finally I want to use the OrleansDashboard, so add the OrleansDashboard package, hence my .csproj looks like this

<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.10" />
<PackageReference Include="Microsoft.Orleans.Hosting.Kubernetes" Version="9.2.1" />
<PackageReference Include="Microsoft.Orleans.Server" Version="9.2.1" />
<PackageReference Include="Orleans.Clustering.Kubernetes" Version="8.2.0" />
<PackageReference Include="OrleansDashboard" Version="8.2.0" />

Notice I also have the Microsoft.Extensions packaged to include logging and to create the host.

For the GrainInterfaces project add the package Microsoft.Orleans.Sdk so the GrainInterfaces .csproj has this

<PackageReference Include="Microsoft.Orleans.Sdk" Version="9.2.1" />

and finally add the same to the Grains project but also let’s add Microsoft.Extensions.Logging.Abstractions for us to do some logging, so the .csproj should look like this

<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
<PackageReference Include="Microsoft.Orleans.Sdk" Version="9.2.1" />

For the GrainInterfaces add an interface IHello.cs which looks like this

public interface IHello : IGrainWithIntegerKey
{
  ValueTask<string> SayHello(string greeting);
}

Add a project reference in the Client to this project.

Next up, the Grains project has a new class named HelloGrain.cs which looks like this

public class HelloGrain(ILogger<HelloGrain> logger) : Grain, IHello
{
  private readonly ILogger _logger = logger;

  ValueTask<string> IHello.SayHello(string greeting)
  {
    _logger.LogInformation("""
            SayHello message received: "{Greeting}"
            """,
            greeting);

        return ValueTask.FromResult($"""
            Client said: "{greeting}"
            """);
    }

    public override Task OnDeactivateAsync(DeactivationReason reason, CancellationToken cancellationToken)
    {
        if(reason.ReasonCode == DeactivationReasonCode.ShuttingDown)
        {
            MigrateOnIdle();
        }

        return base.OnDeactivateAsync(reason, cancellationToken);
    }

For the server code, edit the Program.cs within the Client project as follows

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using GrainInterfaces;

var builder = Host.CreateDefaultBuilder(args)
    .UseOrleansClient(client =>
    {
        client.UseLocalhostClustering();
    })
    .ConfigureLogging(logging => logging.AddConsole())
    .UseConsoleLifetime();

using var host = builder.Build();
await host.StartAsync();

var client = host.Services.GetRequiredService<IClusterClient>();

var friend = client.GetGrain<IHello>(0);
string response = await friend.SayHello("Hi Orleans");

Console.WriteLine($"""
                   {response}

                   Press any key to exit...
                   """);

Console.ReadKey();

await host.StopAsync();

For the server code, edit the Program.cs within the Silo project as follows

using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var builder = Host.CreateDefaultBuilder(args)
    .UseOrleans(silo =>
    {
        silo.UseLocalhostClustering()
            .ConfigureLogging(logging => logging.AddConsole());
        silo.UseDashboard(options => 
        {
            options.HostSelf = true;       // Enables embedded web server
            options.Port = 7000;           // Default port
        });
    })
    .UseConsoleLifetime();

using IHost host = builder.Build();

await host.RunAsync();

If we’re wanting to run these project from a single solution then don’t forget to go to Visual Studio’s solution, right mouse click and select Configure Startup Projects from here select the Common Properties | Configure Startup Projects and then Multiple startup projects, set the Silo and Client project actions to Start.

Persistence

As mentioned previously grains can be have their state persisted by Orleans. If we edit the silo project, Program.cs we can add various types of persistence, table store, SQL database etc. but also for testing we can use an in memory storage.

We just add the following to the UseOrleans method

silo.AddMemoryGrainStorage("docStore");
// Or Azure Table storage
// silo.AddAzureTableGrainStorage("documentStore", options =>
// {
//     options.ConnectionString = "<your-connection-string>";
// });

Now in our HelloGrain we can add persistence as easily as the following, add the PersistanceState attribute as a ctor parameter for the IPersistentState object to be injected

public class HelloGrain(
  [PersistentState("hello", "docStore")] IPersistentState<State> state, 
  ILogger<HelloGrain> logger) : Grain, IHello

The state name here is “hello” and the storageName should match the storage we set up in the Silo project.

Now to save data on the state we just write the following in the SayHello method

state.State.Data = greeting;
await state.WriteStateAsync();

Here’s a very simple State object example

public class State
{
    public string? Data { get; set; }
}