820 lines
		
	
	
		
			25 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
			
		
		
	
	
			820 lines
		
	
	
		
			25 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
| # Phase 3 - Cache Serveur et Optimisations Avancées pour ObsiViewer
 | |
| 
 | |
| ## 🎯 Objectif
 | |
| 
 | |
| Implémenter un système de cache serveur intelligent pour réduire la charge serveur de 50%, améliorer les temps de réponse et optimiser l'indexation Meilisearch tout en maintenant la cohérence des données.
 | |
| 
 | |
| ## 📋 Contexte
 | |
| 
 | |
| ### ✅ Ce qui a été accompli en Phase 1 & 2
 | |
| - **Phase 1 (Metadata-First)** : Chargement ultra-rapide des métadonnées uniquement (75% d'amélioration)
 | |
| - **Phase 2 (Pagination)** : Support pour 10,000+ fichiers avec virtual scrolling et pagination curseur-based
 | |
| 
 | |
| ### ❌ Limites actuelles nécessitant la Phase 3
 | |
| - **Re-scans répétés** : Le serveur rescane le système de fichiers à chaque requête metadata
 | |
| - **Indexation bloquante** : Meilisearch bloque le démarrage du serveur pendant l'indexation initiale
 | |
| - **Charge serveur élevée** : Chaque requête implique des opérations I/O coûteuses
 | |
| - **Pas d'optimisation mémoire** : Cache inexistant côté serveur
 | |
| 
 | |
| ### 🎯 Pourquoi la Phase 3 est nécessaire
 | |
| Pour réduire la charge serveur de **50%** et améliorer l'expérience utilisateur :
 | |
| 1. **Cache en mémoire** : Éviter les re-scans répétés du système de fichiers
 | |
| 2. **Indexation différée** : Ne pas bloquer le démarrage pour l'indexation
 | |
| 3. **Invalidation intelligente** : Maintenir la cohérence lors des changements
 | |
| 4. **Optimisations mémoire** : Réduire l'empreinte serveur
 | |
| 
 | |
| ## 📊 Spécifications Techniques
 | |
| 
 | |
| ### 1. Cache de Métadonnées en Mémoire
 | |
| 
 | |
| #### Architecture du Cache
 | |
| ```typescript
 | |
| class MetadataCache {
 | |
|   private cache: Map<string, CachedMetadata> = new Map();
 | |
|   private lastUpdate: number = 0;
 | |
|   private readonly ttl: number = 5 * 60 * 1000; // 5 minutes
 | |
|   private readonly maxSize: number = 10000; // Max 10k entrées
 | |
|   
 | |
|   // Métriques de performance
 | |
|   private hits: number = 0;
 | |
|   private misses: number = 0;
 | |
|   
 | |
|   get hitRate(): number {
 | |
|     const total = this.hits + this.misses;
 | |
|     return total > 0 ? (this.hits / total) * 100 : 0;
 | |
|   }
 | |
| }
 | |
| 
 | |
| interface CachedMetadata {
 | |
|   data: NoteMetadata[];
 | |
|   timestamp: number;
 | |
|   checksum: string; // Pour détecter les changements
 | |
| }
 | |
| ```
 | |
| 
 | |
| #### Stratégie de Cache
 | |
| - **TTL** : 5 minutes (configurable)
 | |
| - **Invalidation** : Sur changements de fichiers détectés par chokidar
 | |
| - **Taille max** : 10,000 entrées pour éviter les fuites mémoire
 | |
| - **Fallback** : Rechargement depuis filesystem si cache expiré
 | |
| 
 | |
| ### 2. Indexation Meilisearch Différée
 | |
| 
 | |
| #### Problème Actuel
 | |
| ```javascript
 | |
| // ACTUELLEMENT (bloquant)
 | |
| app.listen(PORT, () => {
 | |
|   console.log('Server started');
 | |
|   // Indexation bloque le démarrage !
 | |
|   await fullReindex(vaultDir); // ← 30-60 secondes
 | |
|   console.log('Indexing complete');
 | |
| });
 | |
| ```
 | |
| 
 | |
| #### Solution Proposée
 | |
| ```javascript
 | |
| // NOUVEAU (non-bloquant)
 | |
| app.listen(PORT, () => {
 | |
|   console.log('Server started');
 | |
|   // Démarrage immédiat, indexation en arrière-plan
 | |
|   scheduleIndexing(); // ← Non-bloquant
 | |
| });
 | |
| 
 | |
| async function scheduleIndexing() {
 | |
|   if (indexingInProgress) return;
 | |
|   
 | |
|   setImmediate(async () => {
 | |
|     try {
 | |
|       await fullReindex(vaultDir);
 | |
|       console.log('[Meilisearch] Background indexing complete');
 | |
|     } catch (error) {
 | |
|       console.warn('[Meilisearch] Background indexing failed:', error);
 | |
|     }
 | |
|   });
 | |
| }
 | |
| ```
 | |
| 
 | |
| ### 3. Invalidation Intelligente du Cache
 | |
| 
 | |
| #### Événements de Changement
 | |
| ```javascript
 | |
| // Surveillance des changements avec chokidar
 | |
| const watcher = chokidar.watch(vaultDir, {
 | |
|   ignored: /(^|[\/\\])\../, // ignore dotfiles
 | |
|   persistent: true,
 | |
|   ignoreInitial: true
 | |
| });
 | |
| 
 | |
| // Invalidation sélective du cache
 | |
| watcher.on('add', (path) => {
 | |
|   console.log(`[Cache] File added: ${path}`);
 | |
|   metadataCache.invalidate();
 | |
|   // Optionnel: recharger seulement les nouvelles métadonnées
 | |
| });
 | |
| 
 | |
| watcher.on('change', (path) => {
 | |
|   console.log(`[Cache] File changed: ${path}`);
 | |
|   metadataCache.invalidate();
 | |
| });
 | |
| 
 | |
| watcher.on('unlink', (path) => {
 | |
|   console.log(`[Cache] File deleted: ${path}`);
 | |
|   metadataCache.invalidate();
 | |
| });
 | |
| ```
 | |
| 
 | |
| ## 🛠️ Plan d'Implémentation (1-2 jours)
 | |
| 
 | |
| ### Jour 1 : Cache de Métadonnées (6-8 heures)
 | |
| 
 | |
| #### 1.1 Créer la classe MetadataCache
 | |
| **Fichier** : `server/performance-config.mjs`
 | |
| 
 | |
| ```javascript
 | |
| export class MetadataCache {
 | |
|   constructor(options = {}) {
 | |
|     this.cache = new Map();
 | |
|     this.lastUpdate = 0;
 | |
|     this.ttl = options.ttl || 5 * 60 * 1000; // 5 minutes
 | |
|     this.maxSize = options.maxSize || 10000;
 | |
|     this.hits = 0;
 | |
|     this.misses = 0;
 | |
|     this.isLoading = false;
 | |
|   }
 | |
|   
 | |
|   // Récupérer les métadonnées depuis le cache
 | |
|   async getMetadata(vaultDir) {
 | |
|     const now = Date.now();
 | |
|     const cacheKey = this.getCacheKey(vaultDir);
 | |
|     const cached = this.cache.get(cacheKey);
 | |
|     
 | |
|     // Cache valide ?
 | |
|     if (cached && (now - cached.timestamp) < this.ttl) {
 | |
|       this.hits++;
 | |
|       console.log(`[Cache] HIT - ${this.hitRate.toFixed(1)}% hit rate`);
 | |
|       return cached.data;
 | |
|     }
 | |
|     
 | |
|     // Cache miss - recharger
 | |
|     this.misses++;
 | |
|     console.log(`[Cache] MISS - Loading fresh metadata`);
 | |
|     
 | |
|     return await this.loadFreshMetadata(vaultDir, cacheKey);
 | |
|   }
 | |
|   
 | |
|   // Charger les métadonnées fraiches
 | |
|   async loadFreshMetadata(vaultDir, cacheKey) {
 | |
|     if (this.isLoading) {
 | |
|       // Éviter les chargements concurrents
 | |
|       return this.waitForCurrentLoad(cacheKey);
 | |
|     }
 | |
|     
 | |
|     this.isLoading = true;
 | |
|     
 | |
|     try {
 | |
|       const metadata = await loadVaultMetadataOnly(vaultDir);
 | |
|       const checksum = this.calculateChecksum(metadata);
 | |
|       
 | |
|       // Stocker en cache
 | |
|       this.cache.set(cacheKey, {
 | |
|         data: metadata,
 | |
|         timestamp: Date.now(),
 | |
|         checksum
 | |
|       });
 | |
|       
 | |
|       // Nettoyer le cache si trop gros
 | |
|       this.cleanupIfNeeded();
 | |
|       
 | |
|       return metadata;
 | |
|     } finally {
 | |
|       this.isLoading = false;
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   // Invalider le cache
 | |
|   invalidate() {
 | |
|     console.log('[Cache] Invalidating cache');
 | |
|     this.cache.clear();
 | |
|     this.lastUpdate = 0;
 | |
|   }
 | |
|   
 | |
|   // Générer une clé de cache unique
 | |
|   getCacheKey(vaultDir) {
 | |
|     return `metadata_${vaultDir.replace(/[/\\]/g, '_')}`;
 | |
|   }
 | |
|   
 | |
|   // Calculer un checksum pour détecter les changements
 | |
|   calculateChecksum(metadata) {
 | |
|     const content = metadata.map(m => `${m.id}:${m.updatedAt}`).join('|');
 | |
|     return require('crypto').createHash('md5').update(content).digest('hex');
 | |
|   }
 | |
|   
 | |
|   // Nettoyer le cache si nécessaire
 | |
|   cleanupIfNeeded() {
 | |
|     if (this.cache.size > this.maxSize) {
 | |
|       // Supprimer les entrées les plus anciennes (LRU simple)
 | |
|       const entries = Array.from(this.cache.entries());
 | |
|       entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
 | |
|       
 | |
|       const toRemove = entries.slice(0, Math.floor(this.maxSize * 0.1));
 | |
|       toRemove.forEach(([key]) => this.cache.delete(key));
 | |
|       
 | |
|       console.log(`[Cache] Cleaned up ${toRemove.length} old entries`);
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   // Métriques
 | |
|   getStats() {
 | |
|     const total = this.hits + this.misses;
 | |
|     return {
 | |
|       size: this.cache.size,
 | |
|       hitRate: total > 0 ? (this.hits / total) * 100 : 0,
 | |
|       hits: this.hits,
 | |
|       misses: this.misses,
 | |
|       lastUpdate: this.lastUpdate
 | |
|     };
 | |
|   }
 | |
| }
 | |
| ```
 | |
| 
 | |
| #### 1.2 Intégrer le cache dans les endpoints
 | |
| **Fichier** : `server/index.mjs`
 | |
| 
 | |
| ```javascript
 | |
| // Importer et initialiser le cache
 | |
| import { MetadataCache } from './performance-config.mjs';
 | |
| const metadataCache = new MetadataCache();
 | |
| 
 | |
| // Utiliser le cache dans les endpoints
 | |
| app.get('/api/vault/metadata', async (req, res) => {
 | |
|   try {
 | |
|     console.time('[/api/vault/metadata] Total response time');
 | |
|     
 | |
|     // Récupérer depuis le cache
 | |
|     const metadata = await metadataCache.getMetadata(vaultDir);
 | |
|     
 | |
|     console.timeEnd('[/api/vault/metadata] Total response time');
 | |
|     
 | |
|     res.json(metadata);
 | |
|   } catch (error) {
 | |
|     console.error('[/api/vault/metadata] Error:', error);
 | |
|     res.status(500).json({ error: 'Failed to load metadata' });
 | |
|   }
 | |
| });
 | |
| 
 | |
| app.get('/api/vault/metadata/paginated', async (req, res) => {
 | |
|   try {
 | |
|     const limit = Math.min(parseInt(req.query.limit) || 100, 500);
 | |
|     const cursor = parseInt(req.query.cursor) || 0;
 | |
|     const search = req.query.search || '';
 | |
|     
 | |
|     console.time(`[/api/vault/metadata/paginated] cursor=${cursor}, limit=${limit}`);
 | |
|     
 | |
|     // Récupérer les métadonnées complètes depuis le cache
 | |
|     const allMetadata = await metadataCache.getMetadata(vaultDir);
 | |
|     
 | |
|     // Appliquer la pagination côté serveur
 | |
|     let filtered = allMetadata;
 | |
|     if (search) {
 | |
|       const searchLower = search.toLowerCase();
 | |
|       filtered = allMetadata.filter(item => 
 | |
|         (item.title || '').toLowerCase().includes(searchLower) ||
 | |
|         (item.filePath || '').toLowerCase().includes(searchLower)
 | |
|       );
 | |
|     }
 | |
|     
 | |
|     // Trier par date de modification décroissante
 | |
|     filtered.sort((a, b) => {
 | |
|       const dateA = new Date(a.updatedAt || a.createdAt || 0).getTime();
 | |
|       const dateB = new Date(b.updatedAt || b.createdAt || 0).getTime();
 | |
|       return dateB - dateA;
 | |
|     });
 | |
|     
 | |
|     // Paginer
 | |
|     const paginatedItems = filtered.slice(cursor, cursor + limit);
 | |
|     const hasMore = cursor + limit < filtered.length;
 | |
|     const nextCursor = hasMore ? cursor + limit : null;
 | |
|     
 | |
|     console.timeEnd(`[/api/vault/metadata/paginated] cursor=${cursor}, limit=${limit}`);
 | |
|     
 | |
|     res.json({
 | |
|       items: paginatedItems,
 | |
|       nextCursor,
 | |
|       hasMore,
 | |
|       total: filtered.length,
 | |
|       cacheStats: metadataCache.getStats()
 | |
|     });
 | |
|     
 | |
|   } catch (error) {
 | |
|     console.error('[/api/vault/metadata/paginated] Error:', error);
 | |
|     res.status(500).json({ error: 'Pagination failed' });
 | |
|   }
 | |
| });
 | |
| ```
 | |
| 
 | |
| #### 1.3 Ajouter l'invalidation automatique du cache
 | |
| **Fichier** : `server/index.mjs`
 | |
| 
 | |
| ```javascript
 | |
| // Configurer le watcher pour l'invalidation du cache
 | |
| const vaultWatcher = chokidar.watch(vaultDir, {
 | |
|   ignored: /(^|[\/\\])\../,
 | |
|   persistent: true,
 | |
|   ignoreInitial: true,
 | |
|   awaitWriteFinish: {
 | |
|     stabilityThreshold: 2000,
 | |
|     pollInterval: 100
 | |
|   }
 | |
| });
 | |
| 
 | |
| // Invalidation intelligente du cache
 | |
| vaultWatcher.on('add', (path) => {
 | |
|   if (path.endsWith('.md')) {
 | |
|     console.log(`[Watcher] File added: ${path} - Invalidating cache`);
 | |
|     metadataCache.invalidate();
 | |
|   }
 | |
| });
 | |
| 
 | |
| vaultWatcher.on('change', (path) => {
 | |
|   if (path.endsWith('.md')) {
 | |
|     console.log(`[Watcher] File changed: ${path} - Invalidating cache`);
 | |
|     metadataCache.invalidate();
 | |
|   }
 | |
| });
 | |
| 
 | |
| vaultWatcher.on('unlink', (path) => {
 | |
|   if (path.endsWith('.md')) {
 | |
|     console.log(`[Watcher] File deleted: ${path} - Invalidating cache`);
 | |
|     metadataCache.invalidate();
 | |
|   }
 | |
| });
 | |
| 
 | |
| vaultWatcher.on('error', (error) => {
 | |
|   console.error('[Watcher] Error:', error);
 | |
| });
 | |
| 
 | |
| // Endpoint pour consulter les statistiques du cache
 | |
| app.get('/api/cache/stats', (req, res) => {
 | |
|   res.json({
 | |
|     cache: metadataCache.getStats(),
 | |
|     watcher: {
 | |
|       watched: vaultDir,
 | |
|       ready: true
 | |
|     },
 | |
|     memory: process.memoryUsage()
 | |
|   });
 | |
| });
 | |
| ```
 | |
| 
 | |
| ### Jour 2 : Indexation Différée et Optimisations (4-6 heures)
 | |
| 
 | |
| #### 2.1 Implémenter l'indexation Meilisearch différée
 | |
| **Fichier** : `server/index.mjs`
 | |
| 
 | |
| ```javascript
 | |
| // Variable globale pour suivre l'état de l'indexation
 | |
| let indexingInProgress = false;
 | |
| let indexingCompleted = false;
 | |
| let lastIndexingAttempt = 0;
 | |
| const INDEXING_COOLDOWN = 5 * 60 * 1000; // 5 minutes entre tentatives
 | |
| 
 | |
| // Fonction pour programmer l'indexation en arrière-plan
 | |
| async function scheduleIndexing() {
 | |
|   const now = Date.now();
 | |
|   
 | |
|   // Éviter les indexations trop fréquentes
 | |
|   if (indexingInProgress || (now - lastIndexingAttempt) < INDEXING_COOLDOWN) {
 | |
|     return;
 | |
|   }
 | |
|   
 | |
|   indexingInProgress = true;
 | |
|   lastIndexingAttempt = now;
 | |
|   
 | |
|   console.log('[Meilisearch] Scheduling background indexing...');
 | |
|   
 | |
|   // Utiliser setImmediate pour ne pas bloquer le démarrage
 | |
|   setImmediate(async () => {
 | |
|     try {
 | |
|       console.time('[Meilisearch] Background indexing');
 | |
|       
 | |
|       await fullReindex(vaultDir);
 | |
|       
 | |
|       console.timeEnd('[Meilisearch] Background indexing');
 | |
|       console.log('[Meilisearch] Background indexing completed successfully');
 | |
|       
 | |
|       indexingCompleted = true;
 | |
|       
 | |
|     } catch (error) {
 | |
|       console.error('[Meilisearch] Background indexing failed:', error);
 | |
|       indexingCompleted = false;
 | |
|       
 | |
|       // Programmer une nouvelle tentative dans 5 minutes
 | |
|       setTimeout(() => {
 | |
|         console.log('[Meilisearch] Retrying indexing in 5 minutes...');
 | |
|         indexingInProgress = false; // Reset pour permettre une nouvelle tentative
 | |
|       }, INDEXING_COOLDOWN);
 | |
|       
 | |
|     } finally {
 | |
|       indexingInProgress = false;
 | |
|     }
 | |
|   });
 | |
| }
 | |
| 
 | |
| // Démarrer le serveur et programmer l'indexation
 | |
| const server = app.listen(PORT, () => {
 | |
|   console.log(`🚀 ObsiViewer server running on http://0.0.0.0:${PORT}`);
 | |
|   console.log(`📁 Vault directory: ${vaultDir}`);
 | |
|   
 | |
|   // Programmer l'indexation en arrière-plan (non-bloquant)
 | |
|   scheduleIndexing();
 | |
|   
 | |
|   console.log('✅ Server ready - indexing will complete in background');
 | |
| });
 | |
