* * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. */ namespace CodeIgniter\Cache\Handlers; use CodeIgniter\Exceptions\CriticalError; use CodeIgniter\I18n\Time; use Config\Cache; use Redis; use RedisException; /** * Redis cache handler * * @see \CodeIgniter\Cache\Handlers\RedisHandlerTest */ class RedisHandler extends BaseHandler { /** * Default config * * @var array{ * host: string, * password: string|null, * port: int, * timeout: int, * persistent: bool, * database: int, * } */ protected $config = [ 'host' => '127.0.0.1', 'password' => null, 'port' => 6379, 'timeout' => 0, 'persistent' => false, 'database' => 0, ]; /** * Redis connection * * @var Redis|null */ protected $redis; /** * Note: Use `CacheFactory::getHandler()` to instantiate. */ public function __construct(Cache $config) { $this->prefix = $config->prefix; $this->config = array_merge($this->config, $config->redis); } public function initialize(): void { $config = $this->config; $this->redis = new Redis(); try { $funcConnection = isset($config['persistent']) && $config['persistent'] ? 'pconnect' : 'connect'; // Note:: If Redis is your primary cache choice, and it is "offline", every page load will end up been delayed by the timeout duration. // I feel like some sort of temporary flag should be set, to indicate that we think Redis is "offline", allowing us to bypass the timeout for a set period of time. if (! $this->redis->{$funcConnection}($config['host'], ($config['host'][0] === '/' ? 0 : $config['port']), $config['timeout'])) { // Note:: I'm unsure if log_message() is necessary, however I'm not 100% comfortable removing it. log_message('error', 'Cache: Redis connection failed. Check your configuration.'); throw new CriticalError('Cache: Redis connection failed. Check your configuration.'); } if (isset($config['password']) && ! $this->redis->auth($config['password'])) { log_message('error', 'Cache: Redis authentication failed.'); throw new CriticalError('Cache: Redis authentication failed.'); } if (isset($config['database']) && ! $this->redis->select($config['database'])) { log_message('error', 'Cache: Redis select database failed.'); throw new CriticalError('Cache: Redis select database failed.'); } } catch (RedisException $e) { throw new CriticalError('Cache: RedisException occurred with message (' . $e->getMessage() . ').'); } } public function get(string $key): mixed { $key = static::validateKey($key, $this->prefix); $data = $this->redis->hMget($key, ['__ci_type', '__ci_value']); if (! isset($data['__ci_type'], $data['__ci_value']) || $data['__ci_value'] === false) { return null; } return match ($data['__ci_type']) { 'array', 'object' => unserialize($data['__ci_value']), // Yes, 'double' is returned and NOT 'float' 'boolean', 'integer', 'double', 'string', 'NULL' => settype($data['__ci_value'], $data['__ci_type']) ? $data['__ci_value'] : null, default => null, }; } public function save(string $key, mixed $value, int $ttl = 60): bool { $key = static::validateKey($key, $this->prefix); switch ($dataType = gettype($value)) { case 'array': case 'object': $value = serialize($value); break; case 'boolean': case 'integer': case 'double': // Yes, 'double' is returned and NOT 'float' case 'string': case 'NULL': break; case 'resource': default: return false; } if (! $this->redis->hMset($key, ['__ci_type' => $dataType, '__ci_value' => $value])) { return false; } if ($ttl !== 0) { $this->redis->expireAt($key, Time::now()->getTimestamp() + $ttl); } return true; } public function delete(string $key): bool { $key = static::validateKey($key, $this->prefix); return $this->redis->del($key) === 1; } public function deleteMatching(string $pattern): int { /** @var list $matchedKeys */ $matchedKeys = []; $pattern = static::validateKey($pattern, $this->prefix); $iterator = null; do { /** @var false|list $keys */ $keys = $this->redis->scan($iterator, $pattern); if (is_array($keys)) { $matchedKeys = [...$matchedKeys, ...$keys]; } } while ($iterator > 0); return (int) $this->redis->del($matchedKeys); } public function increment(string $key, int $offset = 1): int { $key = static::validateKey($key, $this->prefix); return $this->redis->hIncrBy($key, '__ci_value', $offset); } public function decrement(string $key, int $offset = 1): int { return $this->increment($key, -$offset); } public function clean(): bool { return $this->redis->flushDB(); } public function getCacheInfo(): array { return $this->redis->info(); } public function getMetaData(string $key): ?array { $value = $this->get($key); if ($value !== null) { $time = Time::now()->getTimestamp(); $ttl = $this->redis->ttl(static::validateKey($key, $this->prefix)); assert(is_int($ttl)); return [ 'expire' => $ttl > 0 ? $time + $ttl : null, 'mtime' => $time, 'data' => $value, ]; } return null; } public function isSupported(): bool { return extension_loaded('redis'); } public function ping(): bool { if (! isset($this->redis)) { return false; } try { $result = $this->redis->ping(); return in_array($result, [true, '+PONG'], true); } catch (RedisException) { return false; } } public function reconnect(): bool { if (isset($this->redis)) { try { $this->redis->close(); } catch (RedisException) { // Connection already dead, that's fine } } try { $this->initialize(); return true; } catch (CriticalError $e) { log_message('error', 'Cache: Redis reconnection failed: ' . $e->getMessage()); return false; } } }