Table of Contents

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

src/ClusterScaling.LoadDriver/Program.cs

using System.Diagnostics;
using System.Text;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Grpc.Net.Client;
using Microsoft.Extensions.DependencyInjection;
using Orleans.Lattice.Api.Data.Grpc;
using Orleans.Lattice.Samples.ClusterScaling.LoadDriver;
using Orleans.Serialization;

// ---------------------------------------------------------------------------
// ClusterScaling.LoadDriver - the bundled compute-axis load generator for the
// ClusterScaling ACA sample.
//
// PowerShell has no first-class gRPC client, so drive-load.ps1 wraps this small
// console. It connects to the deployed data-API gRPC surface over TLS (the ACA
// managed ingress), presents the admin Basic credential, and drives a sustained,
// high-cardinality write/read mix at a configurable offered rate and duration.
//
// The load is deliberately COMPUTE-axis, not storage-axis: it spreads a high op
// rate across many distinct trees and keys (activation + dispatch pressure) with
// a small fixed payload, so the scaling signal's compute-derived scaleValue
// climbs and ACA/KEDA scales replicas OUT. Bulk retained bytes would move the
// storage axis, which is advisory and never inflates replica count - so this
// driver keeps payloads tiny on purpose.
// ---------------------------------------------------------------------------

var options = LoadDriverOptions.Parse(args);
if (options is null)
{
    LoadDriverOptions.PrintUsage();
    return 2;
}

Console.WriteLine("== ClusterScaling load driver ==");
Console.WriteLine($"  target    : {options.Address}");
Console.WriteLine($"  user      : {options.Username}");
Console.WriteLine($"  rate      : {options.OfferedRatePerSecond:N0} ops/sec (offered)");
Console.WriteLine($"  duration  : {options.Duration.TotalSeconds:N0}s");
Console.WriteLine($"  trees     : {options.TreeCount}   keyspace: {options.KeySpace:N0}   read ratio: {options.ReadRatio:P0}");
Console.WriteLine($"  payload   : {options.PayloadBytes} bytes (small on purpose: this drives COMPUTE, not storage)");
Console.WriteLine();

// Orleans serialization drives the gRPC wire marshallers; the provider must have
// the data-API assembly's generated serializers available (AddSerializer scans
// the loaded assemblies, and referencing the wire records loads this one).
using var serializerProvider = new ServiceCollection().AddSerializer().BuildServiceProvider();

var channelOptions = new GrpcChannelOptions();

using var channel = GrpcChannel.ForAddress(options.Address, channelOptions);

// Present the admin Basic credential on every call. Basic-over-TLS is legitimate
// here because the ACA ingress terminates TLS: the header rides an encrypted
// channel end to end.
var basicHeader = "Basic " + Convert.ToBase64String(
    Encoding.UTF8.GetBytes($"{options.Username}:{options.Password}"));
var invoker = channel.CreateCallInvoker().Intercept(metadata =>
{
    metadata.Add("authorization", basicHeader);
    return metadata;
});
var client = LatticeDataApiGrpcClient.Create(invoker, serializerProvider);

// A small fixed payload reused across every write. Reusing one buffer keeps the
// driver from allocating per op and keeps the storage axis flat.
var payload = new byte[options.PayloadBytes];
Random.Shared.NextBytes(payload);

// --- Fail fast on an unauthenticated / wrong-password deployment -------------
try
{
    await client.SetAsync(
        new DataSetRequest { TreeId = TreeId(0, options), Key = "warmup", Value = payload });
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.PermissionDenied)
{
    Console.Error.WriteLine(
        "FATAL: the data API rejected the admin credential (PermissionDenied). " +
        "Check the -AdminPassword matches the one deploy.ps1 hashed into the ACA secret.");
    return 3;
}
catch (RpcException ex)
{
    Console.Error.WriteLine($"FATAL: could not reach the data API ({ex.StatusCode}): {ex.Status.Detail}");
    return 3;
}

Console.WriteLine("Warmup call authenticated. Driving load...\n");

// --- Metrics -----------------------------------------------------------------
long offered = 0;
long completed = 0;
long failed = 0;
var inFlight = new SemaphoreSlim(options.MaxInFlight);

using var driveCts = new CancellationTokenSource(options.Duration);
var driveToken = driveCts.Token;

// Progress reporter: prints an offered-load line every second so the operator
// sees continuous offered throughput alongside the replica-count timeline that
// drive-load.ps1 prints from `az`.
var reporter = Task.Run(async () =>
{
    var sw = Stopwatch.StartNew();
    var lastOffered = 0L;
    var lastElapsed = 0.0;
    try
    {
        while (!driveToken.IsCancellationRequested)
        {
            await Task.Delay(TimeSpan.FromSeconds(1), driveToken).ConfigureAwait(false);
            var elapsed = sw.Elapsed.TotalSeconds;
            var offeredNow = Interlocked.Read(ref offered);
            var windowRate = (offeredNow - lastOffered) / Math.Max(0.001, elapsed - lastElapsed);
            Console.WriteLine(
                $"  t={elapsed,6:0.0}s  offered={offeredNow,10:N0}  offered/s={windowRate,9:N0}  " +
                $"completed={Interlocked.Read(ref completed),10:N0}  failed={Interlocked.Read(ref failed),7:N0}  " +
                $"inFlight={options.MaxInFlight - inFlight.CurrentCount,4}");
            lastOffered = offeredNow;
            lastElapsed = elapsed;
        }
    }
    catch (OperationCanceledException)
    {
        // duration elapsed
    }
});

// --- Pacing loop -------------------------------------------------------------
// Issue ops at the offered rate using a wall-clock schedule. Each op is started
// under a bounded in-flight semaphore so a lagging cluster (mid scale-out) does
// not let unbounded work pile up, while the offered counter still advances at
// the target cadence so the reported offered rate reflects intent, not the
// cluster's current capacity.
var interval = TimeSpan.FromSeconds(1.0 / options.OfferedRatePerSecond);
var startedAt = Stopwatch.StartNew();
long seq = 0;
var tasks = new List<Task>();

try
{
    while (!driveToken.IsCancellationRequested)
    {
        var due = interval * seq;
        var now = startedAt.Elapsed;
        if (due > now)
        {
            var wait = due - now;
            if (wait > TimeSpan.FromMilliseconds(1))
            {
                try
                {
                    await Task.Delay(wait, driveToken).ConfigureAwait(false);
                }
                catch (OperationCanceledException)
                {
                    break;
                }
            }
        }

        var opIndex = seq;
        seq++;
        Interlocked.Increment(ref offered);

        await inFlight.WaitAsync(driveToken).ConfigureAwait(false);
        tasks.Add(IssueAsync(opIndex));

        // Periodically prune completed tasks so the list does not grow for the
        // whole run.
        if (tasks.Count >= 4096)
        {
            tasks.RemoveAll(t => t.IsCompleted);
        }
    }
}
catch (OperationCanceledException)
{
    // duration elapsed
}

await Task.WhenAll(tasks).ConfigureAwait(false);
await reporter.ConfigureAwait(false);

var totalElapsed = startedAt.Elapsed.TotalSeconds;
var offeredFinal = Interlocked.Read(ref offered);
var completedFinal = Interlocked.Read(ref completed);
var failedFinal = Interlocked.Read(ref failed);

