refactor(cache): improve file handling and introduce robust locking mechanisms

- Refactor `FileCache` methods to enhance file operation consistency and error handling.
- Integrate `LockableStorage` for improved locking with fallback to manual lock implementations.
- Replace `glob` usage with `FileSystem` module for directory operations, improving maintainability and testability.
- Optimize cache file listing, filtering, and expiration handling for better performance and reliability.
- Streamline directory and file deletion logic with additional error resilience.
This commit is contained in:
2025-11-03 23:30:07 +01:00
parent a071bea39e
commit 2a0c797051

View File

@@ -10,6 +10,7 @@ use App\Framework\Cache\CacheKey;
use App\Framework\Cache\CacheResult; use App\Framework\Cache\CacheResult;
use App\Framework\Cache\Contracts\Scannable; use App\Framework\Cache\Contracts\Scannable;
use App\Framework\Filesystem\FileStorage; use App\Framework\Filesystem\FileStorage;
use App\Framework\Filesystem\LockableStorage;
use App\Framework\Filesystem\Storage; use App\Framework\Filesystem\Storage;
final readonly class FileCache implements CacheDriver, Scannable 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 private function getFileName(CacheKey $key, ?int $expiresAt): string
{ {
// Schütze vor Pfad/komischen Zeichen und Hash den Key $hash = $this->getHashForKey($key);
$keyString = (string)$key;
$safeKey = preg_replace('/[^a-zA-Z0-9_\-]/', '_', $keyString);
$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; $keyString = (string)$key;
// FIXED: Use same sanitization as getFileName() for consistent hashing
$safeKey = preg_replace('/[^a-zA-Z0-9_\-]/', '_', $keyString); $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 private function getLockFileName(CacheKey $key): string
{ {
$keyString = (string)$key; $hash = $this->getHashForKey($key);
// FIXED: Use same sanitization as getFileName() for consistent hashing
$safeKey = preg_replace('/[^a-zA-Z0-9_\-]/', '_', $keyString);
$hash = md5($safeKey);
return self::CACHE_PATH . DIRECTORY_SEPARATOR . $hash . '.lock'; 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) { if ($lockFile === false) {
// Optional: Fehler werfen oder Logging // Failed to open lock file - execute without lock
return $callback(null); return $operation(null);
} }
try { try {
// Exklusiven Lock setzen, Wartezeit begrenzen wenn gewünscht // Try to acquire exclusive lock (non-blocking)
if (flock($lockFile, LOCK_EX)) { if (@flock($lockFile, LOCK_EX | LOCK_NB)) {
return $callback($lockFile); return $operation($lockFile);
} }
// Lock konnte nicht gesetzt werden // Lock could not be acquired - execute without lock
return $callback(null); return $operation(null);
} finally { } finally {
flock($lockFile, LOCK_UN); if ($lockFile !== false && (is_resource($lockFile) || $lockFile instanceof \Resource)) {
fclose($lockFile); @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 private function getSingleKey(CacheKey $key): CacheItem
{ {
$files = $this->getFilesForKey($key);
$bestFile = null; $bestFile = null;
$bestExpires = null; $bestExpires = null;
foreach ($this->getFilesForKey($key) as $file) { foreach ($files as $file) {
if (! preg_match('/_(\d+)\.cache\.php$/', $file, $m)) { // 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; continue;
} }
$expiresAt = (int)$m[1]; $expiresAt = (int)$m[1];
if ($expiresAt > 0 && $expiresAt < time()) { 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; continue;
} }
if ($bestFile === null || $expiresAt > $bestExpires) { if ($bestFile === null || $expiresAt > $bestExpires) {
$bestFile = $file; $bestFile = $fullPath;
$bestExpires = $expiresAt; $bestExpires = $expiresAt;
} }
} }
@@ -110,10 +215,17 @@ final readonly class FileCache implements CacheDriver, Scannable
} catch (\App\Framework\Filesystem\Exceptions\FilePermissionException $e) { } catch (\App\Framework\Filesystem\Exceptions\FilePermissionException $e) {
// File permissions changed or file being written (race condition) // File permissions changed or file being written (race condition)
return CacheItem::miss($key); return CacheItem::miss($key);
} catch (\Throwable $e) {
// Any other error - treat as miss
return CacheItem::miss($key);
} }
if ($content === null || $content === '') { if ($content === null || $content === '') {
try {
$this->fileSystem->delete($bestFile); $this->fileSystem->delete($bestFile);
} catch (\Throwable $e) {
// Continue even if deletion fails
}
return CacheItem::miss($key); return CacheItem::miss($key);
} }
@@ -205,9 +317,28 @@ final readonly class FileCache implements CacheDriver, Scannable
public function clear(): bool public function clear(): bool
{ {
$files = glob(self::CACHE_PATH . '/*.cache.php') ?: []; try {
foreach ($files as $file) { $allFiles = $this->fileSystem->listDirectory(self::CACHE_PATH);
$this->fileSystem->delete($file); } 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; return true;
@@ -217,18 +348,32 @@ final readonly class FileCache implements CacheDriver, Scannable
public function scan(string $pattern, int $limit = 1000): array 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); $regex = $this->patternToRegex($pattern);
$matches = []; $matches = [];
$count = 0; $count = 0;
$files = glob(self::CACHE_PATH . '/*.cache.php') ?: []; // Filter cache files and match pattern
foreach ($allFiles as $file) {
foreach ($files as $file) {
if ($limit > 0 && $count >= $limit) { if ($limit > 0 && $count >= $limit) {
break; 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)) { if (preg_match($regex, $key)) {
$matches[] = $key; $matches[] = $key;
$count++; $count++;
@@ -240,17 +385,31 @@ final readonly class FileCache implements CacheDriver, Scannable
public function scanPrefix(string $prefix, int $limit = 1000): array 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 = []; $matches = [];
$count = 0; $count = 0;
$files = glob(self::CACHE_PATH . '/*.cache.php') ?: []; // Filter cache files and match prefix
foreach ($allFiles as $file) {
foreach ($files as $file) {
if ($limit > 0 && $count >= $limit) { if ($limit > 0 && $count >= $limit) {
break; 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)) { if (str_starts_with($key, $prefix)) {
$matches[] = $key; $matches[] = $key;
$count++; $count++;
@@ -262,17 +421,30 @@ final readonly class FileCache implements CacheDriver, Scannable
public function getAllKeys(int $limit = 1000): array 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 = []; $keys = [];
$count = 0; $count = 0;
$files = glob(self::CACHE_PATH . '/*.cache.php') ?: []; // Filter cache files
foreach ($allFiles as $file) {
foreach ($files as $file) {
if ($limit > 0 && $count >= $limit) { if ($limit > 0 && $count >= $limit) {
break; 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++; $count++;
} }