In this blog post, I will demonstrate how to use Microsoft Orleans to build an application.
What is Microsoft Orleans?
Microsoft Orleans is a cross-platform framework for building robust, scalable distributed apps. It scales from a single on-premises server to thousands distributed servers.
Microsoft Orleans is sometimes called Distributed .NET. It runs on Linux, Windows and macOS.
For a developer point of view, Orleans provides programming model and runtime to develop stateful, cloud-based applications. The runtime handles scale out/in, fault tolerance, service discovery, load balancing, messaging, routing.
What is current status of application development?
The diagrams below illustrate the prevalent practices in application development, with a particular focus on the use of stateless services and databases. Each request, whether initiated by the user or the system, is processed by the services, which subsequently interact with the database to retrieve the required information. To enhance latency, caching mechanisms have been introduced, resulting in significant performance improvements. However, this also introduces several challenges:
- High latency
- High database load increases cost and hurts scalability
- High write contention
- With a cache:
- Cache coherency & data consistency issues
- Slower (dual) writes
- Inefficient
- Statelessness is the root of these problems!
DEMO App with Microsoft Orleans
The demo application is built using ASP.NET Core Minimal API. The structure of the application, including the nuget package dependencies, is outlined as follows:
The code snippets below are the source code for the demo application.
# TodoItemDTO.cs
public record class TodoItemDTO
{
public Guid Key { get; init; }
public string Title { get; init; } = null!;
public bool IsCompleted { get; init; }
public string CreatedBy { get; init; }
}
# ITodoGrain.cs
public interface ITodoGrain : IGrainWithGuidKey
{
Task SetAsync(TodoItem todoItem);
Task<TodoItem?> GetAsync();
Task ClearAsync();
}
# ITodoManager.cs
using System.Collections.Immutable;
public interface ITodoManagerGrain : IGrainWithStringKey
{
Task RegisterAsync(Guid todoKey);
Task UnRegisterAsync(Guid todoKey);
Task<ImmutableArray<Guid>> GetAllAsync();
}
# TodoItem.cs
[Immutable]
[GenerateSerializer]
public record class TodoItem (
Guid Key,
string Title,
bool IsCompleted,
string CreatedBy);
# TodoGrain.cs
public class TodoGrain : Grain, ITodoGrain
{
private readonly ILogger<TodoGrain> _logger;
private readonly IPersistentState<State> _state;
private string GrainType => nameof(TodoGrain);
private Guid GrainKey => this.GetPrimaryKey();
public TodoGrain(
ILogger<TodoGrain> logger,
[PersistentState("State")] IPersistentState<State> state)
{
_logger = logger;
_state = state;
}
public async Task SetAsync(TodoItem todoItem)
{
_state.State.Item = todoItem;
await _state.WriteStateAsync();
await GrainFactory.GetGrain<ITodoManagerGrain>(todoItem.CreatedBy)
.RegisterAsync(todoItem.Key);
_logger.LogInformation(
"{@GrainType} {@GrainKey} now contains {@Todo}", GrainType, GrainKey, todoItem);
}
public Task<TodoItem?> GetAsync() => Task.FromResult(_state.State.Item);
public async Task ClearAsync()
{
if (_state.State.Item is null) return;
var todoKey = _state.State.Item.Key;
var createdBy = _state.State.Item.CreatedBy;
await GrainFactory.GetGrain<ITodoManagerGrain>(createdBy)
.UnRegisterAsync(todoKey);
await _state.ClearStateAsync();
_logger.LogInformation(
"{@GrainType} {@GrainKey} is now cleared", GrainType, GrainKey);
DeactivateOnIdle();
}
[GenerateSerializer]
public class State
{
[Id(0)]
public TodoItem? Item {get;set;}
}
}
# TodoManagerGrain.cs
using System.Collections.Immutable;
public class TodoManagerGrain : Grain, ITodoManagerGrain
{
private readonly IPersistentState<State> _state;
public TodoManagerGrain(
[PersistentState("State")] IPersistentState<State> state) => _state = state;
public async Task RegisterAsync(Guid todoKey)
{
_state.State.Items.Add(todoKey);
await _state.WriteStateAsync();
}
public async Task UnRegisterAsync(Guid todoKey)
{
_state.State.Items.Remove(todoKey);
await _state.WriteStateAsync();
}
public Task<ImmutableArray<Guid>> GetAllAsync() => Task.FromResult(ImmutableArray.CreateRange(_state.State.Items));
[GenerateSerializer]
public class State
{
[Id(0)]
public HashSet<Guid> Items { get; set; } = new();
}
}
# program.cs
using System.Collections.Immutable;
using System.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseOrleans(static siloBuilder =>
{
siloBuilder.UseLocalhostClustering();
siloBuilder.AddMemoryGrainStorageAsDefault();
siloBuilder.UseDashboard();
});
var app = builder.Build();
// Create a Todo
app.MapPost("/todo", async (TodoItemDTO model, IGrainFactory factory) =>
{
if (model is null)
{
return Results.BadRequest("Invalid model");
}
var item = new TodoItem(model.Key, model.Title, model.IsCompleted, model.CreatedBy);
await factory.GetGrain<ITodoGrain>(item.Key).SetAsync(item);
return Results.Ok();
});
// Get a Todo using key
app.MapGet("/todo/{todoKey}", async (Guid todoKey, IGrainFactory factory) =>
{
var item = await factory.GetGrain<ITodoGrain>(todoKey).GetAsync();
return item is not null ? Results.Ok(item) : Results.NotFound();
});
// Get Todo List using created by
app.MapGet("/todo/list/{createdBy}", async (string createdBy, IGrainFactory factory) =>
{
var keys = await factory.GetGrain<ITodoManagerGrain>(createdBy).GetAllAsync();
if (keys.Length == 0) return Results.Ok(Enumerable.Empty<TodoItem>());
var tasks = keys.Select(key => factory.GetGrain<ITodoGrain>(key).GetAsync()).ToList();
var result = new List<TodoItem>();
foreach (var task in tasks)
{
var item = await task;
if (item is not null) result.Add(item);
}
return Results.Ok(result);
});
// Delete a Todo by key
app.MapDelete("/todo/{todoKey}", async (Guid todoKey, IGrainFactory factory) =>
{
await factory.GetGrain<ITodoGrain>(todoKey).ClearAsync();
return Results.NoContent();
});
app.Run();
# To run the application
dotnet run
Upon successful execution of the run command, you can access the Orleans Dashboard at http://localhost:8080/
and Swagger UI at http://localhost:5068/swagger
NOTE: The port number may differ in your environment. Kindly refer to the terminal window to obtain the correct port number.
Sample data
{
"Key": "d16f4c5a-1c1d-4f8e-97d2-8b4c0dfb1b3a",
"Title": "Grocery shopping",
"IsCompleted": true,
"CreatedBy": "user123"
}
{
"Key": "f7cfc75d-95f2-46a5-ae4c-9e9f61d8e7b8",
"Title": "Doctor's appointment",
"IsCompleted": false,
"CreatedBy": "user123"
}
{
"Key": "a1c4d7f6-bf1d-4d1b-9c76-d8b9e9d5f7d1",
"Title": "Complete project report",
"IsCompleted": true,
"CreatedBy": "carol321"
}
{
"Key": "e8b1c1d2-55d3-4d7a-961f-afe2b58c4d8a",
"Title": "Call John",
"IsCompleted": false,
"CreatedBy": "dave654"
}
{
"Key": "c4a6d7f3-2b1c-4a9d-bfc9-5b1a9f123e56",
"Title": "Plan weekend trip",
"IsCompleted": false,
"CreatedBy": "emily987"
}
Accessing the API's endpoints
Demystifying Orleans key primitives
-
The actor model is a programming model in which each actor is a lightweight, concurrent, immutable object that encapsulates a piece of state and corresponding behavior. Orleans invented the Virtual Actor abstraction, wherein actors exist perpetually.
-
Grains (e.g., TodoGrain and TodoManagerGrain) are entities comprising user-defined identity, behavior, and state.
- Distributed objects (Virtual actors)
- Each one has a unique ID, a class & (persistent) state
- Live forever, virtually
- Managed lifecycle
- Location-transparent
- Communicate by messages (strongly-typed RPC interfaces)
- Single-threaded by default
-
A Silo (e.g., ASP.NET Core Minimal API) hosts one or more grains. A group of silos operates as a cluster to provide scalability and fault tolerance. When running as a cluster, silos coordinate with each other to distribute work and to detect and recover from failures. The runtime allows grains hosted within the cluster to communicate with each other as if they were within a single process.
Grains Benefits
- Less database reads - Hot/warm data is kept in-memory, cold data is in database and No need for a separate cache – great for soft state
- Less database write contention - Each grain owns its own state
- Less cache coherency issues - Writes update the grain’s state, grain updates the database and Grains act like a smart, write-through cache
- Better scalability - Grains are spread out over your application instances
- Low-latency writes - No need for async writes via a queue/worker
- Simplicity - Less code, less infrastructure, less error handling, less retry logic
Stateless vs stateful Services
This section outlines the key advantages and disadvantages of stateless and stateful services.
Stateless service
- Load state from storage, execute request, write to storage if needed
- Easiest to implement, no coordination across requests
- Storage is the bottleneck
- Caches help but complicate
Stateful service
- Keep loaded state in memory
- Route requests to the right server
- Write-through to storage
- Serve read requests from memory, only touch storage on writes
How Orleans works?
The diagrams below illustrate the workflow of Orleans, encompassing Grains, Silos, and the database. When a client requests a Grain, the runtime handles the provision of the Grain along with its initial state information. Subsequent requests do not require state retrieval from the database. During write operations, the Grain updates its state, which is then reflected in the database. Consequently, Grains function as an intelligent write-through cache.
Orleans Features
- Persistence - Plugins for Azure Storage, Cosmos DB, SQL, DynamoDB, Redis, MongoDB, …
- Distributed ACID transactions - Read/write state in multiple grains transactionally
- Virtual streams - Decouple producers & consumers. Backed by Azure Event Hubs, Amazon SQS, GCP, in-memory
- Timers and reminders - Ephemeral timers, persistent reminders for scheduling future event
- Observers - Grains can send events (RPC calls) directly to clients. Eg, push notifications, streams
- Transport layer security - Mutual TLS between clients and the cluster as well as nodes within the cluster
- Deployment freedom - Azure, AWS, GCP, on-prem, Kubernetes, Azure App Service, Windows, Linux, macOS
- Custom placement & directory - Customize how the runtime decides where new grains are instantiated & how it finds them. “Move computation to data”
- Call filters - Wrap incoming & outgoing requests & responses for logging, tracing, error handling
- .NET integration - DI/Logging/Hosting/Configuration
NOTE: This blog provides a general overview of Orleans' capabilities. The framework has a more extensive array of advanced functionalities that are not covered in this discussion.