Table of Contents

AtomicAction 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 AtomicAction sample.

Program.cs

using System.Globalization;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Orleans.Hosting;
using Orleans.Lattice;

// ---------------------------------------------------------------------------
// AtomicAction - a generic saga / TCC coordinator that runs an ordered plan
// all-or-nothing, mixing a *Lattice tree write* with a *custom external effect*
// in one transaction.
//
// The point of this sample is that a Lattice-tree mutation can be one step of a
// larger business transaction without giving up the tree's atomicity: the built-in
// .TreeWrite step delegates to the verified atomic-write machinery, and its
// compensation is library-synthesized from captured pre-images. We demonstrate:
//   1. A committing plan: decrement stock in a tree AND reserve credit in an
//      external ledger, together.
//   2. A rolling-back plan: the same shape, but a later step faults - so the tree
//      write is restored to its pre-saga value AND the external reservation is
//      released, leaving no partial effect behind.
// ---------------------------------------------------------------------------

// The "external system" the custom step touches. In a single process here; in
// production it would be a payment gateway or another service.
var ledger = new CreditLedger();

using var host = Host.CreateDefaultBuilder(args)
    .ConfigureLogging(logging =>
    {
        // Silence Orleans so the console shows only the feature narration.
        logging.ClearProviders();
        logging.SetMinimumLevel(LogLevel.None);
    })
    .UseOrleans(silo =>
    {
        silo.UseLocalhostClustering();
        silo.AddMemoryGrainStorageAsDefault();
        silo.UseInMemoryReminderService();
        silo.AddLattice((s, name) => s.AddMemoryGrainStorage(name));

        // Register the custom handlers once at silo start. A saga step never carries
        // a delegate - it names one of these handlers by id, and resolution fails
        // closed for any id that was not registered here.
        silo.AddLatticeAtomicAction(handlers => handlers
            // Reserves credit in the external ledger; compensation releases it.
            // Both effects are idempotent keyed on ctx.OperationId.
            .AddHandler(
                "reserve-credit",
                versionTag: "v1",
                forward: ctx =>
                {
                    var (account, amount) = ParseReservation(ctx.Args.Span);
                    ledger.Reserve(ctx.OperationId, account, amount);
                    return Task.CompletedTask;
                },
                compensate: ctx =>
                {
                    ledger.Release(ctx.OperationId);
                    return Task.CompletedTask;
                })
            // A step that always faults, standing in for an external effect that
            // fails (a carrier rejecting a shipment). Its fault is what drives the
            // saga into reverse-order compensation of the earlier steps.
            .AddHandler(
                "flaky-carrier",
                versionTag: "v1",
                forward: _ => throw new InvalidOperationException("carrier rejected the shipment"),
                compensate: _ => Task.CompletedTask));
    })
    .Build();

await host.StartAsync();
var grainFactory = host.Services.GetRequiredService<IGrainFactory>();
var inventory = grainFactory.GetGrain<ILattice>("inventory");

Console.WriteLine("== AtomicAction sample ==");
Console.WriteLine();

// --- 1. A committing plan: tree write + external reservation, together ------
// Seed the on-hand stock so we can see it move, then run a saga that decrements
// it AND reserves credit. Both land, or neither would.
await inventory.SetAsync("sku-42/onhand", Encoding.UTF8.GetBytes("41"));
Console.WriteLine("1) Seeded inventory 'sku-42/onhand' = 41, ledger reservation = 0.");

var commitPlan = new AtomicActionPlanBuilder()
    // Step 1: an atomic Lattice tree update (auto-compensated from a pre-image).
    .TreeWrite("inventory", w => w.Upsert("sku-42/onhand", Encoding.UTF8.GetBytes("40")))
    // Step 2: a custom action against an external system.
    .Step("reserve-credit", Encoding.UTF8.GetBytes("alice:100"))
    .Build();

var committing = grainFactory.GetGrain<IAtomicActionGrain>("order-1001");
var commitOutcome = await committing.ExecuteAsync(commitPlan);