| 
 | |
| // Gestion propre de l'arrêt
 | |
| process.on('SIGINT', () => {
 | |
|   console.log('\n🛑 Shutting down server...');
 | |
|   server.close(() => {
 | |
|     console.log('✅ Server shutdown complete');
 | |
|     process.exit(0);
 | |
|   });
 | |
| });
 | |
| ```
 | |
| 
 | |
| #### 2.2 Améliorer la gestion des erreurs et des retries
 | |
| **Fichier** : `server/index.mjs`
 | |
| 
 | |
| ```javascript
 | |
| // Wrapper pour les opérations Meilisearch avec retry
 | |
| async function withRetry(operation, maxRetries = 3, delay = 1000) {
 | |
|   for (let attempt = 1; attempt <= maxRetries; attempt++) {
 | |
|     try {
 | |
|       return await operation();
 | |
|     } catch (error) {
 | |
|       console.warn(`[Retry] Attempt ${attempt}/${maxRetries} failed:`, error.message);
 | |
|       
 | |
|       if (attempt === maxRetries) {
 | |
|         throw error;
 | |
|       }
 | |
|       
 | |
|       // Attendre avant de retry (backoff exponentiel)
 | |
|       await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, attempt - 1)));
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| // Utiliser le retry dans les endpoints Meilisearch
 | |
