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 (0 = never expires) if ($entry['expiresAt'] > 0 && $entry['expiresAt'] < time()) { @unlink($path); $this->removeFromTagIndex($key, $scope, $usage, $entry['tags'] ?? []); return null; } return $entry['value']; } /** * @inheritDoc */ public function set(string $key, mixed $value, CacheScope $scope, string $usage, ?int $ttl = null): bool { return $this->setWithTags($key, $value, $scope, $usage, [], $ttl); } /** * @inheritDoc */ public function setWithTags(string $key, mixed $value, CacheScope $scope, string $usage, array $tags, ?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; } } // Remove from old tags if entry exists $existingEntry = $this->readEntry($path); if ($existingEntry !== null && !empty($existingEntry['tags'])) { $this->removeFromTagIndex($key, $scope, $usage, $existingEntry['tags']); } $ttl = $ttl ?? self::DEFAULT_TTL; $entry = [ 'key' => $key, 'value' => $value, 'tags' => $tags, 'createdAt' => time(), 'expiresAt' => $ttl > 0 ? time() + $ttl : 0, ]; $result = $this->writeEntry($path, $entry); if ($result && !empty($tags)) { $this->addToTagIndex($key, $scope, $usage, $tags); } return $result; } /** * @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)) { $entry = $this->readEntry($path); if ($entry !== null && !empty($entry['tags'])) { $this->removeFromTagIndex($key, $scope, $usage, $entry['tags']); } 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++; } } // Clear tag index $tagIndexPath = $dir . '/.tags'; if (file_exists($tagIndexPath)) { @unlink($tagIndexPath); } 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 invalidateByTag(string $tag, CacheScope $scope, string $usage): int { $tagIndex = $this->readTagIndex($scope, $usage); if (!isset($tagIndex[$tag])) { return 0; } $count = 0; $keys = $tagIndex[$tag]; foreach ($keys as $key) { if ($this->delete($key, $scope, $usage)) { $count++; } } return $count; } /** * @inheritDoc */ public function getVersion(string $key, CacheScope $scope, string $usage): ?int { $path = $this->buildPath($key, $scope, $usage); if (!file_exists($path)) { return null; } $entry = $this->readEntry($path); return $entry['createdAt'] ?? null; } /** * @inheritDoc */ public function isStale(string $key, CacheScope $scope, string $usage, int $reference): bool { $version = $this->getVersion($key, $scope, $usage); if ($version === null) { return true; } return $version < $reference; } /** * 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 { $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; } /** * Read the tag index for a usage bucket */ private function readTagIndex(CacheScope $scope, string $usage): array { $path = $this->buildDir($scope, $usage) . '/.tags'; if (!file_exists($path)) { return []; } $content = @file_get_contents($path); if ($content === false) { return []; } $index = @unserialize($content); return is_array($index) ? $index : []; } /** * Write the tag index for a usage bucket */ private function writeTagIndex(CacheScope $scope, string $usage, array $index): bool { $path = $this->buildDir($scope, $usage) . '/.tags'; $dir = dirname($path); if (!is_dir($dir)) { if (!mkdir($dir, 0755, true) && !is_dir($dir)) { return false; } } return file_put_contents($path, serialize($index), LOCK_EX) !== false; } /** * Add a key to the tag index */ private function addToTagIndex(string $key, CacheScope $scope, string $usage, array $tags): void { $index = $this->readTagIndex($scope, $usage); foreach ($tags as $tag) { if (!isset($index[$tag])) { $index[$tag] = []; } if (!in_array($key, $index[$tag], true)) { $index[$tag][] = $key; } } $this->writeTagIndex($scope, $usage, $index); } /** * Remove a key from the tag index */ private function removeFromTagIndex(string $key, CacheScope $scope, string $usage, array $tags): void { $index = $this->readTagIndex($scope, $usage); $changed = false; foreach ($tags as $tag) { if (isset($index[$tag])) { $pos = array_search($key, $index[$tag], true); if ($pos !== false) { unset($index[$tag][$pos]); $index[$tag] = array_values($index[$tag]); $changed = true; if (empty($index[$tag])) { unset($index[$tag]); } } } } if ($changed) { $this->writeTagIndex($scope, $usage, $index); } } /** * 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; } }