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