| app.get('/api/vault/metadata/paginated', async (req, res) => {
 | |
|   try {
 | |
|     const limit = Math.min(parseInt(req.query.limit) || 100, 500);
 | |
|     const cursor = parseInt(req.query.cursor) || 0;
 | |
|     const search = req.query.search || '';
 | |
|     
 | |
|     console.time(`[/api/vault/metadata/paginated] cursor=${cursor}, limit=${limit}`);
 | |
|     
 | |
|     // Essayer Meilisearch d'abord avec retry
 | |
|     try {
 | |
|       const result = await withRetry(async () => {
 | |
|         const client = meiliClient();
 | |
|         const indexUid = vaultIndexName(vaultDir);
 | |
|         const index = await ensureIndexSettings(client, indexUid);
 | |
|         
 | |
|         return await index.search(search, {
 | |
|           limit: limit + 1,
 | |
|           offset: cursor,
 | |
|           attributesToRetrieve: ['id', 'title', 'path', 'createdAt', 'updatedAt'],
 | |
|           sort: ['updatedAt:desc']
 | |
|         });
 | |
|       });
 | |
|       
 | |
|       // Traiter le résultat Meilisearch
 | |
|       const hasMore = result.hits.length > limit;
 | |
|       const items = result.hits.slice(0, limit);
 | |
|       const nextCursor = hasMore ? cursor + limit : null;
 | |
|       
 | |
|       const metadata = items.map(hit => ({
 | |
|         id: hit.id,
 | |
|         title: hit.title,
 | |
|         filePath: hit.path,
 | |
|         createdAt: typeof hit.createdAt === 'number' ? new Date(hit.createdAt).toISOString() : hit.createdAt,
 | |
|         updatedAt: typeof hit.updatedAt === 'number' ? new Date(hit.updatedAt).toISOString() : hit.updatedAt,
 | |
|       }));
 | |
|       
 | |
|       console.timeEnd(`[/api/vault/metadata/paginated] cursor=${cursor}, limit=${limit}`);
 | |
|       
 | |
|       res.json({
 | |
|         items: metadata,
 | |
|         nextCursor,
 | |
|         hasMore,
 | |
|         total: result.estimatedTotalHits || result.hits.length,
 | |
|         source: 'meilisearch'
 | |
|       });
 | |
|       
 | |
|     } catch (meiliError) {
 | |
|       console.warn('[Meilisearch] Unavailable, falling back to cache:', meiliError.message);
 | |
|       
 | |
|       // Fallback vers le cache avec pagination côté serveur
 | |
|       const allMetadata = await metadataCache.getMetadata(vaultDir);
 | |
|       
 | |
|       let filtered = allMetadata;
 | |
|       if (search) {
 | |
|         const searchLower = search.toLowerCase();
 | |
|         filtered = allMetadata.filter(item => 
 | |
|           (item.title || '').toLowerCase().includes(searchLower) ||
 | |
|           (item.filePath || '').toLowerCase().includes(searchLower)
 | |
|         );
 | |
|       }
 | |
|       
 | |
|       filtered.sort((a, b) => {
 | |
|         const dateA = new Date(a.updatedAt || a.createdAt || 0).getTime();
 | |
|         const dateB = new Date(b.updatedAt || b.createdAt || 0).getTime();
 | |
|         return dateB - dateA;
 | |
|       });
 | |
|       
 | |
|       const paginatedItems = filtered.slice(cursor, cursor + limit);
 | |
|       const hasMore = cursor + limit < filtered.length;
 | |
|       
 | |
|       res.json({
 | |
|         items: paginatedItems,
 | |
|         nextCursor: hasMore ? cursor + limit : null,
 | |
|         hasMore,
 | |
|         total: filtered.length,
 | |
|         source: 'cache_fallback'
 | |
|       });
 | |
|     }
 | |
|     
 | |
|   } catch (error) {
 | |
|     console.error('[/api/vault/metadata/paginated] Error:', error);
 | |
|     res.status(500).json({ error: 'Pagination failed' });
 | |
|   }
 | |
| });
 | |
