# ADR-004 : Invalidation du cache par tenant via un index de clés

**Statut :** Accepté
**Date :** 2026-07-23
**Contexte :** Cache applicatif des agrégations coûteuses (dashboard, config app par tenant)

---

## Contexte

Plusieurs vues coûteuses sont mises en cache par tenant : les statistiques du dashboard (`DashboardStatsService`/`ProfitCalculationService`, TTL 5 min) et la config app par company (`AuthController::app()`, TTL 60s). Le driver de cache par défaut du projet est `file` (`CACHE_DRIVER=file`), qui ne supporte pas `Cache::tags()`.

Un premier incident (signalé par un utilisateur le 2026-07-22 : une dépense ajoutée restait invisible sur le dashboard) a révélé deux défauts cumulés :

1. Plusieurs controllers qui modifient des données agrégées sur le dashboard (Expense, Payment, OrderPayment, Customer/Supplier) n'appelaient jamais l'invalidation.
2. Même quand l'invalidation était appelée (flux commandes), elle ne forgettait qu'une seule clé fixe (`dashboardCacheKey($warehouseId)`, sans dates) — alors que le dashboard envoie toujours une plage de dates réelle, qui produit une clé différente (hash des dates). L'invalidation ne touchait donc jamais la clé réellement utilisée en pratique.

Sans support de tags, il n'existait pas de moyen direct d'« oublier toutes les clés dashboard de ce warehouse, quelle que soit la plage de dates » en une seule opération.

## Décision

Chaque fonction qui construit une clé de cache par tenant (`Common::dashboardCacheKey($warehouseId, $dates)`, `Common::appConfigCacheKey($companyId)`) enregistre la clé générée dans un index dédié (`tenant:dashboard:{warehouseId}:keys`, stocké lui-même en cache, TTL 1 jour). L'invalidation (`Common::invalidateDashboardCache($warehouseId)`, `Common::forgetAppConfigCache($companyId)`) parcourt cet index et oublie chaque clé qu'il contient, puis oublie l'index lui-même.

Toute écriture qui modifie une donnée agrégée sur le dashboard (commandes, dépenses, paiements, règlements de facture, clients/fournisseurs) doit appeler `Common::invalidateDashboardCache($warehouseId)` dans son hook `stored`/`updated`/`destroyed`.

## Alternatives Considérées

### Passer le driver de cache à Redis pour utiliser `Cache::tags()`
Rejetée pour l'instant : changement d'infrastructure disproportionné pour résoudre un problème d'invalidation, alors que l'index de clés le résout sans dépendance supplémentaire. À reconsidérer si le volume de tenants/clés rend l'index lui-même coûteux à maintenir (cf. Quand Réviser).

### TTL court sans invalidation explicite
Rejetée : masque le symptôme (l'utilisateur voit une donnée à jour après quelques minutes) sans corriger la cause — un utilisateur qui vient de modifier une donnée et regarde immédiatement le dashboard verra toujours une valeur obsolète, ce qui a précisément déclenché le signalement initial.

## Conséquences

- Chaque nouvelle donnée agrégée dans `DashboardStatsService`/`ProfitCalculationService` doit avoir son écriture correspondante (controller/service) qui appelle `Common::invalidateDashboardCache($warehouseId)` — un oubli reproduit silencieusement l'incident du 2026-07-22.
- L'index de clés grossit avec le nombre de plages de dates distinctes consultées par warehouse avant expiration ; borné en pratique par le TTL de l'index (1 jour) et le nombre de sélections de dates réalistes qu'un utilisateur effectue en une journée.
- Le même principe (`appConfigCacheKey`/`forgetAppConfigCache`) a été appliqué à la config app par tenant, qui avait le même défaut (clé recalculée indépendamment à 4 endroits, sans invalidation coordonnée).

## Quand Réviser

- Si un driver de cache supportant les tags (Redis, Memcached) est adopté pour d'autres raisons : remplacer l'index par `Cache::tags(["tenant:{$warehouseId}"])->flush()`, plus direct.
- Si le nombre de clés indexées par warehouse devient significatif (usage intensif de plages de dates personnalisées) au point de rendre `Cache::get($indexKey)`/la boucle de `forget()` coûteuse.
- Si une nouvelle vue agrégée par tenant est ajoutée : appliquer le même principe (clé construite + enregistrée via une fonction dédiée) plutôt que de recalculer une clé de cache ad-hoc.
