diff --git a/docs/journal.md b/docs/journal.md index a2bfe7f..e65b5c4 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -1125,3 +1125,26 @@ La leçon : un test local qui passe ne prouve rien sur un comportement qui dépe **Suite** Prochain chantier du bloc B : optimisation MariaDB (index, `EXPLAIN`, requêtes N+1, jeu de données de charge). + +## 2026-10-14 — Optimisation MariaDB (bloc B) + +Dernier chantier annoncé du bloc B pour l'instant : index vérifiés par `EXPLAIN`, requêtes N+1 supprimées, sur un vrai jeu de données de charge — jamais deviné, jamais sur la base de production. + +**Fait** + +- `php artisan charge:generer` : nouvelle commande, refuse de tourner si `APP_ENV=production`. Remplit une base avec des classes, élèves, séances, sessions de lab, devoirs et rendus, en insertions par lot (pas un `Hash::make()` par élève — 20 000 hachages individuels auraient pris des minutes). Lancée en local contre un conteneur MariaDB 11 jetable (même version qu'en production), jamais contre le serveur réel : 20 000 lignes de test géneraient les vrais profs pendant la mesure, pour un bénéfice qui ne demande pas d'y toucher. +- Jeu généré : 500 classes, 20 000 élèves, 1 500 séances, 30 000 sessions de lab, 1 000 devoirs, 13 000 rendus — l'échelle de référence du CLAUDE.md. +- `EXPLAIN` sur les requêtes qui filtrent par statut/état/archivage a confirmé deux vrais manques (et écarté une fausse piste) : + - `lab_sessions.etat` (lu chaque minute par `labs:expirer-sessions` et la surveillance du self-healing) : parcours complet de la table, 30 056 lignes examinées. Avec l'index : 1 161 lignes (-96 %). + - `classes.archivee_a` (lu chaque jour par la purge RGPD) : 500 lignes examinées sans index, 1 avec. + - `devoirs.echeance` : pas touché. L'index existant sur `classe_id` suffisait déjà (2 lignes examinées, la requête filtre d'abord par classe) — `EXPLAIN` a évité d'ajouter un index inutile. +- N+1 supprimé dans `ResultatsController` (« Résultats », un prof avec plusieurs classes) : une requête de corrections et une d'observations étaient lancées par classe. Remplacé par une requête de chaque pour **toutes** les classes du prof, regroupées en mémoire — 4 requêtes au total, qu'il y ait 1 classe ou 50. +- 2 nouveaux tests Pest (117 au total) : le nombre de requêtes de `/resultats` ne bouge pas avec 1 ou 5 classes (compté via `DB::listen()`, pas deviné) ; les données restent correctement associées à la bonne classe après le regroupement en mémoire. + +**Comprendre** + +Deux fois dans ce chantier, mesurer plutôt que deviner a changé la décision : `devoirs.echeance` avait l'air d'un candidat évident à un index composite, `EXPLAIN` a montré que non. À l'inverse, `lab_sessions.etat` n'avait rien d'alarmant en lisant juste le code — c'est le volume réel (30 000 lignes, après des mois d'usage) qui rend le manque d'index coûteux, invisible sur les quelques lignes qu'on a aujourd'hui en production. + +**Suite** + +Déployer la migration d'index et le correctif N+1, vérifier en réel, puis reprendre le dernier point du bloc B : stockage S3 des travaux d'élèves. diff --git a/portail/app/Console/Commands/GenererJeuDeChargeTest.php b/portail/app/Console/Commands/GenererJeuDeChargeTest.php new file mode 100644 index 0000000..9e12345 --- /dev/null +++ b/portail/app/Console/Commands/GenererJeuDeChargeTest.php @@ -0,0 +1,153 @@ +insert() par paquets), pas par Eloquent + * un par un : 20 000 `Hash::make()` individuels prendraient des minutes. + */ +class GenererJeuDeChargeTest extends Command +{ + protected $signature = 'charge:generer {--classes=500} {--eleves-par-classe=40}'; + + protected $description = 'Remplit la base avec un jeu de données de charge réaliste (jamais en production)'; + + public function handle(): int + { + if ($this->laravel->environment('production')) { + $this->error('Jamais en production : lance ceci contre une base locale ou jetable.'); + + return self::FAILURE; + } + + $nombreClasses = (int) $this->option('classes'); + $elevesParClasse = (int) $this->option('eleves-par-classe'); + $maintenant = now(); + $codeSecretHache = Hash::make('123456'); // un seul hachage, réutilisé pour toutes les lignes + + $this->info("Génération de {$nombreClasses} classes, {$elevesParClasse} élève(s) chacune…"); + + $etablissementIds = $this->inserer('etablissements', range(1, 100), fn (int $i) => [ + 'nom' => "Établissement de charge {$i}", 'ville' => 'Test', 'uai' => "CHARGE{$i}", + 'created_at' => $maintenant, 'updated_at' => $maintenant, + ]); + + $profIds = $this->inserer('users', range(1, (int) ceil($nombreClasses / 2)), fn (int $i) => [ + 'name' => "Prof de charge {$i}", 'email' => "prof-charge-{$i}@charge.test", + 'password' => $codeSecretHache, 'etablissement_id' => $etablissementIds[$i % count($etablissementIds)], + 'created_at' => $maintenant, 'updated_at' => $maintenant, + ]); + + $classeIds = $this->inserer('classes', range(1, $nombreClasses), fn (int $i) => [ + 'etablissement_id' => $etablissementIds[$i % count($etablissementIds)], + 'user_id' => $profIds[$i % count($profIds)], + 'nom' => "Classe de charge {$i}", 'code_classe' => Str::upper(Str::random(5)).$i, + 'archivee_a' => null, 'created_at' => $maintenant, 'updated_at' => $maintenant, + ]); + + $labId = DB::table('labs')->first()->id ?? DB::table('labs')->insertGetId([ + 'identifiant' => 'charge-test', 'titre' => 'Lab de charge', 'created_at' => $maintenant, 'updated_at' => $maintenant, + ]); + + $compteurEleve = 0; + $this->withProgressBar($classeIds, function (int $classeId) use (&$compteurEleve, $elevesParClasse, $maintenant, $codeSecretHache, $labId) { + $eleveIds = $this->inserer('eleves', range(1, $elevesParClasse), function (int $i) use (&$compteurEleve, $classeId, $maintenant, $codeSecretHache) { + $compteurEleve++; + + return [ + 'classe_id' => $classeId, 'prenom' => "Élève{$compteurEleve}", 'nom' => 'Charge', + 'identifiant' => "eleve-charge-{$compteurEleve}", 'code_secret' => $codeSecretHache, + 'derniere_connexion_a' => $maintenant->copy()->subDays(random_int(0, 30)), + 'created_at' => $maintenant, 'updated_at' => $maintenant, + ]; + }); + + // 3 séances par classe : deux terminées (historique), une sur dix encore en cours (activité réelle). + $enCours = random_int(1, 10) === 1; + $statuts = [$enCours ? 'en_cours' : 'terminee', 'terminee', 'terminee']; + foreach ($statuts as $indice => $statut) { + $demarreeA = $maintenant->copy()->subDays(random_int(1, 90)); + $seanceId = DB::table('seances')->insertGetId([ + 'classe_id' => $classeId, 'lab_id' => $labId, 'statut' => $statut, + 'demarree_a' => $demarreeA, 'terminee_a' => $statut === 'terminee' ? $demarreeA->copy()->addMinutes(45) : null, + 'mode_examen' => false, 'created_at' => $demarreeA, 'updated_at' => $maintenant, + ]); + + // La moitié des élèves de la classe a une session de lab sur cette séance, avec sa correction. + $participants = array_slice($eleveIds, 0, (int) (count($eleveIds) / 2)); + $labSessions = []; + foreach ($participants as $eleveId) { + $labSessions[] = [ + 'seance_id' => $seanceId, 'eleve_id' => $eleveId, 'lab_id' => $labId, + 'etat' => $statut === 'terminee' ? 'termine' : 'actif', + 'created_at' => $demarreeA, 'updated_at' => $maintenant, + ]; + } + if ($labSessions !== []) { + DB::table('lab_sessions')->insert($labSessions); + } + } + + // 2 devoirs par classe, rendus par un peu plus d'un tiers des élèves. + foreach ([1, 2] as $n) { + $devoirId = DB::table('devoirs')->insertGetId([ + 'classe_id' => $classeId, 'titre' => "Devoir {$n}", 'consigne' => 'Consigne de charge.', + 'echeance' => $maintenant->copy()->addDays(random_int(-30, 30)), + 'created_at' => $maintenant, 'updated_at' => $maintenant, + ]); + $rendus = []; + foreach (array_slice($eleveIds, 0, (int) (count($eleveIds) / 3)) as $eleveId) { + $rendus[] = [ + 'devoir_id' => $devoirId, 'eleve_id' => $eleveId, 'reponse' => 'Réponse de charge.', + 'note' => random_int(5, 20), 'rendu_a' => $maintenant, 'created_at' => $maintenant, 'updated_at' => $maintenant, + ]; + } + if ($rendus !== []) { + DB::table('rendus_devoirs')->insert($rendus); + } + } + }); + $this->newLine(2); + + $this->info('Terminé : '.DB::table('classes')->count().' classes, '.DB::table('eleves')->count().' élèves, ' + .DB::table('seances')->count().' séances, '.DB::table('lab_sessions')->count().' sessions de lab, ' + .DB::table('devoirs')->count().' devoirs, '.DB::table('rendus_devoirs')->count().' rendus.'); + + return self::SUCCESS; + } + + /** + * Insère en lots de 500 et renvoie les ids créés, dans l'ordre. + * + * @param array $indices + * @param callable(int): array $fabrique + * @return array + */ + private function inserer(string $table, array $indices, callable $fabrique): array + { + $ids = []; + + foreach (array_chunk($indices, 500) as $paquet) { + $lignes = array_map($fabrique, $paquet); + DB::table($table)->insert($lignes); + // insert() en lot : LAST_INSERT_ID() renvoie l'id du PREMIER rang + // inséré par l'instruction (comportement MySQL/MariaDB documenté), + // les suivants sont consécutifs (innodb_autoinc_lock_mode par défaut). + $premier = (int) DB::getPdo()->lastInsertId(); + $ids = array_merge($ids, range($premier, $premier + count($lignes) - 1)); + } + + return $ids; + } +} diff --git a/portail/app/Http/Controllers/ResultatsController.php b/portail/app/Http/Controllers/ResultatsController.php index 9028b16..b03e46e 100644 --- a/portail/app/Http/Controllers/ResultatsController.php +++ b/portail/app/Http/Controllers/ResultatsController.php @@ -4,6 +4,8 @@ namespace App\Http\Controllers; use App\Models\Classe; use App\Models\Correction; +use App\Models\Observation; +use App\Models\Seance; use Illuminate\Support\Facades\Auth; use Illuminate\View\View; @@ -11,6 +13,10 @@ use Illuminate\View\View; * « Résultats » (séparation vitrine/application, 2026-10-13) : vue * d'ensemble, par classe, des corrections et des observations. Le détail * séance par séance reste dans le cockpit de chaque classe. + * + * Tout en 4 requêtes au total, quel que soit le nombre de classes du prof + * (bloc B, optimisation MariaDB, 2026-10-14) : une requête par classe pour + * les corrections et une pour les observations étaient un N+1 classique. */ class ResultatsController extends Controller { @@ -18,12 +24,24 @@ class ResultatsController extends Controller { $prof = Auth::guard('web')->user(); $classes = $prof->classes()->whereNull('archivee_a')->orderBy('nom')->get(); + $classeIds = $classes->pluck('id'); - $resultats = $classes->map(function (Classe $classe) { - $corrections = Correction::whereHas('labSession', fn ($q) => $q->whereIn('seance_id', $classe->seances()->select('id'))) - ->latest() - ->limit(50) - ->get(); + $classeIdParSeanceId = Seance::whereIn('classe_id', $classeIds)->pluck('classe_id', 'id'); + + $correctionsParClasse = Correction::whereHas('labSession', fn ($q) => $q->whereIn('seance_id', $classeIdParSeanceId->keys())) + ->with('labSession:id,seance_id') + ->latest() + ->get() + ->groupBy(fn (Correction $c): int => $classeIdParSeanceId[$c->labSession->seance_id] ?? 0); + + $observationsParClasse = Observation::whereIn('classe_id', $classeIds) + ->with('eleve') + ->latest() + ->get() + ->groupBy('classe_id'); + + $resultats = $classes->map(function (Classe $classe) use ($correctionsParClasse, $observationsParClasse) { + $corrections = ($correctionsParClasse->get($classe->id) ?? collect())->take(50); return [ 'classe' => $classe, @@ -31,7 +49,7 @@ class ResultatsController extends Controller 'reussiteMoyenne' => $corrections->isNotEmpty() ? (int) round($corrections->avg(fn ($c) => $c->note_max > 0 ? $c->note / $c->note_max * 100 : 0)) : null, - 'observations' => $classe->observations()->with('eleve')->limit(5)->get(), + 'observations' => ($observationsParClasse->get($classe->id) ?? collect())->take(5), ]; }); diff --git a/portail/database/migrations/2026_10_14_170000_add_index_optimisation_mariadb.php b/portail/database/migrations/2026_10_14_170000_add_index_optimisation_mariadb.php new file mode 100644 index 0000000..4f7e222 --- /dev/null +++ b/portail/database/migrations/2026_10_14_170000_add_index_optimisation_mariadb.php @@ -0,0 +1,42 @@ +index('etat'); + }); + + Schema::table('classes', function (Blueprint $table) { + $table->index('archivee_a'); + }); + } + + public function down(): void + { + Schema::table('lab_sessions', function (Blueprint $table) { + $table->dropIndex(['etat']); + }); + + Schema::table('classes', function (Blueprint $table) { + $table->dropIndex(['archivee_a']); + }); + } +}; diff --git a/portail/tests/Feature/OptimisationMariadbTest.php b/portail/tests/Feature/OptimisationMariadbTest.php new file mode 100644 index 0000000..eba870a --- /dev/null +++ b/portail/tests/Feature/OptimisationMariadbTest.php @@ -0,0 +1,63 @@ +create(); + + for ($i = 0; $i < $nombreClasses; $i++) { + $classe = Classe::factory()->create(['user_id' => $prof->id]); + $eleve = Eleve::factory()->create(['classe_id' => $classe->id]); + $seance = Seance::create(['classe_id' => $classe->id, 'lab_id' => Lab::factory()->create()->id, 'statut' => Seance::TERMINEE]); + $labSession = LabSession::create(['seance_id' => $seance->id, 'eleve_id' => $eleve->id, 'lab_id' => $seance->lab_id, 'etat' => LabSession::TERMINE]); + Correction::create(['lab_session_id' => $labSession->id, 'reussi' => true, 'note' => 8, 'note_max' => 10, 'details' => []]); + Observation::create(['classe_id' => $classe->id, 'eleve_id' => $eleve->id, 'user_id' => $prof->id, 'contenu' => 'Observation de test']); + } + + $requetes = 0; + DB::listen(function () use (&$requetes) { + $requetes++; + }); + + prof($test, $prof)->get(route('resultats'))->assertOk(); + + return $requetes; +} + +test('le nombre de requêtes de /resultats ne dépend pas du nombre de classes (pas de N+1)', function () { + $requetesUneClasse = resultatsPourProfAvecNClasses($this, 1); + $requetesCinqClasses = resultatsPourProfAvecNClasses($this, 5); + + expect($requetesCinqClasses)->toBe($requetesUneClasse); +}); + +test('les corrections et observations affichées restent correctes après le regroupement en lot', function () { + $prof = User::factory()->create(); + $classeA = Classe::factory()->create(['user_id' => $prof->id, 'nom' => 'Classe A']); + $classeB = Classe::factory()->create(['user_id' => $prof->id, 'nom' => 'Classe B']); + + $seanceA = Seance::create(['classe_id' => $classeA->id, 'lab_id' => Lab::factory()->create()->id, 'statut' => Seance::TERMINEE]); + $eleveA = Eleve::factory()->create(['classe_id' => $classeA->id, 'prenom' => 'Alice']); + $labSessionA = LabSession::create(['seance_id' => $seanceA->id, 'eleve_id' => $eleveA->id, 'lab_id' => $seanceA->lab_id, 'etat' => LabSession::TERMINE]); + Correction::create(['lab_session_id' => $labSessionA->id, 'reussi' => true, 'note' => 9, 'note_max' => 10, 'details' => []]); + Observation::create(['classe_id' => $classeA->id, 'eleve_id' => $eleveA->id, 'user_id' => $prof->id, 'contenu' => 'Bonne participation']); + + // Classe B : aucune activité, ne doit jamais récupérer les données de la classe A. + $page = prof($this, $prof)->get(route('resultats')); + $page->assertOk()->assertSeeInOrder(['Classe A', '1 correction(s)', 'Bonne participation', 'Classe B', '0 correction(s)']); +});