434 lines
11 KiB
PHP
434 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace KTXF\Cache\Store;
|
|
|
|
use KTXF\Cache\CacheScope;
|
|
use KTXF\Cache\PersistentCacheInterface;
|
|
|
|
/**
|
|
* File-based Persistent Cache Implementation
|
|
*
|
|
* Stores long-lived cache entries with support for tagging and versioning.
|
|
* Directory structure: var/cache/{scope}/{usage}/{key_hash}.cache
|
|
*/
|
|
class FilePersistentCache implements PersistentCacheInterface
|
|
{
|
|
private string $basePath;
|
|
private ?string $tenantId = null;
|
|
private ?string $userId = null;
|
|
|
|
public function __construct(string $projectDir)
|
|
{
|
|
$this->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;
|
|
}
|
|
}
|