Fix bin/publish: use correct .env path for rspade_system Fix bin/publish script: prevent grep exit code 1 from terminating script 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
425 lines
12 KiB
PHP
Executable File
425 lines
12 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\RSpade\Core\Cache;
|
|
|
|
use Exception;
|
|
use Redis;
|
|
use App\RSpade\Core\Locks\RsxLocks;
|
|
use App\RSpade\Core\Manifest\Manifest;
|
|
|
|
// Ensure helpers are loaded since we may run early in bootstrap
|
|
require_once __DIR__ . '/../../helpers.php';
|
|
|
|
/**
|
|
* Redis-based caching system with LRU eviction
|
|
*
|
|
* Uses Redis database 0 with LRU eviction policy for general caching.
|
|
* Automatically prefixes all keys with the current manifest build key
|
|
* to ensure cache invalidation when code changes.
|
|
*/
|
|
class RsxCache
|
|
{
|
|
// Redis configuration
|
|
private static ?Redis $_redis = null;
|
|
|
|
private static int $cache_db = 0; // Database 0 for cache (with LRU eviction)
|
|
|
|
private static bool $initialized = false;
|
|
|
|
// Request-scoped cache (static property storage)
|
|
private static array $_once_cache = [];
|
|
|
|
// Default expiration times
|
|
public const NO_EXPIRATION = 0;
|
|
|
|
public const HOUR = 3600;
|
|
|
|
public const DAY = 86400;
|
|
|
|
public const WEEK = 604800;
|
|
|
|
/**
|
|
* Initialize the cache system
|
|
* Must be called after manifest is loaded
|
|
*/
|
|
public static function _init()
|
|
{
|
|
if (self::$_redis) {
|
|
return self::$_redis;
|
|
}
|
|
|
|
// Skip Redis in IDE context if extension not available
|
|
if (self::_redis_bypass()) {
|
|
return null;
|
|
}
|
|
|
|
self::$_redis = new Redis();
|
|
|
|
// Connect to Redis (will be configured via environment)
|
|
$host = env('REDIS_HOST', '127.0.0.1');
|
|
$port = env('REDIS_PORT', 6379);
|
|
$socket = env('REDIS_SOCKET', null);
|
|
|
|
if ($socket && file_exists($socket)) {
|
|
$connected = self::$_redis->connect($socket);
|
|
} else {
|
|
$connected = self::$_redis->connect($host, $port, 2.0);
|
|
}
|
|
|
|
if (!$connected) {
|
|
shouldnt_happen('Failed to connect to Redis for caching');
|
|
}
|
|
|
|
// Select the cache database (with LRU eviction)
|
|
self::$_redis->select(self::$cache_db);
|
|
|
|
return self::$_redis;
|
|
}
|
|
|
|
/**
|
|
* Check if we can skip reddis due to special circumstance
|
|
*/
|
|
private static function _redis_bypass()
|
|
{
|
|
if (is_ide() && !class_exists('\Redis') && env('APP_ENV') != 'production') {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get a value from cache
|
|
*
|
|
* @param string $key Cache key
|
|
* @param mixed $default Default value if key not found
|
|
* @return mixed Cached value or default
|
|
*/
|
|
public static function get(string $key, $default = null)
|
|
{
|
|
return self::get_persistent(self::_transform_key_build($key), $default);
|
|
}
|
|
|
|
/**
|
|
* Same as ::get but survives changes in the development environment
|
|
*
|
|
* The developer must call this function during manifest build phase, because
|
|
* the build key to determine if the build has been updated is not available
|
|
* until the manifest resccan is complete. Calling get() during manifest rescan
|
|
* will throw an exception.
|
|
*
|
|
* @param string $key Cache key
|
|
* @param mixed $default Default value if key not found
|
|
* @return mixed Cached value or default
|
|
*/
|
|
public static function get_persistent(string $key, $default = null)
|
|
{
|
|
self::_init();
|
|
|
|
if (self::_redis_bypass()) {
|
|
return null;
|
|
}
|
|
|
|
$full_key = self::_make_key_persistent($key);
|
|
|
|
$value = self::$_redis->get($full_key);
|
|
|
|
if ($value === false) {
|
|
return $default;
|
|
}
|
|
|
|
// Attempt to unserialize
|
|
try {
|
|
return unserialize($value);
|
|
} catch (Exception $e) {
|
|
return $default;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set a value in cache
|
|
*
|
|
* @param string $key Cache key
|
|
* @param mixed $value Value to cache
|
|
* @param int $expiration Expiration time in seconds (0 = never expire)
|
|
* @return bool Success
|
|
*/
|
|
public static function set(string $key, $value, int $expiration = self::NO_EXPIRATION): bool
|
|
{
|
|
return self::set_persistent(self::_transform_key_build($key), $value, $expiration);
|
|
}
|
|
|
|
/**
|
|
* Set a ::set but survives a manifest rescan
|
|
* See ::get_persistent
|
|
*
|
|
* @param string $key Cache key
|
|
* @param mixed $value Value to cache
|
|
* @param int $expiration Expiration time in seconds (0 = never expire)
|
|
* @return bool Success
|
|
*/
|
|
public static function set_persistent(string $key, $value, int $expiration = self::NO_EXPIRATION): bool
|
|
{
|
|
self::_init();
|
|
|
|
if (self::_redis_bypass()) {
|
|
return null;
|
|
}
|
|
|
|
$full_key = self::_make_key_persistent($key);
|
|
|
|
$value = serialize($value);
|
|
|
|
if ($expiration > 0) {
|
|
return self::$_redis->setex($full_key, $expiration, $value);
|
|
}
|
|
|
|
return self::$_redis->set($full_key, $value);
|
|
}
|
|
|
|
/**
|
|
* Delete a key from cache
|
|
*
|
|
* @param string $key Cache key
|
|
* @return bool Success
|
|
*/
|
|
public static function delete(string $key): bool
|
|
{
|
|
self::_init();
|
|
|
|
if (self::_redis_bypass()) {
|
|
return null;
|
|
}
|
|
|
|
$full_key = self::_make_key_persistent(self::_transform_key_build($key));
|
|
|
|
return self::$_redis->del($full_key) > 0;
|
|
}
|
|
|
|
/**
|
|
* Check if a key exists in cache
|
|
*
|
|
* @param string $key Cache key
|
|
* @return bool
|
|
*/
|
|
public static function exists(string $key): bool
|
|
{
|
|
self::_init();
|
|
|
|
if (self::_redis_bypass()) {
|
|
return null;
|
|
}
|
|
|
|
$full_key = self::_make_key_persistent(self::_transform_key_build($key));
|
|
|
|
return self::$_redis->exists($full_key) > 0;
|
|
}
|
|
|
|
/**
|
|
* Clear the entire cache
|
|
* Only clears keys with the current build key prefix
|
|
*/
|
|
public static function clear(): void
|
|
{
|
|
self::_init();
|
|
|
|
if (self::_redis_bypass()) {
|
|
return;
|
|
}
|
|
|
|
self::$_redis->flushDb();
|
|
}
|
|
|
|
/**
|
|
* Increment a numeric value
|
|
*
|
|
* @param string $key Cache key
|
|
* @param int $amount Amount to increment by
|
|
* @return int New value
|
|
*/
|
|
public static function increment(string $key, int $amount = 1): int
|
|
{
|
|
self::_init();
|
|
|
|
if (self::_redis_bypass()) {
|
|
return 0;
|
|
}
|
|
|
|
$full_key = self::_make_key_persistent(self::_transform_key_build($key));
|
|
|
|
if ($amount === 1) {
|
|
return self::$_redis->incr($full_key);
|
|
}
|
|
|
|
return self::$_redis->incrBy($full_key, $amount);
|
|
}
|
|
|
|
/**
|
|
* Decrement a numeric value
|
|
*
|
|
* @param string $key Cache key
|
|
* @param int $amount Amount to decrement by
|
|
* @return int New value
|
|
*/
|
|
public static function decrement(string $key, int $amount = 1): int
|
|
{
|
|
self::_init();
|
|
|
|
if (self::_redis_bypass()) {
|
|
return 0;
|
|
}
|
|
|
|
$full_key = self::_make_key_persistent(self::_transform_key_build($key));
|
|
|
|
if ($amount === 1) {
|
|
return self::$_redis->decr($full_key);
|
|
}
|
|
|
|
returnself::$_redis->decrBy($full_key, $amount);
|
|
}
|
|
|
|
/**
|
|
* Get cache statistics
|
|
*
|
|
* @return array Cache statistics
|
|
*/
|
|
public static function stats(): array
|
|
{
|
|
self::_init();
|
|
|
|
if (self::_redis_bypass()) {
|
|
return 0;
|
|
}
|
|
|
|
$_redis = self::$_redis;
|
|
|
|
// Return empty stats if Redis not available in IDE
|
|
if ($_redis === null) {
|
|
return [
|
|
'total_keys' => 0,
|
|
'used_memory' => 'N/A (IDE context)',
|
|
'used_memory_peak' => 'N/A (IDE context)',
|
|
'maxmemory' => '0',
|
|
'maxmemory_policy' => 'noeviction',
|
|
];
|
|
}
|
|
|
|
// Get info from Redis
|
|
$info = $_redis->info('memory');
|
|
$db_info = $_redis->info('keyspace');
|
|
|
|
// Parse keyspace info for current database
|
|
$db_key = 'db' . self::$cache_db;
|
|
$key_count = 0;
|
|
if (isset($db_info[$db_key])) {
|
|
$match_result = preg_match('/keys=(\d+)/', $db_info[$db_key], $matches);
|
|
if ($match_result) {
|
|
$key_count = (int)$matches[1];
|
|
}
|
|
}
|
|
|
|
// Count keys with our build prefix
|
|
$pattern = self::_make_key('*');
|
|
|
|
return [
|
|
'total_keys' => $key_count,
|
|
'used_memory' => $info['used_memory_human'] ?? 'unknown',
|
|
'used_memory_peak' => $info['used_memory_peak_human'] ?? 'unknown',
|
|
'maxmemory' => $_redis->config('GET', 'maxmemory')['maxmemory'] ?? '0',
|
|
'maxmemory_policy' => $_redis->config('GET', 'maxmemory-policy')['maxmemory-policy'] ?? 'noeviction',
|
|
];
|
|
}
|
|
|
|
// Document me
|
|
private static function _transform_key_build(string $key): string
|
|
{
|
|
return Manifest::get_build_key() . '_' . $key;
|
|
}
|
|
|
|
/**
|
|
* Create a full cache key with build prefix
|
|
*
|
|
* @param string $key User-provided key
|
|
* @return string Full cache key
|
|
*/
|
|
private static function _make_key_persistent(string $key): string
|
|
{
|
|
return 'cache:' . sha1($key);
|
|
}
|
|
|
|
/**
|
|
* Request-scoped cache - stores value in static property for request duration
|
|
*
|
|
* Simplest caching - no Redis, no locks, just memory. Perfect for expensive
|
|
* calculations that might be called multiple times in a single request.
|
|
*
|
|
* @param string $key Cache key (request-scoped)
|
|
* @param callable $callback Callback to generate value if not cached
|
|
* @return mixed Cached or generated value
|
|
*/
|
|
public static function once(string $key, callable $callback)
|
|
{
|
|
if (array_key_exists($key, self::$_once_cache)) {
|
|
return self::$_once_cache[$key];
|
|
}
|
|
|
|
$value = $callback();
|
|
self::$_once_cache[$key] = $value;
|
|
|
|
return $value;
|
|
}
|
|
|
|
/**
|
|
* Build-scoped cache with advisory locking
|
|
*
|
|
* Caches value in Redis with build key prefix. Uses advisory write lock
|
|
* during cache building to prevent stampede (multiple processes building
|
|
* same cache simultaneously). Cache survives until manifest rebuild.
|
|
*
|
|
* @param string $key Cache key (build-scoped)
|
|
* @param callable $callback Callback to generate value if not cached
|
|
* @param int|null $seconds Expiration in seconds (null = never expire)
|
|
* @return mixed Cached or generated value
|
|
*/
|
|
public static function remember(string $key, callable $callback, ?int $seconds = null)
|
|
{
|
|
self::_init();
|
|
|
|
// Check cache first (fast path - no lock needed)
|
|
$value = self::get($key);
|
|
if ($value !== null) {
|
|
return $value;
|
|
}
|
|
|
|
// Cache miss - acquire write lock to build cache
|
|
// This prevents multiple processes from building the same cache
|
|
$lock_key = 'cache_build:' . $key;
|
|
$lock_token = RsxLocks::get_lock(
|
|
RsxLocks::SERVER_LOCK,
|
|
$lock_key,
|
|
RsxLocks::WRITE_LOCK,
|
|
30 // 30 second timeout for cache building
|
|
);
|
|
|
|
try {
|
|
// Check cache again after acquiring lock
|
|
// Another process may have built it while we were waiting
|
|
$value = self::get($key);
|
|
if ($value !== null) {
|
|
return $value;
|
|
}
|
|
|
|
// Build the cache
|
|
$value = $callback();
|
|
|
|
// Store in cache
|
|
$expiration = $seconds ?? self::NO_EXPIRATION;
|
|
self::set($key, $value, $expiration);
|
|
|
|
return $value;
|
|
} finally {
|
|
RsxLocks::release_lock($lock_token);
|
|
}
|
|
}
|
|
}
|