Orleans × Aspire : un cluster de deux silos, l’état dans Redis

Quatrième volet Orleans de la série The Unexpected AI Stack: C#/.NET (EPIC #10473). Les notebooks précédents ont chacun laissé une limite écrite dans leur section « limites honnêtes », et ce notebook les reprend ensemble :

02 03 04 (ce notebook)
Silos un silo orchestré par Aspire un ou deux silos lancés par le notebook deux répliques d’un même projet, orchestrées par Aspire
Appartenance au cluster UseLocalhostClustering, ports fixes UseLocalhostClustering table d’appartenance dans Redis, déclarée par l’AppHost
État des grains en mémoire du silo Redis, fournisseur enregistré dans le code du silo Redis, fournisseur déclaré par l’AppHost (WithGrainStorage)
Code d’infrastructure dans le silo quelques lignes quelques lignes aucune : UseOrleans() lit la configuration injectée

Le notebook 02 désignait WithGrainStorage comme « l’extension naturelle » de son lab, le 03 terminait sur « le faire déclarer par l’AppHost reste à faire ». C’est l’objet de celui-ci. Il en tire une conséquence que les deux autres ne pouvaient pas montrer : avec deux silos, un grain peut changer de process sans perdre son état. C’est ce qu’on mesure ici.

Ce que ce notebook exécute

Le dossier adjacent OrleansClusterLab/ contient un AppHost Aspire (apphost.cs) et un projet silo (Silo/) qui expose une petite API HTTP. L’AppHost déclare un conteneur Redis, un service Orleans rattaché à ce Redis pour deux usages, et deux répliques du silo derrière un même port HTTP (5310). Toutes les mesures passent par cette API ou par la CLI aspire :

  1. la configuration qu’Aspire injecte dans chaque silo, et l’absence de code de clustering dans le silo ;
  2. l’activation unique d’un grain dans le cluster, quelle que soit la réplique qui reçoit la requête HTTP ;
  3. la reprise d’une session quand la réplique qui l’héberge s’arrête proprement, puis quand elle est tuée ;
  4. ce qui survit, et ce qui ne survit pas, au redémarrage complet de l’application.

Prérequis : Docker (le Redis est un conteneur géré par l’AppHost) et la CLI Aspire installée comme outil .NET global.

L’outillage du notebook

La cellule suivante ne contient que des déclarations : en C# de script, aucune instruction ne peut suivre une déclaration de type dans la même cellule. Elle définit :

  • LabShell.Aspire(...) et LabShell.Run(...), qui lancent la CLI Aspire, dotnet ou docker dans le dossier du lab et capturent la sortie ;
  • LabShell.Turn, Session et Once, qui appellent l’API du silo et relisent le reçu JSON (Receipt). Chaque requête ouvre sa propre connexion (Connection: close) : le proxy d’Aspire choisit une réplique à l’ouverture d’une connexion, et un client qui garde la sienne ouverte parlerait toujours à la même réplique. Mesure faite en construisant ce lab : sans cet en-tête, les huit requêtes de la section 4 arrivaient toutes sur une seule réplique ;
  • LabShell.Describe(), qui liste les ressources de l’application vues par aspire describe. Seuls quelques champs choisis sont lus : la section environment de cette commande contient le mot de passe Redis généré, et elle n’est jamais affichée ;
  • LabShell.StartApp() et StopApp(), le cycle complet : arrêter, reconstruire le silo, démarrer, attendre deux silos actifs.

StopApp porte une mesure faite en construisant ce lab : aspire stop coupe l’AppHost et son orchestrateur, mais laisse tourner le conteneur Redis. La méthode relève donc les conteneurs de l’application avant l’arrêt, puis retire par leur identifiant ceux qui restent. La section 8 en affiche le compte.

#nullable enable
// Outillage partage entre les cellules (.NET Interactive : un type declare persiste).
// Cellule = DECLARATIONS SEULES : en C# de script, aucune instruction ne peut suivre
// une declaration de type dans la meme unite de compilation.
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;

/// <summary>Recu d'un appel de grain, tel que l'API du silo le renvoie.</summary>
public record Receipt(string HttpReplica, string ActivationReplica, string ActivationSilo,
                      int ActivationPid, int Turns, string? ETag);

/// <summary>Une ressource de l'application vue par "aspire describe", sans son environnement.</summary>
public record LabResource(string Name, string Resource, string Type, string State,
                          string Image, string ContainerId, int Pid, string[] Volumes);

