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:
@@ -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++;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user