362 lines
12 KiB
PHP
362 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()]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|