diff --git a/src/Framework/Cache/Driver/FileCache.php b/src/Framework/Cache/Driver/FileCache.php index 96f41b94..5ef4da61 100644 --- a/src/Framework/Cache/Driver/FileCache.php +++ b/src/Framework/Cache/Driver/FileCache.php @@ -10,6 +10,7 @@ use App\Framework\Cache\CacheKey; use App\Framework\Cache\CacheResult; use App\Framework\Cache\Contracts\Scannable; use App\Framework\Filesystem\FileStorage; +use App\Framework\Filesystem\LockableStorage; use App\Framework\Filesystem\Storage; final readonly class FileCache implements CacheDriver, Scannable @@ -24,76 +25,180 @@ final readonly class FileCache implements CacheDriver, Scannable private function getFileName(CacheKey $key, ?int $expiresAt): string { - // Schütze vor Pfad/komischen Zeichen und Hash den Key - $keyString = (string)$key; - $safeKey = preg_replace('/[^a-zA-Z0-9_\-]/', '_', $keyString); + $hash = $this->getHashForKey($key); - $hash = md5($safeKey); - - return self::CACHE_PATH . DIRECTORY_SEPARATOR . $hash .'_'. ($expiresAt ?? 0) . '.cache.php'; + return self::CACHE_PATH . DIRECTORY_SEPARATOR . $hash . '_' . ($expiresAt ?? 0) . '.cache.php'; } - private function getFilesForKey(CacheKey $key): array + /** + * Get hash for a cache key (consistent with getFileName and getLockFileName) + */ + private function getHashForKey(CacheKey $key): string { $keyString = (string)$key; - // FIXED: Use same sanitization as getFileName() for consistent hashing $safeKey = preg_replace('/[^a-zA-Z0-9_\-]/', '_', $keyString); - $hash = md5($safeKey); - $pattern = self::CACHE_PATH . DIRECTORY_SEPARATOR . $hash . '*.cache.php'; + return md5($safeKey); + } - return glob($pattern) ?: []; + /** + * Get all cache files for a given key using FileSystem-Modul + */ + private function getFilesForKey(CacheKey $key): array + { + try { + $allFiles = $this->fileSystem->listDirectory(self::CACHE_PATH); + } catch (\App\Framework\Filesystem\Exceptions\DirectoryCreateException $e) { + // Directory doesn't exist - return empty array + return []; + } catch (\Throwable $e) { + // Any other error - return empty array (graceful degradation) + return []; + } + + $hash = $this->getHashForKey($key); + + // Filter files matching the hash pattern + return array_filter($allFiles, function (string $file) use ($hash): bool { + // Convert relative path to absolute if needed + $fullPath = str_starts_with($file, '/') ? $file : self::CACHE_PATH . DIRECTORY_SEPARATOR . $file; + $basename = basename($fullPath); + + // Match: hash_*.cache.php + return str_starts_with($basename, $hash . '_') + && str_ends_with($basename, '.cache.php'); + }); } private function getLockFileName(CacheKey $key): string { - $keyString = (string)$key; - // FIXED: Use same sanitization as getFileName() for consistent hashing - $safeKey = preg_replace('/[^a-zA-Z0-9_\-]/', '_', $keyString); - $hash = md5($safeKey); + $hash = $this->getHashForKey($key); return self::CACHE_PATH . DIRECTORY_SEPARATOR . $hash . '.lock'; } - private function withKeyLock(CacheKey $key, callable $callback): mixed + /** + * Try to execute operation with exclusive lock using FileSystem-Modul + * Falls back gracefully if lock cannot be acquired + */ + private function tryWithExclusiveLock(string $path, callable $operation): mixed { - $lockFile = fopen($this->getLockFileName($key), 'c'); + // Try to use LockableStorage if available + if ($this->fileSystem instanceof LockableStorage) { + try { + // Ensure directory exists before attempting lock + try { + $this->fileSystem->createDirectory(dirname($path)); + } catch (\Throwable $e) { + // Directory creation failed - execute without lock + return $operation(null); + } + + // Use LockableStorage with graceful error handling + return $this->fileSystem->withExclusiveLock($path, function ($handle) use ($operation) { + return $operation($handle); + }); + } catch (\Throwable $e) { + // Lock failed - execute without lock (graceful degradation) + return $operation(null); + } + } + + // Fallback: Use manual lock implementation + return $this->withManualLock($path, $operation); + } + + /** + * Manual lock implementation as fallback when LockableStorage is not available + */ + private function withManualLock(string $path, callable $operation): mixed + { + $lockDir = dirname($path); + + // Ensure directory exists using FileSystem-Modul + try { + if (!$this->fileSystem->exists($lockDir)) { + $this->fileSystem->createDirectory($lockDir); + } + } catch (\Throwable $e) { + // Directory creation failed - execute without lock + return $operation(null); + } + + // Check if directory is writable using FileSystem-Modul + try { + if (!$this->fileSystem->isWritable($lockDir)) { + return $operation(null); + } + } catch (\Throwable $e) { + // Can't check writability - execute without lock + return $operation(null); + } + + // Open lock file with 'c' mode (create if not exists) + $lockFile = @fopen($path, 'c'); if ($lockFile === false) { - // Optional: Fehler werfen oder Logging - return $callback(null); + // Failed to open lock file - execute without lock + return $operation(null); } try { - // Exklusiven Lock setzen, Wartezeit begrenzen wenn gewünscht - if (flock($lockFile, LOCK_EX)) { - return $callback($lockFile); + // Try to acquire exclusive lock (non-blocking) + if (@flock($lockFile, LOCK_EX | LOCK_NB)) { + return $operation($lockFile); } - // Lock konnte nicht gesetzt werden - return $callback(null); + // Lock could not be acquired - execute without lock + return $operation(null); } finally { - flock($lockFile, LOCK_UN); - fclose($lockFile); + if ($lockFile !== false && (is_resource($lockFile) || $lockFile instanceof \Resource)) { + @flock($lockFile, LOCK_UN); + @fclose($lockFile); + } } } + /** + * Execute callback with exclusive lock for the given cache key + * Uses FileSystem-Modul for robust error handling + */ + private function withKeyLock(CacheKey $key, callable $callback): mixed + { + $lockFileName = $this->getLockFileName($key); + + return $this->tryWithExclusiveLock($lockFileName, $callback); + } + private function getSingleKey(CacheKey $key): CacheItem { + $files = $this->getFilesForKey($key); $bestFile = null; $bestExpires = null; - foreach ($this->getFilesForKey($key) as $file) { - if (! preg_match('/_(\d+)\.cache\.php$/', $file, $m)) { + foreach ($files as $file) { + // Convert to absolute path if needed + $fullPath = str_starts_with($file, '/') ? $file : self::CACHE_PATH . DIRECTORY_SEPARATOR . $file; + $basename = basename($fullPath); + + // Extract expiration time from filename: hash_expires.cache.php + if (!preg_match('/_(\d+)\.cache\.php$/', $basename, $m)) { continue; } + $expiresAt = (int)$m[1]; if ($expiresAt > 0 && $expiresAt < time()) { - $this->fileSystem->delete($file); + // File expired - try to delete it + try { + $this->fileSystem->delete($fullPath); + } catch (\Throwable $e) { + // Continue even if deletion fails + } continue; } + if ($bestFile === null || $expiresAt > $bestExpires) { - $bestFile = $file; + $bestFile = $fullPath; $bestExpires = $expiresAt; } } @@ -110,10 +215,17 @@ final readonly class FileCache implements CacheDriver, Scannable } catch (\App\Framework\Filesystem\Exceptions\FilePermissionException $e) { // File permissions changed or file being written (race condition) return CacheItem::miss($key); + } catch (\Throwable $e) { + // Any other error - treat as miss + return CacheItem::miss($key); } if ($content === null || $content === '') { - $this->fileSystem->delete($bestFile); + try { + $this->fileSystem->delete($bestFile); + } catch (\Throwable $e) { + // Continue even if deletion fails + } return CacheItem::miss($key); } @@ -205,9 +317,28 @@ final readonly class FileCache implements CacheDriver, Scannable public function clear(): bool { - $files = glob(self::CACHE_PATH . '/*.cache.php') ?: []; - foreach ($files as $file) { - $this->fileSystem->delete($file); + try { + $allFiles = $this->fileSystem->listDirectory(self::CACHE_PATH); + } catch (\Throwable $e) { + // Directory doesn't exist or can't be accessed - nothing to clear + return true; + } + + // Filter only cache files + $cacheFiles = array_filter($allFiles, function (string $file): bool { + return str_ends_with($file, '.cache.php'); + }); + + // Delete all cache files + foreach ($cacheFiles as $file) { + try { + // Convert to absolute path if needed + $fullPath = str_starts_with($file, '/') ? $file : self::CACHE_PATH . DIRECTORY_SEPARATOR . $file; + $this->fileSystem->delete($fullPath); + } catch (\Throwable $e) { + // Continue with other files even if one fails + continue; + } } return true; @@ -217,18 +348,32 @@ final readonly class FileCache implements CacheDriver, Scannable public function scan(string $pattern, int $limit = 1000): array { + try { + $allFiles = $this->fileSystem->listDirectory(self::CACHE_PATH); + } catch (\Throwable $e) { + // Directory doesn't exist or can't be accessed - return empty + return []; + } + $regex = $this->patternToRegex($pattern); $matches = []; $count = 0; - $files = glob(self::CACHE_PATH . '/*.cache.php') ?: []; - - foreach ($files as $file) { + // Filter cache files and match pattern + foreach ($allFiles as $file) { if ($limit > 0 && $count >= $limit) { break; } - $key = $this->fileToKey($file); + // Only process cache files + if (!str_ends_with($file, '.cache.php')) { + continue; + } + + // Convert to absolute path if needed + $fullPath = str_starts_with($file, '/') ? $file : self::CACHE_PATH . DIRECTORY_SEPARATOR . $file; + $key = $this->fileToKey($fullPath); + if (preg_match($regex, $key)) { $matches[] = $key; $count++; @@ -240,17 +385,31 @@ final readonly class FileCache implements CacheDriver, Scannable public function scanPrefix(string $prefix, int $limit = 1000): array { + try { + $allFiles = $this->fileSystem->listDirectory(self::CACHE_PATH); + } catch (\Throwable $e) { + // Directory doesn't exist or can't be accessed - return empty + return []; + } + $matches = []; $count = 0; - $files = glob(self::CACHE_PATH . '/*.cache.php') ?: []; - - foreach ($files as $file) { + // Filter cache files and match prefix + foreach ($allFiles as $file) { if ($limit > 0 && $count >= $limit) { break; } - $key = $this->fileToKey($file); + // Only process cache files + if (!str_ends_with($file, '.cache.php')) { + continue; + } + + // Convert to absolute path if needed + $fullPath = str_starts_with($file, '/') ? $file : self::CACHE_PATH . DIRECTORY_SEPARATOR . $file; + $key = $this->fileToKey($fullPath); + if (str_starts_with($key, $prefix)) { $matches[] = $key; $count++; @@ -262,17 +421,30 @@ final readonly class FileCache implements CacheDriver, Scannable public function getAllKeys(int $limit = 1000): array { + try { + $allFiles = $this->fileSystem->listDirectory(self::CACHE_PATH); + } catch (\Throwable $e) { + // Directory doesn't exist or can't be accessed - return empty + return []; + } + $keys = []; $count = 0; - $files = glob(self::CACHE_PATH . '/*.cache.php') ?: []; - - foreach ($files as $file) { + // Filter cache files + foreach ($allFiles as $file) { if ($limit > 0 && $count >= $limit) { break; } - $keys[] = $this->fileToKey($file); + // Only process cache files + if (!str_ends_with($file, '.cache.php')) { + continue; + } + + // Convert to absolute path if needed + $fullPath = str_starts_with($file, '/') ? $file : self::CACHE_PATH . DIRECTORY_SEPARATOR . $file; + $keys[] = $this->fileToKey($fullPath); $count++; }