From cb13fcfb9fe6a3afa44274ae74caf7b70b5b0e64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20FAMIBELLE-PRONZOLA?= Date: Mon, 27 Jul 2026 09:15:00 +0400 Subject: [PATCH] perf: cache locking, TTL map, purge script and SW LRU limit --- includes/simple-cache.php | 102 +++++++++++++++++++++++++---------- scripts/purge-cache.php | 21 ++++++++ tests/php/cache-test.php | 111 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 28 deletions(-) create mode 100644 scripts/purge-cache.php diff --git a/includes/simple-cache.php b/includes/simple-cache.php index 0112596..8e18093 100644 --- a/includes/simple-cache.php +++ b/includes/simple-cache.php @@ -6,11 +6,11 @@ class SimpleAPICache { private $cacheDir; private $enabled; - - public function __construct() { - $this->cacheDir = __DIR__ . '/../cache/api'; + + public function __construct($cacheDir = null) { + $this->cacheDir = $cacheDir ?? (__DIR__ . '/../cache/api'); $this->enabled = true; - + // Créer le répertoire de cache s'il n'existe pas if (!is_dir($this->cacheDir)) { mkdir($this->cacheDir, 0755, true); @@ -66,9 +66,28 @@ class SimpleAPICache { 'created' => time() ]; - file_put_contents($file, json_encode($data)); + // Verrou exclusif : évite les écritures concurrentes tronquées + file_put_contents($file, json_encode($data), LOCK_EX); } - + + /** + * Vide entièrement le cache (entrées valides comme expirées) + * + * @return int Nombre de fichiers supprimés + */ + public function clear() { + $files = glob($this->cacheDir . '/cache_*.json'); + $deleted = 0; + + foreach ($files as $file) { + if (unlink($file)) { + $deleted++; + } + } + + return $deleted; + } + /** * Nettoie le cache expiré */ @@ -91,44 +110,71 @@ class SimpleAPICache { // Instance globale $GLOBALS['simple_api_cache'] = new SimpleAPICache(); +/** + * TTL de cache selon l'endpoint PeerTube. + * + * Correspondance exacte d'abord, puis par préfixe. L'ancien matching par + * sous-chaîne (strpos) créait des collisions : « accounts/{nom}/videos » + * capturait le TTL de « videos » (10 min) au lieu de celui de « accounts » + * (5 min, pensé pour les lives). + * + * @param string $endpoint Endpoint de l'API (sans slash initial) + * @return int TTL en secondes + */ +function getPeerTubeCacheTtl($endpoint) { + static $exactMap = [ + 'videos/categories' => 3600, // 1 heure + 'videos' => 600, // 10 minutes + 'search/videos' => 600, // 10 minutes + 'wp-posts' => 900, // 15 minutes pour WordPress + 'accounts' => 300 // 5 minutes pour live streams + ]; + + static $prefixMap = [ + 'accounts/' => 300, // lives : accounts/{nom}/videos + 'video-channels/' => 600, // vidéos d'une chaîne + 'videos/' => 600 // videos/{id}, commentaires + ]; + + if (isset($exactMap[$endpoint])) { + return $exactMap[$endpoint]; + } + + foreach ($prefixMap as $prefix => $ttl) { + if (strpos($endpoint, $prefix) === 0) { + return $ttl; + } + } + + return 300; // Défaut 5 minutes +} + /** * Version cachée de callPeerTubeApi - remplace l'originale */ function callPeerTubeApiCached($endpoint, $params = []) { - $cache = $GLOBALS['simple_api_cache']; - - // TTL selon le type de contenu - $ttlMap = [ - 'videos/categories' => 3600, // 1 heure - 'videos' => 600, // 10 minutes (augmenté) - 'search/videos' => 600, // 10 minutes - 'wp-posts' => 900, // 15 minutes pour WordPress - 'accounts' => 300 // 5 minutes pour live streams - ]; - - // TTL dynamique selon l'endpoint - $ttl = 300; // Défaut 5 minutes - foreach ($ttlMap as $pattern => $time) { - if (strpos($endpoint, $pattern) !== false) { - $ttl = $time; - break; - } + // Cache désactivé : appel direct, sans lecture ni écriture + if (!defined('CACHE_ENABLED') || !CACHE_ENABLED) { + return callPeerTubeApiOriginal($endpoint, $params); } - + + $cache = $GLOBALS['simple_api_cache']; + $ttl = getPeerTubeCacheTtl($endpoint); + // Essayer le cache d'abord $cached = $cache->get($endpoint, $params); if ($cached !== null) { return $cached; } - + // Appeler l'API originale $data = callPeerTubeApiOriginal($endpoint, $params); - + // Mettre en cache si on a des données if (!empty($data)) { $cache->set($endpoint, $params, $data, $ttl); } - + return $data; } diff --git a/scripts/purge-cache.php b/scripts/purge-cache.php new file mode 100644 index 0000000..6c4cf06 --- /dev/null +++ b/scripts/purge-cache.php @@ -0,0 +1,21 @@ +clear(); + +echo "Cache purgé : {$deleted} entrée(s) supprimée(s).\n"; diff --git a/tests/php/cache-test.php b/tests/php/cache-test.php index f8aa624..f0e14e5 100644 --- a/tests/php/cache-test.php +++ b/tests/php/cache-test.php @@ -68,6 +68,117 @@ assertEquals(1, $cleaned, 'cleanup supprime uniquement les entrées expirées'); assertEquals('valide', $cache->get('cle-valide'), 'cleanup conserve les entrées valides'); assertNull($cache->get('cle-perimee'), 'cleanup a bien supprimé l\'entrée expirée'); +// --- Écriture verrouillée (LOCK_EX) ------------------------------------------ + +// Verrou structurel : set() écrit avec un verrou exclusif +$cacheSource = file_get_contents(dirname(__DIR__, 2) . '/includes/simple-cache.php'); +assertContains('LOCK_EX', $cacheSource, 'set() écrit avec file_put_contents et LOCK_EX'); + +// Intégrité d'écriture : un contenu volumineux est relu sans troncature +$bigContent = ['payload' => str_repeat('données-€-', 5000)]; +$cache->set('lock-test', [], $bigContent); +assertEquals($bigContent, $cache->get('lock-test'), 'une écriture volumineuse est relue intégralement'); + +// --- getPeerTubeCacheTtl() : correspondance exacte puis préfixe -------------- + +assertEquals(3600, getPeerTubeCacheTtl('videos/categories'), 'catégories : TTL 1 heure'); +assertEquals(600, getPeerTubeCacheTtl('videos'), 'liste des vidéos : TTL 10 minutes'); +assertEquals(600, getPeerTubeCacheTtl('search/videos'), 'recherche : TTL 10 minutes (match exact)'); +assertEquals(900, getPeerTubeCacheTtl('wp-posts'), 'WordPress : TTL 15 minutes'); +assertEquals(300, getPeerTubeCacheTtl('accounts'), 'comptes : TTL 5 minutes'); +assertEquals( + 300, + getPeerTubeCacheTtl('accounts/membre/videos'), + 'vidéos d\'un compte (lives) : TTL 5 minutes — l\'ancien strpos capturait « videos » à 10 minutes' +); +assertEquals(600, getPeerTubeCacheTtl('videos/9cf2e3a1-abc'), 'détail vidéo : TTL 10 minutes'); +assertEquals(600, getPeerTubeCacheTtl('videos/9cf2e3a1-abc/comment-threads'), 'commentaires : TTL 10 minutes'); +assertEquals(600, getPeerTubeCacheTtl('video-channels/ma-chaine/videos'), 'vidéos d\'une chaîne : TTL 10 minutes'); +assertEquals(300, getPeerTubeCacheTtl('endpoint-inconnu'), 'endpoint inconnu : TTL par défaut de 5 minutes'); + +// --- clear() : purge complète ------------------------------------------------- + +$cache->clear(); // repartir d'un dossier vide +$cache->set('purge-1', [], 'un'); +$cache->set('purge-2', [], 'deux', 300); +assertEquals(2, count(glob($tmpDir . '/cache_*.json')), 'deux entrées présentes avant purge'); +$deleted = $cache->clear(); +assertEquals(2, $deleted, 'clear() supprime toutes les entrées, même valides'); +assertEquals(0, count(glob($tmpDir . '/cache_*.json')), 'le dossier est vide après clear()'); +assertNull($cache->get('purge-1'), 'une entrée purgée n\'est plus lisible'); + +// --- CACHE_ENABLED honoré par callPeerTubeApiCached --------------------------- +// Impossible de redéfinir une constante dans ce processus (CACHE_ENABLED=true +// dans bootstrap.php) : on teste dans un sous-processus PHP avec un stub de +// callPeerTubeApiOriginal qui compte les appels réels. + +$root = dirname(__DIR__, 2); + +$runCacheSnippet = function (string $code): string { + $runner = tempnam(sys_get_temp_dir(), 'cache-runner-') . '.php'; + file_put_contents($runner, "&1'); + unlink($runner); + return $output ?? ''; +}; + +$flagTestDir = sys_get_temp_dir() . '/annu-kute-cache-flag-' . getmypid(); +mkdir($flagTestDir, 0755, true); + +$snippetTemplate = <<<'PHP' +define('CACHE_ENABLED', %s); +require %s; +$GLOBALS['simple_api_cache'] = new SimpleAPICache(%s); +$GLOBALS['api_calls'] = 0; +function callPeerTubeApiOriginal($endpoint, $params = []) { + $GLOBALS['api_calls']++; + return ['data' => ['appel-' . $GLOBALS['api_calls']]]; +} +callPeerTubeApiCached('videos-test-flag'); +callPeerTubeApiCached('videos-test-flag'); +echo $GLOBALS['api_calls']; +PHP; + +$simpleCachePath = var_export($root . '/includes/simple-cache.php', true); +$flagDirExport = var_export($flagTestDir, true); + +// Cache désactivé : deux appels API réels, aucune écriture de cache +$outDisabled = trim($runCacheSnippet(sprintf($snippetTemplate, 'false', $simpleCachePath, $flagDirExport))); +assertEquals('2', $outDisabled, 'CACHE_ENABLED=false : chaque appel va à l\'API (pas de lecture cache)'); +assertEquals(0, count(glob($flagTestDir . '/cache_*.json')), 'CACHE_ENABLED=false : aucune écriture de cache'); + +// Cache activé : le second appel est servi par le cache +$outEnabled = trim($runCacheSnippet(sprintf($snippetTemplate, 'true', $simpleCachePath, $flagDirExport))); +assertEquals('1', $outEnabled, 'CACHE_ENABLED=true : le second appel est servi par le cache'); +assertEquals(1, count(glob($flagTestDir . '/cache_*.json')), 'CACHE_ENABLED=true : une entrée écrite en cache'); + +foreach (glob($flagTestDir . '/cache_*.json') as $file) { + unlink($file); +} +rmdir($flagTestDir); + +// --- scripts/purge-cache.php --------------------------------------------------- + +$purgeDir = sys_get_temp_dir() . '/annu-kute-purge-' . getmypid(); +mkdir($purgeDir, 0755, true); +file_put_contents($purgeDir . '/cache_a.json', '{}'); +file_put_contents($purgeDir . '/cache_b.json', '{}'); +file_put_contents($purgeDir . '/cache_c.json', '{}'); +file_put_contents($purgeDir . '/autre.txt', 'conservé'); // ne doit pas être supprimé + +$purgeOutput = shell_exec( + escapeshellarg(PHP_BINARY) . ' ' . + escapeshellarg($root . '/scripts/purge-cache.php') . ' ' . + escapeshellarg($purgeDir) . ' 2>&1' +) ?? ''; + +assertContains('Cache purgé : 3', $purgeOutput, 'le script rapporte les 3 entrées supprimées'); +assertEquals(0, count(glob($purgeDir . '/cache_*.json')), 'le script supprime tous les fichiers de cache'); +assertTrue(file_exists($purgeDir . '/autre.txt'), 'le script conserve les fichiers hors cache'); + +unlink($purgeDir . '/autre.txt'); +rmdir($purgeDir); + // --- Nettoyage du dossier temporaire ---------------------------------------- foreach (glob($tmpDir . '/cache_*.json') as $file) {