Console.WriteLine();
Console.WriteLine("== FINAL ==");
Console.WriteLine($"  elapsed        : {totalElapsed:0.0}s");
Console.WriteLine($"  offered        : {offeredFinal:N0} ops ({offeredFinal / Math.Max(0.001, totalElapsed):N0}/s avg)");
Console.WriteLine($"  completed      : {completedFinal:N0} ops ({completedFinal / Math.Max(0.001, totalElapsed):N0}/s avg)");
Console.WriteLine($"  failed         : {failedFinal:N0} ops");
Console.WriteLine();
Console.WriteLine("Offered load sustained past the KEDA polling interval should have made ACA add");
Console.WriteLine("replicas during the run (the KEDA cooldown and EWMA smoothing only slow scale-in).");
Console.WriteLine("drive-load.ps1 prints the `az` replica-count timeline: scale-out during the run, then two poll intervals of scale-in.");

return 0;

// --- op body ----------------------------------------------------------------
async Task IssueAsync(long opIndex)
{
    try
    {
        var treeId = TreeId(opIndex, options);
        // Spread across the keyspace so many distinct leaf grains activate.
        var key = "k-" + (opIndex % options.KeySpace).ToString("D9");
        var isRead = options.ReadRatio > 0 &&
            (opIndex % 100) < (long)Math.Round(options.ReadRatio * 100);

        if (isRead)
        {
            _ = await client.GetAsync(new DataGetRequest { TreeId = treeId, Key = key }).ConfigureAwait(false);
        }
        else
        {
            _ = await client.SetAsync(
                new DataSetRequest { TreeId = treeId, Key = key, Value = payload }).ConfigureAwait(false);
        }

        Interlocked.Increment(ref completed);
    }
    catch (RpcException)
    {
        Interlocked.Increment(ref failed);
    }
    catch (OperationCanceledException)
    {
        Interlocked.Increment(ref failed);
    }
    finally
    {
        inFlight.Release();
    }
}

static string TreeId(long opIndex, LoadDriverOptions options) =>
    "tree-" + (opIndex % options.TreeCount).ToString("D3");

src/ClusterScaling.Silo/Program.cs

using Azure.Data.Tables;
using Azure.Identity;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
using Orleans.Configuration;
using Orleans.Lattice;
using Orleans.Lattice.Api.Data;
using Orleans.Lattice.Api.Data.Grpc;
using Orleans.Lattice.Samples.ClusterScaling.Silo;
using Orleans.Lattice.Scaling;
using Orleans.Lattice.Storage.AzureTable;

// ---------------------------------------------------------------------------
// ClusterScaling - a deployable Azure Container Apps (ACA) silo host that proves
// the Orleans.Lattice.Scaling autoscaling signal drives KEDA replica scale-out on
// the COMPUTE axis.
//
// One container image, run as many ACA replicas (a genuine multi-silo Orleans
// cluster). Each replica:
//   1. Joins the cluster over REAL Azure Storage clustering (managed identity).
//   2. Persists grain state + the Lattice WAL to Azure Table storage (managed
//      identity) so scale-out means something - no in-memory / localhost storage.
//   3. Co-hosts the write-capable data-API gRPC surface (guarded by a Basic
//      admin credential whose salted PBKDF2 hash arrives as an ACA secret) and
//      the /lattice/scale HTTP signal endpoint the ACA KEDA metrics-api scale
//      rule scrapes.
//
// Nothing here redefines the scaling endpoint or the scale-rule contract (that
// is Orleans.Lattice.Scaling / issue #1188); this host only wires a concrete
// deployment around them.
// ---------------------------------------------------------------------------

var builder = WebApplication.CreateBuilder(args);

// --- Configuration (all injected by deploy.ps1 / the ACA container app) ------
var tableUri = RequireEnv("CLUSTERSCALING_TABLE_URI");            // https://<acct>.table.core.windows.net
var clusterId = EnvOrDefault("CLUSTERSCALING_CLUSTER_ID", "clusterscaling");
var serviceId = EnvOrDefault("CLUSTERSCALING_SERVICE_ID", "clusterscaling");
var clusteringTable = EnvOrDefault("CLUSTERSCALING_CLUSTERING_TABLE", "OrleansClustering");
var grainTable = EnvOrDefault("CLUSTERSCALING_GRAIN_TABLE", "OrleansGrainState");
var reminderTable = EnvOrDefault("CLUSTERSCALING_REMINDER_TABLE", "OrleansReminders");
var walTable = EnvOrDefault("CLUSTERSCALING_WAL_TABLE", AzureTableWalStorageOptions.DefaultTableName);
var httpPort = EnvIntOrDefault("CLUSTERSCALING_HTTP_PORT", 8080);  // ACA ingress target port
var siloPort = EnvIntOrDefault("CLUSTERSCALING_SILO_PORT", 11111); // Orleans silo-to-silo
var gatewayPort = EnvIntOrDefault("CLUSTERSCALING_GATEWAY_PORT", 30000);

// A single TableServiceClient authenticated with the container's managed
// identity (DefaultAzureCredential resolves the ACA user-assigned identity).
// Every Azure Storage dependency - clustering, grain state, reminders, WAL -
// shares it, so no key or connection string is ever read.
var credential = new DefaultAzureCredential();
var tableServiceClient = new TableServiceClient(new Uri(tableUri), credential);

builder.Logging.AddSimpleConsole(o => o.SingleLine = true);

// ACA terminates external TLS at its managed ingress and forwards to the
// container as CLEARTEXT HTTP/2 - the ingress 'transport: http2' the bicep sets
// talks h2c (HTTP/2 prior knowledge) to the backend. On a plaintext port Kestrel
// cannot ALPN-negotiate the protocol (ALPN needs TLS), so Http1AndHttp2 would
// silently downgrade to HTTP/1.1 only and the ingress's h2c connection preface
// would be rejected (the caller sees 'upstream connect error / refused stream
// reset', and /lattice/scale returns 503). Listen with Http2 (prior-knowledge
// h2c) so the same port serves both the gRPC data API and the plain GET
// /lattice/scale scrape - both arrive from the ingress as HTTP/2. No server
// certificate is needed: the TLS boundary is the ingress, not the container.
builder.WebHost.ConfigureKestrel(options =>
{
    options.ListenAnyIP(httpPort, listen => listen.Protocols = HttpProtocols.Http2);
});

builder.Host.UseOrleans(silo =>
{
    silo.Configure<ClusterOptions>(o =>
    {
        o.ClusterId = clusterId;
        o.ServiceId = serviceId;
    });

    // Real multi-silo clustering over an Azure Storage table. Each replica
    // advertises its endpoints here and discovers its peers, so ACA scaling the
    // replica count out (or in) grows / shrinks a genuine Orleans cluster.
    silo.UseAzureStorageClustering(o =>
    {
        o.TableName = clusteringTable;
        o.TableServiceClient = tableServiceClient;
    });

    // Advertise the container's primary NIC address on fixed ports so peers in
    // the ACA environment's internal network can reach this silo. ACA gives
    // each replica a routable internal IP; ConfigureEndpoints picks it up.
    silo.ConfigureEndpoints(siloPort, gatewayPort);

    // Durable Azure Table grain state (managed identity). LatticeGrain and the
    // B+ tree grains persist here; a replica restart or a fresh replica reads
    // the committed state back.
    silo.AddAzureTableGrainStorageAsDefault(o =>
    {
        o.TableName = grainTable;
        o.TableServiceClient = tableServiceClient;
    });

    // Reminders: the compaction reminder LatticeGrain registers on first write
    // needs a durable, cluster-shared reminder table on a multi-silo cluster.
    silo.UseAzureTableReminderService(o =>
    {
        o.TableName = reminderTable;
        o.TableServiceClient = tableServiceClient;
    });

    // The core lattice, its grain-state storage factory, and the durable Azure
    // Table WAL - all on managed identity.
    silo.AddLattice((services, name) => services.AddAzureTableGrainStorage(name, o =>
    {
        o.TableName = grainTable;
        o.TableServiceClient = tableServiceClient;
    }));
    silo.AddAzureTableWalStorage(o =>
    {
        o.TableName = walTable;
        o.ServiceUri = new Uri(tableUri);
        o.TokenCredential = credential;
    });

    // The write-capable data-plane facade (ILatticeDataApi) the gRPC surface
    // binds. Must be added after AddLattice.
    silo.AddLatticeDataApi();

    // The opt-in autoscaling signal: samples cluster-aggregate compute pressure
    // on a timer and caches the ScalingSignal the /lattice/scale endpoint serves.
    silo.AddLatticeScalingSignal();
});

