Table of Contents

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;
    }
}