| ```
 | |
| 
 | |
| #### 2.3 Ajouter des métriques de performance
 | |
| **Fichier** : `server/performance-config.mjs`
 | |
| 
 | |
| ```javascript
 | |
| export class PerformanceMonitor {
 | |
|   constructor() {
 | |
|     this.metrics = {
 | |
|       requests: 0,
 | |
|       cacheHits: 0,
 | |
|       cacheMisses: 0,
 | |
|       meilisearchQueries: 0,
 | |
|       filesystemScans: 0,
 | |
|       averageResponseTime: 0,
 | |
|       responseTimes: []
 | |
|     };
 | |
|     this.startTime = Date.now();
 | |
|   }
 | |
|   
 | |
|   recordRequest(endpoint, responseTime, cacheHit = false, source = 'unknown') {
 | |
|     this.metrics.requests++;
 | |
|     
 | |
|     if (cacheHit) {
 | |
|       this.metrics.cacheHits++;
 | |
|     } else {
 | |
|       this.metrics.cacheMisses++;
 | |
|     }
 | |
|     
 | |
|     if (source === 'meilisearch') {
 | |
|       this.metrics.meilisearchQueries++;
 | |
|     } else if (source === 'filesystem') {
 | |
|       this.metrics.filesystemScans++;
 | |
|     }
 | |
|     
 | |
|     // Calculer la moyenne mobile des temps de réponse
 | |
|     this.metrics.responseTimes.push(responseTime);
 | |
|     if (this.metrics.responseTimes.length > 100) {
 | |
|       this.metrics.responseTimes.shift(); // Garder seulement les 100 dernières
 | |
|     }
 | |
|     
 | |
|     const sum = this.metrics.responseTimes.reduce((a, b) => a + b, 0);
 | |
|     this.metrics.averageResponseTime = sum / this.metrics.responseTimes.length;
 | |
|   }
 | |
|   
 | |
|   getStats() {
 | |
|     const uptime = Date.now() - this.startTime;
 | |
|     const total = this.metrics.cacheHits + this.metrics.cacheMisses;
 | |
|     const hitRate = total > 0 ? (this.metrics.cacheHits / total) * 100 : 0;
 | |
|     
 | |
|     return {
 | |
|       ...this.metrics,
 | |
|       uptime,
 | |
|       cacheHitRate: hitRate,
 | |
|       requestsPerSecond: this.metrics.requests / (uptime / 1000)
 | |
|     };
 | |
|   }
 | |
| }
 | |
