Cache
Fonctionnement du cache
Chaque résultat de requête est stocké en tant que CacheEntry<T> dans un QueryCache en mémoire, indexé par la clé de requête normalisée. Le cache pilote :
- Réponses instantanées : les données fraîches sont retournées sans appel réseau
- SWR : les données périmées sont retournées immédiatement pendant que les données fraîches sont récupérées en arrière-plan
- Mises à jour réactives : chaque
CacheEntrycontient un broadcast stream ; tout changement d'état atteint immédiatement tous les abonnés actifs
fetchQuery(['users', 1])
│
├─ Cache HIT (frais) → retourner les données immédiatement
├─ Cache HIT (périmé) → retourner les données périmées + revalider en arrière-plan
└─ Cache MISS → fetch → stocker → retourner les données
Cycle de vie d'une entrée de cache
Une CacheEntry<T> traverse les étapes suivantes :
Créée (Initial)
│ fetchQuery / watchQuery déclenché
▼
Loading → Success / Failure
│ staleTime écoulé
▼
Périmée (toujours en cache, accessible comme previousData)
│ dernier abonné se désabonne
▼
Timer GC démarré (= gcTime)
│ timer déclenché, aucun abonné actif
▼
Évincée (supprimée du cache)
Un sweep d'éviction en arrière-plan s'exécute également toutes les minutes pour supprimer les entrées inactives expirées.
staleTime vs gcTime
Ces deux durées contrôlent des comportements indépendants :
| Propriété | Défaut | Contrôle |
|---|---|---|
staleTime | 0 (zéro) | Quand déclencher une revalidation en arrière-plan |
gcTime | 5 minutes | Quand supprimer une entrée inutilisée du cache |
QoraOptions(
staleTime: Duration(minutes: 5), // données fraîches pendant 5 minutes
gcTime: Duration(minutes: 10), // données inutilisées conservées 10 min en cache
)
staleTime: Duration.zero (la valeur par défaut) signifie que les données sont toujours considérées comme périmées. Qora sert quand même les données en cache immédiatement ; il revalide également en arrière-plan à chaque accès.staleTime en détail
staleTime définit la fenêtre de fraîcheur. Pendant cette fenêtre, les données sont retournées depuis le cache sans aucune activité réseau :
// staleTime : 5 minutes
// t = 0:00 → fetch déclenché, Success stocké
// t = 0:01 → fetchQuery → Cache HIT (frais) : pas d'appel réseau
// t = 5:01 → fetchQuery → Cache HIT (périmé) : retourne les données en cache + revalide
// t = 5:01 → requête réseau déclenchée en arrière-plan
// t = 5:02 → Success mis à jour, abonnés au stream notifiés
Définir un staleTime long pour les données qui changent rarement :
// Données de configuration : valides pendant 1 heure
options: QoraOptions(staleTime: Duration(hours: 1)),
// Profil utilisateur : valide pendant 10 minutes
options: QoraOptions(staleTime: Duration(minutes: 10)),
// Prix en direct : toujours périmé (défaut)
options: QoraOptions(staleTime: Duration.zero),
gcTime en détail
gcTime détermine combien de temps les données inactives restent en cache après le départ de tous les abonnés. Il préserve les données pour une re-navigation rapide :
// L'utilisateur quitte → dernier abonné annule → timer GC démarre
// L'utilisateur revient dans gcTime → entrée toujours en cache → affichage instantané
// L'utilisateur revient après gcTime → entrée évincée → fetch complet requis
// Liste de posts : conserver en cache 2 minutes après avoir quitté la page
options: QoraOptions(gcTime: Duration(minutes: 2)),
gcTime: Duration.zero supprime l'entrée dès que tous les abonnés partent. La visite suivante repart toujours de Initial. Ce comportement est correct pour les données sensibles qui ne doivent pas survivre à la session courante.Partage structurel (Structural Sharing)
Le partage structurel préserve l'égalité référentielle pour les données imbriquées inchangées entre les fetches. Quand un fetch retourne des données profondément égales aux données en cache, Qora conserve la référence précédente au lieu de la remplacer.
Cette fonctionnalité est activée par défaut (QoraOptions.structuralSharing: true). Elle répond directement au problème des reconstructions inutiles de widgets dans les interfaces utilisateur profondément imbriquées :
// Sans partage structurel : chaque fetch crée une nouvelle référence,
// ce qui oblige tous les widgets consommant cette requête à se reconstruire.
//
// Avec partage structurel (défaut) : si les données retournées sont
// structurellement identiques aux données en cache, l'ancienne référence
// est conservée. Les widgets qui vérifient identical(oldData, newData)
// ou == ignorent le rendu.
final client = QoraClient();
await client.fetchQuery<Map<String, dynamic>>(
key: ['config'],
fetcher: () => api.getConfig(),
options: QoraOptions(structuralSharing: true),
);
Fonctionnement
La comparaison d'égalité profonde est récursive :
- Map : compare la présence des clés et les valeurs récursivement
- List : compare le nombre d'éléments et les éléments récursivement
- Set : compare l'appartenance
- Autres types : utilise
==(surcharger==ethashCodesur les classes de modèle personnalisées pour de meilleurs résultats)
final oldData = {'user': {'name': 'Alice', 'roles': ['admin']}};
final newData = {'user': {'name': 'Alice', 'roles': ['admin']}};
// structuralShare retourne oldData, elles sont profondément égales.
// Tout widget détenant oldData verra identical(oldData, newData) == true.
Quand désactiver
Désactiver le partage structurel uniquement si la vérification d'égalité profonde devient un goulot d'étranglement mesuré avec de très grandes structures de données :
QoraOptions(structuralSharing: false),
Portée
Le partage structurel s'applique à tous les chemins d'écriture du cache :
fetchQueryaprès une réponse réseau réussiesetQueryDatapour les écritures manuelles dans le cachesetInfiniteQueryDataetupdateInfiniteQueryStatepour les requêtes infinieshydrateQuerylors de la restauration de données persistées dans une entrée non-initiale
Pipeline de transformation
Qora vous permet de découpler la transformation des données du fetcher via QoraOptions.transform et QoraOptions.transformError. Cela maintient le fetcher simple (appel API brut) et la transformation pure (testable isolément).
transform: succès
Appelée après la réussite du fetcher mais avant l'entrée des données dans le cache (et avant le partage structurel). La valeur transformée est ce qui est mis en cache et retourné aux appelants.
fetchQuery<User>(
key: ['users', userId],
fetcher: () => api.getUser(userId), // retourne Map<String, dynamic>
options: QoraOptions(
transform: (raw) => User.fromJson(raw as Map<String, dynamic>),
),
);
transformError: erreur
Appelée après l'échec de toutes les tentatives mais avant l'entrée de l'erreur dans le cache et avant QoraClientConfig.errorMapper. Utilisez-la pour normaliser les types d'erreur au niveau de la requête.
fetchQuery<Post>(
key: ['post', postId],
fetcher: () => api.getPost(postId),
options: QoraOptions(
transformError: (error) => AppError.fromException(error),
),
);
Conserver les données précédentes
Utilisez QoraOptions.keepPreviousData pour supprimer le flash de chargement lors des re-fetches. L'entrée reste dans l'état Success avec les données précédentes pendant que le fetch est en cours, puis passe à Success avec les nouvelles données à la fin du fetch.
C'est particulièrement utile pour la pagination basée sur un offset, où le changement de paramètre de page déclenche un fetch mais vous voulez que la page actuelle reste visible jusqu'à ce que la suivante soit prête :
fetchQuery<List<Post>>(
key: ['posts', 'page', page],
fetcher: () => api.getPosts(page: page, limit: 20),
options: QoraOptions(
keepPreviousData: true,
staleTime: Duration.zero,
),
);
En cas d'échec du fetch, l'entrée reste dans Success avec les données précédentes au lieu de passer à Failure. L'erreur est toujours propagée aux appelants impératifs (fetchQuery) afin que la gestion d'erreur (toasts, boutons de reprise) fonctionne normalement.
Éviction LRU
Quand maxCacheSize est défini sur QoraClientConfig, le cache applique une limite via une stratégie LRU (Least Recently Used) :
- L'entrée accédée le moins récemment et sans abonnés actifs est évincée pour faire de la place
- Les entrées actives (avec abonnés) ne sont jamais évincées de force
final client = QoraClient(
config: QoraClientConfig(maxCacheSize: 100),
);
Chaque appel à fetchQuery, watchQuery ou getQueryData met à jour lastAccessedAt, maintenant les entrées en vie dans le classement LRU.
Contrôle manuel du cache
Lire le cache
// Obtenir uniquement les données (null si pas Success / pas de previousData)
final user = client.getQueryData<User>(['users', userId]);
// Obtenir l'état complet
final state = client.getQueryState<User>(['users', userId]);
// Inspecter toutes les clés en cache
for (final key in client.cachedKeys) {
print(key);
}
Écrire dans le cache
// Injecter des données directement : tous les streams watchQuery actifs sont notifiés
client.setQueryData<User>(['users', userId], updatedUser);
// Rollback vers un snapshot précédent
final snapshot = client.getQueryData<User>(['users', userId]);
client.setQueryData(['users', userId], optimisticUser);
try {
await api.updateUser(userId, payload);
} catch (_) {
client.restoreQueryData(['users', userId], snapshot);
}
Invalider (marquer comme périmé)
// Clé unique : les streams actifs passent immédiatement à Loading
client.invalidate(['users', userId]);
// Plusieurs clés via prédicat
client.invalidateWhere((key) => key.firstOrNull == 'users');
// Basée sur des tags : revalider toutes les requêtes fournissant le tag 'post'
client.invalidateTags([QueryTag('post')]);
// Ressource spécifique : revalider uniquement les requêtes avec QueryTag('post', '123')
client.invalidateTags([QueryTag('post', '123')]);
L'invalidation basée sur les tags est l'approche la plus flexible. Les requêtes déclarent des tags via providesTags dans QoraOptions. Les mutations déclarent des tags via invalidatesTags dans MutationOptions. Les tags découplent l'invalidation de la structure des clés de requête : vous pouvez invalider des requêtes avec différents motifs de clés en un seul tag.
options: QoraOptions(
providesTags: [QueryTag('post'), QueryTag('user', userId)],
)
Voir MutationOptions.invalidatesTags et QoraOptions.providesTags.
Supprimer et vider
// Supprimer une entrée unique (GC immédiat)
client.removeQuery(['users', userId]);
// Vider le cache entier (ex. lors d'une déconnexion)
client.clear();
Callback onCacheEvict
Réagir aux évictions de cache pour le logging ou la synchronisation du stockage :
final client = QoraClient(
config: QoraClientConfig(
onCacheEvict: (key) {
analytics.track('cache_evict', {'key': key.toString()});
},
),
);
Snapshot de debug
print(client.debugInfo());
// {
// total_queries: 12,
// active_queries: 3,
// inactive_queries: 9,
// non_success_queries: 1,
// pending_requests: 2,
// max_size: 100,
// keys: [['users', 1], ['posts'], ...]
// }
Configuration globale vs par requête
gcTime et staleTime peuvent être définis globalement via QoraClientConfig.defaultOptions et surchargés par requête :
final client = QoraClient(
config: QoraClientConfig(
defaultOptions: QoraOptions(
staleTime: Duration(minutes: 5), // défaut global
gcTime: Duration(minutes: 10),
),
),
);
// Cette requête surcharge uniquement staleTime
await client.fetchQuery<Config>(
key: ['config'],
fetcher: api.getConfig,
options: QoraOptions(staleTime: Duration(hours: 1)),
// gcTime hérite toujours des 10 minutes du global
);
Étapes suivantes
- Stale-While-Revalidate : Fonctionnement de la revalidation en arrière-plan
- Déduplication : Une seule requête réseau pour de nombreux appelants
- API QoraClientConfig : Référence de configuration complète
États des requêtes
Les états des requêtes représentent le cycle de vie des opérations de récupération de données. Qora utilise des classes scellées pour fournir une gestion des états exhaustive et sûre au niveau des types, éliminant des catégories entières d'erreurs à l'exécution.
Stale-While-Revalidate
Le SWR de Qora retourne les données en cache immédiatement et revalide en arrière-plan pour maintenir les UIs réactives et les données à jour.