6.4 KiB
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 deloadAll()(tous les articles avec contenu)$searchIndexCache— contenu desearch_index.json
// 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.jsonest 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 :
{
"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) :
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.