| 
 | |
| // Instance globale
 | |
| export const performanceMonitor = new PerformanceMonitor();
 | |
| ```
 | |
| 
 | |
| #### 2.4 Endpoint de monitoring
 | |
| **Fichier** : `server/index.mjs`
 | |
| 
 | |
| ```javascript
 | |
| // Endpoint pour les métriques de performance
 | |
| app.get('/api/performance/stats', (req, res) => {
 | |
|   res.json({
 | |
|     cache: metadataCache.getStats(),
 | |
|     performance: performanceMonitor.getStats(),
 | |
|     meilisearch: {
 | |
|       indexingInProgress,
 | |
|       indexingCompleted,
 | |
|       lastIndexingAttempt: new Date(lastIndexingAttempt).toISOString()
 | |
|     },
 | |
|     server: {
 | |
|       uptime: process.uptime(),
 | |
|       memory: process.memoryUsage(),
 | |
|       nodeVersion: process.version
 | |
|     }
 | |
|   });
 | |
| });
 | |
| ```
 | |
| 
 | |
| ## ✅ Critères d'Acceptation
 | |
| 
 | |
| ### Fonctionnels
 | |
| - [ ] **Cache opérationnel** : Métadonnées mises en cache pendant 5 minutes
 | |
| - [ ] **Invalidation automatique** : Cache vidé lors de changements de fichiers
 | |
| - [ ] **Indexation différée** : Serveur démarre immédiatement, indexation en arrière-plan
 | |
| - [ ] **Fallback gracieux** : Fonctionne sans Meilisearch (cache + filesystem)
 | |
| - [ ] **Retry automatique** : Tentatives répétées en cas d'échec Meilisearch
 | |
| 
 | |
| ### Performances
 | |
| - [ ] **Cache hit rate > 80%** : Après période d'échauffement
 | |
| - [ ] **Charge serveur réduite 50%** : Moins d'I/O disque
 | |
| - [ ] **Démarrage instantané** : Pas de blocage par l'indexation
 | |
| - [ ] **Temps de réponse < 200ms** : Pour les requêtes en cache
 | |
| - [ ] **Mémoire serveur < 100MB** : Cache contrôlé en taille
 | |
| 
 | |
| ### Robustesse
 | |
| - [ ] **Nettoyage automatique** : Cache nettoyé quand taille max atteinte
 | |
| - [ ] **Gestion d'erreurs** : Fallbacks en cas de panne Meilisearch
 | |
| - [ ] **Recovery automatique** : Tentatives répétées d'indexation
 | |
| - [ ] **Monitoring intégré** : Métriques disponibles via API
 | |
| - [ ] **Logging détaillé** : Traçabilité des opérations cache/indexation
 | |
| 
 | |
| ### UX
 | |
| - [ ] **Démarrage rapide** : Application utilisable immédiatement
 | |
| - [ ] **Cohérence des données** : Cache invalidé lors de changements
 | |
| - [ ] **Transparence** : Utilisateur non affecté par les optimisations
 | |
| - [ ] **Monitoring** : Possibilité de consulter les performances
 | |
| 
 | |
| ## 📊 Métriques de Succès
 | |
| 
 | |
| ### Avant Phase 3 (avec Phase 1 & 2)
 | |
| ```
 | |
