diff --git a/docs/journal.md b/docs/journal.md index 0e27ae1..a2bfe7f 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -1098,3 +1098,30 @@ Le choix qui limite le plus le risque ici : exposer seulement ce qui est déjà **Suite** Prochain chantier du bloc B : cache Redis sur les lectures lourdes, avec mesure avant/après dans Grafana. + +## 2026-10-14 — Cache Redis sur les lectures lourdes (bloc B) + +Deuxième chantier du bloc B : cache sur le catalogue (labs, banque de quiz officielle, cours) et sur la liste des classes du tableau de bord prof — relue à chaque chargement de page et, pendant une séance en cours, toutes les 15 secondes par le rafraîchissement automatique. + +**Fait** + +- `App\Services\Catalogue\CacheCatalogue` (labs, quiz officiels, cours) et `App\Services\CacheTableauDeBordProf` (classes d'un prof, avec le nombre d'élèves) : jamais d'expiration, invalidation explicite au bon moment plutôt qu'une durée de vie devinée. + - Catalogue : invalidé par les trois commandes de synchronisation (`labs:synchroniser`, `quiz:synchroniser-banque`, `cours:synchroniser`), déjà rejouées à chaque déploiement. + - Tableau de bord prof : invalidé à l'archivage, au désarchivage, et par la purge RGPD (`classes:purger-archivees`). +- Branché sur l'API publique (`/api/v1/labs`, `/quiz`, `/cours`), `/bibliotheque-app`, et le cockpit d'une classe (`/mes-classes/{classe}`, formulaire de séance). + +**Bug trouvé en vérifiant en réel, pas en local** : mettre en cache directement des modèles Eloquent hydratés plante à la relecture avec « incomplete class » — `unserialize()` reconstruit un objet PHP, et selon le moment où il est relu, l'autoloader n'a pas forcément encore chargé sa classe. Les tests locaux ne l'ont pas vu : `CACHE_STORE=array` en test ne sérialise jamais rien, contrairement à Redis en production. Corrigé en mettant en cache les attributs bruts (`getAttributes()`, un tableau de valeurs scalaires, aucun risque de ce genre) et en reconstruisant de vrais modèles à la lecture avec `Model::hydrate()` — aucun changement nécessaire côté vues ou ressources API, qui continuent de recevoir de vraies collections Eloquent. + +**Mesuré sur le serveur réel** (avant/après, par `DB::listen()` — l'équivalent de ce que Grafana afficherait via les métriques MariaDB déjà exposées, juste lu directement plutôt que dans l'interface, faute d'extension Chrome connectée cette fois) : +- `Lab::orderBy(...)->get()` direct, 5 appels : **5 requêtes SQL**. +- `CacheCatalogue::labs()`, 5 appels : **1 requête SQL** (le premier appel, un cache miss ; les 4 suivants sont servis par Redis). Même résultat pour `quizOfficiels()` et `cours()`, et pour `CacheTableauDeBordProf::classes()`. +- Invalidation vérifiée : une valeur sentinelle posée sur chaque clé disparaît bien après avoir rejoué la commande de synchronisation correspondante. +- Vérification HTTP complète (prof jetable, supprimé ensuite) : `/mes-classes`, le formulaire de séance de `/mes-classes/{classe}` (liste des labs), `/bibliotheque-app` et `/api/v1/labs` affichent tous les bonnes données, réellement mises en cache. + +**Comprendre** + +La leçon : un test local qui passe ne prouve rien sur un comportement qui dépend du driver de cache réellement utilisé. `CACHE_STORE=array` en test est rapide et pratique, mais invisible aux bugs de sérialisation — seule une vérification contre le vrai Redis de production les révèle. Pour ce genre de risque (sérialisation, comportement spécifique à un driver), le test local ne remplace pas la vérification en réel : il la complète. + +**Suite** + +Prochain chantier du bloc B : optimisation MariaDB (index, `EXPLAIN`, requêtes N+1, jeu de données de charge). diff --git a/portail/app/Console/Commands/PurgerClassesArchivees.php b/portail/app/Console/Commands/PurgerClassesArchivees.php index c0b8ec2..c6d4a9a 100644 --- a/portail/app/Console/Commands/PurgerClassesArchivees.php +++ b/portail/app/Console/Commands/PurgerClassesArchivees.php @@ -3,6 +3,7 @@ namespace App\Console\Commands; use App\Models\Classe; +use App\Services\CacheTableauDeBordProf; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -44,6 +45,7 @@ class PurgerClassesArchivees extends Command } DB::transaction(fn () => $classe->delete()); + CacheTableauDeBordProf::vider($classe->user_id); Log::info('Purge RGPD : classe supprimée', [ 'classe_id' => $classe->id, 'eleves' => $classe->eleves_count, diff --git a/portail/app/Console/Commands/SynchroniserBanqueQuiz.php b/portail/app/Console/Commands/SynchroniserBanqueQuiz.php index 8fc2efa..b70fe22 100644 --- a/portail/app/Console/Commands/SynchroniserBanqueQuiz.php +++ b/portail/app/Console/Commands/SynchroniserBanqueQuiz.php @@ -3,6 +3,7 @@ namespace App\Console\Commands; use App\Models\Quiz; +use App\Services\Catalogue\CacheCatalogue; use App\Services\Quiz\BanqueOfficielle; use App\Services\Quiz\LecteurGift; use Illuminate\Console\Command; @@ -70,5 +71,7 @@ class SynchroniserBanqueQuiz extends Command $this->warn("{$identifiant} : {$avertissement}"); } } + + CacheCatalogue::viderQuizOfficiels(); } } diff --git a/portail/app/Console/Commands/SynchroniserCours.php b/portail/app/Console/Commands/SynchroniserCours.php index 08030fe..9f62a24 100644 --- a/portail/app/Console/Commands/SynchroniserCours.php +++ b/portail/app/Console/Commands/SynchroniserCours.php @@ -3,6 +3,7 @@ namespace App\Console\Commands; use App\Models\Cours; +use App\Services\Catalogue\CacheCatalogue; use Illuminate\Console\Command; use Illuminate\Support\Facades\File; use Symfony\Component\Yaml\Yaml; @@ -41,5 +42,7 @@ class SynchroniserCours extends Command $this->line(($cours->wasRecentlyCreated ? 'créé' : 'à jour')." : {$identifiant}"); } + + CacheCatalogue::viderCours(); } } diff --git a/portail/app/Console/Commands/SynchroniserLabs.php b/portail/app/Console/Commands/SynchroniserLabs.php index 73e5059..8bc6f38 100644 --- a/portail/app/Console/Commands/SynchroniserLabs.php +++ b/portail/app/Console/Commands/SynchroniserLabs.php @@ -3,6 +3,7 @@ namespace App\Console\Commands; use App\Models\Lab; +use App\Services\Catalogue\CacheCatalogue; use App\Services\Labs\DefinitionLab; use Illuminate\Console\Command; @@ -29,5 +30,7 @@ class SynchroniserLabs extends Command $this->line(($lab->wasRecentlyCreated ? 'créé' : 'à jour')." : {$identifiant}"); } + + CacheCatalogue::viderLabs(); } } diff --git a/portail/app/Http/Controllers/Api/V1/CoursController.php b/portail/app/Http/Controllers/Api/V1/CoursController.php index 7836568..b6b3bc5 100644 --- a/portail/app/Http/Controllers/Api/V1/CoursController.php +++ b/portail/app/Http/Controllers/Api/V1/CoursController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\Http\Resources\Api\V1\CoursResource; use App\Models\Cours; +use App\Services\Catalogue\CacheCatalogue; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; /** @@ -14,7 +15,7 @@ class CoursController extends Controller { public function index(): AnonymousResourceCollection { - return CoursResource::collection(Cours::orderBy('identifiant')->get()); + return CoursResource::collection(CacheCatalogue::cours()); } public function show(Cours $cours): CoursResource diff --git a/portail/app/Http/Controllers/Api/V1/LabController.php b/portail/app/Http/Controllers/Api/V1/LabController.php index aab9b46..b85722d 100644 --- a/portail/app/Http/Controllers/Api/V1/LabController.php +++ b/portail/app/Http/Controllers/Api/V1/LabController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\Http\Resources\Api\V1\LabResource; use App\Models\Lab; +use App\Services\Catalogue\CacheCatalogue; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; /** @@ -14,7 +15,7 @@ class LabController extends Controller { public function index(): AnonymousResourceCollection { - return LabResource::collection(Lab::orderBy('identifiant')->get()); + return LabResource::collection(CacheCatalogue::labs()); } public function show(Lab $lab): LabResource diff --git a/portail/app/Http/Controllers/Api/V1/QuizController.php b/portail/app/Http/Controllers/Api/V1/QuizController.php index 4b8bbb9..7537de3 100644 --- a/portail/app/Http/Controllers/Api/V1/QuizController.php +++ b/portail/app/Http/Controllers/Api/V1/QuizController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\Http\Resources\Api\V1\QuizResource; use App\Models\Quiz; +use App\Services\Catalogue\CacheCatalogue; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; /** @@ -16,9 +17,7 @@ class QuizController extends Controller { public function index(): AnonymousResourceCollection { - return QuizResource::collection( - Quiz::where('partage', true)->whereNotNull('theme')->withCount('questions')->orderBy('theme')->get() - ); + return QuizResource::collection(CacheCatalogue::quizOfficiels()); } public function show(Quiz $quiz): QuizResource diff --git a/portail/app/Http/Controllers/BibliothequeProfController.php b/portail/app/Http/Controllers/BibliothequeProfController.php index 7b4afef..9fed9f6 100644 --- a/portail/app/Http/Controllers/BibliothequeProfController.php +++ b/portail/app/Http/Controllers/BibliothequeProfController.php @@ -2,8 +2,8 @@ namespace App\Http\Controllers; -use App\Models\Lab; use App\Models\Quiz; +use App\Services\Catalogue\CacheCatalogue; use Illuminate\View\View; /** @@ -22,7 +22,7 @@ class BibliothequeProfController extends Controller ->get(); return view('bibliotheque-app', [ - 'labs' => Lab::orderBy('titre')->get(), + 'labs' => CacheCatalogue::labs()->sortBy('titre')->values(), 'groupesQuiz' => $quizzes->groupBy(fn (Quiz $q) => $q->theme ?? '')->sortKeys(), ]); } diff --git a/portail/app/Http/Controllers/ClasseController.php b/portail/app/Http/Controllers/ClasseController.php index d7bf73b..5fc3216 100644 --- a/portail/app/Http/Controllers/ClasseController.php +++ b/portail/app/Http/Controllers/ClasseController.php @@ -8,6 +8,8 @@ use App\Models\DemandeInscription; use App\Models\Eleve; use App\Models\LabSession; use App\Models\Seance; +use App\Services\CacheTableauDeBordProf; +use App\Services\Catalogue\CacheCatalogue; use App\Services\GenerateurCompteEleve; use App\Services\Labs\DefinitionLab; use Illuminate\Http\JsonResponse; @@ -24,7 +26,7 @@ class ClasseController extends Controller public function index(): View { - $classes = Auth::guard('web')->user()->classes()->withCount('eleves')->latest()->get(); + $classes = CacheTableauDeBordProf::classes(Auth::guard('web')->id())->sortByDesc('created_at')->values(); return view('mes-classes.index', ['classes' => $classes]); } @@ -80,7 +82,7 @@ class ClasseController extends Controller : null; return view('mes-classes.show', [ - 'classes' => Auth::guard('web')->user()->classes()->withCount('eleves')->orderBy('nom')->get(), + 'classes' => CacheTableauDeBordProf::classes(Auth::guard('web')->id()), 'classe' => $classe->load(['eleves', 'annonces.auteur', 'observations.eleve']), 'demandes' => $classe->demandesInscription()->where('statut', DemandeInscription::EN_ATTENTE)->oldest()->get(), 'seance' => $seance, @@ -88,6 +90,7 @@ class ClasseController extends Controller 'progressionSeance' => $progressionSeance, 'reussiteMoyenne' => $corrections->isNotEmpty() ? (int) round($corrections->avg(fn ($c) => $c->note_max > 0 ? $c->note / $c->note_max * 100 : 0)) : null, 'mains' => ($seance && $seance->visioOuverte()) ? VisioController::mainsLevees($seance) : [], + 'labsCatalogue' => CacheCatalogue::labs()->sortBy('titre')->values(), ]); } @@ -237,6 +240,7 @@ class ClasseController extends Controller $classe->forceFill(['archivee_a' => now()])->save(); $classe->seances()->whereIn('statut', [Seance::PLANIFIEE, Seance::SALLE_ATTENTE, Seance::EN_COURS]) ->update(['statut' => Seance::TERMINEE, 'terminee_a' => now()]); + CacheTableauDeBordProf::vider($classe->user_id); return redirect()->route('mes-classes.show', $classe); } @@ -245,6 +249,7 @@ class ClasseController extends Controller { $this->verifierProprietaire($classe); $classe->forceFill(['archivee_a' => null])->save(); + CacheTableauDeBordProf::vider($classe->user_id); return redirect()->route('mes-classes.show', $classe); } diff --git a/portail/app/Services/CacheTableauDeBordProf.php b/portail/app/Services/CacheTableauDeBordProf.php new file mode 100644 index 0000000..c7bc986 --- /dev/null +++ b/portail/app/Services/CacheTableauDeBordProf.php @@ -0,0 +1,35 @@ + */ + public static function classes(int $profId): Collection + { + return Classe::hydrate(Cache::rememberForever( + "prof:{$profId}:classes", + fn () => Classe::where('user_id', $profId)->withCount('eleves')->orderBy('nom')->get()->map->getAttributes()->all() + )); + } + + public static function vider(int $profId): void + { + Cache::forget("prof:{$profId}:classes"); + } +} diff --git a/portail/app/Services/Catalogue/CacheCatalogue.php b/portail/app/Services/Catalogue/CacheCatalogue.php new file mode 100644 index 0000000..03c5be1 --- /dev/null +++ b/portail/app/Services/Catalogue/CacheCatalogue.php @@ -0,0 +1,69 @@ + */ + public static function labs(): Collection + { + return Lab::hydrate(Cache::rememberForever( + 'catalogue:labs', + fn () => Lab::orderBy('identifiant')->get()->map->getAttributes()->all() + )); + } + + /** @return Collection */ + public static function quizOfficiels(): Collection + { + return Quiz::hydrate(Cache::rememberForever( + 'catalogue:quiz-officiels', + fn () => Quiz::where('partage', true)->whereNotNull('theme')->withCount('questions')->orderBy('theme')->get()->map->getAttributes()->all() + )); + } + + /** @return Collection */ + public static function cours(): Collection + { + return Cours::hydrate(Cache::rememberForever( + 'catalogue:cours', + fn () => Cours::orderBy('identifiant')->get()->map->getAttributes()->all() + )); + } + + public static function viderLabs(): void + { + Cache::forget('catalogue:labs'); + } + + public static function viderQuizOfficiels(): void + { + Cache::forget('catalogue:quiz-officiels'); + } + + public static function viderCours(): void + { + Cache::forget('catalogue:cours'); + } +} diff --git a/portail/resources/views/mes-classes/show.blade.php b/portail/resources/views/mes-classes/show.blade.php index 0469cf0..3e19f0d 100644 --- a/portail/resources/views/mes-classes/show.blade.php +++ b/portail/resources/views/mes-classes/show.blade.php @@ -432,7 +432,7 @@
@csrf diff --git a/portail/tests/Feature/CacheApplicatifTest.php b/portail/tests/Feature/CacheApplicatifTest.php new file mode 100644 index 0000000..fec19f2 --- /dev/null +++ b/portail/tests/Feature/CacheApplicatifTest.php @@ -0,0 +1,84 @@ +create(['identifiant' => 'a-lab']); + expect(CacheCatalogue::labs())->toHaveCount(1); + + Lab::factory()->create(['identifiant' => 'b-lab']); + expect(CacheCatalogue::labs())->toHaveCount(1); // toujours la version en cache + + CacheCatalogue::viderLabs(); + expect(CacheCatalogue::labs())->toHaveCount(2); +}); + +test('CacheCatalogue::quizOfficiels() est en cache tant que viderQuizOfficiels() n’est pas appelé', function () { + $classe = Classe::factory()->create(); + Quiz::create(['classe_id' => $classe->id, 'titre' => 'Un', 'theme' => 'NET', 'partage' => true]); + expect(CacheCatalogue::quizOfficiels())->toHaveCount(1); + + Quiz::create(['classe_id' => $classe->id, 'titre' => 'Deux', 'theme' => 'LIN', 'partage' => true]); + expect(CacheCatalogue::quizOfficiels())->toHaveCount(1); + + CacheCatalogue::viderQuizOfficiels(); + expect(CacheCatalogue::quizOfficiels())->toHaveCount(2); +}); + +test('CacheCatalogue::cours() est en cache tant que viderCours() n’est pas appelé', function () { + Cours::create(['identifiant' => 'a', 'titre' => 'Un', 'contenu_markdown' => 'x']); + expect(CacheCatalogue::cours())->toHaveCount(1); + + Cours::create(['identifiant' => 'b', 'titre' => 'Deux', 'contenu_markdown' => 'x']); + expect(CacheCatalogue::cours())->toHaveCount(1); + + CacheCatalogue::viderCours(); + expect(CacheCatalogue::cours())->toHaveCount(2); +}); + +test('CacheTableauDeBordProf::classes() est isolé par prof et vidé par vider()', function () { + $profA = User::factory()->create(); + $profB = User::factory()->create(); + Classe::factory()->create(['user_id' => $profA->id]); + Classe::factory()->create(['user_id' => $profB->id]); + + expect(CacheTableauDeBordProf::classes($profA->id))->toHaveCount(1) + ->and(CacheTableauDeBordProf::classes($profB->id))->toHaveCount(1); + + Classe::factory()->create(['user_id' => $profA->id]); + expect(CacheTableauDeBordProf::classes($profA->id))->toHaveCount(1); // en cache + + CacheTableauDeBordProf::vider($profA->id); + expect(CacheTableauDeBordProf::classes($profA->id))->toHaveCount(2) + ->and(CacheTableauDeBordProf::classes($profB->id))->toHaveCount(1); // l'autre prof n'a pas bougé +}); + +test('archiver une classe invalide le cache tout de suite (pas de statut périmé sur /mes-classes)', function () { + $prof = User::factory()->create(); + $classe = Classe::factory()->create(['user_id' => $prof->id]); + + prof($this, $prof)->get(route('mes-classes.index'))->assertOk(); // remplit le cache + + prof($this, $prof)->post(route('mes-classes.archiver', $classe))->assertRedirect(); + + $page = prof($this, $prof)->get(route('mes-classes.index')); + $page->assertOk()->assertSee('archivée'); +}); + +test('purger une classe invalide le cache du prof : elle disparaît tout de suite de /mes-classes', function () { + $prof = User::factory()->create(); + $classe = Classe::factory()->create(['user_id' => $prof->id, 'nom' => 'À purger']); + + prof($this, $prof)->get(route('mes-classes.index'))->assertSee('À purger'); // remplit le cache + + $classe->forceFill(['archivee_a' => now()->subDays(91)])->save(); + $this->artisan('classes:purger-archivees'); + + prof($this, $prof)->get(route('mes-classes.index'))->assertDontSee('À purger'); +}); diff --git a/portail/tests/Pest.php b/portail/tests/Pest.php index 941e024..64c44ad 100644 --- a/portail/tests/Pest.php +++ b/portail/tests/Pest.php @@ -1,6 +1,7 @@ extend(TestCase::class) ->use(RefreshDatabase::class) + ->beforeEach(function () { + // RefreshDatabase remet SQLite à vide (donc les ids recommencent à 1) + // mais ne touche pas le cache : sans ça, un cache Redis gardé entre + // deux tests (CacheCatalogue, CacheTableauDeBordProf) pourrait servir + // la réponse d'un test précédent à un id recyclé par un autre. + Cache::flush(); + }) ->in('Feature'); /*