Paillasse/portail/app/Services/Labs/StockChaud.php
2026-10-12 21:32:52 +02:00

416 lines
15 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++) {
// 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<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(),
]));
}
/**
* Stacks prêtes, tous labs confondus (lues par SurveillanceVm).
*
* @return list<array{lab: string, nom: string, id: string, instances: array<string, string>, 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<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;
}
/**
* $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<string, mixed>|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()]);
}
}
}