| Charge serveur:
 | |
| - I/O disque: Élevé (scan répété à chaque requête)
 | |
| - Mémoire: 50-100MB
 | |
| - Démarrage: 5-10s (avec indexation Meilisearch)
 | |
| - Cache: Aucun
 | |
| ```
 | |
| 
 | |
| ### Après Phase 3
 | |
| ```
 | |
| Charge serveur:
 | |
| - I/O disque: Réduit 80% (cache 5min)
 | |
| - Mémoire: 50-100MB (cache intelligent)
 | |
| - Démarrage: < 2s (indexation différée)
 | |
| - Cache: Hit rate > 80%
 | |
| ```
 | |
| 
 | |
| ### Métriques Clés
 | |
| - **Cache Hit Rate** : > 80% après 5 minutes d'utilisation
 | |
| - **Temps de démarrage** : Réduction de 50-80% (pas d'attente indexation)
 | |
| - **Charge CPU** : Réduction de 30-50% (moins d'I/O)
 | |
| - **Mémoire stable** : Pas de fuites malgré le cache
 | |
| - **Disponibilité** : 99.9% même si Meilisearch down
 | |
| 
 | |
| ## 🔧 Dépendances et Prérequis
 | |
| 
 | |
| ### Dépendances Techniques
 | |
| - **Chokidar** : Surveillance des changements de fichiers (déjà présent)
 | |
| - **Crypto** : Calcul des checksums (natif Node.js)
 | |
| - **Meilisearch** : Recherche avancée (optionnel avec fallback)
 | |
| 
 | |
| ### Prérequis
 | |
| - ✅ **Phase 1 terminée** : Metadata-first loading opérationnel
 | |
| - ✅ **Phase 2 terminée** : Pagination et virtual scrolling actifs
 | |
| - ✅ **Chokidar configuré** : Surveillance des fichiers déjà en place
 | |
| 
 | |
| ## 🚨 Points d'Attention
 | |
| 
 | |
| ### Cache
 | |
| 1. **TTL optimal** : 5 minutes équilibre performance/cohérence
 | |
| 2. **Taille max** : 10k entrées évite les fuites mémoire
 | |
| 3. **Invalidation** : Tous les changements de fichiers doivent invalider
 | |
| 4. **Checksum** : Détection précise des changements sans false positives
 | |
| 
 | |
| ### Indexation
 | |
| 1. **Background processing** : Ne jamais bloquer le démarrage utilisateur
 | |
| 2. **Retry logic** : Tentatives répétées avec backoff exponentiel
 | |
| 3. **Cooldown** : Éviter l'indexation trop fréquente
 | |
| 4. **Error handling** : Fallback transparent vers filesystem
 | |
| 
 | |
| ### Performance
 | |
| 1. **Memory limits** : Monitoring et cleanup automatique
 | |
| 2. **Concurrent access** : Protection contre les race conditions
 | |
| 3. **Metrics overhead** : Monitoring léger pour ne pas impacter les performances
 | |
| 
 | |
| ## 🧪 Plan de Test
 | |
| 
 | |
| ### Tests Unitaires
 | |
| ```typescript
 | |
