Paillasse/portail/app/Services/Labs/StockChaud.php
Adam Belkacemi 80b6c0722d ajoute le stock chaud, la correction automatique et les nouveaux essais de création de stack
Stock de stacks prêtes par lab dans Redis (prise atomique en Lua, ménage
par comparaison avec Heat, disjoncteur), files gérées par Horizon, séances
planifiables. Correcteur Go sur l'hyperviseur : socket Unix, commandes lues
dans le lab.yaml uniquement, exécution par l'agent invité QEMU. Une création
de stack ratée est supprimée et réessayée deux fois, puis la session passe
en échec, visible du prof.

Corrige aussi trois défauts des étapes précédentes : la console se coupait
à la première frappe d'un navigateur (trames texte), l'élève ne pouvait pas
rejoindre une séance, la déconnexion n'avait pas de jeton CSRF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:52:16 +02:00

361 lines
12 KiB
PHP

<?php
namespace App\Services\Labs;
use App\Jobs\CreerStackStock;
use App\Models\Lab;
use App\Models\LabSession;
use App\Models\LabSessionMachine;
use App\Models\Seance;
use App\Services\OpenStack\HeatClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
use Throwable;
/**
* Stock chaud (étape 11) : pour chaque lab, une liste Redis de stacks déjà
* construites, prêtes à être données à un élève sans attendre Heat.
*
* Clés Redis :
* - stock:pretes:{lab} liste (JSON d'une stack prête), FIFO ;
* - stock:commandes:{lab} zset base de nom => 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<string, string>, 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++) {
$brut = Redis::eval($script, 2, self::clePretes($lab), self::ATTRIBUEES, time());
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<string, string>} $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(),
]));
}
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<string, array{cible: int, pretes: int, en_cours: int, commandees: int, retirees: int, disjoncte: bool}>
*/
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<string, DefinitionLab> $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<string, DefinitionLab>
*/
private function labsDisponibles(): array
{
$presents = DefinitionLab::identifiantsDisponibles();
$labs = [];
foreach (Lab::whereIn('identifiant', $presents)->pluck('identifiant') as $identifiant) {
$labs[$identifiant] = DefinitionLab::depuis($identifiant);
}
return $labs;
}
/**
* @param array{nom: string, id: string}|null $stack
*/
private function estUtilisable(?array $stack): bool
{
if ($stack === null || ! isset($stack['nom'], $stack['id'], $stack['instances'])) {
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()]);
}
}
}