public static class LabShell
{
    public static readonly string AspireCmd = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
        ".dotnet", "tools",
        OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire");   // CLI Aspire (outil .NET global)
    public static readonly string LabDir = FindLabDir();
    public const string Api = "http://localhost:5310";  // proxy Aspire devant les deux repliques
    private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(15) };
    private static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web);

    public static string FindLabDir()
    {
        // Le notebook vit dans Integrations-DotNet/Orleans/, le lab juste a cote.
        var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
        while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "MyIA.AI.Notebooks")))
            dir = dir.Parent;
        return Path.Combine(dir!.FullName, "MyIA.AI.Notebooks", "GenAI",
            "Integrations-DotNet", "Orleans", "OrleansClusterLab");
    }

    // Lancer un programme dans le dossier du lab. Un .cmd exige cmd.exe (Windows) ;
    // hors Windows, aspire se lance directement, comme dotnet.
    public static string Run(string fileName, string arguments, int timeoutSeconds = 180)
    {
        var useCmd = fileName.EndsWith(".cmd");
        var psi = new ProcessStartInfo(useCmd ? "cmd.exe" : fileName,
                                      useCmd ? $"/c {Path.GetFileName(fileName)} {arguments}" : arguments)
        {
            WorkingDirectory = LabDir,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
        };
        // Hors Windows, sans terminal, la CLI Aspire ne connait pas la largeur de la console
        // et reduit chaque ligne a une ellipse : on la fixe.
        if (fileName == AspireCmd && !OperatingSystem.IsWindows())
            psi.Environment["ASPIRE_CONSOLE_WIDTH"] = "200";
        using var proc = Process.Start(psi)!;
        var stderr = proc.StandardError.ReadToEndAsync();
        var output = proc.StandardOutput.ReadToEnd();
        if (!proc.WaitForExit(timeoutSeconds * 1000)) { proc.Kill(); throw new TimeoutException($"{fileName} {arguments}"); }
        var err = stderr.Result.Trim();
        var text = output + (err.Length > 0 ? "\n[stderr] " + err : "");
        // Retirer les liens (OSC 8) et les couleurs (SGR) de la CLI Aspire.
        text = Regex.Replace(text, @"\x1b\]8;[^\x1b]*\x1b\\", "");
        text = Regex.Replace(text, @"\x1b\[[0-9;]*m", "");
        return Regex.Replace(text, @"[\u2600-\u27BF]\uFE0F?\s?", "");    // pictogrammes de la CLI
    }

    public static string Aspire(string arguments, int timeoutSeconds = 180)
        => Run(AspireCmd, $"{arguments} --non-interactive --nologo", timeoutSeconds);

    // Ce qu'une sortie committee ne doit pas porter : jeton du dashboard, chemins locaux.
    public static string Redact(string text)
    {
        text = Regex.Replace(text, @"\?t=[0-9A-Za-z]+", "?t=<jeton local omis>");
        text = Regex.Replace(text, @"file:///\S+", "<journal CLI local>");
        // Le lookbehind epargne "http:" : seul un lecteur isole (C:\...) est un chemin local.
        return Regex.Replace(text, @"(?<![A-Za-z])[A-Za-z]:[\\/]\S*", "<chemin local>");
    }

    public static (int Status, string Body) Get(string path) => Send(HttpMethod.Get, path);
    public static (int Status, string Body) Post(string path) => Send(HttpMethod.Post, path);

    private static (int Status, string Body) Send(HttpMethod method, string path)
    {
        try
        {
            // Une connexion par requete : le proxy choisit la replique a l'ouverture de la
            // connexion, et une connexion reutilisee enverrait tout vers la meme replique.
            using var request = new HttpRequestMessage(method, Api + path);
            request.Headers.ConnectionClose = true;
            using var response = Http.Send(request);
            return ((int)response.StatusCode, response.Content.ReadAsStringAsync().GetAwaiter().GetResult());
        }
        catch (Exception ex) { return (0, ex.GetType().Name); }   // aucune reponse HTTP
    }

    private static Receipt? ToReceipt((int Status, string Body) response) =>
        response.Status == 200 ? JsonSerializer.Deserialize<Receipt>(response.Body, Web) : null;

    public static (int Status, Receipt? Receipt) Turn(string session, string text)
    {
        var response = Post($"/session/{session}/turn?text={Uri.EscapeDataString(text)}");
        return (response.Status, ToReceipt(response));
    }

    public static (int Status, Receipt? Receipt) Once(string session, string requestId, string text)
    {
        var response = Post($"/session/{session}/once?requestId={requestId}&text={Uri.EscapeDataString(text)}");
        return (response.Status, ToReceipt(response));
    }

    public static Receipt? Session(string session) => ToReceipt(Get($"/session/{session}"));

    // Un endpoint qui rend un tableau de chaines.
    public static string[] Lines(string path)
    {
        var (status, body) = Get(path);
        return status == 200 ? JsonSerializer.Deserialize<string[]>(body, Web)! : new[] { $"(HTTP {status})" };
    }

    public static JsonElement Json(string path) => JsonDocument.Parse(Get(path).Body).RootElement;

    public static int ActiveSilos() => Lines("/cluster").Count(l => l.EndsWith(" Active"));

    // Attendre que le runtime compte le nombre de silos actifs demande. Abandonner plus
    // tot si toutes les repliques du silo se sont arretees (plantage au demarrage).
    public static bool WaitForCluster(int silos, int timeoutSeconds = 120)
    {
        var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
        for (int attempt = 1; DateTime.UtcNow < deadline; attempt++)
        {
            if (ActiveSilos() == silos) return true;
            if (attempt % 5 == 0)
            {
                var replicas = Describe().Where(r => r.Resource == "silo").ToList();
                if (replicas.Count > 0 && replicas.All(r => r.State is "Finished" or "Exited" or "FailedToStart"))
                    return false;
            }
            Thread.Sleep(2000);
        }
        return false;
    }

    public static List<LabResource> Describe()
    {
        var text = Aspire("describe --format Json", 60);
        int start = text.IndexOf('{'), end = text.LastIndexOf('}');
        var resources = new List<LabResource>();
        if (start < 0 || end < start) return resources;             // aucune application en cours
        using var doc = JsonDocument.Parse(text[start..(end + 1)]);
        foreach (var r in doc.RootElement.GetProperty("resources").EnumerateArray())
        {
            // Champs choisis uniquement : "environment" porte le mot de passe Redis.
            var props = r.TryGetProperty("properties", out var p) ? p : default;
            string Prop(string name) => props.ValueKind == JsonValueKind.Object
                && props.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString()! : "";
            int pid = props.ValueKind == JsonValueKind.Object && props.TryGetProperty("executable.pid", out var pv)
                ? (pv.ValueKind == JsonValueKind.Number ? pv.GetInt32() : int.TryParse(pv.GetString(), out var n) ? n : 0) : 0;
            var volumes = r.TryGetProperty("volumes", out var vs) && vs.ValueKind == JsonValueKind.Array
                ? vs.EnumerateArray().Select(v => v.ValueKind == JsonValueKind.Object
                    ? v.EnumerateObject().FirstOrDefault(f => f.Name.Equals("source", StringComparison.OrdinalIgnoreCase)).Value.ToString()
                    : v.ToString()).ToArray()
                : Array.Empty<string>();
            resources.Add(new LabResource(r.GetProperty("name").GetString()!, r.GetProperty("displayName").GetString()!,
                r.GetProperty("resourceType").GetString()!, r.TryGetProperty("state", out var s) ? s.GetString() ?? "" : "",
                Prop("container.image"), Prop("container.id"), pid, volumes));
        }
        return resources;
    }

    // Arreter l'application. Mesure faite en construisant ce lab : "aspire stop" coupe
    // l'AppHost et son orchestrateur, mais laisse tourner le conteneur Redis. On releve
    // donc les conteneurs AVANT l'arret, et on retire par leur identifiant ceux qui restent.
    public static (string Output, int Orphans) StopApp()
    {
        var containers = Describe().Where(r => r.ContainerId.Length > 0).ToList();
        var output = Aspire("stop");
        Thread.Sleep(3000);
        int orphans = 0;
        foreach (var c in containers)
        {
            if (Run("docker", $"ps -q --filter id={c.ContainerId}").Trim().Length == 0) continue;
            Run("docker", $"rm -f {c.ContainerId}");
            orphans++;
        }
        return (output, orphans);
    }

    // Une iteration complete : arreter, reconstruire le silo, demarrer, attendre deux silos
    // actifs. Modifier apphost.cs ou Grains.cs n'a d'effet qu'apres ce cycle.
    public static (string Output, bool Built, bool Ready, int Orphans) StartApp(int timeoutSeconds = 120)
    {
        var (_, orphans) = StopApp();
        var build = Run("dotnet", "build Silo/Silo.csproj -v q");
        var built = Regex.IsMatch(build, @"\b0 (Erreur|Error)");
        var output = Aspire("run --detach", 240);
        return (output, built, WaitForCluster(2, timeoutSeconds), orphans);
    }
}

1. Construire le lab