// The Basic-hash authorizer replaces the data-API binding's default-deny
// authorizer; register it BEFORE AddLatticeDataApiGrpc so the binding's TryAdd
// preserves ours. The surface stays fail-closed for anonymous / wrong-password
// callers.
builder.Services.AddSingleton<ILatticeDataApiAuthorizer, BasicAdminDataApiAuthorizer>();
builder.Services.AddLatticeDataApiGrpc(o => o.RequireAuthorization = true);

// A readiness probe backed by the scaling signal (Degraded / Unhealthy derived
// from the signal's own thresholds). ACA can point its health probes at it.
builder.Services.AddHealthChecks().AddLatticeScalingHealthCheck(tags: new[] { "ready" });

var app = builder.Build();

// The KEDA metrics-api scale rule scrapes this route (default /lattice/scale)
// and reads the top-level scaleValue. Unauthenticated by design - it is a
// scrape target that discloses only aggregate pressure, never data.
app.MapLatticeScalingSignal();

// The write-capable data-API gRPC surface (Basic-credential gated).
app.MapLatticeDataApiGrpc();

// Liveness / readiness endpoints for the ACA health probes.
app.MapHealthChecks("/healthz");
app.MapHealthChecks("/readyz", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
    Predicate = registration => registration.Tags.Contains("ready"),
});

app.Logger.LogInformation(
    "ClusterScaling silo starting: clusterId={ClusterId} httpPort={HttpPort} siloPort={SiloPort} gatewayPort={GatewayPort} tableUri={TableUri}",
    clusterId,
    httpPort,
    siloPort,
    gatewayPort,
    tableUri);

await app.RunAsync();

// --- helpers ---------------------------------------------------------------

static string RequireEnv(string name)
{
    var value = Environment.GetEnvironmentVariable(name);
    if (string.IsNullOrWhiteSpace(value))
    {
        throw new InvalidOperationException(
            $"Required environment variable '{name}' is not set. deploy.ps1 injects it into the container app.");
    }

    return value;
}

static string EnvOrDefault(string name, string fallback)
{
    var value = Environment.GetEnvironmentVariable(name);
    return string.IsNullOrWhiteSpace(value) ? fallback : value;
}

static int EnvIntOrDefault(string name, int fallback)
{
    var value = Environment.GetEnvironmentVariable(name);
    return int.TryParse(value, out var parsed) && parsed > 0 ? parsed : fallback;
}

deploy/deploy.ps1

#Requires -Version 7.0
<#
.SYNOPSIS
    Deploys the ClusterScaling Orleans.Lattice.Scaling sample to Azure Container Apps.

.DESCRIPTION
    Provisions (idempotently) the whole sample stack: a Basic Azure Container
    Registry (unless you point it at an existing one), builds and pushes the silo
    image into that registry via `az acr build` (server-side, no local Docker),
    then a user-assigned managed identity,
    a Tables-only storage account (Orleans clustering + reminders + grain state +
    Lattice WAL, shared-key access disabled), the Storage Table Data Contributor
    role for the identity, an AcrPull grant so the identity can pull the image, a
    Log Analytics workspace, a Container Apps managed environment, and the
    ClusterScaling silo container app with a KEDA metrics-api scale rule that
    reads scaleValue from /lattice/scale.

    The operator supplies a plaintext admin password (as a SecureString). This
    script hashes it with the repository's tools/New-LatticeStateCredential.ps1
    helper (salted PBKDF2-SHA256) and passes only the HASH to the bicep template,
    which injects it as a container-app secret surfaced through the
    LATTICE_DATA_USER_<admin> env var. The plaintext is never stored, never
    baked into the image, and never passed on a command line.

.PARAMETER ResourceGroup
    Target resource group. Created if it does not exist.

.PARAMETER Location
    Azure region. Defaults to eastus.

.PARAMETER Registry
    OPTIONAL. Name of an existing Azure Container Registry (not its login server)
    to build and push the silo image into, e.g. myregistry. When omitted, the
    script provisions a Basic ACR as part of the deployment and uses that. Either
    way the container app is wired to pull from the registry via the managed
    identity (AcrPull). Mutually exclusive with -ContainerImage.

.PARAMETER ContainerImage
    Escape hatch: a fully-qualified, already-built silo image (e.g.
    myregistry.azurecr.io/clusterscaling-silo:latest). Supply this INSTEAD of
    -Registry to skip both the registry provisioning and the build and deploy a
    pre-built image. No AcrPull is wired; you own the image's pull access.

.PARAMETER ImageName
    Repository name for the built image. Defaults to clusterscaling-silo. Unused
    with -ContainerImage.

.PARAMETER ImageTag
    Tag for the built image. Defaults to a unique per-deploy tag (git short sha
    plus a UTC timestamp) so every redeploy rolls a fresh Container App revision;
    a fixed tag such as 'latest' would not. Unused with -ContainerImage.

.PARAMETER AdminPassword
    Plaintext admin password as a SecureString (prompted if omitted). Presented
    later by drive-load.ps1 as an HTTP Basic credential over the managed TLS
    ingress.

.PARAMETER AdminUsername
    Admin username. Must be environment-variable-name-safe. Defaults to admin.

.PARAMETER NamePrefix
    Base name for derived resources (2-16 lowercase alphanumerics). Defaults to latscale.

.PARAMETER MinReplicas
    Scale-in floor (keep >= 1). Defaults to 1.

.PARAMETER MaxReplicas
    Scale-out ceiling. Defaults to 10.

.NOTES
    Requires the Azure CLI (az) with the containerapp extension and an active
    `az login` on a subscription where you can create role assignments.
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [string] $ResourceGroup,

    [string] $Location = 'eastus',

    [string] $Registry,

    [string] $ContainerImage,

    [string] $ImageName = 'clusterscaling-silo',

    [string] $ImageTag = '',

    [Parameter(Mandatory = $true)]
    [System.Security.SecureString] $AdminPassword,

    [string] $AdminUsername = 'admin',

    [ValidatePattern('^[a-z0-9]{2,16}$')]
    [string] $NamePrefix = 'latscale',

    [ValidateRange(1, 30)]
    [int] $MinReplicas = 1,

    [ValidateRange(1, 30)]
    [int] $MaxReplicas = 10
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Write-Step([string] $message) {
    Write-Host "==> $message" -ForegroundColor Cyan
}

$scriptRoot = $PSScriptRoot
$templateFile = Join-Path $scriptRoot 'main.bicep'
$toolScript = Join-Path $scriptRoot '..\..\..\tools\New-LatticeStateCredential.ps1'

if (-not (Test-Path $templateFile)) { throw "Template not found: $templateFile" }
if (-not (Test-Path $toolScript)) { throw "Credential helper not found: $toolScript" }