| describe('MetadataCache', () => {
 | |
|   it('should cache metadata for 5 minutes', async () => {
 | |
|     // Test TTL
 | |
|   });
 | |
|   
 | |
|   it('should invalidate on file changes', async () => {
 | |
|     // Test invalidation
 | |
|   });
 | |
|   
 | |
|   it('should cleanup when max size reached', async () => {
 | |
|     // Test nettoyage
 | |
|   });
 | |
| });
 | |
| ```
 | |
| 
 | |
| ### Tests d'Intégration
 | |
| ```typescript
 | |
| describe('Server Caching E2E', () => {
 | |
|   it('should start server immediately without indexing', () => {
 | |
|     // Test démarrage rapide
 | |
|   });
 | |
|   
 | |
|   it('should serve from cache after first request', () => {
 | |
|     // Test cache hit
 | |
|   });
 | |
|   
 | |
|   it('should invalidate cache on file change', () => {
 | |
|     // Test invalidation
 | |
|   });
 | |
| });
 | |
| ```
 | |
| 
 | |
| ### Tests de Performance
 | |
| ```bash
 | |
| # Benchmark du cache
 | |
| npm run test:cache-performance
 | |
| 
 | |
| # Résultats attendus:
 | |
| # - Cache hit rate: > 80%
 | |