Pièce Rôle
apphost.cs l’AppHost (fichier #:sdk, .NET 10) : un Redis, un service Orleans, deux répliques du silo
Silo/Program.cs le silo : UseOrleans() sans argument, plus l’API HTTP du lab (/cluster, /redis/members, /session/{id}/turn…)
Silo/Grains.cs le grain de session (IPersistentState<T>, comme au lab 03) et deux des trois exercices
Silo/Silo.csproj Orleans 10.3.1 avec les fournisseurs Redis de clustering et de persistance

La cellule vérifie les outils (CLI Aspire, Docker), construit le silo et relève ses dépendances.

// Outils presents, vrai build MSBuild du silo, et ses dependances.
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
var labRelatif = LabShell.LabDir.Substring(LabShell.LabDir.IndexOf("MyIA.AI.Notebooks"));
Console.WriteLine($"lab : {labRelatif}");
Console.WriteLine($"CLI Aspire presente : {File.Exists(LabShell.AspireCmd)}");
Console.WriteLine($"Docker (serveur) : {LabShell.Run("docker", "version --format {{.Server.Version}}").Trim()}");
var build = LabShell.Run("dotnet", "build Silo/Silo.csproj -v q");
var bilan = build.Split('\n').Select(l => l.Trim())
    .Where(l => Regex.IsMatch(l, @"^\d+ (Erreur|Avertissement|Error|Warning)"));
Console.WriteLine($"[build Silo] {string.Join(" ; ", bilan)}");
var csproj = File.ReadAllText(Path.Combine(LabShell.LabDir, "Silo", "Silo.csproj"));
foreach (Match m in Regex.Matches(csproj, @"PackageReference Include=""([^""]+)"" Version=""([^""]+)"""))
    Console.WriteLine($"  paquet : {m.Groups[1].Value} {m.Groups[2].Value}");
lab : MyIA.AI.Notebooks/GenAI/Integrations-DotNet/Orleans/OrleansClusterLab
CLI Aspire presente : True
Docker (serveur) : 29.3.1
[build Silo] 0 Avertissement(s) ; 0 Erreur(s)
  paquet : Microsoft.Orleans.Server 10.3.1
  paquet : Microsoft.Orleans.Clustering.Redis 10.3.1
  paquet : Microsoft.Orleans.Persistence.Redis 10.3.1
  paquet : Aspire.StackExchange.Redis 13.4.6

Lecture du build

Le silo référence deux fournisseurs Redis, l’un pour l’appartenance au cluster (Clustering.Redis), l’autre pour l’état des grains (Persistence.Redis), ainsi que le client Redis d’Aspire. Aucune ligne de son code ne les appelle pourtant, comme le montre la section 3. Les paquets apportent les fournisseurs, et la configuration injectée choisit lesquels activer.

2. Démarrer l’application distribuée

aspire run --detach compile l’AppHost, démarre l’orchestrateur, le conteneur Redis et les deux répliques du silo, puis rend la main. La cellule attend ensuite que le runtime Orleans compte deux silos actifs : c’est le signe que les deux process ont trouvé la même table d’appartenance et se sont reconnus.

// Demarrage orchestre : aspire run --detach, puis attente de deux silos actifs.
using System.Linq;
var demarrage = LabShell.StartApp();
foreach (var ligne in LabShell.Redact(demarrage.Output).Split('\n').Select(l => l.TrimEnd())
             .Where(l => l.Trim().Length > 0).TakeLast(5))
    Console.WriteLine(ligne);
Console.WriteLine($"\n[build] {demarrage.Built} ; [cluster] deux silos actifs : {demarrage.Ready}");
Console.WriteLine("\n[aspire describe] ressources de l'application :");
foreach (var r in LabShell.Describe())
    Console.WriteLine($"  {r.Resource,-6} {r.Name,-16} {r.Type,-10} {r.State,-8} {(r.Image.Length > 0 ? r.Image : $"pid {r.Pid}")}");
if (!demarrage.Ready) throw new InvalidOperationException("le cluster n'a pas atteint deux silos actifs");
           AppHost:  apphost.cs
   Tableau de bord:  https://localhost:17193/login?t=<jeton local omis>
          Journaux:  /root/.aspire/logs/cli_20261004T020340539_detach-child_ca604958fbaa4e93b4da769d526cd507.log
               PID:  30369
AppHost a démarré correctement.

[build] True ; [cluster] deux silos actifs : True

[aspire describe] ressources de l'application :
  redis  redis-twekazwm   Container  Running  docker.io/library/redis:8.6
  silo   silo-ufxexndg    Project    Running  pid 30904
  silo   silo-umyqtehs    Project    Running  pid 30894

Lecture du démarrage

apphost.cs déclare trois choses : un Redis, un service Orleans et un projet silo. aspire describe ne montre pourtant que des conteneurs et des process : AddOrleans ne lance rien. C’est une déclaration de topologie, résolue dans la configuration des projets qui la référencent (le notebook 02 l’a mesuré sur le nuspec du paquet).

Les deux silos sont deux répliques de la même ressource silo. Aspire leur donne des noms générés (silo- suivi d’un suffixe), que la CLI accepte ensuite comme cibles, par exemple aspire resource <nom> stop en section 6. Les deux répliques partagent le port HTTP 5310 : un proxy de l’orchestrateur répartit les connexions entre elles.

3. Ce qu’Aspire injecte dans chaque silo

/config rend la section Orleans de la configuration du silo, telle qu’il la reçoit, et le nom de la réplique qui répond. La cellule interroge les deux répliques, puis passe Program.cs au crible (commentaires retirés) à la recherche d’un appel de clustering ou de stockage écrit à la main. La chaîne de connexion Redis, qui porte le mot de passe généré, vit dans une autre section de la configuration et n’est jamais rendue.

// La configuration Orleans recue par chaque replique, puis le code du silo passe au crible.
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
var vues = new Dictionary<string, string[]>();
for (int i = 0; i < 12 && vues.Count < 2; i++)
{
    var c = LabShell.Json("/config");
    vues[c.GetProperty("replica").GetString()!] = c.GetProperty("settings").EnumerateArray().Select(e => e.GetString()!).ToArray();
}
var premiere = vues.First();
Console.WriteLine($"[/config] section Orleans recue par {premiere.Key} :");
foreach (var l in premiere.Value) Console.WriteLine($"  {l}");
if (vues.Count == 2)
{
    var seconde = vues.Last();
    var differentes = premiere.Value.Except(seconde.Value).Select(l => l.Split(" = ")[0]).ToList();
    bool memesCles = premiere.Value.Select(l => l.Split(" = ")[0]).SequenceEqual(seconde.Value.Select(l => l.Split(" = ")[0]));
    Console.WriteLine($"\n[/config] {seconde.Key} : memes cles {memesCles} ; valeurs differentes : {string.Join(", ", differentes)}");
}
var programme = Regex.Replace(File.ReadAllText(Path.Combine(LabShell.LabDir, "Silo", "Program.cs")), @"//.*", "");
var appelsManuels = new[] { "UseLocalhostClustering", "UseRedisClustering", "AddRedisGrainStorage", "AddMemoryGrainStorage", "ConfigurationOptions.Parse" }
    .Where(programme.Contains).ToList();
Console.WriteLine($"\n[Program.cs, hors commentaires] appels de clustering ou de stockage : {(appelsManuels.Count == 0 ? "aucun" : string.Join(", ", appelsManuels))}");
Console.WriteLine($"[Program.cs] UseOrleans() appele sans argument : {programme.Contains("builder.UseOrleans();")}");
[/config] section Orleans recue par silo-ufxexndg :
  ClusterId = cluster-lab
  Clustering:ProviderType = Redis
  Clustering:ServiceKey = redis
  EnableDistributedTracing = true
  Endpoints:GatewayPort = 44915
  Endpoints:SiloPort = 34757
  GrainStorage:sessions:ProviderType = Redis
  GrainStorage:sessions:ServiceKey = redis
  ServiceId = orleans-cluster-lab

[/config] silo-umyqtehs : memes cles True ; valeurs differentes : Endpoints:GatewayPort, Endpoints:SiloPort

[Program.cs, hors commentaires] appels de clustering ou de stockage : aucun
[Program.cs] UseOrleans() appele sans argument : True

Lecture de la configuration

Chaque clé reçue correspond à une ligne de apphost.cs :

Clé reçue par le silo Ligne de l’AppHost
ClusterId, ServiceId .WithClusterId("cluster-lab"), .WithServiceId("orleans-cluster-lab")
Clustering:ProviderType = Redis, Clustering:ServiceKey = redis .WithClustering(redis)
GrainStorage:sessions:ProviderType, GrainStorage:sessions:ServiceKey .WithGrainStorage("sessions", redis)
Endpoints:SiloPort, Endpoints:GatewayPort alloués par Aspire, différents pour chaque réplique

ServiceKey = redis est un nom, pas une adresse. Il désigne le client Redis que le silo enregistre sous cette clé (AddKeyedRedisClient), dont la chaîne de connexion arrive par ConnectionStrings:redis. UseOrleans() fait le reste : il lit ProviderType, trouve le fournisseur Redis dans les paquets référencés et lui passe ce client. Le code du silo ne nomme ni hôte, ni port, ni fournisseur ; changer de Redis, ou de fournisseur, est une modification de l’AppHost seul.

La seule différence entre les deux répliques porte sur les ports : c’est ce qui permet à deux process du même projet de cohabiter sur une machine.

4. Deux répliques, un cluster

Trois points de vue sur le même cluster : le runtime (IManagementGrain.GetHosts), la table d’appartenance telle qu’elle est stockée dans Redis, et le proxy HTTP qui répartit les requêtes.

// Le cluster vu par le runtime, par Redis, et par le proxy HTTP.
using System.Linq;
Console.WriteLine("[runtime] silos actifs (IManagementGrain.GetHosts) :");
foreach (var l in LabShell.Lines("/cluster")) Console.WriteLine($"  {l}");
Console.WriteLine("\n[redis] table d'appartenance, hash orleans-cluster-lab/members/cluster-lab :");
foreach (var l in LabShell.Lines("/redis/members")) Console.WriteLine($"  {l}");
Console.WriteLine("\n[redis] cles du service, par cle de service Aspire :");
foreach (var p in LabShell.Json("/redis/keys").EnumerateObject())
    Console.WriteLine($"  {p.Name} : {string.Join(", ", p.Value.EnumerateArray().Select(k => k.GetString()))}");
var repondants = Enumerable.Range(0, 8).Select(_ => LabShell.Json("/whoami").GetProperty("replica").GetString()).ToList();
Console.WriteLine($"\n[proxy :5310] 8 requetes /whoami servies par : {string.Join(", ", repondants.GroupBy(r => r).OrderBy(g => g.Key).Select(g => $"{g.Key} x{g.Count()}"))}");
[runtime] silos actifs (IManagementGrain.GetHosts) :
  S172.18.0.1:34757:150084232 Active
  S172.18.0.1:41169:150084232 Active

[redis] table d'appartenance, hash orleans-cluster-lab/members/cluster-lab :
  S172.18.0.1:34757:150084232 status=Active demarre=02:03:52Z
  S172.18.0.1:41169:150084232 status=Active demarre=02:03:52Z

[redis] cles du service, par cle de service Aspire :
  redis : orleans-cluster-lab/members/cluster-lab

[proxy :5310] 8 requetes /whoami servies par : silo-ufxexndg x5, silo-umyqtehs x3

Lecture : l’appartenance est une table partagée

Un silo s’identifie par son adresse, son port de silo et un numéro de génération : S<ip>:<port>:<génération>. L’adresse est celle de la première interface réseau de la machine, et le port est celui qu’Aspire a alloué en section 3. La génération distingue deux incarnations successives d’un même silo au même port. Les deux silos du runtime sont les deux champs du hash Redis, et c’est ainsi qu’ils se sont trouvés : chacun s’y inscrit au démarrage, lit les autres, et les sonde avant de se déclarer actif.

À ce stade, Redis ne contient que cette table : aucune session n’a encore été créée, donc aucun enregistrement d’état. La dernière ligne montre que le proxy répartit les requêtes entre les deux répliques, chacune arrivant par sa propre connexion. Le point de la section suivante en dépend.

5. Une activation unique, où qu’arrive la requête

Huit appels successifs sur la même session. Chaque requête HTTP peut arriver sur l’une ou l’autre réplique, mais le grain, lui, ne doit exister qu’une fois dans le cluster.

// Huit tours sur une meme session : quelle replique recoit la requete, ou vit l'activation.
using System.Collections.Generic;
using System.Linq;
var sessionUnique = $"unique-{Guid.NewGuid():N}"[..15];
var recus = new List<Receipt>();
for (int i = 1; i <= 8; i++)
{
    var (statut, recu) = LabShell.Turn(sessionUnique, $"tour {i}");
    recus.Add(recu!);
    Console.WriteLine($"appel {i} : HTTP {statut}  requete servie par {recu!.HttpReplica,-14}  activation sur {recu.ActivationReplica,-14}  tours={recu.Turns}");
}
int httpDistinctes = recus.Select(r => r.HttpReplica).Distinct().Count();
int activationsDistinctes = recus.Select(r => r.ActivationReplica).Distinct().Count();
bool sansTrou = recus.Select(r => r.Turns).SequenceEqual(Enumerable.Range(1, 8));
Console.WriteLine($"\nrepliques HTTP distinctes : {httpDistinctes} ; activations distinctes : {activationsDistinctes} ; tours 1..8 sans trou : {sansTrou}");
if (activationsDistinctes != 1 || !sansTrou) throw new InvalidOperationException("activation unique non observee");
appel 1 : HTTP 200  requete servie par silo-ufxexndg   activation sur silo-umyqtehs   tours=1
appel 2 : HTTP 200  requete servie par silo-ufxexndg   activation sur silo-umyqtehs   tours=2
appel 3 : HTTP 200  requete servie par silo-ufxexndg   activation sur silo-umyqtehs   tours=3
appel 4 : HTTP 200  requete servie par silo-umyqtehs   activation sur silo-umyqtehs   tours=4
appel 5 : HTTP 200  requete servie par silo-umyqtehs   activation sur silo-umyqtehs   tours=5
appel 6 : HTTP 200  requete servie par silo-ufxexndg   activation sur silo-umyqtehs   tours=6
appel 7 : HTTP 200  requete servie par silo-ufxexndg   activation sur silo-umyqtehs   tours=7
appel 8 : HTTP 200  requete servie par silo-umyqtehs   activation sur silo-umyqtehs   tours=8

repliques HTTP distinctes : 2 ; activations distinctes : 1 ; tours 1..8 sans trou : True

Lecture : une réplique reçoit, une autre exécute

Les requêtes HTTP se répartissent entre les deux répliques, mais il n’y a qu’une seule activation du grain. Quand la requête arrive sur l’autre réplique, GetGrain lui rend une référence, et l’appel traverse le réseau jusqu’au silo qui détient l’activation. C’est le répertoire des grains, partagé par le cluster, qui garantit cette unicité. Le code de l’endpoint HTTP est le même dans les deux cas.

La suite des tours, de 1 à 8 sans trou ni doublon, en est la conséquence : tous les appels passent par la même activation, un à la fois (la concurrence tour par tour mesurée au notebook 01). Deux copies du grain auraient chacune compté leurs propres tours.

Où naît une activation : le placement

La réplique qui détient l’activation est choisie à la première requête, par la stratégie de placement. La cellule relève la stratégie par défaut et l’attribut éventuellement posé sur SessionGrain, puis crée 16 sessions neuves et compte celles dont l’activation est née sur la réplique qui a reçu la requête HTTP.

// Placement par defaut : 16 sessions neuves, activation locale ou distante ?
using System.Linq;
var placement = LabShell.Json("/placement");
Console.WriteLine($"strategie de placement par defaut : {placement.GetProperty("defaultStrategy").GetString()}");
Console.WriteLine($"attribut de placement sur SessionGrain : {placement.GetProperty("sessionGrainAttribute").GetString()}");
Console.WriteLine($"marge de preference du silo local (LocalSiloPreferenceMargin) : {placement.GetProperty("localSiloPreferenceMargin")}");
Console.WriteLine($"poids du score de charge : {string.Join(", ", placement.GetProperty("weights").EnumerateObject().Select(p => $"{p.Name}={p.Value}"))}");
var lot = Enumerable.Range(1, 16).Select(_ => LabShell.Turn($"place-{Guid.NewGuid():N}"[..14], "premier tour").Receipt!).ToList();
int colocalisees = lot.Count(r => r.HttpReplica == r.ActivationReplica);
Console.WriteLine($"\n16 sessions neuves : {colocalisees}/16 activees sur la replique qui a recu la requete HTTP");
Console.WriteLine($"requetes HTTP par replique : {string.Join(", ", lot.GroupBy(r => r.HttpReplica).OrderBy(g => g.Key).Select(g => $"{g.Key} x{g.Count()}"))}");
Console.WriteLine($"activations par replique   : {string.Join(", ", lot.GroupBy(r => r.ActivationReplica).OrderBy(g => g.Key).Select(g => $"{g.Key} x{g.Count()}"))}");
int etats = LabShell.Json("/redis/keys").EnumerateObject().SelectMany(p => p.Value.EnumerateArray()).Count(k => k.GetString()!.Contains("/state/"));
Console.WriteLine($"\n[redis] enregistrements d'etat de session : {etats} (attendu {1 + lot.Count} : la session de la cellule precedente et les {lot.Count} nouvelles)");
strategie de placement par defaut : ResourceOptimizedPlacement
attribut de placement sur SessionGrain : (aucun)
marge de preference du silo local (LocalSiloPreferenceMargin) : 5
poids du score de charge : cpuUsage=40, memoryUsage=20, availableMemory=20, maxAvailableMemory=5, activationCount=15

16 sessions neuves : 13/16 activees sur la replique qui a recu la requete HTTP
requetes HTTP par replique : silo-ufxexndg x10, silo-umyqtehs x6
activations par replique   : silo-ufxexndg x13, silo-umyqtehs x3

[redis] enregistrements d'etat de session : 17 (attendu 17 : la session de la cellule precedente et les 16 nouvelles)

Lecture : une préférence pour le silo local, pas une garantie

ResourceOptimizedPlacement, la stratégie par défaut d’Orleans 10, donne à chaque silo un score de charge : une somme pondérée du processeur, de la mémoire et du nombre d’activations, avec les poids affichés. Elle retient le silo le moins chargé, sauf quand le silo local reste dans une marge de ce meilleur score (LocalSiloPreferenceMargin). Le silo local est celui d’où part l’appel, ici la réplique qui a reçu la requête HTTP. Dans ce cas, elle le préfère, parce qu’il évite un saut réseau.

Sur deux silos peu chargés, les scores sont proches et le silo local l’emporte souvent, mais rien ne le garantit : il suffit qu’un écart de charge le sorte de la marge. La ligne activees sur la replique qui a recu la requete HTTP n’est donc pas une constante, et la vérification de l’exercice 2 la remesure plus loin, sur d’autres sessions, avec un résultat qui n’a pas de raison d’être le même. Pour une session née sur l’autre réplique, chaque appel qui entre par la réplique d’origine paie un saut réseau, pour toute la durée de vie de l’activation. L’exercice 2 rend la colocalisation systématique.

La dernière ligne relie cette section à la persistance : chaque session a désormais un enregistrement d’état dans Redis, écrit par le fournisseur sessions que l’AppHost a déclaré.

6. Arrêt propre d’une réplique

aspire resource <nom> stop arrête la réplique qui héberge une session de trois tours. L’appel suivant retrouve-t-il la session, et où ? La table d’appartenance est relue juste après l’arrêt ; la ligne du silo arrêté y est marquée.

// Arret propre de la replique qui porte l'activation, puis appel suivant.
using System.Diagnostics;
using System.Linq;
var sessionPropre = $"propre-{Guid.NewGuid():N}"[..15];
Receipt avant = null!;
for (int i = 1; i <= 3; i++) avant = LabShell.Turn(sessionPropre, $"tour {i}").Receipt!;
var porteur = avant.ActivationReplica;
Console.WriteLine($"[avant] {sessionPropre} : tours={avant.Turns}, activation sur {porteur} (pid {avant.ActivationPid}), etag={avant.ETag}");
var chrono = Stopwatch.StartNew();
var arret = LabShell.Aspire($"resource {porteur} stop");
Console.WriteLine($"[aspire resource {porteur} stop] {arret.Trim().Split('\n').Last().Trim()} ({chrono.ElapsedMilliseconds} ms)");
Console.WriteLine("[redis] table d'appartenance apres l'arret :");
foreach (var l in LabShell.Lines("/redis/members"))
    Console.WriteLine($"  {l}{(l.StartsWith(avant.ActivationSilo) ? "   <- silo arrete" : "")}");
chrono.Restart();
var (statut, apres) = LabShell.Turn(sessionPropre, "tour 4");
Console.WriteLine($"\n[appel suivant] HTTP {statut} en {chrono.ElapsedMilliseconds} ms : tours={apres?.Turns}, activation sur {apres?.ActivationReplica} (pid {apres?.ActivationPid}), etag={apres?.ETag}");
Console.WriteLine($"etat retrouve (tours {avant.Turns} -> {apres?.Turns}) : {apres?.Turns == avant.Turns + 1} ; activation deplacee : {apres?.ActivationReplica != porteur}");
LabShell.Aspire($"resource {porteur} start");
Console.WriteLine($"\n[aspire resource {porteur} start] deux silos actifs de nouveau : {LabShell.WaitForCluster(2)}");
[avant] propre-05944834 : tours=3, activation sur silo-umyqtehs (pid 31016), etag=6c7353f8eb27423ebc51dd6121baad71
[aspire resource silo-umyqtehs stop] Resource 'silo-umyqtehs' stopped successfully. (1711 ms)
[redis] table d'appartenance apres l'arret :
  S172.18.0.1:34757:150084232 status=Active demarre=02:03:52Z
  S172.18.0.1:41169:150084232 status=Dead demarre=02:03:52Z   <- silo arrete

[appel suivant] HTTP 200 en 14 ms : tours=4, activation sur silo-ufxexndg (pid 31027), etag=06a39b62c8234def8fc94419fdebdbdd
etat retrouve (tours 3 -> 4) : True ; activation deplacee : True

[aspire resource silo-umyqtehs start] deux silos actifs de nouveau : True

Lecture : l’état n’a pas voyagé, il a été relu

Un arrêt propre suit le cycle de vie du silo : il désactive ses grains, puis annonce son départ dans la table d’appartenance, où sa ligne passe à Dead. L’appel suivant ne trouve plus d’activation dans le répertoire. Le runtime en crée une nouvelle sur le survivant, et le fournisseur sessions y relit l’état depuis Redis : la session reprend au quatrième tour, dans un autre process (PID différent).

Rien n’a été transféré d’un process à l’autre. L’ancien silo n’a rien transmis, le nouveau a tout relu, et c’est pour cela que la persistance du notebook 03 est la condition du basculement. Avec l’état en mémoire du notebook 02, le même geste aurait rendu tours=1. Le délai de l’appel suivant comprend la création de l’activation et la lecture Redis.

La réplique redémarrée revient avec un nouveau port de silo et une nouvelle génération. C’est un autre silo pour le cluster : la table relue en section 7 garde l’ancienne ligne Dead et en compte une nouvelle, Active.

7. Mort brutale d’une réplique

Un arrêt propre prévient le cluster, alors qu’un process tué ne prévient personne. La cellule relève d’abord les réglages de détection de panne du runtime, puis appelle Process.Kill() (l’équivalent de taskkill /F) sur le PID qui porte l’activation d’une session de trois tours. Elle rappelle ensuite la session une fois par seconde jusqu’au succès, en notant chaque réponse.

#nullable enable
// Mort brutale du silo qui porte l'activation, puis un appel par seconde jusqu'au succes.
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
var options = LabShell.Json("/membership/options");
double sonde = options.GetProperty("probeTimeout").GetDouble();
int manques = options.GetProperty("numMissedProbesLimit").GetInt32();
Console.WriteLine($"[options] ProbeTimeout={sonde} s, NumMissedProbesLimit={manques}, NumVotesForDeathDeclaration={options.GetProperty("numVotesForDeathDeclaration").GetInt32()}");
Console.WriteLine($"[options] ordre de grandeur de la detection : {manques} sondes manquees x {sonde} s = {sonde * manques} s");
var sessionCrash = $"crash-{Guid.NewGuid():N}"[..14];
Receipt avantCrash = null!;
for (int i = 1; i <= 3; i++) avantCrash = LabShell.Turn(sessionCrash, $"tour {i}").Receipt!;
var victime = avantCrash.ActivationReplica;
Console.WriteLine($"\n[avant] {sessionCrash} : tours={avantCrash.Turns}, activation sur {victime} (pid {avantCrash.ActivationPid})");
Process.GetProcessById(avantCrash.ActivationPid).Kill();   // TerminateProcess : aucun arret propre
var chronoCrash = Stopwatch.StartNew();
var essais = new List<string>();
Receipt? repris = null;
while (chronoCrash.Elapsed < TimeSpan.FromSeconds(90))
{
    var debut = chronoCrash.ElapsedMilliseconds;
    var (s, r) = LabShell.Turn(sessionCrash, "tour 4");
    essais.Add($"t+{debut / 1000.0:0.0} s -> HTTP {s} en {chronoCrash.ElapsedMilliseconds - debut} ms");
    if (s == 200) { repris = r; break; }
    Thread.Sleep(1000);
}
foreach (var e in essais) Console.WriteLine($"  {e}");
Console.WriteLine($"\n[reprise] premier succes {chronoCrash.ElapsedMilliseconds / 1000.0:0.0} s apres le kill, apres {essais.Count - 1} echec(s) : tours={repris?.Turns}, activation sur {repris?.ActivationReplica}");
Console.WriteLine($"aucun tour perdu ni double (attendu {avantCrash.Turns + 1}) : {repris?.Turns == avantCrash.Turns + 1}");
Console.WriteLine("[redis] table d'appartenance :");
foreach (var l in LabShell.Lines("/redis/members"))
    Console.WriteLine($"  {l}{(l.StartsWith(avantCrash.ActivationSilo) ? "   <- silo tue" : "")}");
var etatVictime = LabShell.Describe().FirstOrDefault(r => r.Name == victime)?.State ?? "?";
Console.WriteLine($"\n[aspire describe] {victime} : {etatVictime}");
if (etatVictime != "Running") LabShell.Aspire($"resource {victime} start");
Console.WriteLine($"[aspire resource {victime} start] deux silos actifs de nouveau : {LabShell.WaitForCluster(2)}");
[options] ProbeTimeout=5 s, NumMissedProbesLimit=3, NumVotesForDeathDeclaration=2
[options] ordre de grandeur de la detection : 3 sondes manquees x 5 s = 15 s

[avant] crash-cade3f3e : tours=3, activation sur silo-ufxexndg (pid 31027)
  t+0,0 s -> HTTP 500 en 36 ms
  t+1,0 s -> HTTP 500 en 545 ms
  t+2,6 s -> HTTP 500 en 306 ms
  t+3,9 s -> HTTP 500 en 522 ms
  t+5,4 s -> HTTP 500 en 480 ms
  t+6,9 s -> HTTP 500 en 466 ms
  t+8,4 s -> HTTP 500 en 311 ms
  t+9,7 s -> HTTP 500 en 388 ms
  t+11,1 s -> HTTP 500 en 410 ms
  t+12,5 s -> HTTP 500 en 409 ms
  t+13,9 s -> HTTP 500 en 482 ms
  t+15,4 s -> HTTP 200 en 92 ms

[reprise] premier succes 15,4 s apres le kill, apres 11 echec(s) : tours=4, activation sur silo-umyqtehs
aucun tour perdu ni double (attendu 4) : True
[redis] table d'appartenance :
  S172.18.0.1:34757:150084232 status=Dead demarre=02:03:52Z   <- silo tue
  S172.18.0.1:41169:150084232 status=Dead demarre=02:03:52Z
  S172.18.0.1:46851:150084245 status=Active demarre=02:04:05Z

[aspire describe] silo-ufxexndg : Finished
[aspire resource silo-ufxexndg start] deux silos actifs de nouveau : True

Lecture : la fenêtre d’indisponibilité est la détection de panne

Entre le kill et le premier succès, les appels échouent : le survivant route l’appel vers une activation qui, pour lui, existe encore sur un silo qu’il croit vivant. Cette fenêtre dure le temps que le survivant constate la mort, à force de sondes sans réponse, et l’inscrive dans la table (ligne Dead du silo tué). Sa durée se compare à l’ordre de grandeur calculé en tête de cellule. Sa forme, elle, varie d’une exécution à l’autre : plusieurs échecs rapides, ou un seul appel qui reste suspendu jusqu’à ce que la mort soit déclarée, puis échoue. Dans les deux cas, le premier succès suit la détection. Ce sont des réglages (ClusterMembershipOptions) : les raccourcir accélère la reprise au prix de faux positifs sur un réseau lent.

Deux faits comptent pour un workload IA :

  • aucun tour acquitté n’est perdu : chaque AppendTurnAsync a attendu l’écriture Redis avant de répondre, si bien qu’un tour confirmé au client survit au crash ;
  • les échecs sont visibles, pas silencieux : le client reçoit une erreur et doit réessayer. Ici les essais échoués n’avaient rien appliqué, d’où le compte exact. Mais une requête peut aussi échouer après avoir été appliquée, si la réponse se perd en route. Dans ce cas, le réessai appliquerait le tour deux fois. L’exercice 3 rend ce réessai sûr.

La réplique tuée a été redémarrée par la CLI (aspire resource <nom> start) : l’orchestrateur ne relance pas seul un process mort.

8. Redémarrer toute l’application

Dernier niveau : aspire stop puis aspire run, c’est-à-dire tous les silos et le conteneur Redis. La cellule écrit une session, relève ce que Redis a mis sur disque et les volumes montés sur son conteneur, redémarre tout, puis relit la session.

// Redemarrage complet : silos ET conteneur Redis.
using System.Linq;
var sessionDurable = $"durable-{Guid.NewGuid():N}"[..16];
Receipt ecrit = null!;
for (int i = 1; i <= 3; i++) ecrit = LabShell.Turn(sessionDurable, $"tour {i}").Receipt!;
Console.WriteLine($"[avant] {sessionDurable} : tours={ecrit.Turns}, etag={ecrit.ETag}");
var persistance = LabShell.Json("/redis/persistence");
Console.WriteLine($"[redis] ecritures pas encore sur disque : {persistance.GetProperty("changesSinceLastSave").GetInt32()} ; dernier instantane (ou demarrage de Redis) il y a {persistance.GetProperty("lastSaveAgeSeconds").GetInt64()} s ; AOF actif : {persistance.GetProperty("aofEnabled").GetBoolean()}");
foreach (var r in LabShell.Describe().Where(r => r.Type == "Container"))
    Console.WriteLine($"[aspire describe] {r.Name} ({r.Image}) : volumes montes = {(r.Volumes.Length == 0 ? "aucun" : string.Join(", ", r.Volumes))}");
var (sortieArret, orphelins) = LabShell.StopApp();
Console.WriteLine($"\n[aspire stop] {sortieArret.Trim().Split('\n').Last().Trim()}");
Console.WriteLine($"[docker] conteneurs encore en marche apres 'aspire stop', retires par identifiant : {orphelins}");
var relance = LabShell.StartApp();
Console.WriteLine($"[aspire run] deux silos actifs : {relance.Ready}");
var relu = LabShell.Session(sessionDurable);
Console.WriteLine($"\n[apres] {sessionDurable} : tours={relu?.Turns}, etag={relu?.ETag ?? "(aucun)"}");
Console.WriteLine("[redis] table d'appartenance apres redemarrage :");
foreach (var l in LabShell.Lines("/redis/members")) Console.WriteLine($"  {l}");
[avant] durable-dafd883d : tours=3, etag=13f5c18b9f7f460e90dd74eff7bf01d4
[redis] ecritures pas encore sur disque : 94 ; dernier instantane (ou demarrage de Redis) il y a 40 s ; AOF actif : False
[aspire describe] redis-twekazwm (docker.io/library/redis:8.6) : volumes montes = aucun

[aspire stop] apphost.cs stopped successfully.
[docker] conteneurs encore en marche apres 'aspire stop', retires par identifiant : 1
[aspire run] deux silos actifs : True

[apres] durable-dafd883d : tours=0, etag=(aucun)
[redis] table d'appartenance apres redemarrage :
  S172.18.0.1:35171:150084290 status=Active demarre=02:04:50Z
  S172.18.0.1:36255:150084290 status=Active demarre=02:04:50Z

Lecture : Redis a survécu aux silos, pas à lui-même

Avec le lab tel que livré, la session relue est vide (tours=0, pas d’ETag). Les sections 6 et 7 ont montré que l’état survit à la mort d’un silo, parce qu’il vit dans Redis. Mais le conteneur Redis n’a aucun volume : ses données vivent dans le système de fichiers du conteneur, qui disparaît avec lui. Même un Redis qui aurait gardé son disque n’aurait pas tout : la ligne ecritures pas encore sur disque compte les modifications, sessions et table d’appartenance confondues, que Redis n’avait pas encore reportées dans un instantané.

La table d’appartenance, elle, repart de zéro, avec deux silos de nouvelle génération et aucune ligne Dead. C’est ce qui a permis au cluster de se reformer. L’exercice 1 montre pourquoi ce détail compte.

La ligne docker est la mesure annoncée en tête du notebook : après aspire stop, le conteneur Redis tournait encore, et StopApp l’a retiré par son identifiant. Sans ce geste, chaque redémarrage du lab laisserait un conteneur orphelin de plus.

Une fois l’exercice 1 résolu, cette même cellule relit tours=3.

9. Exercices

Trois exercices, chacun vérifié par une cellule qui relance toute l’application (StartApp) : une modification de apphost.cs ou de Grains.cs n’a d’effet qu’après ce cycle, qui prend une vingtaine de secondes. La cellule suivante déclare les trois vérifications. Chacune distingue trois issues : le témoin du lab tel que livré, une solution à revoir, et OK.

// Verifications des exercices (declarations seules, comme LabShell).
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading;

public static class Verif
{
    const string NonDemarre = "le cluster n'a pas demarre : lire 'aspire describe' et 'aspire logs <replique>'";

    public static string Ex1()
    {
        var log = new StringBuilder();
        if (!LabShell.StartApp().Ready) return $"[ex1] {NonDemarre}";
        var volumes = LabShell.Describe().Where(r => r.Type == "Container").SelectMany(r => r.Volumes).ToList();
        log.AppendLine($"[ex1] volumes montes sur les conteneurs Redis : {(volumes.Count == 0 ? "aucun" : string.Join(", ", volumes))}");
        var session = $"ex1-{Guid.NewGuid():N}"[..12];
        for (int i = 1; i <= 3; i++) LabShell.Turn(session, $"tour {i}");
        // Laisser a Redis le temps de reporter les ecritures sur disque (15 s au plus).
        int enAttente = -1;
        var chrono = Stopwatch.StartNew();
        while (chrono.Elapsed < TimeSpan.FromSeconds(15))
        {
            enAttente = LabShell.Json("/redis/persistence").GetProperty("changesSinceLastSave").GetInt32();
            if (enAttente == 0) break;
            Thread.Sleep(1000);
        }
        log.AppendLine($"[ex1] 3 tours ecrits ; ecritures pas encore sur disque apres {chrono.Elapsed.TotalSeconds:0} s : {enAttente}");
        if (!LabShell.StartApp().Ready)
        {
            var silos = LabShell.Describe().Where(r => r.Resource == "silo").Select(r => $"{r.Name} {r.State}");
            log.AppendLine($"[ex1] apres redemarrage, le cluster ne se reforme pas : {string.Join(", ", silos)}");
            log.Append("[ex1] a revoir : la table d'appartenance a survecu avec l'etat. Les nouveaux silos y trouvent "
                     + "les anciens, morts mais toujours 'Active', et refusent d'entrer sans les avoir joints.");
            return log.ToString();
        }
        var relu = LabShell.Session(session);
        log.AppendLine($"[ex1] apres redemarrage complet : tours={relu?.Turns}, etag={relu?.ETag ?? "(aucun)"}");
        if (relu?.Turns == 3)
            log.Append("[ex1] OK : l'etat des sessions a survecu au redemarrage de toute l'application");
        else if (volumes.Count == 0)
            log.Append("[ex1] temoin du lab livre : aucun volume, le Redis redemarre vide. Completer apphost.cs puis relancer cette cellule.");
        else if (enAttente != 0)
            log.Append("[ex1] a revoir : un volume est monte, mais les ecritures n'etaient pas encore sur disque a l'arret.");
        else
            log.Append("[ex1] a revoir : les ecritures etaient sur disque, mais pas dans le Redis que lit le fournisseur 'sessions'.");
        return log.ToString();
    }

    public static string Ex2()
    {
        if (!LabShell.StartApp().Ready) return $"[ex2] {NonDemarre}";
        var attribut = LabShell.Json("/placement").GetProperty("sessionGrainAttribute").GetString();
        var lot = Enumerable.Range(1, 16).Select(_ => LabShell.Turn($"ex2-{Guid.NewGuid():N}"[..12], "premier tour").Receipt!).ToList();
        int colocalisees = lot.Count(r => r.HttpReplica == r.ActivationReplica);
        var log = $"[ex2] attribut de placement sur SessionGrain : {attribut} ; activations colocalisees : {colocalisees}/16\n";
        if (colocalisees == 16) return log + "[ex2] OK : chaque session est nee sur la replique qui a recu sa premiere requete";
        if (attribut == "(aucun)") return log + "[ex2] temoin du lab livre : placement par defaut. Completer Grains.cs puis relancer cette cellule.";
        return log + $"[ex2] a revoir : l'attribut {attribut} ne place pas l'activation sur le silo appelant";
    }

    public static string Ex3()
    {
        if (!LabShell.StartApp().Ready) return $"[ex3] {NonDemarre}";
        var session = $"ex3-{Guid.NewGuid():N}"[..12];
        var premier = LabShell.Once(session, "req-1", "bonjour").Receipt!;
        var rejeu = LabShell.Once(session, "req-1", "bonjour").Receipt!;
        var second = LabShell.Once(session, "req-2", "et ensuite ?").Receipt!;
        var log = $"[ex3] req-1          -> tours={premier.Turns} etag={premier.ETag}\n"
                + $"[ex3] req-1 rejouee  -> tours={rejeu.Turns} etag={rejeu.ETag}\n"
                + $"[ex3] req-2          -> tours={second.Turns} etag={second.ETag}\n";
        if (premier.Turns == 1 && rejeu.Turns == 1 && second.Turns == 2 && rejeu.ETag == premier.ETag)
            return log + "[ex3] OK : le rejeu est ecarte sans ecriture (ETag inchange), la requete suivante s'applique";
        if (rejeu.Turns == 2)
            return log + "[ex3] temoin du lab livre : le rejeu ajoute un second tour. Completer Grains.cs puis relancer cette cellule.";
        return log + "[ex3] a revoir : attendu 1 puis 1 (meme ETag) puis 2";
    }
}

Exercice 1 : un état qui survit au redémarrage de l’application

Dans OrleansClusterLab/apphost.cs, faire en sorte que l’état des sessions survive au redémarrage de toute l’application (section 8). La cellule écrit trois tours, attend jusqu’à 15 s que Redis les ait reportés sur disque, redémarre l’application et relit la session. Attendu : tours=3.

  • Indice : la ressource Redis d’Aspire sait monter un volume de données nommé, et régler la fréquence de ses instantanés (WithPersistence). Sans ce second réglage, l’image Redis n’écrit un instantané qu’au bout de 60 s d’activité, et le redémarrage arrive avant.
  • Piège mesuré en construisant ce lab : ce Redis porte aussi la table d’appartenance. Si elle devient durable, les silos d’avant le redémarrage y restent Active. Les nouveaux silos tentent de les joindre avant d’entrer dans le cluster, n’y parviennent pas et s’arrêtent (OrleansClusterConnectivityCheckFailedException). La cellule le détecte et répond a revoir. La sortie ne passe pas par un nettoyage de la table : elle passe par la séparation des deux usages, une appartenance éphémère et un état durable.
  • Si ce piège s’est déclenché, le volume garde les silos fantômes d’une exécution à l’autre : docker volume rm <nom> repart de zéro.
// Exercice 1 : un etat durable, une appartenance ephemere.
// TODO etudiant : completer la declaration Redis dans OrleansClusterLab/apphost.cs, puis relancer cette cellule.
Console.WriteLine(Verif.Ex1());
[ex1] volumes montes sur les conteneurs Redis : aucun
[ex1] 3 tours ecrits ; ecritures pas encore sur disque apres 15 s : 16
[ex1] apres redemarrage complet : tours=0, etag=(aucun)
[ex1] temoin du lab livre : aucun volume, le Redis redemarre vide. Completer apphost.cs puis relancer cette cellule.

Exercice 2 : activer là où arrive la requête

Dans OrleansClusterLab/Silo/Grains.cs, faire naître chaque nouvelle activation de SessionGrain sur le silo qui a reçu la requête HTTP, pour supprimer le saut réseau mesuré en section 5. La cellule crée 16 sessions neuves. Attendu : 16/16 activations colocalisées.

  • Indice : un attribut de placement du namespace Orleans.Placement, posé sur la classe du grain.
  • Étape 2 : se demander ce que devient ce placement quand un silo reçoit beaucoup plus de requêtes que l’autre, et pourquoi Orleans ne l’a pas choisi par défaut.
// Exercice 2 : placement local.
// TODO etudiant : completer SessionGrain dans OrleansClusterLab/Silo/Grains.cs, puis relancer cette cellule.
Console.WriteLine(Verif.Ex2());
[ex2] attribut de placement sur SessionGrain : (aucun) ; activations colocalisees : 8/16
[ex2] temoin du lab livre : placement par defaut. Completer Grains.cs puis relancer cette cellule.

Exercice 3 : un tour au plus une fois

Dans OrleansClusterLab/Silo/Grains.cs, compléter AppendOnceAsync(requestId, text) : si requestId a déjà été appliqué, retourner le reçu sans écrire ; sinon ajouter le tour, mémoriser requestId dans l’état, puis écrire. La cellule envoie req-1, rejoue req-1, puis envoie req-2. Attendu : tours vaut 1, puis 1 avec le même ETag, puis 2.

  • Indice : SessionState.RequestIds existe déjà. Mémoriser l’identifiant dans l’état persisté, et non dans un champ du grain, est ce qui rend la déduplication valable même après un basculement comme celui de la section 7.
  • Étape 2 : cette liste grandit sans fin. Proposer une borne (les N derniers identifiants, ou une fenêtre de temps) et dire ce qu’elle coûte en garantie.
// Exercice 3 : ajout idempotent.
// TODO etudiant : completer AppendOnceAsync dans OrleansClusterLab/Silo/Grains.cs, puis relancer cette cellule.
Console.WriteLine(Verif.Ex3());
[ex3] req-1          -> tours=1 etag=0e51bb61176f42bdb24c8633d8ba2f4a
[ex3] req-1 rejouee  -> tours=2 etag=c357236ab8844c14a9c4fcab8dc3364d
[ex3] req-2          -> tours=3 etag=1fbe25059ae6496dbc67b56ac9c688b5
[ex3] temoin du lab livre : le rejeu ajoute un second tour. Completer Grains.cs puis relancer cette cellule.

Lecture des témoins : ce que le lab livré laisse voir

Les trois sorties ci-dessus sont celles du lab tel que livré, et chacune dit déjà ce qui manque :

  • Exercice 1 : aucun volume monté, des écritures jamais reportées sur disque dans les 15 s d’attente, et une session relue vide après le redémarrage. C’est la section 8, rejouée.
  • Exercice 2 : aucun attribut sur SessionGrain, et un compte de colocalisations sous 16, qui n’égale pas forcément celui de la section 5 : la préférence locale de ResourceOptimizedPlacement ne joue que dans sa marge.
  • Exercice 3 : le rejeu de req-1 ajoute un second tour, et l’ETag change à chaque appel. Un client qui réessaie après un échec, comme en section 7, dupliquerait donc le tour.

Les trois exercices ont été vérifiés solubles, avec une copie de la solution tenue hors du dépôt : tours=3 après redémarrage, 16/16, puis 1, 1, 2 avec l’ETag inchangé.

10. Garde SOTA : le vrai outil, mesuré

Le lab doit exécuter les vrais runtimes, sans réimplémentation : Orleans 10.3.1 avec ses fournisseurs Redis officiels, l’intégration Aspire officielle et un vrai serveur Redis. La garde vérifie aussi, sur le code, le fait central du notebook : le silo ne contient aucun code de clustering ni de stockage.

// Garde SOTA : paquets reels, AppHost reel, silo sans infrastructure, Redis reel.
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
string csprojSilo = File.ReadAllText(Path.Combine(LabShell.LabDir, "Silo", "Silo.csproj"));
string apphost = File.ReadAllText(Path.Combine(LabShell.LabDir, "apphost.cs"));
string codeSilo = Regex.Replace(File.ReadAllText(Path.Combine(LabShell.LabDir, "Silo", "Program.cs")), @"//.*", "");
bool orleansReel = new[] { "Microsoft.Orleans.Server", "Microsoft.Orleans.Clustering.Redis", "Microsoft.Orleans.Persistence.Redis" }
    .All(p => Regex.IsMatch(csprojSilo, $@"Include=""{Regex.Escape(p)}"" Version=""10\.3\.1"""));
bool aspireReel = new[] { "Aspire.AppHost.Sdk@13.4.6", "Aspire.Hosting.Orleans@13.4.6", "Aspire.Hosting.Redis@13.4.6",
                          ".WithClustering(", ".WithGrainStorage(", ".WithReplicas(2)" }.All(apphost.Contains);
bool siloSansInfra = codeSilo.Contains("builder.UseOrleans();") && !Regex.IsMatch(codeSilo, @"Use\w*Clustering|Add\w*GrainStorage");
var images = LabShell.Describe().Where(r => r.Type == "Container").Select(r => r.Image).ToList();
Console.WriteLine($"runtime Orleans 10.3.1 + fournisseurs Redis officiels : {orleansReel}");
Console.WriteLine($"AppHost Aspire 13.4.6 : clustering, stockage et repliques declares : {aspireReel}");
Console.WriteLine($"silo sans code de clustering ni de stockage : {siloSansInfra}");
Console.WriteLine($"serveur Redis reel (image du conteneur) : {string.Join(", ", images)}");
if (!orleansReel || !aspireReel || !siloSansInfra || images.Count == 0) throw new InvalidOperationException("garde SOTA violee");
runtime Orleans 10.3.1 + fournisseurs Redis officiels : True
AppHost Aspire 13.4.6 : clustering, stockage et repliques declares : True
silo sans code de clustering ni de stockage : True
serveur Redis reel (image du conteneur) : docker.io/library/redis:8.6

11. Arrêter proprement

Dernier geste : arrêter l’application, retirer les conteneurs qu’aspire stop laisse derrière lui, et lister les volumes nommés. Le lab livré n’en monte aucun. Une solution de l’exercice 1 en ajoute un, que ce geste conserve volontairement, puisque c’est l’état durable construit par l’exercice (docker volume rm <nom> repart de zéro).

// Arret de l'application et bilan de ce qui reste sur la machine.
using System.Linq;
using System.Net.Sockets;
var volumesNommes = LabShell.Describe().SelectMany(r => r.Volumes).ToList();
var (sortieFinale, orphelinsFinaux) = LabShell.StopApp();
Console.WriteLine($"[aspire stop] {sortieFinale.Trim().Split('\n').Last().Trim()}");
Console.WriteLine($"[docker] conteneurs laisses par 'aspire stop', retires par identifiant : {orphelinsFinaux}");
Console.WriteLine($"[docker] volumes nommes conserves : {(volumesNommes.Count == 0 ? "aucun" : string.Join(", ", volumesNommes))}");
bool portLibere;
try
{
    var tcp = new TcpClient();
    var connexion = tcp.BeginConnect("127.0.0.1", 5310, null, null);
    portLibere = !(connexion.AsyncWaitHandle.WaitOne(1500) && tcp.Connected);
    tcp.Close();
}
catch { portLibere = true; }
Console.WriteLine($"[api] port 5310 libere : {portLibere}");
[aspire stop] apphost.cs stopped successfully.
[docker] conteneurs laisses par 'aspire stop', retires par identifiant : 1
[docker] volumes nommes conserves : aucun
[api] port 5310 libere : True

Ce que ce notebook ne couvre pas (limites honnêtes)

  • Un seul Redis, sans réplication : la durabilité de l’exercice 1 est celle d’un volume Docker local. En production, la question se déplace vers un Redis répliqué ou managé, ou vers un autre fournisseur (Azure Table, Cosmos DB, ADO.NET), déclaré de la même façon par l’AppHost.
  • Deux silos sur une machine : pas de partition réseau ni de latence réelle. La détection mesurée en section 7 est celle d’un process tué, dont les connexions se ferment aussitôt. Un réseau coupé laisse les sondes expirer, et c’est là que les votes entre silos (NumVotesForDeathDeclaration) prennent leur sens, ce qu’un cluster de deux silos ne peut pas montrer.
  • Pas de client Orleans externe : la passerelle (Endpoints:GatewayPort) n’est pas exercée. L’API HTTP co-hébergée dans chaque silo joue le rôle de client, ce qui est un choix fréquent mais pas le seul.
  • Le proxy d’Aspire est un outil de développement : en production, la répartition HTTP revient à un équilibreur de charge, et le nombre de répliques à l’orchestrateur de la cible de déploiement.
  • Le conteneur laissé par aspire stop a été mesuré avec la CLI 13.5.2 et l’AppHost 13.4.6. Le nettoyage de StopApp est un geste local, à revérifier sur une version ultérieure.

Conclusion

Notebook 03 Ce notebook
Qui choisit le fournisseur le code du silo (AddRedisGrainStorage) l’AppHost (WithGrainStorage), injecté en configuration
Qui trouve les autres silos personne : un silo, ou deux clusters fabriqués la table d’appartenance Redis, lue par chaque réplique
Ce à quoi une session survit la mort du process qui l’a écrite l’arrêt propre et la mort brutale du silo qui l’héberge
Ce qui reste à décider la durabilité de Redis la durabilité de Redis, sans rendre l’appartenance durable (exercice 1)

Le motif à retenir pour un workload IA : une session d’agent n’appartient à aucun serveur. Elle vit dans le cluster, s’active où l’on en a besoin et se relit ailleurs quand son silo disparaît. La couche d’orchestration, elle, décrit cette topologie en quelques lignes d’AppHost au lieu de la coder dans chaque service.

Où aller ensuite

  • Le registre des axes de la série, où chaque axe distillé renvoie à son grain.
  • L’EPIC #10473 pour le fil complet The Unexpected AI Stack.
  • Les notebooks 01 (le modèle acteur), 02 (le silo orchestré) et 03 (l’état persisté), dont celui-ci assemble les pièces.
Retour au sommet