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 ! 🎯**
|