# --- Preflight: az CLI present and logged in ---------------------------------
if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
    throw 'Azure CLI (az) is not on PATH. Install it and run `az login`.'
}

Write-Step 'Verifying Azure CLI login'
az account show --output none 2>$null
if ($LASTEXITCODE -ne 0) {
    throw 'Not logged in. Run `az login` (and `az account set --subscription <id>`) first.'
}

# Ensure the containerapp extension is present (idempotent).
az extension add --name containerapp --upgrade --only-show-errors --output none 2>$null

# --- Hash the admin password (never handle plaintext beyond this block) -------
# Done up front so a weak password (or a hashing failure) fails the deploy before
# any Azure resources are provisioned or the silo image is built and pushed.
Write-Step 'Hashing admin password (salted PBKDF2-SHA256)'
$plaintextPtr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($AdminPassword)
try {
    $plaintext = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($plaintextPtr)
    $env:LATTICE_DEPLOY_PW = $plaintext
    # Invoke the helper as a child process: it writes the secret straight to the
    # process stdout ([Console]::Out) rather than the PowerShell success stream, so
    # an in-process '& $toolScript' call would not capture it. A child process's
    # stdout IS captured, and its exit code (0 = ok, 3 = policy rejected) lands in
    # $LASTEXITCODE. The password is passed via the inherited LATTICE_DEPLOY_PW env
    # var, never on the command line.
    $psExe = (Get-Process -Id $PID).Path
    $passwordHash = (& $psExe -NoProfile -File $toolScript -Username $AdminUsername -PasswordEnv 'LATTICE_DEPLOY_PW' -Format value | Out-String).Trim()
    if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($passwordHash)) {
        throw 'Password hashing failed (see the helper diagnostics above).'
    }
}
finally {
    Remove-Item Env:LATTICE_DEPLOY_PW -ErrorAction SilentlyContinue
    [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($plaintextPtr)
    $plaintext = $null
}

# --- Resource group (created first so the registry + app land in it) ---------
Write-Step "Ensuring resource group '$ResourceGroup' in $Location"
az group create --name $ResourceGroup --location $Location --output none
if ($LASTEXITCODE -ne 0) { throw "Resource group creation failed (az exited $LASTEXITCODE)." }

# --- Resolve the silo image and registry -------------------------------------
# Three modes:
#   * -ContainerImage <ref> : deploy a pre-built external image as-is. No ACR is
#     provisioned and no managed pull is wired; you own its pull access.
#   * -Registry <name>      : build+push into an existing ACR you already have,
#     and wire the app to pull from it via the managed identity.
#   * (neither)             : provision a Basic ACR in this resource group, build
#     into it, and wire the app to pull from it via the managed identity.
$repoRoot = (Resolve-Path (Join-Path $scriptRoot '..\..\..')).Path
$dockerfile = Join-Path $repoRoot 'samples/ClusterScaling/src/ClusterScaling.Silo/Dockerfile'
$registryName = ''   # non-empty => main.bicep wires managed-identity pull (AcrPull)

if ($ContainerImage) {
    if ($Registry) {
        throw 'Specify at most one of -ContainerImage (pre-built external image) or -Registry (existing ACR to build into).'
    }
    Write-Step "Using pre-built image '$ContainerImage' (skipping build; you own its pull access)"
}
else {
    if (-not (Test-Path $dockerfile)) { throw "Dockerfile not found: $dockerfile" }

    if ($Registry) {
        $registryName = $Registry
        Write-Step "Using existing container registry '$registryName'"
    }
    else {
        # Provision a Basic ACR as part of the sample's own infrastructure.
        $registryTemplate = Join-Path $scriptRoot 'registry.bicep'
        if (-not (Test-Path $registryTemplate)) { throw "Template not found: $registryTemplate" }
        $registryDeployment = "clusterscaling-acr-$([DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))"
        Write-Step 'Provisioning a Basic Azure Container Registry'
        az deployment group create `
            --resource-group $ResourceGroup `
            --name $registryDeployment `
            --template-file $registryTemplate `
            --parameters namePrefix=$NamePrefix `
            --output none
        if ($LASTEXITCODE -ne 0) { throw "Registry provisioning failed (az exited $LASTEXITCODE)." }
        $registryName = az deployment group show `
            --resource-group $ResourceGroup `
            --name $registryDeployment `
            --query properties.outputs.registryName.value `
            --output tsv
        if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($registryName)) {
            throw 'Could not read the provisioned registry name from the deployment outputs.'
        }
        Write-Host "    registry: $registryName" -ForegroundColor DarkGray
    }

    # A stable ':latest' tag does NOT roll the Container App to a new revision on
    # redeploy: ACA only creates a revision when the template changes, and the
    # image reference string is byte-identical, so the app keeps running the old
    # cached image. Default to a unique tag per deploy (git short sha plus a UTC
    # stamp) so every redeploy produces a fresh, traceable revision.
    if ([string]::IsNullOrWhiteSpace($ImageTag)) {
        $sha = (& git -C $repoRoot rev-parse --short HEAD 2>$null)
        $stamp = [DateTime]::UtcNow.ToString('yyyyMMddHHmmss')
        $ImageTag = if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($sha)) {
            "$($sha.Trim())-$stamp"
        } else {
            $stamp
        }
        Write-Host "    image tag (auto): $ImageTag" -ForegroundColor DarkGray
    }

    $imageRef = "${ImageName}:${ImageTag}"

    # az acr build packs the source dir client-side and, unlike docker build,
    # does not reliably honour .dockerignore: it walks into .vs/bin/obj and dies
    # on Visual-Studio-locked files ([Errno 13] Permission denied). Stage only
    # the inputs the image needs into a clean temp dir so the pack is small,
    # deterministic, and free of locked or oversized files.
    $stageRoot = Join-Path ([IO.Path]::GetTempPath()) "clusterscaling-ctx-$([Guid]::NewGuid().ToString('N'))"
    try {
        Write-Step "Staging a clean build context in $stageRoot"
        New-Item -ItemType Directory -Path $stageRoot -Force | Out-Null

        function Copy-BuildTree([string] $relative) {
            $src = Join-Path $repoRoot $relative
            if (-not (Test-Path $src)) { throw "Required build input missing: $src" }
            $dst = Join-Path $stageRoot $relative
            New-Item -ItemType Directory -Path $dst -Force | Out-Null
            # Mirror the tree minus build/IDE dirs. robocopy exit codes < 8 are success.
            robocopy $src $dst /E /XD bin obj .vs /NFL /NDL /NJH /NJS /NP /R:1 /W:1 | Out-Null
            if ($LASTEXITCODE -ge 8) { throw "robocopy failed staging '$relative' (exit $LASTEXITCODE)." }
        }

        Copy-Item (Join-Path $repoRoot 'Directory.Build.targets') (Join-Path $stageRoot 'Directory.Build.targets')
        Copy-BuildTree 'src'
        Copy-BuildTree 'samples/ClusterScaling/src'

        Write-Step "Building and pushing silo image '$imageRef' into registry '$registryName'"
        Write-Host '    (az acr build runs server-side in the registry and streams its logs below)' -ForegroundColor DarkGray
        # Run from the context root so az acr build resolves the relative --file
        # (and the in-tar Dockerfile path) against the staged context, not CWD.
        Push-Location $stageRoot
        try {
            az acr build `
                --registry $registryName `
                --image $imageRef `
                --file 'samples/ClusterScaling/src/ClusterScaling.Silo/Dockerfile' `
                .
            if ($LASTEXITCODE -ne 0) {
                throw "Container image build/push failed (az acr build exited $LASTEXITCODE)."
            }
        }
        finally {
            Pop-Location
        }
    }
    finally {
        Remove-Item -Path $stageRoot -Recurse -Force -ErrorAction SilentlyContinue
    }

    Write-Step "Resolving login server for registry '$registryName'"
    $loginServer = az acr show --name $registryName --query loginServer --output tsv
    if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($loginServer)) {
        throw "Could not resolve the login server for registry '$registryName'."
    }
    $ContainerImage = "$loginServer/$imageRef"
    Write-Host "    pushed: $ContainerImage" -ForegroundColor DarkGray
}

# --- Deploy the template -----------------------------------------------------
$deploymentName = "clusterscaling-$([DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))"
Write-Step "Deploying template (deployment: $deploymentName)"
$deployParams = @(
    "namePrefix=$NamePrefix"
    "containerImage=$ContainerImage"
    "adminUsername=$AdminUsername"
    "adminPasswordHash=$passwordHash"
    "minReplicas=$MinReplicas"
    "maxReplicas=$MaxReplicas"
)
if ($registryName) { $deployParams += "registryName=$registryName" }

az deployment group create `
    --resource-group $ResourceGroup `
    --name $deploymentName `
    --template-file $templateFile `
    --parameters $deployParams `
    --output none

# --- Read outputs ------------------------------------------------------------
Write-Step 'Reading deployment outputs'
$outputsJson = az deployment group show `
    --resource-group $ResourceGroup `
    --name $deploymentName `
    --query properties.outputs `
    --output json
$outputs = $outputsJson | ConvertFrom-Json

$fqdn = $outputs.ingressFqdn.value
$dataApiAddress = $outputs.dataApiAddress.value
$appName = $outputs.containerAppName.value

Write-Host ''
Write-Host 'ClusterScaling deployed.' -ForegroundColor Green
Write-Host "  resource group : $ResourceGroup"
Write-Host "  container app  : $appName"
Write-Host "  ingress FQDN   : $fqdn"
Write-Host "  data API       : $dataApiAddress"
Write-Host "  scale rule     : metrics-api reads scaleValue from https://$fqdn/lattice/scale (target 0.5, replicas $MinReplicas..$MaxReplicas)"
Write-Host ''
Write-Host 'Drive compute-axis load and watch ACA scale out with:' -ForegroundColor Yellow
Write-Host "  ./drive-load.ps1 -ResourceGroup $ResourceGroup -AppName $appName"
Write-Host ''
Write-Host 'Tear everything down when done with:' -ForegroundColor Yellow
Write-Host "  ./teardown.ps1 -ResourceGroup $ResourceGroup"

deploy/drive-load.ps1

#Requires -Version 7.0
<#
.SYNOPSIS
    Drives compute-axis load against a deployed ClusterScaling app and reports the
    ACA replica-count timeline.

.DESCRIPTION
    Resolves the deployed app's ingress FQDN, launches the bundled .NET LoadDriver
    console (which speaks gRPC to the write-capable data API over the managed TLS
    ingress, presenting the admin Basic credential), and - while the driver runs -
    polls `az containerapp replica list` once per interval to print a replica-count
    timeline alongside the driver's continuous offered-load throughput.

    The load is COMPUTE-axis by construction: a high op rate spread across many
    distinct trees and keys with a tiny payload. That grows activation + dispatch
    pressure, which is what the scaling signal's scaleValue tracks; it does NOT
    grow retained bytes, which is the storage axis and never inflates replica
    count. Expect scale-out to LAG the load by at least the KEDA polling interval
    and container-app scheduling time. EWMA smoothing applies on falling demand,
    so it affects scale-in rather than scale-out. Sustain the load (default 5 minutes) so the window
    is comfortably crossed, then watch the count settle back to minReplicas after
    the driver stops.

.PARAMETER ResourceGroup
    The resource group deploy.ps1 provisioned into.

.PARAMETER AppName
    The container app name. Resolved from the resource group if omitted (works
    when exactly one ClusterScaling app is present).

.PARAMETER AdminPassword
    Plaintext admin password as a SecureString (prompted if omitted). Must match
    the password deploy.ps1 hashed into the ACA secret.

.PARAMETER AdminUsername
    Admin username. Defaults to admin.

.PARAMETER Rate
    Offered operations per second. Defaults to 2000.

.PARAMETER Duration
    Seconds to sustain the load. Defaults to 300.

.PARAMETER Trees
    Distinct trees to spread load across. Defaults to 64.

.PARAMETER KeySpace
    Distinct keys per tree cycle. Defaults to 100000.

.PARAMETER ReadRatio
    Fraction of ops issued as reads (0..1). Defaults to 0.2.

.PARAMETER PollIntervalSeconds
    Replica-count polling cadence. Defaults to 10.

.NOTES
    Requires the Azure CLI (az) with the containerapp extension, an active
    `az login`, and the .NET SDK (to run the bundled LoadDriver via dotnet run).
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [string] $ResourceGroup,

    [string] $AppName,

    [Parameter(Mandatory = $true)]
    [System.Security.SecureString] $AdminPassword,

    [string] $AdminUsername = 'admin',

    [double] $Rate = 2000,

    [double] $Duration = 300,

    [int] $Trees = 64,

    [long] $KeySpace = 100000,

    [double] $ReadRatio = 0.2,

    [int] $PollIntervalSeconds = 10
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Write-Step([string] $message) {
    Write-Host "==> $message" -ForegroundColor Cyan
}

$scriptRoot = $PSScriptRoot
$loadDriverProject = Join-Path $scriptRoot '..\src\ClusterScaling.LoadDriver\ClusterScaling.LoadDriver.csproj'
if (-not (Test-Path $loadDriverProject)) { throw "LoadDriver project not found: $loadDriverProject" }

if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
    throw 'Azure CLI (az) is not on PATH. Install it and run `az login`.'
}
if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
    throw 'The .NET SDK (dotnet) is not on PATH. Install it to run the bundled LoadDriver.'
}

# --- Resolve the app + ingress FQDN ------------------------------------------
if ([string]::IsNullOrWhiteSpace($AppName)) {
    Write-Step 'Resolving container app name'
    $apps = @(az containerapp list --resource-group $ResourceGroup --query "[].name" --output json | ConvertFrom-Json)
    if ($apps.Count -eq 0) { throw "No container apps found in resource group '$ResourceGroup'." }
    if ($apps.Count -gt 1) { throw "Multiple container apps found; pass -AppName explicitly. Found: $($apps -join ', ')" }
    $AppName = $apps[0]
}

Write-Step "Resolving ingress FQDN for '$AppName'"
$fqdn = az containerapp show --resource-group $ResourceGroup --name $AppName `
    --query properties.configuration.ingress.fqdn --output tsv
if ([string]::IsNullOrWhiteSpace($fqdn)) { throw "Could not resolve ingress FQDN for app '$AppName'." }
$target = "https://$fqdn"

Write-Host "  app    : $AppName"
Write-Host "  target : $target"

# --- Pre-flight: confirm the app is actually serving before offering load -----
# Hits the unauthenticated /healthz endpoint (mapped by the silo, served over the
# same ingress). If the container is crash-looping or has no ready replica, the
# ingress returns 5xx / refuses the connection here - so we fail fast with the
# exact diagnostic commands instead of the load driver's cryptic gRPC stream
# reset thirty seconds into the run.
Write-Step "Checking data-API readiness at $target/healthz"
$healthUrl = "$target/healthz"
$ready = $false
for ($attempt = 1; $attempt -le 10; $attempt++) {
    try {
        $resp = Invoke-WebRequest -Uri $healthUrl -Method Get -TimeoutSec 10 -SkipHttpErrorCheck
        if ($resp.StatusCode -eq 200) { $ready = $true; break }
        Write-Host ("  attempt {0,2}: HTTP {1} (not ready yet)" -f $attempt, $resp.StatusCode) -ForegroundColor DarkYellow
    }
    catch {
        Write-Host ("  attempt {0,2}: {1}" -f $attempt, $_.Exception.Message) -ForegroundColor DarkYellow
    }
    Start-Sleep -Seconds 6
}
if (-not $ready) {
    Write-Host ''
    Write-Host 'The container app is not serving: the readiness probe never returned 200.' -ForegroundColor Red
    Write-Host 'The silo container is likely crash-looping or has no ready replica. Diagnose with:' -ForegroundColor Red
    Write-Host "  az containerapp revision list -g $ResourceGroup -n $AppName -o table"
    Write-Host "  az containerapp replica list  -g $ResourceGroup -n $AppName -o table"
    Write-Host "  az containerapp logs show     -g $ResourceGroup -n $AppName --tail 200"
    Write-Host "  az containerapp logs show     -g $ResourceGroup -n $AppName --type system --tail 100"
    throw 'Data API not ready; aborting before generating load.'
}
Write-Host '  ready  : yes (healthz returned 200)' -ForegroundColor Green

# --- Convert the password to plaintext for the driver argument ---------------
$plaintextPtr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($AdminPassword)
$plaintext = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($plaintextPtr)

# Replica-count poller (records a timeline while the driver runs).
function Get-ReplicaCount {
    $replicas = az containerapp replica list --resource-group $ResourceGroup --name $AppName `
        --query "length(@)" --output tsv 2>$null
    if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($replicas)) { return $null }
    return [int] $replicas
}

