remember() had no protection against a slow cache-miss recomputation overwriting a fresher write. If Request A started computing stats just before a ticket mutation + invalidateCache(), and Request B started just after (correctly computing fresh, post-mutation data), A could finish (using stale pre-mutation data) after B and overwrite B's fresh cache entry — extending staleness by up to another full TTL. Added a per-prefix invalidation epoch: delete() bumps it, and remember() snapshots it before running the callback and only writes if the epoch hasn't changed since — otherwise a newer invalidation happened mid-computation and the result being written is already stale, so it's dropped (the caller still gets its own result; only the cache write is skipped). Verified with two real concurrent PHP processes racing against the same cache key (a slow "Request A" callback vs. a fast "Request B" that invalidates then recomputes): the cache ends up holding B's fresh value, not A's late stale overwrite. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
256 lines
8.4 KiB
PHP
256 lines
8.4 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Simple File-Based Cache Helper
|
|
*
|
|
* Provides caching for frequently accessed data that doesn't change often,
|
|
* such as workflow rules, user preferences, and configuration data.
|
|
*/
|
|
class CacheHelper
|
|
{
|
|
private static ?string $cacheDir = null;
|
|
private static array $memoryCache = [];
|
|
|
|
/**
|
|
* Get the cache directory path
|
|
*
|
|
* @return string Cache directory path
|
|
*/
|
|
private static function getCacheDir(): string
|
|
{
|
|
if (self::$cacheDir === null) {
|
|
self::$cacheDir = sys_get_temp_dir() . '/tinker_tickets_cache';
|
|
if (!is_dir(self::$cacheDir)) {
|
|
// 0700: only the app user may read cached data or create files.
|
|
// mkdir mode is masked by umask, so chmod to enforce it.
|
|
mkdir(self::$cacheDir, 0700, true);
|
|
@chmod(self::$cacheDir, 0700);
|
|
} elseif (!function_exists('posix_geteuid') || fileowner(self::$cacheDir) === posix_geteuid()) {
|
|
// Existing dir we own: harden a previously world-readable dir.
|
|
@chmod(self::$cacheDir, 0700);
|
|
}
|
|
}
|
|
return self::$cacheDir;
|
|
}
|
|
|
|
/**
|
|
* Generate a cache key from components
|
|
*
|
|
* @param string $prefix Cache prefix (e.g., 'workflow', 'user_prefs')
|
|
* @param mixed $identifier Unique identifier
|
|
* @return string Cache key
|
|
*/
|
|
private static function makeKey(string $prefix, $identifier = null): string
|
|
{
|
|
$key = $prefix;
|
|
if ($identifier !== null) {
|
|
$key .= '_' . md5(serialize($identifier));
|
|
}
|
|
return preg_replace('/[^a-zA-Z0-9_]/', '_', $key);
|
|
}
|
|
|
|
/**
|
|
* Get cached data
|
|
*
|
|
* @param string $prefix Cache prefix
|
|
* @param mixed $identifier Unique identifier
|
|
* @param int $ttl Time-to-live in seconds (default 300 = 5 minutes)
|
|
* @return mixed|null Cached data or null if not found/expired
|
|
*/
|
|
public static function get(string $prefix, $identifier = null, int $ttl = 300)
|
|
{
|
|
$key = self::makeKey($prefix, $identifier);
|
|
|
|
// Check memory cache first (fastest)
|
|
if (isset(self::$memoryCache[$key])) {
|
|
$cached = self::$memoryCache[$key];
|
|
if (time() - $cached['time'] < $ttl) {
|
|
return $cached['data'];
|
|
}
|
|
unset(self::$memoryCache[$key]);
|
|
}
|
|
|
|
// Check file cache
|
|
$filePath = self::getCacheDir() . '/' . $key . '.json';
|
|
if (file_exists($filePath)) {
|
|
$content = @file_get_contents($filePath);
|
|
if ($content !== false) {
|
|
$cached = json_decode($content, true);
|
|
if ($cached && isset($cached['time']) && isset($cached['data'])) {
|
|
if (time() - $cached['time'] < $ttl) {
|
|
// Store in memory cache for faster subsequent access
|
|
self::$memoryCache[$key] = $cached;
|
|
return $cached['data'];
|
|
}
|
|
}
|
|
}
|
|
// Expired - delete file
|
|
@unlink($filePath);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Store data in cache
|
|
*
|
|
* @param string $prefix Cache prefix
|
|
* @param mixed $identifier Unique identifier
|
|
* @param mixed $data Data to cache
|
|
* @return bool Success
|
|
*/
|
|
public static function set(string $prefix, $identifier, $data): bool
|
|
{
|
|
$key = self::makeKey($prefix, $identifier);
|
|
$cached = [
|
|
'time' => time(),
|
|
'data' => $data
|
|
];
|
|
|
|
// Store in memory cache
|
|
self::$memoryCache[$key] = $cached;
|
|
|
|
// Store in file cache
|
|
$filePath = self::getCacheDir() . '/' . $key . '.json';
|
|
$written = @file_put_contents($filePath, json_encode($cached), LOCK_EX) !== false;
|
|
if ($written) {
|
|
// 0600: cache may feed security-relevant reads; keep it non-readable
|
|
// to other local users and non-poisonable by pre-created files.
|
|
@chmod($filePath, 0600);
|
|
}
|
|
return $written;
|
|
}
|
|
|
|
/**
|
|
* Read the current invalidation epoch for a prefix (0 if never bumped).
|
|
* Used by remember() to detect an invalidation that happened while a
|
|
* cache-miss recomputation was in flight.
|
|
*/
|
|
private static function getEpoch(string $prefix): int
|
|
{
|
|
$safePrefix = preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix);
|
|
$file = self::getCacheDir() . '/' . $safePrefix . '.epoch';
|
|
$val = @file_get_contents($file);
|
|
return $val !== false ? (int)$val : 0;
|
|
}
|
|
|
|
/**
|
|
* Bump a prefix's invalidation epoch. Called whenever anything under the
|
|
* prefix is invalidated.
|
|
*/
|
|
private static function bumpEpoch(string $prefix): void
|
|
{
|
|
$safePrefix = preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix);
|
|
$file = self::getCacheDir() . '/' . $safePrefix . '.epoch';
|
|
$next = self::getEpoch($prefix) + 1;
|
|
if (@file_put_contents($file, (string)$next, LOCK_EX) !== false) {
|
|
@chmod($file, 0600);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete cached data
|
|
*
|
|
* @param string $prefix Cache prefix
|
|
* @param mixed $identifier Unique identifier (null to delete all with prefix)
|
|
* @return bool Success
|
|
*/
|
|
public static function delete(string $prefix, $identifier = null): bool
|
|
{
|
|
self::bumpEpoch($prefix);
|
|
|
|
if ($identifier !== null) {
|
|
$key = self::makeKey($prefix, $identifier);
|
|
unset(self::$memoryCache[$key]);
|
|
$filePath = self::getCacheDir() . '/' . $key . '.json';
|
|
return !file_exists($filePath) || @unlink($filePath);
|
|
}
|
|
|
|
// Delete all entries for this prefix. A key is either the bare prefix or
|
|
// prefix + '_' + md5(identifier) (32 hex chars, see makeKey). Match exactly
|
|
// that so a prefix can't clobber a different prefix that merely shares a
|
|
// leading substring — e.g. delete('workflow') must not wipe 'workflow_rules'.
|
|
$safePrefix = preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix);
|
|
$keyRegex = '/^' . preg_quote($safePrefix, '/') . '(_[0-9a-f]{32})?$/';
|
|
|
|
$files = glob(self::getCacheDir() . '/' . $safePrefix . '*.json') ?: [];
|
|
foreach ($files as $file) {
|
|
if (preg_match($keyRegex, basename($file, '.json'))) {
|
|
@unlink($file);
|
|
}
|
|
}
|
|
|
|
// Clear matching memory cache entries
|
|
foreach (array_keys(self::$memoryCache) as $key) {
|
|
if (preg_match($keyRegex, $key)) {
|
|
unset(self::$memoryCache[$key]);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Clear all cache
|
|
*
|
|
* @return bool Success
|
|
*/
|
|
public static function clearAll(): bool
|
|
{
|
|
self::$memoryCache = [];
|
|
|
|
$files = glob(self::getCacheDir() . '/*.json');
|
|
foreach ($files as $file) {
|
|
@unlink($file);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Get data from cache or fetch it using a callback
|
|
*
|
|
* @param string $prefix Cache prefix
|
|
* @param mixed $identifier Unique identifier
|
|
* @param callable $callback Function to call if cache miss
|
|
* @param int $ttl Time-to-live in seconds
|
|
* @return mixed Cached or freshly fetched data
|
|
*/
|
|
public static function remember(string $prefix, $identifier, callable $callback, int $ttl = 300)
|
|
{
|
|
$data = self::get($prefix, $identifier, $ttl);
|
|
|
|
if ($data === null) {
|
|
// Snapshot the epoch before running the (possibly slow) callback so
|
|
// a concurrent invalidation mid-computation can be detected below —
|
|
// otherwise this request's stale pre-invalidation result could
|
|
// overwrite a newer request's fresher write, extending staleness by
|
|
// up to another full TTL.
|
|
$epochBefore = self::getEpoch($prefix);
|
|
$data = $callback();
|
|
if ($data !== null && self::getEpoch($prefix) === $epochBefore) {
|
|
self::set($prefix, $identifier, $data);
|
|
}
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Clean up expired cache files (call periodically)
|
|
*
|
|
* @param int $maxAge Maximum age in seconds (default 1 hour)
|
|
*/
|
|
public static function cleanup(int $maxAge = 3600): void
|
|
{
|
|
$files = glob(self::getCacheDir() . '/*.json');
|
|
$now = time();
|
|
|
|
foreach ($files as $file) {
|
|
if ($now - filemtime($file) > $maxAge) {
|
|
@unlink($file);
|
|
}
|
|
}
|
|
}
|
|
}
|