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\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++;
}