$driverJob = $null
try {
    $baseline = Get-ReplicaCount
    Write-Step "Baseline replica count: $(if ($null -eq $baseline) { 'n/a (no ready replica reported)' } else { $baseline })"

    # Launch the LoadDriver in a background job so this script can poll replicas
    # concurrently. Its stdout is drained and echoed each poll.
    Write-Step "Starting LoadDriver (rate=$Rate ops/s, duration=$Duration s)"
    $driverJob = Start-Job -ScriptBlock {
        param($proj, $target, $user, $password, $rate, $duration, $trees, $keyspace, $readRatio)
        & dotnet run --project $proj --configuration Release -- `
            --target $target `
            --user $user `
            --password $password `
            --rate $rate `
            --duration $duration `
            --trees $trees `
            --keyspace $keyspace `
            --read-ratio $readRatio 2>&1
    } -ArgumentList $loadDriverProject, $target, $AdminUsername, $plaintext, $Rate, $Duration, $Trees, $KeySpace, $ReadRatio

    # The plaintext is now captured in the job's argument list; drop our copy.
    $plaintext = $null

    Write-Host ''
    Write-Host 'Replica-count timeline (offered-load lines come from the driver):' -ForegroundColor Yellow
    $startUtc = [DateTime]::UtcNow
    while ($driverJob.State -eq 'Running') {
        Start-Sleep -Seconds $PollIntervalSeconds
        $count = Get-ReplicaCount
        $elapsed = [int]([DateTime]::UtcNow - $startUtc).TotalSeconds
        Write-Host ("  [t={0,5}s] replicas = {1}" -f $elapsed, ($count ?? 'n/a')) -ForegroundColor Green

        # Drain any driver output produced since the last poll.
        Receive-Job -Job $driverJob | ForEach-Object { Write-Host "    $_" }
    }

    # Flush remaining driver output.
    Receive-Job -Job $driverJob | ForEach-Object { Write-Host "    $_" }

    Write-Host ''
    Write-Step 'LoadDriver finished. Watching scale-in for two poll intervals'
    for ($i = 0; $i -lt 2; $i++) {
        Start-Sleep -Seconds $PollIntervalSeconds
        $elapsed = [int]([DateTime]::UtcNow - $startUtc).TotalSeconds
        Write-Host ("  [t={0,5}s] replicas = {1}" -f $elapsed, ((Get-ReplicaCount) ?? 'n/a')) -ForegroundColor Green
    }

    Write-Host ''
    Write-Host 'Scale-in continues after the load stops (KEDA cooldown + stabilization).' -ForegroundColor Yellow
    Write-Host "Watch it settle to minReplicas with:"
    Write-Host "  az containerapp replica list -g $ResourceGroup -n $AppName --query 'length(@)' -o tsv"
}
finally {
    $plaintext = $null
    [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($plaintextPtr)
    if ($null -ne $driverJob) {
        Remove-Job -Job $driverJob -Force -ErrorAction SilentlyContinue
    }
}

deploy/teardown.ps1

#Requires -Version 7.0
<#
.SYNOPSIS
    Tears down the ClusterScaling deployment by deleting its resource group.

.DESCRIPTION
    Deletes the entire resource group deploy.ps1 created (container app, managed
    environment, Log Analytics workspace, storage account, managed identity, and
    the role assignment). This is the cheapest and most complete cleanup.

    Cost discipline: an idle ClusterScaling deployment is not free even at
    minReplicas=1. The container app holds at least one always-on replica
    (vCPU + memory billed per second), the Log Analytics workspace bills for
    ingested logs, and the storage account bills for the clustering / reminder /
    grain-state / WAL tables it retains. Delete the resource group as soon as you
    finish a scaling experiment rather than leaving it parked.

    Ingress note: the app was provisioned with EXTERNAL ingress so you can reach
    the data API and drive load from your workstation. If you keep a deployment
    running, scope who can reach it (an IP allow-list on the managed environment,
    or switch the ingress to internal and drive load from inside the environment)
    - the Basic credential rides the managed TLS ingress but an internet-exposed
    write API still benefits from network scoping.

.PARAMETER ResourceGroup
    The resource group to delete.

.PARAMETER Yes
    Skip the confirmation prompt.

.PARAMETER NoWait
    Return immediately instead of waiting for the deletion to complete.

.NOTES
    Requires the Azure CLI (az) and an active `az login`.
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [string] $ResourceGroup,

    [switch] $Yes,

    [switch] $NoWait
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
    throw 'Azure CLI (az) is not on PATH. Install it and run `az login`.'
}

