horodatage : stacks en construction ; * - stock:attribuees zset JSON => horodatage : stacks sorties du stock, pas * encore enregistrées en base (fenêtre de quelques ms) ; * - stock:echecs:{lab} compteur d'échecs consécutifs (disjoncteur, 30 min). * * Redis fait foi pour ce qui est prêt ; Heat sert à retrouver les fuites * (stacks « stock-… » que plus rien ne référence), supprimées par menage(). */ class StockChaud { private const ATTRIBUEES = 'stock:attribuees'; // Une commande plus vieille que ça a perdu son job (processus tué, Redis // vidé…) : on cesse de la compter comme « en construction ». private const COMMANDE_PERIMEE_SECONDES = 900; // Une stack inconnue n'est supprimée qu'après ce délai : marge pour les // opérations en vol entre Redis, la base et Heat. private const AGE_MIN_MENAGE_SECONDES = 600; // Après 3 échecs définitifs de suite sur un lab, on arrête de commander // pour lui pendant 30 min, au lieu de recréer en boucle une stack vouée // à l'échec (image absente, quota…). private const SEUIL_DISJONCTEUR = 3; private const DUREE_DISJONCTEUR_SECONDES = 1800; public function __construct(private readonly HeatClient $heat) {} /** * Sort une stack prête du stock, ou null si le stock du lab est vide. * LPOP et ZADD dans un seul script Lua : Redis l'exécute d'un bloc, donc * à aucun moment la stack n'est ni dans la liste ni dans « attribuees » * — le ménage ne peut jamais la prendre pour une fuite. * * @return array{nom: string, id: string, instances: array, brut: string}|null */ public function prendre(string $lab): ?array { $script = <<<'LUA' local item = redis.call('LPOP', KEYS[1]) if item then redis.call('ZADD', KEYS[2], ARGV[1], item) end return item LUA; for ($essai = 0; $essai < 3; $essai++) { // Redis::eval(...) (facade) : la classe réellement utilisée au // runtime (PhpRedisConnection) redispose les arguments pour // l'extension phpredis, mais PHPStan ne résout le mixin de la // façade Redis que sur la classe abstraite Connection (@mixin // \Redis, la signature native à 3 arguments) — jamais sur la // classe effective choisie par REDIS_CLIENT. `command()` est une // vraie méthode (pas un mixin) : même effet, correctement typé. $brut = Redis::command('eval', [$script, [self::clePretes($lab), self::ATTRIBUEES, time()], 2]); if (! is_string($brut)) { return null; } $stack = json_decode($brut, true); if ($this->estUtilisable($stack)) { return $stack + ['brut' => $brut]; } Log::warning("Stock chaud : stack {$stack['nom']} inutilisable, supprimée"); $this->supprimerSansErreur($stack['nom'], $stack['id']); Redis::zrem(self::ATTRIBUEES, $brut); } return null; } /** * @param array{brut: string} $stack */ public function confirmerAttribution(array $stack): void { Redis::zrem(self::ATTRIBUEES, $stack['brut']); } /** * @param array{nom: string, id: string, instances: array} $stack */ public function deposer(string $lab, array $stack): void { Redis::rpush(self::clePretes($lab), json_encode([ 'nom' => $stack['nom'], 'id' => $stack['id'], 'instances' => $stack['instances'], 'cree_a' => time(), ])); } /** * Stacks prêtes, tous labs confondus (lues par SurveillanceVm). * * @return list, cree_a: int, brut: string}> */ public function pretes(): array { $pretes = []; foreach (array_keys($this->labsDisponibles()) as $lab) { foreach (Redis::lrange(self::clePretes($lab), 0, -1) as $brut) { $stack = json_decode($brut, true); if (is_array($stack) && isset($stack['nom'], $stack['id'], $stack['instances'])) { $pretes[] = ['lab' => $lab, 'cree_a' => (int) ($stack['cree_a'] ?? 0), 'brut' => $brut] + $stack; } } } return $pretes; } /** * Retire une stack prête précise du stock et la supprime. Faux si elle * n'y est plus (déjà donnée à un élève entre-temps) : on n'y touche pas. */ public function retirer(string $lab, string $brut): bool { // Même raison que dans prendre() : command() plutôt que le mixin de // la façade, qui ne voit pas le réordonnancement des arguments fait // par PhpRedisConnection::lrem(). if ((int) Redis::command('lrem', [self::clePretes($lab), $brut, 1]) === 0) { return false; } $stack = json_decode($brut, true); $this->supprimerSansErreur($stack['nom'], $stack['id']); return true; } public function commandeTerminee(string $lab, string $base): void { Redis::zrem(self::cleCommandes($lab), $base); } public function compterEchec(string $lab): void { $cle = "stock:echecs:{$lab}"; Redis::incr($cle); Redis::expire($cle, self::DUREE_DISJONCTEUR_SECONDES); } public function reinitialiserEchecs(string $lab): void { Redis::del("stock:echecs:{$lab}"); } /** * Appelé chaque minute (labs:remplir-stock) : ajuste chaque stock à sa * cible, dans la limite de la capacité, puis fait le ménage. * * @return array */ public function remplir(): array { $labs = $this->labsDisponibles(); $vmLibres = (int) config('labs.capacite_vm') - $this->vmOccupees($labs); $rapport = []; $cibles = []; foreach ($labs as $identifiant => $definition) { $cibles[$identifiant] = $this->cible($identifiant); } // Les labs dont une séance approche sont servis en premier. arsort($cibles); foreach ($cibles as $identifiant => $cible) { $machines = $labs[$identifiant]->nombreMachines(); $pretes = (int) Redis::llen(self::clePretes($identifiant)); $enCours = $this->enCours($identifiant); $disjoncte = (int) Redis::get("stock:echecs:{$identifiant}") >= self::SEUIL_DISJONCTEUR; $commandees = 0; $retirees = 0; if ($pretes + $enCours > $cible) { $retirees = $this->reduire($identifiant, min($pretes, $pretes + $enCours - $cible)); } elseif (! $disjoncte) { $possibles = intdiv(max(0, $vmLibres), $machines); $commandees = min($cible - $pretes - $enCours, $possibles); for ($i = 0; $i < $commandees; $i++) { $this->commander($identifiant); } $vmLibres -= $commandees * $machines; } $rapport[$identifiant] = compact('cible', 'pretes', 'commandees', 'retirees', 'disjoncte') + ['en_cours' => $enCours]; } $this->menage(); return $rapport; } /** * Cible = taille de base + élèves qui vont avoir besoin d'une stack : * ceux des séances qui commencent dans les 10 minutes, et ceux des * séances ouvertes qui n'ont pas encore été admis (ou dont le lancement * a échoué et sera relancé). */ public function cible(string $lab): int { $avance = (int) config('labs.stock.avance_minutes'); $besoin = 0; $seances = Seance::query() ->whereHas('lab', fn ($q) => $q->where('identifiant', $lab)) ->where(function ($q) use ($avance) { $q->where(fn ($q) => $q->where('statut', Seance::PLANIFIEE) ->whereBetween('prevue_a', [now()->subHours(2), now()->addMinutes($avance)])) ->orWhereIn('statut', [Seance::SALLE_ATTENTE, Seance::EN_COURS]); }) ->withCount(['labSessions as servies_count' => fn ($q) => $q->whereIn('etat', [ LabSession::ADMIS, LabSession::ACTIF, LabSession::TERMINE, LabSession::EXPIRE, ])]) ->with('classe') ->get(); foreach ($seances as $seance) { $besoin += max(0, $seance->classe->eleves()->count() - $seance->servies_count); } return (int) config('labs.stock.taille_par_defaut') + $besoin; } public function enCours(string $lab): int { $cle = self::cleCommandes($lab); Redis::zremrangebyscore($cle, '-inf', (string) (time() - self::COMMANDE_PERIMEE_SECONDES)); return (int) Redis::zcard($cle); } /** * Supprime les stacks « stock-… » que plus rien ne référence : ni la * liste des prêtes, ni une attribution en cours, ni une session en base, * ni une commande en construction. */ public function menage(): void { $connues = []; $bases = []; foreach ($this->labsDisponibles() as $identifiant => $definition) { foreach (Redis::lrange(self::clePretes($identifiant), 0, -1) as $brut) { $connues[json_decode($brut, true)['nom']] = true; } foreach (Redis::zrange(self::cleCommandes($identifiant), 0, -1) as $base) { $bases[$base] = true; } } // Attributions vieilles de plus de 5 min : la requête qui les a // faites est terminée depuis longtemps (ou a planté) — on les oublie. Redis::zremrangebyscore(self::ATTRIBUEES, '-inf', (string) (time() - 300)); foreach (Redis::zrange(self::ATTRIBUEES, 0, -1) as $brut) { $connues[json_decode($brut, true)['nom']] = true; } foreach (LabSession::whereNotNull('heat_stack_name')->where('heat_stack_name', 'like', 'stock-%')->pluck('heat_stack_name') as $nom) { $connues[$nom] = true; } foreach ($this->heat->listerStacks() as $stack) { if (! str_starts_with($stack['nom'], 'stock-') || $stack['statut'] === 'DELETE_IN_PROGRESS') { continue; } $base = preg_replace('/-e\d+$/', '', $stack['nom']); $age = time() - Carbon::parse($stack['cree_a'])->getTimestamp(); if (isset($connues[$stack['nom']]) || isset($bases[$base]) || $age < self::AGE_MIN_MENAGE_SECONDES) { continue; } Log::warning("Stock chaud : stack orpheline {$stack['nom']} ({$stack['statut']}) supprimée"); $this->supprimerSansErreur($stack['nom'], $stack['id']); } } public static function clePretes(string $lab): string { return "stock:pretes:{$lab}"; } private static function cleCommandes(string $lab): string { return "stock:commandes:{$lab}"; } private function commander(string $lab): void { $base = "stock-{$lab}-".Str::lower(Str::random(6)); Redis::zadd(self::cleCommandes($lab), time(), $base); CreerStackStock::dispatch($lab, $base); } private function reduire(string $lab, int $nombre): int { $retirees = 0; for ($i = 0; $i < $nombre; $i++) { $brut = Redis::lpop(self::clePretes($lab)); if (! is_string($brut)) { break; } $stack = json_decode($brut, true); $this->supprimerSansErreur($stack['nom'], $stack['id']); $retirees++; } return $retirees; } /** * VM réservées : sessions d'élèves en cours (ou en construction) et * stocks (prêtes et en construction), tous labs confondus. * * @param array $labs */ private function vmOccupees(array $labs): int { $vm = LabSessionMachine::whereHas('labSession', fn ($q) => $q->where('etat', LabSession::ACTIF))->count(); $admises = LabSession::where('etat', LabSession::ADMIS)->with('lab')->get(); foreach ($admises as $session) { $vm += isset($labs[$session->lab->identifiant]) ? $labs[$session->lab->identifiant]->nombreMachines() : 1; } foreach ($labs as $identifiant => $definition) { $stacks = (int) Redis::llen(self::clePretes($identifiant)) + $this->enCours($identifiant); $vm += $stacks * $definition->nombreMachines(); } return $vm; } /** * Labs du catalogue (table labs) dont le lab.yaml est présent. * * @return array */ private function labsDisponibles(): array { $presents = DefinitionLab::identifiantsDisponibles(); $labs = []; foreach (Lab::whereIn('identifiant', $presents)->pluck('identifiant') as $identifiant) { $labs[$identifiant] = DefinitionLab::depuis($identifiant); } return $labs; } /** * $stack vient d'un `json_decode` (Redis) : sa forme n'est pas garantie * par le type (contrairement à ce que laissait croire l'ancienne * signature `array{nom: string, id: string}`, qui rendait les `isset()` * ci-dessous inutiles aux yeux de PHPStan alors qu'ils sont le seul * rempart contre une entrée du stock corrompue ou tronquée). * * @param array|null $stack */ private function estUtilisable(?array $stack): bool { if ($stack === null || ! isset($stack['nom'], $stack['id'], $stack['instances']) || ! is_string($stack['nom']) || ! is_string($stack['id'])) { return false; } try { return $this->heat->etatStack($stack['nom'], $stack['id'])['statut'] === 'CREATE_COMPLETE'; } catch (Throwable) { return false; } } private function supprimerSansErreur(string $nom, string $id): void { try { $this->heat->supprimerStack($nom, $id); } catch (Throwable $e) { Log::error("Stock chaud : suppression de {$nom} impossible", ['raison' => $e->getMessage()]); } } }