# Architecture du cache — Moteur Folio ## Contexte et problème initial Le moteur stocke chaque article dans un sous-répertoire `data/{uuid}/` contenant deux fichiers : - `meta.json` — métadonnées (titre, slug, catégorie, cover, dates…) - `index.md` — contenu Markdown Avec 1 000+ articles, chaque vue de page déclenchait **3 appels à `getAll()`** (via `getBySlug()`, `getCategories()` et directement pour les articles liés), ce qui représentait ~6 000 lectures de fichiers par requête. La page mettait **+5 secondes** à charger. --- ## Les quatre niveaux de cache ### 1. Cache mémoire de requête — `$allCache` et `$searchIndexCache` **Scope** : durée de vie d'une requête PHP (in-process). `ArticleManager` mémoïse deux tableaux en propriétés privées : - `$allCache` — résultat de `loadAll()` (tous les articles avec contenu) - `$searchIndexCache` — contenu de `search_index.json` ```php // Premier appel : scan disque + construction du tableau $this->allCache = $this->loadAll(); // Appels suivants dans la même requête : tableau déjà en mémoire return $this->allCache; ``` **Invalidation** : `writeMeta()` et `delete()` mettent les deux propriétés à `null`. --- ### 2. Cache disque par article — `_cache/articles/{uuid}.json` **Scope** : persistant entre les requêtes, jusqu'à modification de l'article. `loadArticle()` vérifie si le cache est plus récent que `meta.json` avant de lire les sources : ``` _cache/articles/{uuid}.json <-- filemtime >= meta.json ? → utiliser le cache → lire meta.json + index.md, écrire le cache ``` Le fichier cache contient toutes les données de l'article (métadonnées + contenu), ce qui réduit les lectures de **2 fichiers à 1** par article chargé. **Invalidation** : `writeMeta()` supprime `_cache/articles/{uuid}.json` avant d'écrire le nouveau `meta.json`. `delete()` le supprime aussi. --- ### 3. Index slug → UUID — `_cache/slug_index.json` **Scope** : persistant, mis à jour incrémentalement. Permet à `getBySlug()` de trouver un article en **O(1)** (lecture de l'index + lecture du cache article) au lieu de parcourir tous les articles. ``` slug_index.json : {"mon-article": "uuid-xxxx", "autre-article": "uuid-yyyy", ...} ``` **Construction** : à la première utilisation, `buildSlugIndex()` lit le `search_index.json` (un seul fichier) pour construire la correspondance. Si le search_index n'existe pas encore, il tombe en repli sur `loadAll()`. **Invalidation** : `writeMeta()` supprime le fichier (reconstruction automatique à la prochaine requête). `delete()` fait de même. > Suppression plutôt que mise à jour incrémentale : la reconstruction depuis `search_index.json` est quasi instantanée (lecture d'un seul fichier JSON), donc il n'y a pas d'intérêt à maintenir des mises à jour partielles. --- ### 4. Index de recherche — `search_index.json` **Scope** : persistant, reconstruit après chaque modification d'article. Fichier JSON plat contenant un tableau de tous les articles avec leurs champs essentiels et leur texte brut pré-calculé (`plain`, sans syntaxe Markdown). Utilisé pour : - La recherche plein-texte (`SearchEngine`) - La liste des articles publiés pour les articles liés/similaires (évite `getAll()`) - Les catégories (`getCategories()`) - La construction du slug index **Champs stockés** : ```json { "uuid": "...", "slug": "...", "title": "...", "category": "...", "author": "...", "cover": "...", "published": true, "published_at": "2026-01-15 10:00:00", "created_at": "2026-01-14 09:30:00", "updated_at": "2026-01-15 10:00:00", "plain": "texte brut de l'article sans markdown..." } ``` **Rebuild automatique** : si le fichier ne contient pas le champ `cover` (format antérieur à la v2 du cache), `getSearchIndex()` déclenche automatiquement un rebuild. **Invalidation** : `rebuildSearchIndex()` (appelé par `create()`, `update()`, `delete()`). --- ## Chemin d'une requête de vue d'article après optimisation ``` GET /post/{slug} │ ├── getBySlug(slug) │ ├── Lire slug_index.json [1 lecture] │ ├── → UUID trouvé │ └── getByUuid(uuid) │ └── Lire _cache/articles/{uuid}.json [1 lecture] │ ├── getCategories() │ └── getSearchIndex() [lecture de search_index.json, mise en cache mémoire] │ ├── $_allPublished (articles liés + similaires) │ └── getSearchIndex() [déjà en cache mémoire → 0 lecture] │ ├── scorePool(mots_du_titre, $_allPublished) │ └── Tokenisation unique par article, pas de re-calcul par mot │ └── getBacklinks(slug) └── Lire _cache/backlinks.json [1 lecture] Total : ~4 lectures de fichiers, indépendamment du nombre total d'articles. ``` Avant optimisation (1 062 articles) : ~6 300 lectures de fichiers. --- ## Performances mesurées | Scénario | Avant | Après | |---|---|---| | Cold cache (aucun cache disque) | +5 s | ~0,6 s | | Warm cache (cache disque présent) | +5 s | ~0,4 s | --- ## Scalabilité | Volume d'articles | Lectures de fichiers par vue (après) | |---|---| | 1 000 | ~4 | | 100 000 | ~4 | | 500 000 | ~4 | Le nombre de lectures est **constant** : le chemin de vue ne dépend plus du nombre total d'articles, seulement de la présence des fichiers de cache. La seule opération encore en O(N) est `rebuildSearchIndex()`, mais elle n'est déclenchée que sur écriture (création, modification, suppression d'article), jamais sur lecture. --- ## Invalidation — résumé | Événement | Caches invalidés | |---|---| | `writeMeta()` (toute écriture d'article) | `$allCache`, `$searchIndexCache`, cache article (`{uuid}.json`), slug index | | `delete()` | idem + suppression physique du cache article | | `rebuildSearchIndex()` | `$searchIndexCache` (remplacé par les nouvelles données) | --- ## Maintenance ### Vider manuellement les caches disque En cas de besoin (migration, incohérence) : ```bash ssh varlog "sudo rm -rf /var/www/lan.acegrp.varlog/data/_cache/articles/" ssh varlog "sudo rm /var/www/lan.acegrp.varlog/data/_cache/slug_index.json" ``` Les caches se reconstruisent automatiquement à la première requête suivante. ### Forcer un rebuild du search_index Modifier et sauvegarder n'importe quel article depuis l'interface admin déclenche un rebuild complet. Il n'existe pas de commande CLI dédiée pour l'instant.