Initial Version
This commit is contained in:
346
shared/lib/Cache/Store/FileEphemeralCache.php
Normal file
346
shared/lib/Cache/Store/FileEphemeralCache.php
Normal file
@@ -0,0 +1,346 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Cache\Store;
|
||||
|
||||
use KTXF\Cache\CacheScope;
|
||||
use KTXF\Cache\EphemeralCacheInterface;
|
||||
|
||||
/**
|
||||
* File-based Ephemeral Cache Implementation
|
||||
*
|
||||
* Stores cache entries as serialized files with expiration metadata.
|
||||
* Directory structure: var/cache/{scope}/{usage}/{key_hash}.cache
|
||||
*/
|
||||
class FileEphemeralCache implements EphemeralCacheInterface
|
||||
{
|
||||
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
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user