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