Troisième volet Orleans de la série The Unexpected AI Stack: C#/.NET (EPIC #10473). Les deux premiers notebooks terminent sur la même limite, écrite noir sur blanc dans leurs sections « limites honnêtes » : l’état des grains est volatil. Il vit dans la mémoire du silo, et il meurt avec lui.
01
02
03 (ce notebook)
Où vit l’état
champ privé du grain
champ privé du grain
IPersistentState<T> injecté, écrit dans un fournisseur
Ce qui le détruit
la fin du process
aspire stop
la suppression de l’enregistrement dans Redis
Topologie
silo co-hébergé
silo orchestré par Aspire
silo co-hébergé, un ou deux silos
Pour un workload IA, la différence n’est pas cosmétique : une session d’agent qui perd son historique au redéploiement du service n’est pas une session, c’est un cache.
Ce que ce notebook exécute
Le dossier adjacent OrleansPersistenceLab/ est un projet .NET 10 réel qui référence Microsoft.Orleans.ServeretMicrosoft.Orleans.Persistence.Redis 10.3.1. Chaque scénario est une invocation séparée de dotnet run : un process démarre un silo, joue le scénario, puis meurt. C’est ce qui rend les mesures honnêtes : ce qui survit d’une invocation à l’autre a forcément transité par le stockage.
Trois mesures :
Survie au redémarrage : la même session écrite puis relue par deux process différents, une fois avec Redis, une fois avec le stockage mémoire. Le code du grain est identique dans les deux cas.
Ce qui est écrit : la clé et le document réellement stockés dans Redis, lus avec redis-cli.
Concurrence optimiste : deux activations du même grain, sur deux silos, écrivent le même enregistrement ; l’ETag arbitre.
Redis tourne dans un conteneur Docker (redis:7-alpine) démarré et arrêté par ce notebook.
1. Un Redis réel, dans un conteneur
La cellule suivante définit l’utilitaire Run (lancer un programme et capturer sa sortie), retire un éventuel conteneur laissé par une exécution précédente, puis démarre un Redis vierge sur le port 6390 de l’hôte. Le port n’est pas le 6379 par défaut, pour ne pas entrer en collision avec un Redis déjà présent sur la machine.
// Demarrer un Redis vierge dans un conteneur, et definir l'utilitaire de lancement.using System.Diagnostics;using System.Threading;constint RedisPort =6390;conststring RedisContainer ="coursia-orleans-redis";(int Exit,string Out,string Err)Run(string file,string arguments,string workDir ="."){var psi =newProcessStartInfo(file, arguments){ WorkingDirectory = workDir, RedirectStandardOutput =true, RedirectStandardError =true, UseShellExecute =false,};// Le lab lit l'adresse de Redis dans cette variable : rien n'est code en dur dans le lab. psi.Environment["ORLEANS_REDIS"]= $"localhost:{RedisPort}";var proc = Process.Start(psi)!;var stdout = proc.StandardOutput.ReadToEndAsync();var stderr = proc.StandardError.ReadToEndAsync(); proc.WaitForExit();return(proc.ExitCode, stdout.Result, stderr.Result);}// Repartir d'une base vide : le conteneur d'une execution precedente est retire s'il existe.Run("docker", $"rm -f {RedisContainer}");var started =Run("docker", $"run -d --rm -p {RedisPort}:6379 --name {RedisContainer} redis:7-alpine");Console.WriteLine($"[redis] docker run exit = {started.Exit}");string pong ="";for(int i =0; i <20&& pong !="PONG"; i++){ Thread.Sleep(500); pong =Run("docker", $"exec {RedisContainer} redis-cli PING").Out.Trim();}Console.WriteLine($"[redis] PING -> {pong}");Console.WriteLine($"[redis] {Run("docker", $"exec {RedisContainer} redis-server --version").Out.Trim()}");Console.WriteLine($"[redis] cles au depart (DBSIZE) = {Run("docker", $"exec {RedisContainer} redis-cli DBSIZE").Out.Trim()}");
The below script needs to be able to find the current output cell; this is an easy method to get it.
[redis] docker run exit = 0
[redis] PING -> PONG
[redis] Redis server v=7.4.7 sha=00000000:0 malloc=jemalloc-5.3.0 bits=64 build=31a84af2bcf7caf1
[redis] cles au depart (DBSIZE) = 0
Lecture du démarrage
PONG atteste que le serveur répond, et DBSIZE = 0 que la base est vide : toute clé qui apparaîtra plus loin aura été écrite par le lab, pendant ce notebook. La version affichée est celle du binaire redis-server qui tourne réellement dans le conteneur, pas celle d’une documentation.
2. Construire le lab
Le lab tient en trois fichiers. Grains.cs déclare un seul grain, PersistentSessionGrain, dont l’état n’est plus un champ privé mais un IPersistentState<SessionState> reçu par injection :
"session" nomme l’état à l’intérieur du grain, "sessions" nomme le fournisseur enregistré sur le silo. Le grain ne sait pas où vit son état : c’est Program.cs qui choisit, au démarrage du silo, entre AddRedisGrainStorage("sessions", ...) et AddMemoryGrainStorage("sessions").
// Construire le lab (vrai MSBuild : grains, generateur de code Orleans, fournisseur Redis).var labDir ="OrleansPersistenceLab";var build =Run("dotnet","build -v q", labDir);foreach(var line in build.Out.Split('\n').Where(l => l.Contains("Erreur(s)")|| l.Contains("Avertissement(s)")|| l.Contains("Error(s)")|| l.Contains("Warning(s)"))){ Console.WriteLine(line.Trim());}Console.WriteLine($"[cellule] build exit code = {build.Exit}");// Utilitaire des exercices : reconstruire le lab (les stubs vivent dans Grains.cs), puis jouer le scenario.stringExercise(string scenario){var rebuild =Run("dotnet","build -v q", labDir);if(rebuild.Exit!=0){return"[build] echec : "+string.Join(" | ", rebuild.Out.Split('\n').Where(l => l.Contains("error")).Take(3).Select(l => l.Trim()));}returnRun("dotnet", $"run --no-build -- {scenario}", labDir).Out.TrimEnd();}
Zéro erreur, zéro avertissement, exit code 0 : les proxys du grain sont générés à la compilation, et le fournisseur Redis est lié au silo. Le build n’exécute encore rien ; la suite lance le binaire produit, une fois par scénario, avec dotnet run --no-build.
3. Survie au redémarrage : Redis contre mémoire
Le scénario write ajoute trois tours à une session puis arrête son silo : le process se termine. Le scénario read démarre un autre process, avec un silo neuf, et relit la même session. Rien ne relie les deux process sinon le fournisseur de stockage. La première ligne de chaque process affiche son pid : c’est la preuve qu’on parle bien de deux process.
On commence par Redis.
// Ecrire trois tours avec Redis, laisser mourir le process, relire dans un process neuf.var writeRedis =Run("dotnet","run --no-build -- write redis session-redis 3", labDir);var readRedis =Run("dotnet","run --no-build -- read redis session-redis", labDir);Console.WriteLine(writeRedis.Out.TrimEnd());Console.WriteLine(readRedis.Out.TrimEnd());Console.WriteLine($"[cellule] exit write = {writeRedis.Exit}, exit read = {readRedis.Exit}");
Le process d’écriture et celui de lecture ont deux pid différents, et pourtant le second relit tours=3 tokens=240 : les trois tours et leurs 240 tokens cumulés (40 + 80 + 120). Il relit aussi le même ETag que celui produit par l’écriture : ce n’est pas un état recalculé, c’est l’enregistrement écrit par le premier process, lu tel quel.
Deux détails se lisent dans la sortie :
avant la première écriture, le grain affiche existe=False et aucun ETag : une activation sans enregistrement démarre sur un SessionState neuf, elle ne lève pas d’erreur ;
l’écriture est explicite (WriteStateAsync dans AppendTurnAsync) : Orleans ne persiste rien de lui-même, c’est le grain qui décide quand son état devient durable.
Même scénario, même code de grain, avec le fournisseur mémoire.
Lecture : le fournisseur mémoire n’est pas un stockage
L’écriture se déroule exactement comme avec Redis : trois tours, 240 tokens, un ETag, existe=True. Le grain ne voit aucune différence. Mais le process de lecture repart de tours=0 et existe=False : l’état écrit vivait dans la mémoire du premier silo, et il est mort avec lui.
C’est le point exact où le choix du fournisseur devient visible. AddMemoryGrainStorage est un vrai fournisseur, utile en test, mais il ne survit pas au process. La cellule suivante vérifie ces deux constats contre les sorties réelles plutôt que contre la prose.
// Verifier les deux constats contre les sorties reelles (fail-loud).using System.Text.RegularExpressions;var etatPattern = @"tours=(\d+) tokens=(\d+) etag=(\S+) existe=(\w+)";(int Pid,int Tours,long Tokens,string ETag,bool Existe)Parse(string output,string marker){var pid =int.Parse(Regex.Match(output, @"pid=(\d+)").Groups[1].Value);var m = Regex.Match(output, Regex.Escape(marker)+ @"\s*: "+ etatPattern);return(pid,int.Parse(m.Groups[1].Value),long.Parse(m.Groups[2].Value), m.Groups[3].Value, m.Groups[4].Value=="True");}var wR =Parse(writeRedis.Out,"[write] apres");var rR =Parse(readRedis.Out,"[read] etat relu");var wM =Parse(writeMemory.Out,"[write] apres");var rM =Parse(readMemory.Out,"[read] etat relu");Console.WriteLine($"redis : pid {wR.Pid} -> {rR.Pid}, tours {wR.Tours} -> {rR.Tours}, tokens {wR.Tokens} -> {rR.Tokens}, meme etag : {wR.ETag == rR.ETag}");Console.WriteLine($"memoire : pid {wM.Pid} -> {rM.Pid}, tours {wM.Tours} -> {rM.Tours}, tokens {wM.Tokens} -> {rM.Tokens}, existe apres relecture : {rM.Existe}");var echecs =new List<string>();if(wR.Pid== rR.Pid|| wM.Pid== rM.Pid) echecs.Add("ecriture et lecture doivent etre deux process distincts");if(rR.Tours!= wR.Tours|| rR.Tokens!= wR.Tokens|| rR.ETag!= wR.ETag) echecs.Add("redis : l'etat relu differe de l'etat ecrit");if(wM.Tours!=3|| rM.Tours!=0|| rM.Existe) echecs.Add("memoire : l'etat aurait du etre perdu avec le process");if(echecs.Count>0)thrownewException("Invariants de persistance violes : "+string.Join(" ; ", echecs));Console.WriteLine("[verification] OK : Redis conserve l'etat entre deux process, la memoire le perd");
redis : pid 32452 -> 30404, tours 3 -> 3, tokens 240 -> 240, meme etag : True
memoire : pid 27992 -> 35680, tours 3 -> 0, tokens 240 -> 0, existe apres relecture : False
[verification] OK : Redis conserve l'etat entre deux process, la memoire le perd
Interprétation : l’identité survit, l’état survit seulement s’il est stocké
Le lab 01 montrait que l’identité d’un grain survit à la référence C# : une nouvelle référence sur la même clé retrouve le même grain. Cette mesure va un cran plus loin : elle montre que l’identité survit aussi au process, et que l’état la suit si, et seulement si, un fournisseur durable est branché. La clé session-redis a été réactivée dans un silo qui n’avait jamais vu ce grain, et l’activation a relu son état au démarrage (ReadStateAsync implicite à l’activation).
Pour une session d’agent, c’est la propriété qui compte : le silo peut être redéployé, déplacé ou redémarré après un crash, la conversation reprend là où elle s’était arrêtée. Et le code métier du grain n’a pas changé d’une ligne entre les deux fournisseurs : la durabilité est une décision de configuration, prise sur le silo.
4. Ce qui est réellement écrit dans Redis
Qu’a laissé le lab dans Redis ? On interroge le serveur directement, avec redis-cli, sans passer par Orleans.
// Lire directement dans Redis ce que le fournisseur Orleans y a ecrit.stringCli(string arguments)=>Run("docker", $"exec {RedisContainer} redis-cli {arguments}").Out.Trim();var keys =Cli("--scan").Split('\n').Select(k => k.Trim()).Where(k => k.Length>0).ToList();Console.WriteLine($"[redis] DBSIZE = {Cli("DBSIZE")}");foreach(var key in keys) Console.WriteLine($"[redis] cle : {key}");var sessionKey = keys.First(k => k.Contains("session-redis"));Console.WriteLine($"[redis] TYPE -> {Cli("TYPE " + sessionKey)}");Console.WriteLine($"[redis] TTL -> {Cli("TTL " + sessionKey)}");Console.WriteLine("[redis] HGETALL :");Console.WriteLine(Cli("HGETALL "+ sessionKey));
Une seule clé. La session session-memoire n’a jamais atteint Redis : son fournisseur était la mémoire du silo. DBSIZE = 1 le confirme.
Le nom de la clé est une adresse : orleans-persistence-lab/state/persistentsession/session-redis/session, soit le ServiceId du silo, le type de grain, la clé du grain et le nom de l’état. C’est le ServiceId, et non le ClusterId, qui préfixe la clé : deux clusters qui partagent un ServiceId partagent donc leurs enregistrements. La section suivante s’en sert.
Un hash à deux champs : data porte l’état, etag porte sa version. Le TTL vaut -1, la clé n’expire pas : c’est un enregistrement, pas un cache.
Le document est un JSON qui nomme le type ("$type":"OrleansPersistenceLab.SessionState, OrleansPersistenceLab") et les propriétés par leur nom (Turns, TokenTotal). Les attributs [Id(0)] et [Id(1)] de SessionState n’y apparaissent pas : ils gouvernent la sérialisation des messages Orleans (ici le SessionSnapshot qui traverse l’appel de grain), pas le format de stockage du fournisseur Redis. Le nom de type qualifié est inscrit dans chaque enregistrement : renommer la classe ou son espace de noms est un changement de format de stockage, à traiter comme une migration.
5. Deux activations, un seul enregistrement : l’ETag
À l’intérieur d’un cluster sain, le répertoire d’Orleans garantit qu’un grain a une seule activation. Le stockage ne peut pas compter uniquement sur cette garantie : une partition réseau, une désactivation qui chevauche une réactivation, ou deux déploiements qui partagent un ServiceId peuvent produire deux activations du même grain.
Le scénario conflict fabrique cette situation délibérément : deux silos dans deux clusters différents (ClusterId distincts) mais avec le mêmeServiceId, donc la même clé Redis. Chacun active la session et la lit ; A écrit en premier, puis B tente d’écrire à son tour.
// Deux silos, deux activations du meme grain, une meme cle Redis.var conflict =Run("dotnet","run --no-build -- conflict session-partagee", labDir);Console.WriteLine(conflict.Out.TrimEnd());Console.WriteLine($"[cellule] exit = {conflict.Exit}");
[conflict] A lit : tours=0 tokens=0 etag=(aucun) existe=False
[conflict] B lit : tours=0 tokens=0 etag=(aucun) existe=False
[conflict] A ecrit : tours=1 tokens=100 etag=da499a2f5dbd4413ad760aa1ceca3bd8 existe=True
[conflict] B refuse : InconsistentStateException
[conflict] Version conflict (WriteStateAsync): ServiceId=orleans-persistence-lab ProviderName=sessions GrainType=session GrainId=persistentsession/session-partagee ETag=.
[conflict] B relit : tours=1 tokens=100 etag=da499a2f5dbd4413ad760aa1ceca3bd8 existe=True
[conflict] user : ecrit par le silo A
[cellule] exit = 0
Lecture : l’écriture de B est refusée, pas écrasée
Les deux activations lisent un enregistrement absent (existe=False, aucun ETag). A écrit la première : l’enregistrement naît avec un ETag. B tente ensuite d’écrire en présentant l’ETag qu’il avait lu, c’est-à-dire aucun (ETag=. dans le message). Le fournisseur compare cet ETag à celui qui est stocké, constate qu’ils diffèrent, et lève InconsistentStateException au lieu d’écrire.
Après ReloadAsync, B voit l’état réel : un seul tour, celui de A, et l’ETag que A a produit. Le tour de B n’a pas été écrit. Sans cette comparaison, la seconde écriture remplacerait le champ data entier, qui porte tout l’historique (section 4) : le tour de A disparaîtrait sans erreur ni trace. La cellule suivante vérifie ce déroulé contre la sortie.
// Verifier le deroule du conflit contre la sortie reelle (fail-loud).var lignes = conflict.Out.Split('\n').Select(l => l.TrimEnd()).ToList();stringetagDe(string marker)=> Regex.Match(lignes.First(l => l.StartsWith(marker)), @"etag=(\S+)").Groups[1].Value;bool refus = lignes.Any(l => l.Contains("B refuse : InconsistentStateException"));bool ecrasement = lignes.Any(l => l.Contains("ecrasement silencieux"));var toursRelus = lignes.SkipWhile(l =>!l.StartsWith("[conflict] B relit")).Skip(1).Where(l => l.StartsWith("[conflict] ")).ToList();string etagA =etagDe("[conflict] A ecrit");string etagRelu =etagDe("[conflict] B relit");Console.WriteLine($"ecriture de B refusee : {refus}");Console.WriteLine($"tours visibles apres relecture : {toursRelus.Count} ({string.Join("|", toursRelus.Select(t => t.Trim()))})");Console.WriteLine($"ETag relu par B = ETag ecrit par A : {etagRelu == etagA}");var echecsConflit =new List<string>();if(!refus || ecrasement) echecsConflit.Add("la seconde ecriture aurait du etre refusee");if(toursRelus.Count!=1||!toursRelus[0].Contains("silo A")) echecsConflit.Add("seul le tour de A doit etre stocke");if(etagRelu != etagA) echecsConflit.Add("B doit relire la version ecrite par A");if(echecsConflit.Count>0)thrownewException("Invariants du conflit violes : "+string.Join(" ; ", echecsConflit));Console.WriteLine("[verification] OK : concurrence optimiste, la premiere ecriture gagne et la seconde est rejetee");
ecriture de B refusee : True
tours visibles apres relecture : 1 ([conflict] user : ecrit par le silo A)
ETag relu par B = ETag ecrit par A : True
[verification] OK : concurrence optimiste, la premiere ecriture gagne et la seconde est rejetee
Interprétation : l’ETag déplace le conflit vers le code du grain
La concurrence optimiste ne résout pas le conflit, elle le rend visible : l’écriture perdante devient une exception au lieu d’une perte silencieuse. Que faire ensuite est une décision métier. Pour un historique de conversation, relire puis rejouer l’ajout est correct, car les deux tours sont légitimes. Pour un compteur de budget, rejouer aveuglément peut dépasser le plafond, et il faut revalider après relecture.
C’est aussi ce qui distingue ce mécanisme du lab 01 : la sérialisation turn-based d’Orleans protège un grain contre ses appelants concurrents dans une activation ; l’ETag protège l’enregistrement contre des activations concurrentes. Les deux garanties sont complémentaires, aucune ne remplace l’autre.
6. Exercices
Les trois exercices se complètent dans OrleansPersistenceLab/Grains.cs. Chaque cellule d’exercice reconstruit le lab avant de jouer son scénario : après une modification de Grains.cs, il suffit de relancer la cellule, et la ligne de témoin ... est encore le stub doit laisser place à OK. Une erreur de compilation s’affiche à la place du scénario.
Exercice 1 : effacer une session
Compléter ResetAsync pour effacer l’état persisté de la session. Le scénario ajoute un tour, appelle ResetAsync, puis relit l’état : l’enregistrement ne doit plus exister et la session doit être vide.
Indice : IPersistentState<T> expose une opération dédiée, distincte de WriteStateAsync. Écrire un SessionState vide ne donnerait pas existe=False.
// Exercice 1 : effacer une session.// TODO etudiant : completer ResetAsync dans OrleansPersistenceLab/Grains.cs, puis relancer cette cellule.Console.WriteLine(Exercise("ex1"));
[ex1] avant reset : tours=1 tokens=50 etag=e4b11a44cd0f467b8a94bf4a9bfc70ca existe=True
[ex1] apres reset : tours=1 tokens=50 etag=e4b11a44cd0f467b8a94bf4a9bfc70ca existe=True
[ex1] ResetAsync est encore le stub : complete Grains.cs puis relance cette cellule
Exercice 2 : consommer sous un budget, sans écriture inutile
Compléter TryConsumeAsync(tokens, budget) : si TokenTotal + tokens reste sous le budget, cumuler, écrire l’état et retourner true ; sinon retourner falsesans écrire. Le scénario consomme 300 tokens sur un budget de 500, puis tente 300 de plus.
Indice : la vérification compare l’ETag avant et après le refus. Un refus qui écrit quand même change l’ETag, et c’est détecté.
Étape 2 : se demander ce que deviendrait cette vérification si deux activations consommaient en même temps (section 5).
// Exercice 2 : budget de tokens.// TODO etudiant : completer TryConsumeAsync dans OrleansPersistenceLab/Grains.cs, puis relancer cette cellule.Console.WriteLine(Exercise("ex2"));
[ex2] 300/500 -> False ; 300 de plus -> False ; tours=0 tokens=0 etag=(aucun) existe=False
[ex2] TryConsumeAsync est encore le stub : complete Grains.cs puis relance cette cellule
Exercice 3 : survivre au conflit d’ETag
Compléter AppendTurnWithRetryAsync : ajouter le tour et écrire ; sur InconsistentStateException, relire l’état (ReadStateAsync), rejouer l’ajout sur l’état frais, et réécrire. Le scénario reproduit le conflit de la section 5 : A écrit d’abord, puis B appelle cette méthode. Attendu : 2 tours, celui de A puis celui de B.
Indice : borner le nombre de tentatives. Une boucle de reprise infinie transforme une contention forte en blocage.
Indice : l’ajout de l’essai qui a échoué est encore dans State au moment de l’exception ; la relecture le remplace, c’est pourquoi la mutation doit être rejouée après ReadStateAsync et non conservée.
// Exercice 3 : reprise sur conflit.// TODO etudiant : completer AppendTurnWithRetryAsync dans OrleansPersistenceLab/Grains.cs, puis relancer cette cellule.Console.WriteLine(Exercise("ex3"));
[ex3] B apres ecriture avec reprise : tours=-1
[ex3] AppendTurnWithRetryAsync est encore le stub : complete Grains.cs puis relance cette cellule
Lecture des témoins : ce que chaque stub laisse voir
Les trois sorties ci-dessus sont celles des stubs, et chacune dit déjà ce qui manque :
Exercice 1 : tours=1 et le même ETag avant et après ResetAsync : le stub ne touche ni l’état en mémoire ni l’enregistrement. La vérification distingue aussi une fausse piste mesurée : écrire un SessionState vide rend tours=0 mais laisse existe=True, et la cellule répond alors a revoir au lieu de OK.
Exercice 2 : 300/500 -> False dès la première consommation, et existe=False : le stub refuse tout et n’écrit jamais. Refuser sans écrire n’est que la moitié du contrat ; l’autre moitié est d’accepter, et d’écrire, quand le budget le permet.
Exercice 3 : tours=-1, la valeur sentinelle du stub. La solution attendue rend 2 : le tour de A, relu après le conflit, puis celui de B, rejoué sur l’état frais.
Pris ensemble, les trois exercices exercent les trois opérations de IPersistentState<T> : ClearStateAsync pour le cycle de vie de l’enregistrement, WriteStateAsync appelé à bon escient (l’ETag sert de mesure des écritures), et ReadStateAsync comme réponse à un conflit.
7. Garde SOTA : le vrai fournisseur, mesuré
Le lab doit exécuter le vrai fournisseur Redis d’Orleans contre un vrai serveur Redis, pas une réimplémentation. La garde lit les versions résolues par NuGet (dotnet list package), y compris le client Redis tiré en transitif, et vérifie le branchement dans les sources.
// Garde SOTA : packages resolus, branchement du fournisseur, serveur Redis reel.using System.IO;var paquets =Run("dotnet","list package --include-transitive", labDir).Out.Split('\n').Where(l => l.Contains("Microsoft.Orleans.Server")|| l.Contains("Microsoft.Orleans.Persistence.Redis")|| l.Contains("StackExchange.Redis")).Select(l => Regex.Replace(l.Trim().TrimStart('>').Trim(), @"\s+"," ")).ToList();foreach(var p in paquets) Console.WriteLine($"[nuget] {p}");bool redisBranche = File.ReadAllText(Path.Combine(labDir,"Program.cs")).Contains("AddRedisGrainStorage(\"sessions\"");bool etatInjecte = File.ReadAllText(Path.Combine(labDir,"Grains.cs")).Contains("[PersistentState(\"session\", \"sessions\")]");Console.WriteLine($"fournisseur Redis enregistre sous le nom \"sessions\" : {redisBranche}");Console.WriteLine($"etat injecte par [PersistentState] dans le grain : {etatInjecte}");Console.WriteLine($"serveur : {Run("docker", $"exec {RedisContainer} redis-server --version").Out.Trim()}");if(paquets.Count<3||!redisBranche ||!etatInjecte)thrownewException("Garde SOTA : le lab ne branche pas le vrai fournisseur Redis");
[nuget] Microsoft.Orleans.Persistence.Redis 10.3.1 10.3.1
[nuget] Microsoft.Orleans.Server 10.3.1 10.3.1
[nuget] StackExchange.Redis 2.11.0
fournisseur Redis enregistre sous le nom "sessions" : True
etat injecte par [PersistentState] dans le grain : True
serveur : Redis server v=7.4.7 sha=00000000:0 malloc=jemalloc-5.3.0 bits=64 build=31a84af2bcf7caf1
Lecture de la garde
Les deux packages Orleans sont résolus en 10.3.1, la version déclarée par le .csproj, et StackExchange.Redis apparaît en transitif : c’est le client Redis que le fournisseur utilise sous le capot. Le lab ne le référence pas lui-même. Le fournisseur est bien enregistré sous le nom que le grain réclame, et le serveur interrogé est le binaire du conteneur. Aucune pièce de la chaîne n’est simulée.
8. Arrêter Redis
Le conteneur a été démarré avec --rm : l’arrêter le supprime, et ses données avec lui.
// Arreter le conteneur : --rm le supprime, avec ses donnees.var stop =Run("docker", $"stop {RedisContainer}");Console.WriteLine($"[redis] docker stop exit = {stop.Exit}");var restant =Run("docker", $"ps -a --filter name={RedisContainer} --format {{{{.Names}}}}").Out.Trim();Console.WriteLine($"[redis] conteneurs restants : {(restant.Length == 0 ? "aucun" : restant)}");
Ce que ce notebook ne couvre pas (limites honnêtes)
La durabilité de Redis lui-même : ce notebook mesure que l’état survit au silo. Il ne mesure pas que Redis survit à son propre arrêt : le conteneur est éphémère et supprimé à la fin. En production, la persistance de Redis (AOF, snapshots RDB) et sa réplication sont une question distincte, à régler côté serveur.
Un conflit fabriqué : deux clusters partageant un ServiceId sont une construction de laboratoire, choisie parce qu’elle rend le conflit reproductible. Dans un cluster sain, le répertoire rend ce cas rare ; l’ETag reste le filet de sécurité pour les cas où il ne suffit pas.
L’évolution du schéma : la section 4 montre que le nom de type est inscrit dans chaque document, mais aucune migration n’est exercée.
Transactions et event sourcing : les transactions Orleans (plusieurs grains, atomicité) et les grains journalisés (JournaledGrain) sont d’autres modèles de persistance, non abordés ici.
Le branchement côté Aspire : le notebook 02 nomme WithGrainStorage dans l’AppHost comme l’extension naturelle de son lab. Ce notebook branche le fournisseur côté silo ; le notebook 04 le fait déclarer par l’AppHost.
Côté Orleans, le notebook 04 déclare ce même Redis comme ressource d’un AppHost Aspire et le relie au silo par WithGrainStorage : la persistance de ce notebook, l’orchestration du notebook 02, sur un cluster de deux silos.