Files
rspade_system/app/RSpade/Core/Cache/RsxCache.php
root 29c657f7a7 Exclude tests directory from framework publish
Add 100+ automated unit tests from .expect file specifications
Add session system test
Add rsx:constants:regenerate command test
Add rsx:logrotate command test
Add rsx:clean command test
Add rsx:manifest:stats command test
Add model enum system test
Add model mass assignment prevention test
Add rsx:check command test
Add migrate:status command test

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-25 03:59:58 +00:00

425 lines
12 KiB
PHP

<?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);
}
}
}