| # - Response time: < 200ms cached, < 500ms fresh
 | |
| # - Memory usage: < 100MB
 | |
| # - Startup time: < 2s
 | |
| ```
 | |
| 
 | |
| ### Tests de Charge
 | |
| ```bash
 | |
| # Test avec gros vault
 | |
| npm run test:large-vault
 | |
| 
 | |
| # Simulation de changements fréquents
 | |
| npm run test:cache-invalidation
 | |
| ```
 | |
| 
 | |
| ## 🎯 Livrables
 | |
| 
 | |
| ### Code
 | |
| - ✅ **MetadataCache class** : Cache intelligent avec TTL et invalidation
 | |
| - ✅ **Indexation différée** : Démarrage non-bloquant du serveur
 | |
| - ✅ **Monitoring intégré** : Métriques de performance en temps réel
 | |
| - ✅ **Fallback robuste** : Fonctionne sans Meilisearch
 | |
| - ✅ **Invalidation automatique** : Surveillance des changements de fichiers
 | |
| 
 | |
| ### Documentation
 | |
| - ✅ **Guide d'implémentation** : Étapes détaillées pour chaque composant
 | |
| - ✅ **Configuration** : Paramètres optimaux du cache
 | |
| - ✅ **Monitoring** : Comment surveiller les performances
 | |
| - ✅ **Troubleshooting** : Résolution des problèmes courants
 | |
| 
 | |
| ### Tests
 | |
| - ✅ **Tests unitaires** : Couverture des classes de cache
 | |
| - ✅ **Tests d'intégration** : Flux complets serveur
 | |
| - ✅ **Tests de performance** : Benchmarks automatisés
 | |
| - ✅ **Tests de résilience** : Gestion des pannes
 | |
| 
 | |
| ### Monitoring
 | |
| - ✅ **Métriques temps réel** : `/api/performance/stats`
 | |
| - ✅ **Logging détaillé** : Traçabilité des opérations
 | |
| - ✅ **Alertes** : Seuils configurables pour les métriques
 | |
| - ✅ **Dashboard** : Interface pour consulter les performances
 | |
| 
 | |
| ---
 | |
| 
 | |
| ## 🚀 Résumé
 | |
| 
 | |
| La Phase 3 transforme ObsiViewer en une application **hautement optimisée** avec un cache serveur intelligent qui réduit la charge de **50%** et permet un démarrage instantané.
 | |
| 
 | |
| **Effort** : 1-2 jours  
 | |
| **Risque** : Très faible  
 | |
| **Impact** : Réduction charge serveur 50%  
 | |
| **ROI** : Infrastructure scalable et performante
 | |
| 
 | |
| **Prêt pour implémentation ! 🎯**
 |