From 673e1198bfa3bdb4852cf43334375199d16f68de Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Sun, 16 Aug 2026 03:29:54 -0700 Subject: [PATCH 1/2] feat(samples): add basic clustering sample Add an Aspire-orchestrated two-silo cluster backed by Redis membership, with observable membership changes and grain routing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../BasicClustering.AppHost.csproj | 23 ++++++++ .../BasicClustering.AppHost/Program.cs | 12 +++++ .../Properties/launchSettings.json | 18 +++++++ .../BasicClustering.Silo.csproj | 18 +++++++ .../BasicClustering.Silo/ClusterMonitor.cs | 54 +++++++++++++++++++ .../BasicClustering.Silo/HelloGrain.cs | 7 +++ .../BasicClustering.Silo/IHelloGrain.cs | 6 +++ .../BasicClustering.Silo/Program.cs | 11 ++++ samples/BasicClustering/BasicClustering.slnx | 4 ++ .../BasicClustering/Directory.Packages.props | 17 ++++++ samples/BasicClustering/README.md | 47 ++++++++++++++++ samples/README.md | 1 + samples/Samples.slnx | 4 ++ samples/gallery.json | 11 ++++ 14 files changed, 233 insertions(+) create mode 100644 samples/BasicClustering/BasicClustering.AppHost/BasicClustering.AppHost.csproj create mode 100644 samples/BasicClustering/BasicClustering.AppHost/Program.cs create mode 100644 samples/BasicClustering/BasicClustering.AppHost/Properties/launchSettings.json create mode 100644 samples/BasicClustering/BasicClustering.Silo/BasicClustering.Silo.csproj create mode 100644 samples/BasicClustering/BasicClustering.Silo/ClusterMonitor.cs create mode 100644 samples/BasicClustering/BasicClustering.Silo/HelloGrain.cs create mode 100644 samples/BasicClustering/BasicClustering.Silo/IHelloGrain.cs create mode 100644 samples/BasicClustering/BasicClustering.Silo/Program.cs create mode 100644 samples/BasicClustering/BasicClustering.slnx create mode 100644 samples/BasicClustering/Directory.Packages.props create mode 100644 samples/BasicClustering/README.md diff --git a/samples/BasicClustering/BasicClustering.AppHost/BasicClustering.AppHost.csproj b/samples/BasicClustering/BasicClustering.AppHost/BasicClustering.AppHost.csproj new file mode 100644 index 00000000000..33ceb0aa917 --- /dev/null +++ b/samples/BasicClustering/BasicClustering.AppHost/BasicClustering.AppHost.csproj @@ -0,0 +1,23 @@ + + + + + + Exe + net10.0 + enable + enable + true + + + + + + + + + + + + + diff --git a/samples/BasicClustering/BasicClustering.AppHost/Program.cs b/samples/BasicClustering/BasicClustering.AppHost/Program.cs new file mode 100644 index 00000000000..8ddad5462e3 --- /dev/null +++ b/samples/BasicClustering/BasicClustering.AppHost/Program.cs @@ -0,0 +1,12 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var redis = builder.AddRedis("clustering"); +var orleans = builder.AddOrleans("cluster") + .WithClustering(redis); + +builder.AddProject("silo") + .WithReference(orleans) + .WaitFor(redis) + .WithReplicas(2); + +builder.Build().Run(); diff --git a/samples/BasicClustering/BasicClustering.AppHost/Properties/launchSettings.json b/samples/BasicClustering/BasicClustering.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..1d5ba5c09d6 --- /dev/null +++ b/samples/BasicClustering/BasicClustering.AppHost/Properties/launchSettings.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15347", + "environmentVariables": { + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true", + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19347", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20347" + } + } + } +} diff --git a/samples/BasicClustering/BasicClustering.Silo/BasicClustering.Silo.csproj b/samples/BasicClustering/BasicClustering.Silo/BasicClustering.Silo.csproj new file mode 100644 index 00000000000..fbbe1dcd86e --- /dev/null +++ b/samples/BasicClustering/BasicClustering.Silo/BasicClustering.Silo.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + true + + + + + + + + + + diff --git a/samples/BasicClustering/BasicClustering.Silo/ClusterMonitor.cs b/samples/BasicClustering/BasicClustering.Silo/ClusterMonitor.cs new file mode 100644 index 00000000000..ad5786e99ba --- /dev/null +++ b/samples/BasicClustering/BasicClustering.Silo/ClusterMonitor.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Orleans.Runtime; + +namespace BasicClustering; + +public sealed partial class ClusterMonitor( + IClusterMembershipService membership, + IGrainFactory grainFactory, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var grainCalled = false; + + await foreach (var snapshot in membership.MembershipUpdates.WithCancellation(stoppingToken)) + { + var activeMembers = snapshot.Members.Values + .Where(static member => member.Status is SiloStatus.Active) + .OrderBy(static member => member.Name) + .ToArray(); + + var memberList = string.Join( + ", ", + activeMembers.Select(static member => $"{member.Name} at {member.SiloAddress}")); + LogClusterView(logger, activeMembers.Length, memberList); + + if (activeMembers.Length < 2 || grainCalled) + { + continue; + } + + var grain = grainFactory.GetGrain(0); + var response = await grain.SayHello("Hello from the cluster"); + LogGrainResponse(logger, response); + grainCalled = true; + } + } + + [LoggerMessage( + EventId = 1, + Level = LogLevel.Information, + Message = "Observed {ActiveSiloCount} active silo(s): {ActiveSilos}")] + private static partial void LogClusterView( + ILogger logger, + int activeSiloCount, + string activeSilos); + + [LoggerMessage( + EventId = 2, + Level = LogLevel.Information, + Message = "The two-silo cluster is ready. {GrainResponse}")] + private static partial void LogGrainResponse(ILogger logger, string grainResponse); +} diff --git a/samples/BasicClustering/BasicClustering.Silo/HelloGrain.cs b/samples/BasicClustering/BasicClustering.Silo/HelloGrain.cs new file mode 100644 index 00000000000..79593b236c0 --- /dev/null +++ b/samples/BasicClustering/BasicClustering.Silo/HelloGrain.cs @@ -0,0 +1,7 @@ +namespace BasicClustering; + +public sealed class HelloGrain : Grain, IHelloGrain +{ + public Task SayHello(string greeting) => + Task.FromResult($"{greeting}. Grain {this.GetPrimaryKeyLong()} is running on {RuntimeIdentity}."); +} diff --git a/samples/BasicClustering/BasicClustering.Silo/IHelloGrain.cs b/samples/BasicClustering/BasicClustering.Silo/IHelloGrain.cs new file mode 100644 index 00000000000..677fc7c1d56 --- /dev/null +++ b/samples/BasicClustering/BasicClustering.Silo/IHelloGrain.cs @@ -0,0 +1,6 @@ +namespace BasicClustering; + +public interface IHelloGrain : IGrainWithIntegerKey +{ + Task SayHello(string greeting); +} diff --git a/samples/BasicClustering/BasicClustering.Silo/Program.cs b/samples/BasicClustering/BasicClustering.Silo/Program.cs new file mode 100644 index 00000000000..1dc062ccb96 --- /dev/null +++ b/samples/BasicClustering/BasicClustering.Silo/Program.cs @@ -0,0 +1,11 @@ +using BasicClustering; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +var builder = Host.CreateApplicationBuilder(args); + +builder.AddKeyedRedisClient("clustering"); +builder.UseOrleans(); +builder.Services.AddHostedService(); + +await builder.Build().RunAsync(); diff --git a/samples/BasicClustering/BasicClustering.slnx b/samples/BasicClustering/BasicClustering.slnx new file mode 100644 index 00000000000..13772f89d0a --- /dev/null +++ b/samples/BasicClustering/BasicClustering.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/samples/BasicClustering/Directory.Packages.props b/samples/BasicClustering/Directory.Packages.props new file mode 100644 index 00000000000..7c24e918659 --- /dev/null +++ b/samples/BasicClustering/Directory.Packages.props @@ -0,0 +1,17 @@ + + + true + true + + + + + + + + + + + + + diff --git a/samples/BasicClustering/README.md b/samples/BasicClustering/README.md new file mode 100644 index 00000000000..1c8f63cee1f --- /dev/null +++ b/samples/BasicClustering/README.md @@ -0,0 +1,47 @@ +# Basic Clustering + +This sample starts two Orleans silos, joins them through Redis cluster membership, +and calls a grain after both silos are active. It focuses only on the step between +running Orleans in one process and deploying a production cluster. + +## What the sample demonstrates + +- `Aspire.Hosting.Orleans` supplies each replica with a unique silo endpoint and + shared cluster identity. +- Redis provides cluster membership and gateway discovery. +- `.WithReplicas(2)` starts two instances of the same silo project. +- Orleans routes both callers to the same grain activation, regardless of which + silo hosts it. + +`UseLocalhostClustering()` and `UseDevelopmentClustering()` are the modern +in-memory equivalents of the former `MembershipTableGrain` approach. They are +development-only and require manually coordinating endpoints when multiple +processes run on one machine. This sample instead uses the same external +membership-provider model as a production cluster while Aspire supplies a local +Redis container and process-specific endpoints. + +## Run the sample + +Install the .NET 10 SDK, the Aspire CLI, and a Docker-compatible container +runtime. From this directory, run: + +```powershell +aspire run --apphost BasicClustering.AppHost/BasicClustering.AppHost.csproj +``` + +In the Aspire dashboard, open the structured logs for either `silo` replica. +After both replicas join, each reports two active silos and a response like: + +```text +The two-silo cluster is ready. Hello from the cluster. Grain 0 is running on S10.0.0.1:11111:... +``` + +Stop either replica in the dashboard to see the remaining silo observe the +membership change. Restart it to return to a two-silo cluster. + +## Production guidance + +The Redis container created by the AppHost is for local development. In +production, configure a secured, highly available managed Redis service or +another supported clustering provider, keep cluster and service IDs stable +within an environment, and run silo replicas across failure domains. diff --git a/samples/README.md b/samples/README.md index 5f7104399d2..a2228079fdb 100644 --- a/samples/README.md +++ b/samples/README.md @@ -37,6 +37,7 @@ The command checks the gallery manifest and builds every project in `Samples.sln | [Adventure](Adventure) | A text adventure game demonstrating grains, external clients, and application modeling. | C# | games, clients, grains | [dotnet/samples](https://github.com/dotnet/samples) | | [AWS Kinesis and DynamoDB](AWS/KinesisDynamoDB) | An AWS-hosted Orleans application using DynamoDB for clustering, persistence, reminders, and Kinesis checkpoints. | C# | aws, kinesis, dynamodb, streaming | [dotnet/orleans](https://github.com/dotnet/orleans) | | [Bank Account](BankAccount) | A bank transfer simulation demonstrating ACID transactions across stateful grains. | C# | transactions, persistence | [dotnet/samples](https://github.com/dotnet/samples) | +| [Basic Clustering](BasicClustering) | A minimal Aspire-hosted Orleans cluster with two silo replicas and Redis membership. | C# | clustering, aspire, redis, getting-started | [dotnet/orleans](https://github.com/dotnet/orleans) | | [Blazor Server](Blazor/BlazorServer) | An interactive Blazor Server application backed by Orleans grains. | C#, Razor | blazor, aspnet-core, web | [dotnet/samples](https://github.com/dotnet/samples) | | [Blazor WebAssembly](Blazor/BlazorWasm) | A hosted Blazor WebAssembly application with an Orleans-backed server. | C#, Razor | blazor, webassembly, web | [dotnet/samples](https://github.com/dotnet/samples) | | [Chat Room](ChatRoom) | A terminal chat application demonstrating Orleans Streams. | C# | streaming, client, terminal | [dotnet/samples](https://github.com/dotnet/samples) | diff --git a/samples/Samples.slnx b/samples/Samples.slnx index 94e04c7f148..47f1f501221 100644 --- a/samples/Samples.slnx +++ b/samples/Samples.slnx @@ -14,6 +14,10 @@ + + + + diff --git a/samples/gallery.json b/samples/gallery.json index bec41ac1fd0..9d8183bbdac 100644 --- a/samples/gallery.json +++ b/samples/gallery.json @@ -32,6 +32,17 @@ "tags": ["transactions", "persistence"], "featured": false }, + { + "slug": "basic-clustering", + "title": "Basic Clustering", + "description": "A minimal Aspire-hosted Orleans cluster with two silo replicas and Redis membership.", + "path": "BasicClustering", + "sourceRepository": "https://github.com/dotnet/orleans", + "image": null, + "languages": ["C#"], + "tags": ["clustering", "aspire", "redis", "getting-started"], + "featured": false + }, { "slug": "blazor-server", "title": "Blazor Server", From 7b0db439eb00f192be0de2cbbfb926248a243431 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Sun, 16 Aug 2026 07:08:43 -0700 Subject: [PATCH 2/2] fix(samples): use conventional Orleans resource name Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- samples/BasicClustering/BasicClustering.AppHost/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/BasicClustering/BasicClustering.AppHost/Program.cs b/samples/BasicClustering/BasicClustering.AppHost/Program.cs index 8ddad5462e3..15cb7386c6b 100644 --- a/samples/BasicClustering/BasicClustering.AppHost/Program.cs +++ b/samples/BasicClustering/BasicClustering.AppHost/Program.cs @@ -1,7 +1,7 @@ var builder = DistributedApplication.CreateBuilder(args); var redis = builder.AddRedis("clustering"); -var orleans = builder.AddOrleans("cluster") +var orleans = builder.AddOrleans("default") .WithClustering(redis); builder.AddProject("silo")