$exists = az group exists --name $ResourceGroup --output tsv
if ($exists -ne 'true') {
    Write-Host "Resource group '$ResourceGroup' does not exist. Nothing to do." -ForegroundColor Yellow
    return
}

if (-not $Yes) {
    $answer = Read-Host "Delete resource group '$ResourceGroup' and everything in it? (y/N)"
    if ($answer -notin @('y', 'Y')) {
        Write-Host 'Aborted.' -ForegroundColor Yellow
        return
    }
}

Write-Host "==> Deleting resource group '$ResourceGroup'" -ForegroundColor Cyan
$azArgs = @('group', 'delete', '--name', $ResourceGroup, '--yes')
if ($NoWait) { $azArgs += '--no-wait' }
az @azArgs

Write-Host ''
Write-Host "Resource group '$ResourceGroup' deletion requested." -ForegroundColor Green
if ($NoWait) {
    Write-Host "Deletion runs in the background. Confirm with: az group exists --name $ResourceGroup"
}

src/ClusterScaling.LoadDriver/ClusterScaling.LoadDriver.csproj

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

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

  <ItemGroup>
    <!-- The gRPC client + wire records for the write-capable data API, and the
         Orleans serializer registration the marshallers dial. -->
    <PackageReference Include="Grpc.Net.Client" Version="2.83.0" />
    <PackageReference Include="Microsoft.Orleans.Serialization" Version="10.2.2" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\..\..\src\lattice.api.data\Orleans.Lattice.Api.Data.csproj" />
    <ProjectReference Include="..\..\..\..\src\lattice.api.data.grpc\Orleans.Lattice.Api.Data.Grpc.csproj" />
  </ItemGroup>

