CrossClusterReplication source
This page is part of the documentation for Orleans.Lattice 9.9.0 (release line 9.9), built 2026-10-04. It is also published as markdown, with every table and list, at source.md, and llms.txt lists every page.The source of the CrossClusterReplication sample.
Program.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Orleans.Lattice;
using Orleans.Lattice.Samples.CrossClusterReplication;
// Cross-cluster replication ships batches over grpc-dotnet. For a loopback dev
// sample we talk plaintext HTTP/2 (h2c): each site binds Kestrel to HTTP/2 with
// no certificate, and grpc-dotnet speaks h2c by prior knowledge over an http://
// address. Production uses https:// instead. No process-global switch is
// involved either way.
// Two mirror-image sites. Site A ships to B; B ships to A (active-active).
// Distinct Orleans ports let both clusters live in one process; distinct gRPC
// ports give each its own inbound replication endpoint.
var siteA = new SiteConfig(
ClusterId: "site-a", SiloPort: 11111, GatewayPort: 30000,
GrpcPort: 17001, PeerClusterId: "site-b", PeerGrpcPort: 17002);
var siteB = new SiteConfig(
ClusterId: "site-b", SiloPort: 11112, GatewayPort: 30001,
GrpcPort: 17002, PeerClusterId: "site-a", PeerGrpcPort: 17001);
var appA = SiteFactory.Build(siteA);
var appB = SiteFactory.Build(siteB);
Console.WriteLine("Starting two independent Orleans clusters (site-a, site-b)...");
await appA.StartAsync();
await appB.StartAsync();
Console.WriteLine("Both clusters ready and peered over gRPC.\n");
// Resolve the replicated "orders" tree on each cluster. Same tree name, two
// physically separate clusters, kept convergent only by replication.
var treeA = appA.Services.GetRequiredService<IGrainFactory>().GetGrain<ILattice>(SiteFactory.TreeName);
var treeB = appB.Services.GetRequiredService<IGrainFactory>().GetGrain<ILattice>(SiteFactory.TreeName);
const string key = "order/1001";
Console.WriteLine("== Before ==");
var beforeB = await treeB.GetAsync(key);
Console.WriteLine($" site-b sees '{key}' = {Render(beforeB)}");
// Write ONLY on site A. Nothing is written directly to site B.
Console.WriteLine("\n== Writing on site-a only ==");
await treeA.SetAsync(key, System.Text.Encoding.UTF8.GetBytes("CONFIRMED"));
Console.WriteLine($" site-a wrote '{key}' = CONFIRMED");
// Wait for the value to converge on site B purely via cross-cluster shipping.
Console.WriteLine("\n== Waiting for convergence on site-b (no direct write) ==");
var converged = await WaitForAsync(treeB, key, "CONFIRMED", TimeSpan.FromSeconds(30));
var afterB = await treeB.GetAsync(key);
Console.WriteLine($" site-b now sees '{key}' = {Render(afterB)}");
Console.WriteLine();
Console.WriteLine(converged
? "[OK] the write made on site-a converged onto site-b across clusters."
: "[FAIL] the write did not converge within the timeout.");
await appA.StopAsync();
await appB.StopAsync();
static string Render(byte[]? value) =>
value is null ? "(absent)" : System.Text.Encoding.UTF8.GetString(value);
// Polls the target tree until the key holds the expected value or the budget
// elapses, printing a dot per poll so progress is visible.
static async Task<bool> WaitForAsync(ILattice tree, string key, string expected, TimeSpan budget)
{
var deadline = DateTime.UtcNow + budget;
while (DateTime.UtcNow < deadline)
{
var current = await tree.GetAsync(key);
if (current is not null && System.Text.Encoding.UTF8.GetString(current) == expected)
{
Console.WriteLine(" converged.");
return true;
}
Console.Write(" .");
await Task.Delay(TimeSpan.FromMilliseconds(500));
}
Console.WriteLine();
return false;
}
CrossClusterReplication.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Orleans.Lattice.Samples.CrossClusterReplication</RootNamespace>
<AssemblyName>Orleans.Lattice.Samples.CrossClusterReplication</AssemblyName>
</PropertyGroup>
<!-- WebApplication + Kestrel h2c + MapGrpcService require the ASP.NET Core
shared framework. The gRPC replication transport is served over an
ASP.NET Core pipeline, so this sample (unlike the others) hosts a web
app rather than a bare generic host. -->
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.11" />
<PackageReference Include="Microsoft.Orleans.Server" Version="10.2.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\lattice\Orleans.Lattice.csproj" />
<ProjectReference Include="..\..\src\lattice.replication\Orleans.Lattice.Replication.csproj" />
<ProjectReference Include="..\..\src\lattice.replication.grpc\Orleans.Lattice.Replication.Grpc.csproj" />
</ItemGroup>
</Project>
SiteConfig.cs
namespace Orleans.Lattice.Samples.CrossClusterReplication;
/// <summary>
/// Immutable description of one in-process Orleans cluster ("site") that
/// participates in cross-cluster replication: its cluster id, its Orleans
/// silo/gateway ports, the local Kestrel port that serves its inbound
/// replication gRPC endpoint, and the single peer it ships to.
/// </summary>
internal sealed record SiteConfig(
string ClusterId,
int SiloPort,
int GatewayPort,
int GrpcPort,
string PeerClusterId,
int PeerGrpcPort);
SiteFactory.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Orleans.Lattice.Replication;
using Orleans.Lattice.Replication.Grpc;
namespace Orleans.Lattice.Samples.CrossClusterReplication;
/// <summary>
/// Builds one in-process Orleans cluster wired for active-active cross-cluster
/// replication over the canonical gRPC push transport. Two of these, given
/// mirror-image <see cref="SiteConfig"/>s, form a two-site topology that
/// converges a write made on either side onto the other.
/// </summary>
internal static class SiteFactory
{
/// <summary>The single tree both sites replicate, merged last-writer-wins.</summary>
public const string TreeName = "orders";
public static WebApplication Build(SiteConfig site)
{
// A WebApplication (not a bare generic host) because the replication
// gRPC receiver is served over an ASP.NET Core / Kestrel pipeline.
var builder = WebApplication.CreateBuilder();
// Deterministic, quiet console: silence the framework + Orleans chatter
// so the only output is this sample's own before/after narration.
builder.Logging.ClearProviders();
builder.Logging.SetMinimumLevel(LogLevel.None);
// Serve the inbound replication gRPC endpoint as plaintext HTTP/2
// (h2c) on this site's local port. Loopback-only, no TLS ceremony.
// Clear the ASP.NET default URLs first (http://localhost:5000 +
// https://localhost:5001) so the two sites do not collide on them;
// the only endpoint each site exposes is its explicit gRPC port.
builder.WebHost.UseSetting(WebHostDefaults.ServerUrlsKey, string.Empty);
builder.WebHost.ConfigureKestrel(k =>
k.ListenLocalhost(site.GrpcPort, o => o.Protocols = HttpProtocols.Http2));
builder.Host.UseOrleans(silo =>
{
// Each site is its own Orleans cluster: distinct ClusterId and
// distinct silo/gateway ports so both can run in one process.
silo.UseLocalhostClustering(
siloPort: site.SiloPort,
gatewayPort: site.GatewayPort,
serviceId: "xcluster-sample",
clusterId: site.ClusterId);
silo.AddMemoryGrainStorageAsDefault();
silo.UseInMemoryReminderService();
silo.AddLattice((services, name) => services.AddMemoryGrainStorage(name));
// Opt the "orders" tree into replication and name this site's peer.
// ReplicatedTrees declares the per-tree merge mode; ReplicationPeers
// lists the cluster ids this site ships to.
silo.AddLatticeReplication(opts =>
{
opts.ClusterId = site.ClusterId;
// A replicated tree is single-shape: every value must be
// authored under the one merge mode declared here. This tree is
// LwwRegister, so plain SetAsync/DeleteAsync writes are correct.
// Had it been declared as a CRDT mode (OrSet, PnCounter, ...),
// the origin cluster would reject any write that did not match -
// a plain LWW write, or a different CRDT type - with
// LatticeReplicationModeMismatchException, because the receiver
// could not decode the bytes under the declared shape. See
// docs/lattice.replication/replication-modes.md#single-shape-per-tree.
opts.ReplicatedTrees = new Dictionary<string, LatticeMergeMode>(StringComparer.Ordinal)
{
[TreeName] = LatticeMergeMode.LwwRegister,
};
opts.ReplicationPeers = new[] { site.PeerClusterId };
});
});
// Cross-cluster gRPC binding. One Peers entry wires both the live-push
// transport and the bootstrap snapshot transport to the peer's h2c
// endpoint. AllowPlaintextEndpoints permits the http:// loopback URL;
// LocalClusterId stamps this site's origin on every outbound batch.
builder.Services.AddLatticeReplicationGrpc(opts =>
{
opts.Peers[site.PeerClusterId] = new Uri($"http://localhost:{site.PeerGrpcPort}");
opts.AllowPlaintextEndpoints = true;
opts.LocalClusterId = site.ClusterId;
});
// This is a loopback dev sample with no shared secret, so turn off
// the receiver-side shared-secret authenticator (it is on by default;
// production deployments must supply a secret and leave it on).
builder.Services.Configure<LatticeReplicationSecurityOptions>(o =>
o.RequireAuthentication = false);
var app = builder.Build();
// Map the inbound replication routes (live-push + snapshot) onto this
// site's Kestrel pipeline so the peer can ship batches to it.
app.MapLatticeReplicationGrpc();
return app;
}
}