25 KiB
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 :
- Cache en mémoire : Éviter les re-scans répétés du système de fichiers
- Indexation différée : Ne pas bloquer le démarrage pour l'indexation
- Invalidation intelligente : Maintenir la cohérence lors des changements
- Optimisations mémoire : Réduire l'empreinte serveur
📊 Spécifications Techniques
1. Cache de Métadonnées en Mémoire
Architecture du Cache
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
// 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
// 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
// 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
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
// 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
// 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
// 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
// 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
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
// 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
- TTL optimal : 5 minutes équilibre performance/cohérence
- Taille max : 10k entrées évite les fuites mémoire
- Invalidation : Tous les changements de fichiers doivent invalider
- Checksum : Détection précise des changements sans false positives
Indexation
- Background processing : Ne jamais bloquer le démarrage utilisateur
- Retry logic : Tentatives répétées avec backoff exponentiel
- Cooldown : Éviter l'indexation trop fréquente
- Error handling : Fallback transparent vers filesystem
Performance
- Memory limits : Monitoring et cleanup automatique
- Concurrent access : Protection contre les race conditions
- Metrics overhead : Monitoring léger pour ne pas impacter les performances
🧪 Plan de Test
Tests Unitaires
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
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
# 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
# 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 ! 🎯