</Project>

src/ClusterScaling.LoadDriver/LoadDriverOptions.cs

using System.Globalization;

namespace Orleans.Lattice.Samples.ClusterScaling.LoadDriver;

/// <summary>
/// Parsed command-line options for the ClusterScaling load driver. Kept as a
/// simple option bag with a hand-rolled parser so the console has no argument
/// dependency; <c>drive-load.ps1</c> passes these through.
/// </summary>
internal sealed class LoadDriverOptions
{
    /// <summary>The data-API gRPC address (an <c>https://...</c> URL against the ACA ingress).</summary>
    public required string Address { get; init; }

    /// <summary>The admin username presented in the Basic credential.</summary>
    public required string Username { get; init; }

    /// <summary>The admin password presented in the Basic credential.</summary>
    public required string Password { get; init; }

    /// <summary>Offered operations per second (the intended load, independent of cluster capacity).</summary>
    public required double OfferedRatePerSecond { get; init; }

    /// <summary>How long to sustain the offered load.</summary>
    public required TimeSpan Duration { get; init; }

    /// <summary>The number of distinct trees load is spread across (activation fan-out).</summary>
    public required int TreeCount { get; init; }

    /// <summary>The number of distinct keys per tree cycle (leaf-grain activation fan-out).</summary>
    public required long KeySpace { get; init; }

    /// <summary>Fraction of operations issued as reads (the remainder are writes).</summary>
    public required double ReadRatio { get; init; }

    /// <summary>The fixed write payload size in bytes. Small on purpose to keep the storage axis flat.</summary>
    public required int PayloadBytes { get; init; }

    /// <summary>The maximum number of concurrent in-flight RPCs.</summary>
    public required int MaxInFlight { get; init; }

    /// <summary>
    /// Parses the command line, returning <see langword="null"/> when required
    /// arguments are missing or malformed (the caller then prints usage).
    /// </summary>
    public static LoadDriverOptions? Parse(string[] args)
    {
        string? target = null;
        var username = "admin";
        string? password = null;
        var rate = 2000.0;
        var durationSeconds = 300.0;
        var treeCount = 64;
        var keySpace = 100_000L;
        var readRatio = 0.2;
        var payloadBytes = 256;
        var maxInFlight = 512;
        var allowInsecure = false;

        for (var i = 0; i < args.Length; i++)
        {
            var arg = args[i];
            switch (arg)
            {
                case "--target" or "-t":
                    target = Next(args, ref i);
                    break;
                case "--user" or "-u":
                    username = Next(args, ref i) ?? username;
                    break;
                case "--password" or "-p":
                    password = Next(args, ref i);
                    break;
                case "--rate" or "-r":
                    rate = ParseDouble(Next(args, ref i), rate);
                    break;
                case "--duration" or "-d":
                    durationSeconds = ParseDouble(Next(args, ref i), durationSeconds);
                    break;
                case "--trees":
                    treeCount = (int)ParseDouble(Next(args, ref i), treeCount);
                    break;
                case "--keyspace":
                    keySpace = (long)ParseDouble(Next(args, ref i), keySpace);
                    break;
                case "--read-ratio":
                    readRatio = ParseDouble(Next(args, ref i), readRatio);
                    break;
                case "--payload-bytes":
                    payloadBytes = (int)ParseDouble(Next(args, ref i), payloadBytes);
                    break;
                case "--max-in-flight":
                    maxInFlight = (int)ParseDouble(Next(args, ref i), maxInFlight);
                    break;
                case "--insecure":
                    allowInsecure = true;
                    break;
                default:
                    Console.Error.WriteLine($"Unknown argument: {arg}");
                    return null;
            }
        }

        if (string.IsNullOrWhiteSpace(target) || string.IsNullOrEmpty(password))
        {
            return null;
        }

        if (rate <= 0 || durationSeconds <= 0 || treeCount <= 0 || keySpace <= 0 ||
            payloadBytes < 0 || maxInFlight <= 0 || readRatio < 0 || readRatio > 1)
        {
            Console.Error.WriteLine("One or more numeric arguments are out of range.");
            return null;
        }

        var address = NormaliseAddress(target, allowInsecure);

        return new LoadDriverOptions
        {
            Address = address,
            Username = username,
            Password = password,
            OfferedRatePerSecond = rate,
            Duration = TimeSpan.FromSeconds(durationSeconds),
            TreeCount = treeCount,
            KeySpace = keySpace,
            ReadRatio = readRatio,
            PayloadBytes = payloadBytes,
            MaxInFlight = maxInFlight,
        };
    }

    /// <summary>Prints the usage banner to stderr.</summary>
    public static void PrintUsage()
    {
        Console.Error.WriteLine(
            """
            ClusterScaling.LoadDriver - compute-axis load generator for the ACA scaling sample.

            Required:
              --target,   -t <fqdn|url>   Data-API ingress (FQDN or https:// URL).
              --password, -p <password>   Admin password (plaintext; presented as Basic over TLS).

            Optional:
              --user,     -u <name>       Admin username (default: admin).
              --rate,     -r <ops/sec>    Offered operations per second (default: 2000).
              --duration, -d <seconds>    How long to sustain the load (default: 300).
              --trees        <count>      Distinct trees to spread load across (default: 64).
              --keyspace     <count>      Distinct keys per tree cycle (default: 100000).
              --read-ratio   <0..1>       Fraction of ops issued as reads (default: 0.2).
              --payload-bytes <n>         Write payload size in bytes (default: 256).
              --max-in-flight <n>         Max concurrent in-flight RPCs (default: 512).
              --insecure                  Allow an http:// (h2c) target for local testing.
            """);
    }

