Files
folio/docs/cache-architecture.md
T

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 de loadAll() (tous les articles avec contenu)
  • $searchIndexCache — contenu de search_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.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 :

{
  "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.