basePath = rtrim($projectDir, '/') . '/var/cache'; } /** * Set the tenant context for scoped operations */ public function setTenantContext(?string $tenantId): void { $this->tenantId = $tenantId; } /** * Set the user context for scoped operations */ public function setUserContext(?string $userId): void { $this->userId = $userId; } /** * @inheritDoc */ public function get(string $key, CacheScope $scope, string $usage): mixed { $path = $this->buildPath($key, $scope, $usage); if (!file_exists($path)) { return null; } $entry = $this->readEntry($path); if ($entry === null) { return null; } // Check expiration if ($entry['expiresAt'] > 0 && $entry['expiresAt'] < time()) { @unlink($path); return null; } return $entry['value']; } /** * @inheritDoc */ public function set(string $key, mixed $value, CacheScope $scope, string $usage, ?int $ttl = null): bool { $path = $this->buildPath($key, $scope, $usage); $dir = dirname($path); if (!is_dir($dir)) { if (!mkdir($dir, 0755, true) && !is_dir($dir)) { return false; } } $ttl = $ttl ?? self::DEFAULT_TTL; $entry = [ 'key' => $key, 'value' => $value, 'createdAt' => time(), 'expiresAt' => $ttl > 0 ? time() + $ttl : 0, ]; return $this->writeEntry($path, $entry); } /** * @inheritDoc */ public function has(string $key, CacheScope $scope, string $usage): bool { return $this->get($key, $scope, $usage) !== null; } /** * @inheritDoc */ public function delete(string $key, CacheScope $scope, string $usage): bool { $path = $this->buildPath($key, $scope, $usage); if (file_exists($path)) { return @unlink($path); } return true; } /** * @inheritDoc */ public function clear(CacheScope $scope, string $usage): int { $dir = $this->buildDir($scope, $usage); if (!is_dir($dir)) { return 0; } $count = 0; $files = glob($dir . '/*.cache'); foreach ($files as $file) { if (@unlink($file)) { $count++; } } return $count; } /** * @inheritDoc */ public function cleanup(): int { $count = 0; $now = time(); // Scan all scope directories $scopeDirs = glob($this->basePath . '/*', GLOB_ONLYDIR); foreach ($scopeDirs as $scopeDir) { $files = $this->findCacheFiles($scopeDir); foreach ($files as $file) { $entry = $this->readEntry($file); if ($entry !== null && $entry['expiresAt'] > 0 && $entry['expiresAt'] < $now) { if (@unlink($file)) { $count++; } } } } return $count; } /** * @inheritDoc */ public function remember(string $key, callable $callback, CacheScope $scope, string $usage, ?int $ttl = null): mixed { $value = $this->get($key, $scope, $usage); if ($value !== null) { return $value; } $value = $callback(); $this->set($key, $value, $scope, $usage, $ttl); return $value; } /** * @inheritDoc */ public function increment(string $key, CacheScope $scope, string $usage, int $amount = 1): int|false { $path = $this->buildPath($key, $scope, $usage); // Use file locking for atomic increment $handle = @fopen($path, 'c+'); if ($handle === false) { // File doesn't exist, create with initial value $dir = dirname($path); if (!is_dir($dir)) { if (!mkdir($dir, 0755, true) && !is_dir($dir)) { return false; } } $handle = @fopen($path, 'c+'); if ($handle === false) { return false; } } if (!flock($handle, LOCK_EX)) { fclose($handle); return false; } try { $content = stream_get_contents($handle); $entry = $content ? @unserialize($content) : null; if ($entry === null || ($entry['expiresAt'] > 0 && $entry['expiresAt'] < time())) { // Initialize new entry $newValue = $amount; $entry = [ 'key' => $key, 'value' => $newValue, 'createdAt' => time(), 'expiresAt' => time() + self::DEFAULT_TTL, ]; } else { $newValue = (int)$entry['value'] + $amount; $entry['value'] = $newValue; } ftruncate($handle, 0); rewind($handle); fwrite($handle, serialize($entry)); return $newValue; } finally { flock($handle, LOCK_UN); fclose($handle); } } /** * @inheritDoc */ public function decrement(string $key, CacheScope $scope, string $usage, int $amount = 1): int|false { return $this->increment($key, $scope, $usage, -$amount); } /** * Build the full path for a cache entry */ private function buildPath(string $key, CacheScope $scope, string $usage): string { $dir = $this->buildDir($scope, $usage); $hash = $this->hashKey($key); return $dir . '/' . $hash . '.cache'; } /** * Build the directory path for a scope/usage combination */ private function buildDir(CacheScope $scope, string $usage): string { $prefix = $scope->buildPrefix($this->tenantId, $this->userId); $usage = preg_replace('/[^a-zA-Z0-9_-]/', '_', $usage); return $this->basePath . '/' . $prefix . '/' . $usage; } /** * Hash a cache key for filesystem safety */ private function hashKey(string $key): string { // Use a prefix of the original key for debugging + hash for uniqueness $safe = preg_replace('/[^a-zA-Z0-9_-]/', '_', substr($key, 0, 32)); $hash = substr(hash('sha256', $key), 0, 16); return $safe . '_' . $hash; } /** * Read and unserialize a cache entry */ private function readEntry(string $path): ?array { $content = @file_get_contents($path); if ($content === false) { return null; } $entry = @unserialize($content); if (!is_array($entry) || !isset($entry['value'])) { return null; } return $entry; } /** * Serialize and write a cache entry atomically */ private function writeEntry(string $path, array $entry): bool { $content = serialize($entry); $tempPath = $path . '.tmp.' . getmypid(); if (file_put_contents($tempPath, $content, LOCK_EX) === false) { return false; } chmod($tempPath, 0600); if (!rename($tempPath, $path)) { @unlink($tempPath); return false; } return true; } /** * Recursively find all cache files in a directory */ private function findCacheFiles(string $dir): array { $files = []; $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::LEAVES_ONLY ); foreach ($iterator as $file) { if ($file->isFile() && $file->getExtension() === 'cache') { $files[] = $file->getPathname(); } } return $files; } }