    private static string NormaliseAddress(string target, bool allowInsecure)
    {
        if (target.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
            target.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
        {
            return target;
        }

        // A bare FQDN defaults to TLS (the ACA ingress). --insecure only affects
        // an explicitly http:// address.
        return (allowInsecure ? "http://" : "https://") + target;
    }

    private static string? Next(string[] args, ref int i)
    {
        if (i + 1 >= args.Length)
        {
            return null;
        }

        i++;
        return args[i];
    }

    private static double ParseDouble(string? value, double fallback) =>
        double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
            ? parsed
            : fallback;
}

src/ClusterScaling.Silo/BasicAdminDataApiAuthorizer.cs

using System.Text;
using Grpc.Core;
using Microsoft.Extensions.Logging;
using Orleans.Lattice.Api.Data.Grpc;
using Orleans.Lattice.Api.State.Grpc;

namespace Orleans.Lattice.Samples.ClusterScaling.Silo;

/// <summary>
/// Coarse <see cref="ILatticeDataApiAuthorizer"/> for the write-capable data-API
/// gRPC surface that validates the inbound
/// <c>authorization: Basic base64(user:pass)</c> header against an
/// environment-variable-backed dictionary of salted PBKDF2-SHA256 password hashes
/// (never plaintext), replacing the binding's default
/// <see cref="DenyAllDataApiAuthorizer"/>.
/// </summary>
/// <remarks>
/// <para>
/// Each credential lives in an environment variable named
/// <c><see cref="EnvironmentVariablePrefix"/>&lt;username&gt;</c> whose value is an
/// encoded <c>pbkdf2-sha256$&lt;iterations&gt;$&lt;salt&gt;$&lt;key&gt;</c> hash. In
/// this sample the Azure Container Apps deployment injects that hash as a
/// container-app <b>secret</b> surfaced through the env var, so the plaintext
/// admin password is never baked into an image, stored in the resource group, or
/// passed on a command line. The verification re-derives the presented password
/// with the salt and iteration count embedded in the stored hash and compares in
/// constant time via <see cref="LatticePasswordHash.Verify"/>, exactly as the
/// reference <c>EnvVarCredentialAuthorizer</c> does.
/// </para>
/// <para>
/// Basic-over-cleartext is only safe here because Azure Container Apps terminates
/// TLS at its managed ingress: the credential rides an encrypted HTTP/2 channel
/// from the client to the ingress, and the container is reachable only through
/// that ingress. The coarse gate runs first; every mutation still routes through
/// the gated <see cref="ILattice"/> surface so per-tree enforcement (when a host
/// wires <c>AddLatticeAuth</c>) applies afterwards.
/// </para>
/// </remarks>
internal sealed class BasicAdminDataApiAuthorizer : ILatticeDataApiAuthorizer
{
    /// <summary>
    /// The environment-variable name prefix each credential hash is published
    /// under. The username follows the prefix, so <c>admin</c> reads from
    /// <c>LATTICE_DATA_USER_admin</c>. Matches the <c>LATTICE_*_USER_&lt;name&gt;</c>
    /// convention the reference credential-generation scripts under <c>tools/</c>
    /// use.
    /// </summary>
    public const string EnvironmentVariablePrefix = "LATTICE_DATA_USER_";

    private const string AuthorizationHeaderName = "authorization";
    private const string BasicScheme = "Basic ";

    // A well-formed dummy hash so an unknown username spends the same PBKDF2 cost
    // as a real one, keeping response timing from revealing whether a user exists.
    private static readonly string DummyHash =
        LatticePasswordHash.Hash("not-a-real-password-placeholder", LatticePasswordHash.DefaultIterations);

    private readonly ILogger<BasicAdminDataApiAuthorizer> _logger;

    /// <summary>Initialises the authorizer.</summary>
    /// <param name="logger">The logger.</param>
    public BasicAdminDataApiAuthorizer(ILogger<BasicAdminDataApiAuthorizer> logger)
    {
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    /// <inheritdoc />
    public Task<bool> IsAuthorizedAsync(
        LatticeDataApiAuthorizationContext authorizationContext,
        CancellationToken cancellationToken)
    {
        var header = authorizationContext.Call.RequestHeaders.GetValue(AuthorizationHeaderName);
        return Task.FromResult(Authorize(header));
    }

    /// <summary>
    /// Validates an <c>authorization</c> header value (for example
    /// <c>Basic dXNlcjpwYXNz</c>) against the credential dictionary. Exposed for
    /// unit testing without constructing a gRPC <see cref="ServerCallContext"/>.
    /// </summary>
    /// <param name="authorizationHeader">The raw header value, or <see langword="null"/> when absent.</param>
    /// <returns><see langword="true"/> when the credential is valid.</returns>
    internal bool Authorize(string? authorizationHeader)
    {
        if (!TryDecodeBasic(authorizationHeader, out var username, out var password))
        {
            _logger.LogWarning(
                "ClusterScaling: rejected data-API call - missing or malformed Basic authorization header.");
            return false;
        }

        if (!IsValidUsername(username))
        {
            _logger.LogWarning(
                "ClusterScaling: rejected data-API call - username is not a valid environment-variable name.");
            return false;
        }

        var encodedHash = Environment.GetEnvironmentVariable(EnvironmentVariablePrefix + username);
        if (encodedHash is null)
        {
            // Unknown user: spend the equivalent verification cost against a dummy
            // hash so response timing does not reveal whether the user exists.
            _ = LatticePasswordHash.Verify(password, DummyHash);
            _logger.LogWarning("ClusterScaling: rejected data-API call - unknown username.");
            return false;
        }

        if (LatticePasswordHash.Verify(password, encodedHash))
        {
            return true;
        }

        _logger.LogWarning("ClusterScaling: rejected data-API call - incorrect password.");
        return false;
    }

    private static bool TryDecodeBasic(string? header, out string username, out string password)
    {
        username = string.Empty;
        password = string.Empty;

        if (string.IsNullOrWhiteSpace(header) ||
            !header.StartsWith(BasicScheme, StringComparison.OrdinalIgnoreCase))
        {
            return false;
        }

        var encoded = header[BasicScheme.Length..].Trim();
        string decoded;
        try
        {
            decoded = Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
        }
        catch (FormatException)
        {
            return false;
        }

        var separator = decoded.IndexOf(':', StringComparison.Ordinal);
        if (separator < 0)
        {
            return false;
        }

        username = decoded[..separator];
        password = decoded[(separator + 1)..];
        return username.Length > 0;
    }

    private static bool IsValidUsername(string username)
    {
        if (username.Length == 0)
        {
            return false;
        }

        var first = username[0];
        if (!char.IsAsciiLetter(first) && first != '_')
        {
            return false;
        }

        foreach (var c in username)
        {
            if (!char.IsAsciiLetterOrDigit(c) && c != '_')
            {
                return false;
            }
        }

        return true;
    }
}

src/ClusterScaling.Silo/ClusterScaling.Silo.csproj

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

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

  <ItemGroup>
    <!-- Orleans silo host plus the real Azure Storage clustering, grain-state,
         and reminder providers. Managed identity means no key or connection
         string is ever read; Azure.Identity supplies DefaultAzureCredential. -->
    <PackageReference Include="Microsoft.Orleans.Server" Version="10.2.2" />
    <PackageReference Include="Microsoft.Orleans.Clustering.AzureStorage" Version="10.2.2" />
    <PackageReference Include="Microsoft.Orleans.Persistence.AzureStorage" Version="10.2.2" />
    <PackageReference Include="Microsoft.Orleans.Reminders.AzureStorage" Version="10.2.2" />
    <PackageReference Include="Azure.Identity" Version="1.21.0" />
  </ItemGroup>

  <ItemGroup>
    <!-- Core lattice + the durable Azure Table WAL (managed identity). -->
    <ProjectReference Include="..\..\..\..\src\lattice\Orleans.Lattice.csproj" />
    <ProjectReference Include="..\..\..\..\src\lattice.storage.azuretable\Orleans.Lattice.Storage.AzureTable.csproj" />
    <!-- The write-capable data-plane facade and its gRPC binding. -->
    <ProjectReference Include="..\..\..\..\src\lattice.api.data\Orleans.Lattice.Api.Data.csproj" />
    <ProjectReference Include="..\..\..\..\src\lattice.api.data.grpc\Orleans.Lattice.Api.Data.Grpc.csproj" />
    <!-- The autoscaling signal + its HTTP scrape endpoint and health check. -->
    <ProjectReference Include="..\..\..\..\src\lattice.scaling\Orleans.Lattice.Scaling.csproj" />
    <!-- LatticePasswordHash: the salted PBKDF2 verifier shared with the
         tools/ credential-generation scripts and the deploy.ps1 hashing step. -->
    <ProjectReference Include="..\..\..\..\src\lattice.api.state.grpc\Orleans.Lattice.Api.State.Grpc.csproj" />
  </ItemGroup>

</Project>