Initial Version
This commit is contained in:
123
shared/lib/Cache/BlobCacheInterface.php
Normal file
123
shared/lib/Cache/BlobCacheInterface.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Cache;
|
||||
|
||||
/**
|
||||
* Blob Cache Interface
|
||||
*
|
||||
* For binary/media data like preview images, thumbnails, and file caches.
|
||||
* Stored in storage/ rather than var/cache/ due to larger sizes and user ownership.
|
||||
*
|
||||
* Use cases: contact previews, file thumbnails, generated images.
|
||||
*/
|
||||
interface BlobCacheInterface
|
||||
{
|
||||
/**
|
||||
* Default TTL for blob cache entries (7 days)
|
||||
*/
|
||||
public const DEFAULT_TTL = 604800;
|
||||
|
||||
/**
|
||||
* Get blob data as a string
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name (e.g., 'previews', 'thumbnails')
|
||||
* @return string|null Blob data or null if not found
|
||||
*/
|
||||
public function get(string $key, CacheScope $scope, string $usage): ?string;
|
||||
|
||||
/**
|
||||
* Get blob data as a stream resource
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return resource|null Stream resource or null if not found
|
||||
*/
|
||||
public function getStream(string $key, CacheScope $scope, string $usage);
|
||||
|
||||
/**
|
||||
* Store blob data from a string
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param string $data Blob data
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @param string|null $mimeType MIME type of the blob
|
||||
* @param int|null $ttl Time-to-live in seconds
|
||||
* @return bool True if stored successfully
|
||||
*/
|
||||
public function set(string $key, string $data, CacheScope $scope, string $usage, ?string $mimeType = null, ?int $ttl = null): bool;
|
||||
|
||||
/**
|
||||
* Store blob data from a stream
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param resource $stream Stream resource to read from
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @param string|null $mimeType MIME type of the blob
|
||||
* @param int|null $ttl Time-to-live in seconds
|
||||
* @return bool True if stored successfully
|
||||
*/
|
||||
public function putStream(string $key, $stream, CacheScope $scope, string $usage, ?string $mimeType = null, ?int $ttl = null): bool;
|
||||
|
||||
/**
|
||||
* Check if a blob exists
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return bool True if exists and not expired
|
||||
*/
|
||||
public function has(string $key, CacheScope $scope, string $usage): bool;
|
||||
|
||||
/**
|
||||
* Delete a blob
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return bool True if deleted
|
||||
*/
|
||||
public function delete(string $key, CacheScope $scope, string $usage): bool;
|
||||
|
||||
/**
|
||||
* Get the local filesystem path to a blob (if available)
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return string|null Filesystem path or null if not available/not local
|
||||
*/
|
||||
public function getPath(string $key, CacheScope $scope, string $usage): ?string;
|
||||
|
||||
/**
|
||||
* Get metadata for a blob
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return array{mimeType: string|null, size: int, createdAt: int, expiresAt: int|null}|null
|
||||
*/
|
||||
public function getMetadata(string $key, CacheScope $scope, string $usage): ?array;
|
||||
|
||||
/**
|
||||
* Clear all blobs in a usage bucket
|
||||
*
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return int Number of blobs removed
|
||||
*/
|
||||
public function clear(CacheScope $scope, string $usage): int;
|
||||
|
||||
/**
|
||||
* Clean up expired blobs
|
||||
*
|
||||
* @return int Number of blobs cleaned up
|
||||
*/
|
||||
public function cleanup(): int;
|
||||
}
|
||||
72
shared/lib/Cache/CacheInterface.php
Normal file
72
shared/lib/Cache/CacheInterface.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Cache;
|
||||
|
||||
/**
|
||||
* Base Cache Interface
|
||||
*
|
||||
* Common interface for all cache implementations.
|
||||
* Supports scoped caching with automatic key prefixing based on scope.
|
||||
*/
|
||||
interface CacheInterface
|
||||
{
|
||||
/**
|
||||
* Retrieve an item from the cache
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name (e.g., 'sessions', 'config')
|
||||
* @return mixed|null Cached value or null if not found/expired
|
||||
*/
|
||||
public function get(string $key, CacheScope $scope, string $usage): mixed;
|
||||
|
||||
/**
|
||||
* Store an item in the cache
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param mixed $value Value to cache (must be serializable)
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @param int|null $ttl Time-to-live in seconds (null = default, 0 = indefinite)
|
||||
* @return bool True if stored successfully
|
||||
*/
|
||||
public function set(string $key, mixed $value, CacheScope $scope, string $usage, ?int $ttl = null): bool;
|
||||
|
||||
/**
|
||||
* Check if an item exists in the cache
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return bool True if exists and not expired
|
||||
*/
|
||||
public function has(string $key, CacheScope $scope, string $usage): bool;
|
||||
|
||||
/**
|
||||
* Remove an item from the cache
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return bool True if deleted (or didn't exist)
|
||||
*/
|
||||
public function delete(string $key, CacheScope $scope, string $usage): bool;
|
||||
|
||||
/**
|
||||
* Remove all items matching a usage pattern
|
||||
*
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return int Number of items removed
|
||||
*/
|
||||
public function clear(CacheScope $scope, string $usage): int;
|
||||
|
||||
/**
|
||||
* Clean up expired entries
|
||||
*
|
||||
* @return int Number of entries cleaned up
|
||||
*/
|
||||
public function cleanup(): int;
|
||||
}
|
||||
50
shared/lib/Cache/CacheScope.php
Normal file
50
shared/lib/Cache/CacheScope.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Cache;
|
||||
|
||||
/**
|
||||
* Cache Scope
|
||||
*
|
||||
* Defines the scope/namespace level for cached entries:
|
||||
* - Global: Shared across all tenants and users (e.g., routes, modules)
|
||||
* - Tenant: Scoped to a specific tenant (e.g., config, sessions)
|
||||
* - User: Scoped to a specific user within a tenant (e.g., rate limits)
|
||||
*/
|
||||
enum CacheScope: string
|
||||
{
|
||||
case Global = 'global';
|
||||
case Tenant = 'tenant';
|
||||
case User = 'user';
|
||||
|
||||
/**
|
||||
* Build the cache path prefix for this scope
|
||||
*
|
||||
* @param string|null $tenantId Tenant identifier (required for Tenant/User scope)
|
||||
* @param string|null $userId User identifier (required for User scope)
|
||||
* @return string Path prefix (e.g., "global", "tenant/{tid}", "user/{tid}/{uid}")
|
||||
*/
|
||||
public function buildPrefix(?string $tenantId = null, ?string $userId = null): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Global => 'global',
|
||||
self::Tenant => $tenantId ? "tenant/{$tenantId}" : 'tenant/_unknown',
|
||||
self::User => $tenantId && $userId
|
||||
? "user/{$tenantId}/{$userId}"
|
||||
: "user/_unknown/_unknown",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that required identifiers are provided for this scope
|
||||
*/
|
||||
public function validate(?string $tenantId, ?string $userId): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::Global => true,
|
||||
self::Tenant => $tenantId !== null,
|
||||
self::User => $tenantId !== null && $userId !== null,
|
||||
};
|
||||
}
|
||||
}
|
||||
57
shared/lib/Cache/EphemeralCacheInterface.php
Normal file
57
shared/lib/Cache/EphemeralCacheInterface.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Cache;
|
||||
|
||||
/**
|
||||
* Ephemeral Cache Interface
|
||||
*
|
||||
* For short-lived cached data with automatic expiration.
|
||||
* Use cases: sessions, rate limits, challenges, temporary config.
|
||||
*
|
||||
* Default TTL is typically seconds to minutes.
|
||||
*/
|
||||
interface EphemeralCacheInterface extends CacheInterface
|
||||
{
|
||||
/**
|
||||
* Default TTL for ephemeral cache entries (5 minutes)
|
||||
*/
|
||||
public const DEFAULT_TTL = 300;
|
||||
|
||||
/**
|
||||
* Get or set a value with a callback
|
||||
*
|
||||
* If the key doesn't exist, execute the callback and cache the result.
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param callable $callback Function to generate value if not cached
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @param int|null $ttl Time-to-live in seconds
|
||||
* @return mixed Cached or generated value
|
||||
*/
|
||||
public function remember(string $key, callable $callback, CacheScope $scope, string $usage, ?int $ttl = null): mixed;
|
||||
|
||||
/**
|
||||
* Increment a numeric value
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @param int $amount Amount to increment by
|
||||
* @return int|false New value or false on failure
|
||||
*/
|
||||
public function increment(string $key, CacheScope $scope, string $usage, int $amount = 1): int|false;
|
||||
|
||||
/**
|
||||
* Decrement a numeric value
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @param int $amount Amount to decrement by
|
||||
* @return int|false New value or false on failure
|
||||
*/
|
||||
public function decrement(string $key, CacheScope $scope, string $usage, int $amount = 1): int|false;
|
||||
}
|
||||
66
shared/lib/Cache/PersistentCacheInterface.php
Normal file
66
shared/lib/Cache/PersistentCacheInterface.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Cache;
|
||||
|
||||
/**
|
||||
* Persistent Cache Interface
|
||||
*
|
||||
* For long-lived cached data with optional tagging for bulk invalidation.
|
||||
* Use cases: routes, modules, compiled configs, firewall rules.
|
||||
*
|
||||
* Default TTL is typically hours to days, or indefinite until explicit invalidation.
|
||||
*/
|
||||
interface PersistentCacheInterface extends CacheInterface
|
||||
{
|
||||
/**
|
||||
* Default TTL for persistent cache entries (1 hour)
|
||||
* Use 0 for indefinite storage
|
||||
*/
|
||||
public const DEFAULT_TTL = 3600;
|
||||
|
||||
/**
|
||||
* Store an item with tags for bulk invalidation
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param mixed $value Value to cache
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @param array<string> $tags Tags for grouping/invalidation
|
||||
* @param int|null $ttl Time-to-live in seconds (null = default, 0 = indefinite)
|
||||
* @return bool True if stored successfully
|
||||
*/
|
||||
public function setWithTags(string $key, mixed $value, CacheScope $scope, string $usage, array $tags, ?int $ttl = null): bool;
|
||||
|
||||
/**
|
||||
* Invalidate all entries with a specific tag
|
||||
*
|
||||
* @param string $tag Tag to invalidate
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return int Number of entries invalidated
|
||||
*/
|
||||
public function invalidateByTag(string $tag, CacheScope $scope, string $usage): int;
|
||||
|
||||
/**
|
||||
* Get the version/timestamp of a cached entry
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @return int|null Timestamp when entry was cached, or null if not found
|
||||
*/
|
||||
public function getVersion(string $key, CacheScope $scope, string $usage): ?int;
|
||||
|
||||
/**
|
||||
* Check if an entry is stale based on a reference timestamp
|
||||
*
|
||||
* @param string $key Cache key
|
||||
* @param CacheScope $scope Cache scope level
|
||||
* @param string $usage Usage/bucket name
|
||||
* @param int $reference Reference timestamp to compare against
|
||||
* @return bool True if entry is older than reference (or doesn't exist)
|
||||
*/
|
||||
public function isStale(string $key, CacheScope $scope, string $usage, int $reference): bool;
|
||||
}
|
||||
412
shared/lib/Cache/Store/FileBlobCache.php
Normal file
412
shared/lib/Cache/Store/FileBlobCache.php
Normal file
@@ -0,0 +1,412 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Cache\Store;
|
||||
|
||||
use KTXF\Cache\BlobCacheInterface;
|
||||
use KTXF\Cache\CacheScope;
|
||||
|
||||
/**
|
||||
* File-based Blob Cache Implementation
|
||||
*
|
||||
* Stores binary/media data with metadata in the storage directory.
|
||||
* Directory structure: storage/{tid}/cache/{usage}/ or storage/{tid}/{uid}/cache/{usage}/
|
||||
*/
|
||||
class FileBlobCache implements BlobCacheInterface
|
||||
{
|
||||
private string $basePath;
|
||||
private ?string $tenantId = null;
|
||||
private ?string $userId = null;
|
||||
|
||||
public function __construct(string $projectDir)
|
||||
{
|
||||
$this->basePath = rtrim($projectDir, '/') . '/storage';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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): ?string
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
|
||||
if (!$this->isValid($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = @file_get_contents($path);
|
||||
|
||||
return $content !== false ? $content : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getStream(string $key, CacheScope $scope, string $usage)
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
|
||||
if (!$this->isValid($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$handle = @fopen($path, 'rb');
|
||||
|
||||
return $handle !== false ? $handle : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function set(string $key, string $data, CacheScope $scope, string $usage, ?string $mimeType = null, ?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;
|
||||
}
|
||||
}
|
||||
|
||||
// Write data file
|
||||
$tempPath = $path . '.tmp.' . getmypid();
|
||||
|
||||
if (file_put_contents($tempPath, $data, LOCK_EX) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
chmod($tempPath, 0600);
|
||||
|
||||
if (!rename($tempPath, $path)) {
|
||||
@unlink($tempPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write metadata
|
||||
$this->writeMetadata($path, [
|
||||
'mimeType' => $mimeType,
|
||||
'size' => strlen($data),
|
||||
'createdAt' => time(),
|
||||
'expiresAt' => $ttl !== null && $ttl > 0 ? time() + $ttl : null,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function putStream(string $key, $stream, CacheScope $scope, string $usage, ?string $mimeType = null, ?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;
|
||||
}
|
||||
}
|
||||
|
||||
// Write data file from stream
|
||||
$tempPath = $path . '.tmp.' . getmypid();
|
||||
$dest = @fopen($tempPath, 'wb');
|
||||
|
||||
if ($dest === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$size = 0;
|
||||
while (!feof($stream)) {
|
||||
$chunk = fread($stream, 8192);
|
||||
if ($chunk === false) {
|
||||
fclose($dest);
|
||||
@unlink($tempPath);
|
||||
return false;
|
||||
}
|
||||
$written = fwrite($dest, $chunk);
|
||||
if ($written === false) {
|
||||
fclose($dest);
|
||||
@unlink($tempPath);
|
||||
return false;
|
||||
}
|
||||
$size += $written;
|
||||
}
|
||||
|
||||
fclose($dest);
|
||||
chmod($tempPath, 0600);
|
||||
|
||||
if (!rename($tempPath, $path)) {
|
||||
@unlink($tempPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write metadata
|
||||
$this->writeMetadata($path, [
|
||||
'mimeType' => $mimeType,
|
||||
'size' => $size,
|
||||
'createdAt' => time(),
|
||||
'expiresAt' => $ttl !== null && $ttl > 0 ? time() + $ttl : null,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function has(string $key, CacheScope $scope, string $usage): bool
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
|
||||
return $this->isValid($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function delete(string $key, CacheScope $scope, string $usage): bool
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
$metaPath = $path . '.meta';
|
||||
|
||||
$result = true;
|
||||
|
||||
if (file_exists($path)) {
|
||||
$result = @unlink($path);
|
||||
}
|
||||
|
||||
if (file_exists($metaPath)) {
|
||||
@unlink($metaPath);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getPath(string $key, CacheScope $scope, string $usage): ?string
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
|
||||
if (!$this->isValid($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getMetadata(string $key, CacheScope $scope, string $usage): ?array
|
||||
{
|
||||
$path = $this->buildPath($key, $scope, $usage);
|
||||
|
||||
if (!file_exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->readMetadata($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 . '/*');
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (is_file($file) && !str_ends_with($file, '.meta')) {
|
||||
if (@unlink($file)) {
|
||||
$count++;
|
||||
// Also remove metadata file
|
||||
@unlink($file . '.meta');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function cleanup(): int
|
||||
{
|
||||
$count = 0;
|
||||
$now = time();
|
||||
|
||||
// Scan all tenant directories
|
||||
$tenantDirs = glob($this->basePath . '/*', GLOB_ONLYDIR);
|
||||
|
||||
foreach ($tenantDirs as $tenantDir) {
|
||||
$files = $this->findBlobFiles($tenantDir);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$meta = $this->readMetadata($file);
|
||||
|
||||
if ($meta !== null && $meta['expiresAt'] !== null && $meta['expiresAt'] < $now) {
|
||||
if (@unlink($file)) {
|
||||
@unlink($file . '.meta');
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a blob is valid (exists and not expired)
|
||||
*/
|
||||
private function isValid(string $path): bool
|
||||
{
|
||||
if (!file_exists($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$meta = $this->readMetadata($path);
|
||||
|
||||
if ($meta !== null && $meta['expiresAt'] !== null && $meta['expiresAt'] < time()) {
|
||||
@unlink($path);
|
||||
@unlink($path . '.meta');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full path for a blob
|
||||
*/
|
||||
private function buildPath(string $key, CacheScope $scope, string $usage): string
|
||||
{
|
||||
$dir = $this->buildDir($scope, $usage);
|
||||
$hash = $this->hashKey($key);
|
||||
|
||||
return $dir . '/' . $hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the directory path for a scope/usage combination
|
||||
*/
|
||||
private function buildDir(CacheScope $scope, string $usage): string
|
||||
{
|
||||
$usage = preg_replace('/[^a-zA-Z0-9_-]/', '_', $usage);
|
||||
|
||||
return match ($scope) {
|
||||
CacheScope::Global => $this->basePath . '/_global/cache/' . $usage,
|
||||
CacheScope::Tenant => $this->basePath . '/' . ($this->tenantId ?? '_unknown') . '/cache/' . $usage,
|
||||
CacheScope::User => $this->basePath . '/' . ($this->tenantId ?? '_unknown') . '/' . ($this->userId ?? '_unknown') . '/cache/' . $usage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a cache key for filesystem safety
|
||||
*/
|
||||
private function hashKey(string $key): string
|
||||
{
|
||||
// Extract extension if present in key
|
||||
$ext = '';
|
||||
if (preg_match('/\.([a-zA-Z0-9]{2,5})$/', $key, $matches)) {
|
||||
$ext = '.' . strtolower($matches[1]);
|
||||
}
|
||||
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_-]/', '_', substr($key, 0, 32));
|
||||
$hash = substr(hash('sha256', $key), 0, 16);
|
||||
|
||||
return $safe . '_' . $hash . $ext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read metadata for a blob
|
||||
*/
|
||||
private function readMetadata(string $path): ?array
|
||||
{
|
||||
$metaPath = $path . '.meta';
|
||||
|
||||
if (!file_exists($metaPath)) {
|
||||
// Return basic metadata from file stats
|
||||
if (file_exists($path)) {
|
||||
$stat = stat($path);
|
||||
return [
|
||||
'mimeType' => null,
|
||||
'size' => $stat['size'] ?? 0,
|
||||
'createdAt' => $stat['ctime'] ?? time(),
|
||||
'expiresAt' => null,
|
||||
];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = @file_get_contents($metaPath);
|
||||
|
||||
if ($content === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$meta = @unserialize($content);
|
||||
|
||||
return is_array($meta) ? $meta : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write metadata for a blob
|
||||
*/
|
||||
private function writeMetadata(string $path, array $metadata): bool
|
||||
{
|
||||
$metaPath = $path . '.meta';
|
||||
|
||||
return file_put_contents($metaPath, serialize($metadata), LOCK_EX) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find all blob files in a directory
|
||||
*/
|
||||
private function findBlobFiles(string $dir): array
|
||||
{
|
||||
$files = [];
|
||||
|
||||
$cacheDirs = glob($dir . '/cache/*', GLOB_ONLYDIR) ?: [];
|
||||
$cacheDirs = array_merge($cacheDirs, glob($dir . '/*/cache/*', GLOB_ONLYDIR) ?: []);
|
||||
|
||||
foreach ($cacheDirs as $cacheDir) {
|
||||
$blobFiles = glob($cacheDir . '/*');
|
||||
foreach ($blobFiles as $file) {
|
||||
if (is_file($file) && !str_ends_with($file, '.meta')) {
|
||||
$files[] = $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
433
shared/lib/Cache/Store/FilePersistentCache.php
Normal file
433
shared/lib/Cache/Store/FilePersistentCache.php
Normal file
@@ -0,0 +1,433 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user