Console.WriteLine($"   Outcome: {commitOutcome.Status}");
Console.WriteLine($"   inventory 'sku-42/onhand' = {await ReadAsync(inventory, "sku-42/onhand")} (was 41)");
Console.WriteLine($"   ledger reservation for order-1001 = {ledger.Reserved("order-1001")}");
Console.WriteLine("   -> one saga committed a Lattice tree write and a custom external action together.");
Console.WriteLine();

// --- 2. A rolling-back plan: a later step faults -> everything is undone -----
// Same shape, but a third step ('flaky-carrier') faults. The saga compensates in
// strict reverse order: it releases the credit reservation and restores the tree
// key's pre-saga value from the captured pre-image.
await inventory.SetAsync("sku-99/onhand", Encoding.UTF8.GetBytes("5"));
Console.WriteLine("2) Seeded inventory 'sku-99/onhand' = 5, ledger reservation = 0.");

var rollbackPlan = new AtomicActionPlanBuilder()
    .TreeWrite("inventory", w => w.Upsert("sku-99/onhand", Encoding.UTF8.GetBytes("4")))
    .Step("reserve-credit", Encoding.UTF8.GetBytes("bob:50"))
    .Step("flaky-carrier")
    .Build();

var rollingBack = grainFactory.GetGrain<IAtomicActionGrain>("order-2002");
var rollbackOutcome = await rollingBack.ExecuteAsync(rollbackPlan);

Console.WriteLine($"   Outcome: {rollbackOutcome.Status} (faulted at step {rollbackOutcome.FailedStepIndex}: {rollbackOutcome.FailureMessage})");
Console.WriteLine($"   inventory 'sku-99/onhand' = {await ReadAsync(inventory, "sku-99/onhand")}");
Console.WriteLine($"   ledger reservation for order-2002 = {ledger.Reserved("order-2002")}");
Console.WriteLine("   -> the tree write was restored and the reservation released: no partial effect.");
Console.WriteLine();

// --- 3. Idempotent retry: re-issuing a terminal operation id is memoized -----
var replay = await committing.ExecuteAsync(commitPlan);
Console.WriteLine($"3) Re-issuing operation 'order-1001' returns the memoized outcome: {replay.Status}");
Console.WriteLine("   -> a client retry after a timeout observes the original result, not a double-apply.");
Console.WriteLine();

Console.WriteLine("Done.");
await host.StopAsync();

static (string Account, decimal Amount) ParseReservation(ReadOnlySpan<byte> args)
{
    var text = Encoding.UTF8.GetString(args);
    var parts = text.Split(':', 2);
    return (parts[0], decimal.Parse(parts[1], CultureInfo.InvariantCulture));
}

static async Task<string> ReadAsync(ILattice tree, string key)
{
    var bytes = await tree.GetAsync(key);
    return bytes is null ? "(absent)" : Encoding.UTF8.GetString(bytes);
}

AtomicAction.csproj

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <RootNamespace>Orleans.Lattice.Samples.AtomicAction</RootNamespace>
    <AssemblyName>Orleans.Lattice.Samples.AtomicAction</AssemblyName>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <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" />
  </ItemGroup>

</Project>

CreditLedger.cs

using System.Collections.Concurrent;

/// <summary>
/// A stand-in for an <b>external</b> system a custom saga step touches - here an
/// in-process credit ledger. In a real application this would be a payment gateway
/// or another service; the saga does not know or care, it just runs the registered
/// handler's forward and compensating effects. Both effects are keyed on the saga's
/// operation id so they are idempotent across a crash-resume.
/// </summary>
public sealed class CreditLedger
{
    private readonly ConcurrentDictionary<string, decimal> _reservedByOperation = new();

    /// <summary>Reserves <paramref name="amount"/> for <paramref name="operationId"/> (idempotent).</summary>
    public void Reserve(string operationId, string account, decimal amount) =>
        _reservedByOperation[operationId] = amount;

    /// <summary>Releases any reservation held for <paramref name="operationId"/> (idempotent).</summary>
    public void Release(string operationId) =>
        _reservedByOperation.TryRemove(operationId, out _);

    /// <summary>The amount currently reserved for <paramref name="operationId"/>, or 0.</summary>
    public decimal Reserved(string operationId) =>
        _reservedByOperation.TryGetValue(operationId, out var amount) ? amount : 0m;
}