From ac638229779f3bdf5b8af40ac6f57e9545bc7ddc Mon Sep 17 00:00:00 2001 From: Bart Declercq Date: Wed, 15 Jul 2026 11:08:47 +0200 Subject: [PATCH 1/2] Add username support for Sentinel --- Queue/Backend/Redis.php | 9 +++++++-- Queue/Backend/Sentinel.php | 2 +- Queue/Factory.php | 3 ++- SystemSettings.php | 16 ++++++++++++++++ libs/credis/Client.php | 22 +++++++++++++++++----- 5 files changed, 43 insertions(+), 9 deletions(-) diff --git a/Queue/Backend/Redis.php b/Queue/Backend/Redis.php index e9d6f9c..a3bd01d 100644 --- a/Queue/Backend/Redis.php +++ b/Queue/Backend/Redis.php @@ -23,6 +23,7 @@ class Redis implements Backend protected $port; protected $timeout; protected $password; + protected $username; /** * @var int @@ -264,7 +265,7 @@ protected function connect() $success = $this->redis->connect($this->host, $this->port, $this->timeout, null, 100); if ($success && !empty($this->password)) { - $success = $this->redis->auth($this->password); + $success = $this->redis->auth($this->password, $this->username); } if (!empty($this->database) || 0 === $this->database) { @@ -279,7 +280,8 @@ public function setConfig( $port, $timeout, #[\SensitiveParameter] - $password + $password, + $username = null ) { $this->disconnect(); @@ -290,6 +292,9 @@ public function setConfig( if (!empty($password)) { $this->password = $password; } + if (!empty($username)) { + $this->username = $username; + } } private function disconnect() diff --git a/Queue/Backend/Sentinel.php b/Queue/Backend/Sentinel.php index fc73d8a..90b1c0d 100644 --- a/Queue/Backend/Sentinel.php +++ b/Queue/Backend/Sentinel.php @@ -52,7 +52,7 @@ protected function connect() $this->timeout = 0.05; } - $client = new \Credis_Client($master[0], $master[1], $this->timeout, $persistent = false, $this->database, $this->password); + $client = new \Credis_Client($master[0], $master[1], $this->timeout, $persistent = false, $this->database, $this->password, $this->username); $client->connect(); $this->redis = $client; diff --git a/Queue/Factory.php b/Queue/Factory.php index e2d4c28..8b5d6b9 100644 --- a/Queue/Factory.php +++ b/Queue/Factory.php @@ -61,6 +61,7 @@ public static function makeBackendFromSettings(SystemSettings $settings) $host = $settings->redisHost->getValue(); $port = $settings->redisPort->getValue(); $timeout = $settings->redisTimeout->getValue(); + $username = $settings->redisUsername->getValue(); $password = $settings->redisPassword->getValue(); $database = $settings->redisDatabase->getValue(); @@ -83,7 +84,7 @@ public static function makeBackendFromSettings(SystemSettings $settings) $redis->setDatabase($database); } - $redis->setConfig($host, $port, $timeout, $password); + $redis->setConfig($host, $port, $timeout, $password, $username); return $redis; } } diff --git a/SystemSettings.php b/SystemSettings.php index d8722b4..c5fe896 100644 --- a/SystemSettings.php +++ b/SystemSettings.php @@ -39,6 +39,9 @@ class SystemSettings extends \Piwik\Settings\Plugin\SystemSettings /** @var Setting */ public $redisDatabase; + /** @var Setting */ + public $redisUsername; + /** @var Setting */ public $redisPassword; @@ -90,6 +93,7 @@ protected function init() $this->redisHost = $this->createRedisHostSetting(); $this->redisPort = $this->createRedisPortSetting(); $this->redisTimeout = $this->createRedisTimeoutSetting(); + $this->redisUsername = $this->createRedisUsernameSetting(); $this->redisDatabase = $this->createRedisDatabaseSetting(); $this->redisPassword = $this->createRedisPasswordSetting(); $this->queueEnabled = $this->createQueueEnabledSetting(); @@ -235,6 +239,18 @@ private function createNumberOfQueueWorkerSetting() return $numQueueWorkers; } + private function createRedisUsernameSetting() + { + return $this->makeSetting('redisUsername', $default = '', FieldConfig::TYPE_STRING, function (FieldConfig $field) { + $field->title = 'Redis Username'; + $field->condition = 'backend=="redis"'; + $field->uiControl = FieldConfig::UI_CONTROL_TEXT; + $field->uiControlAttributes = array('size' => 128); + $field->inlineHelp = 'Username for Redis ACL authentication. Leave empty if not used.'; + $field->validators[] = new CharacterLength(null, 128); + }); + } + private function createRedisPasswordSetting() { return $this->makeSetting('redisPassword', $default = '', FieldConfig::TYPE_STRING, function (FieldConfig $field) { diff --git a/libs/credis/Client.php b/libs/credis/Client.php index 18ac947..baabb2c 100755 --- a/libs/credis/Client.php +++ b/libs/credis/Client.php @@ -269,6 +269,11 @@ class Credis_Client { */ protected $isWatching = FALSE; + /** + * @var string + */ + protected $authUsername; + /** * @var string */ @@ -312,7 +317,7 @@ class Credis_Client { * @param int $db The selected datbase of the Redis server * @param string $password The authentication password of the Redis server */ - public function __construct($host = '127.0.0.1', $port = 6379, $timeout = null, $persistent = '', $db = 0, $password = null) + public function __construct($host = '127.0.0.1', $port = 6379, $timeout = null, $persistent = '', $db = 0, $password = null, $username = null) { $this->host = (string) $host; $this->port = (int) $port; @@ -320,6 +325,7 @@ public function __construct($host = '127.0.0.1', $port = 6379, $timeout = null, $this->timeout = $timeout; $this->persistent = (string) $persistent; $this->standalone = ! extension_loaded('redis'); + $this->authUsername = $username; $this->authPassword = $password; $this->selectedDb = (int)$db; $this->convertHost(); @@ -502,7 +508,7 @@ public function connect() } if($this->authPassword) { - $this->auth($this->authPassword); + $this->auth($this->authPassword, $this->authUsername); } if($this->selectedDb !== 0) { $this->select($this->selectedDb); @@ -637,12 +643,18 @@ public function getRenamedCommand($command) /** * @param string $password + * @param string|null $username * @return bool */ - public function auth($password) + public function auth($password, $username = null) { - $response = $this->__call('auth', array($password)); + if ($username !== null) { + $response = $this->__call('auth', array($username, $password)); + } else { + $response = $this->__call('auth', array($password)); + } $this->authPassword = $password; + $this->authUsername = $username; return $response; } @@ -1254,7 +1266,7 @@ protected function write_command($command) $this->close(true); $this->connect(); if($this->authPassword) { - $this->auth($this->authPassword); + $this->auth($this->authPassword, $this->authUsername); } if($this->selectedDb != 0) { $this->select($this->selectedDb); From ad1bf73adb0e759747893089554a80c2c15f45a0 Mon Sep 17 00:00:00 2001 From: Bart Declercq Date: Wed, 15 Jul 2026 11:47:25 +0200 Subject: [PATCH 2/2] Add updated credis library --- libs/credis/Client.php | 978 +++++++++++++++++++++--------------- libs/credis/Cluster.php | 517 ++++++++----------- libs/credis/LICENSE | 0 libs/credis/Module.php | 2 +- libs/credis/README.markdown | 165 ++---- libs/credis/Sentinel.php | 184 ++++--- libs/credis/composer.json | 13 +- 7 files changed, 933 insertions(+), 926 deletions(-) mode change 100755 => 100644 libs/credis/Cluster.php mode change 100755 => 100644 libs/credis/LICENSE mode change 100755 => 100644 libs/credis/README.markdown mode change 100755 => 100644 libs/credis/Sentinel.php mode change 100755 => 100644 libs/credis/composer.json diff --git a/libs/credis/Client.php b/libs/credis/Client.php index baabb2c..f7b3317 100755 --- a/libs/credis/Client.php +++ b/libs/credis/Client.php @@ -1,4 +1,5 @@ 'del', 'getkeys' => 'keys', 'sremove' => 'srem'); /** - * @var array + * @var array|callable|null */ protected $renamedCommands; @@ -305,32 +311,66 @@ class Credis_Client { */ protected $subscribed = false; + /** @var bool */ + protected $oldPhpRedis = false; + + /** @var array */ + protected $tlsOptions = []; + /** - * Creates a Redisent connection to the Redis server on host {@link $host} and port {@link $port}. + * @var bool + */ + protected $isTls = false; + + /** + * Gets Useful Meta debug information about the SSL + * + * @return array|null + */ + public function getSslMeta() + { + return $this->sslMeta; + } + + /** + * Creates a connection to the Redis server on host {@link $host} and port {@link $port}. * $host may also be a path to a unix socket or a string in the form of tcp://[hostname]:[port] or unix://[path] * * @param string $host The hostname of the Redis server - * @param integer $port The port number of the Redis server - * @param float $timeout Timeout period in seconds - * @param string $persistent Flag to establish persistent connection - * @param int $db The selected datbase of the Redis server - * @param string $password The authentication password of the Redis server + * @param int|null $port The port number of the Redis server + * @param float|null $timeout Timeout period in seconds + * @param string $persistent Flag to establish persistent connection + * @param int $db The selected database of the Redis server + * @param string|null $password The authentication password of the Redis server + * @param string|null $username The authentication username of the Redis server + * @param array|null $tlsOptions The TLS/SSL context options. See https://www.php.net/manual/en/context.ssl.php for details + * @throws CredisException */ - public function __construct($host = '127.0.0.1', $port = 6379, $timeout = null, $persistent = '', $db = 0, $password = null, $username = null) + public function __construct($host = '127.0.0.1', $port = 6379, $timeout = null, $persistent = '', $db = 0, $password = null, $username = null, $tlsOptions = null) { - $this->host = (string) $host; - $this->port = (int) $port; + $this->host = (string)$host; + if ($port !== null) { + $this->port = (int)$port; + } $this->scheme = null; $this->timeout = $timeout; - $this->persistent = (string) $persistent; - $this->standalone = ! extension_loaded('redis'); - $this->authUsername = $username; + $this->persistent = (string)$persistent; + $this->standalone = !extension_loaded('redis'); $this->authPassword = $password; + $this->authUsername = $username; $this->selectedDb = (int)$db; $this->convertHost(); - if ($this->scheme == 'tls') { - // PHP Redis extension doesn't work with TLS + if (is_array($tlsOptions) && count($tlsOptions) !== 0) { + $this->setTlsOptions($tlsOptions); + } + // PHP Redis extension support TLS/ACL AUTH since 5.3.0 + $this->oldPhpRedis = (bool)version_compare(phpversion('redis'), '5.3.0', '<'); + if (( + $this->isTls + || $this->authUsername !== null + ) + && !$this->standalone && $this->oldPhpRedis) { $this->standalone = true; } } @@ -347,7 +387,7 @@ public function __destruct() */ public function isSubscribed() { - return $this->subscribed; + return $this->subscribed; } /** @@ -358,15 +398,24 @@ public function getHost() { return $this->host; } + /** * Return the port of the Redis instance - * @return int + * @return int|null */ public function getPort() { return $this->port; } + /** + * @return bool + */ + public function isTls() + { + return $this->isTls; + } + /** * Return the selected database * @return int @@ -375,6 +424,7 @@ public function getSelectedDb() { return $this->selectedDb; } + /** * @return string */ @@ -382,19 +432,20 @@ public function getPersistence() { return $this->persistent; } + /** - * @throws CredisException * @return Credis_Client + * @throws CredisException */ public function forceStandalone() { if ($this->standalone) { return $this; } - if($this->connected) { + if ($this->connected) { throw new CredisException('Cannot force Credis_Client to use standalone PHP driver after a connection has already been established.'); } - $this->standalone = TRUE; + $this->standalone = true; return $this; } @@ -417,37 +468,54 @@ public function setCloseOnDestruct($flag) $this->closeOnDestruct = $flag; return $this; } + + /** + * @throws CredisException + */ + public function setTlsOptions(array $tlsOptions) + { + if ($this->connected) { + throw new CredisException('Cannot change TLS options after a connection has already been established.'); + } + $this->tlsOptions = $tlsOptions; + } + + /** + * @throws CredisException + */ protected function convertHost() { - if (preg_match('#^(tcp|tls|unix)://(.*)$#', $this->host, $matches)) { - if($matches[1] == 'tcp' || $matches[1] == 'tls') { + if (preg_match('#^(tcp|tls|ssl|tlsv\d(?:\.\d)?|unix)://(.+)$#', $this->host, $matches)) { + $this->isTls = strpos($matches[1], 'tls') === 0 || strpos($matches[1], 'ssl') === 0; + if ($this->isTls || $matches[1] === 'tcp') { $this->scheme = $matches[1]; - if ( ! preg_match('#^([^:]+)(:([0-9]+))?(/(.+))?$#', $matches[2], $matches)) { - throw new CredisException('Invalid host format; expected '.$this->scheme.'://host[:port][/persistence_identifier]'); + if (!preg_match('#^([^:]+)(:([0-9]+))?(/(.+))?$#', $matches[2], $matches)) { + throw new CredisException('Invalid host format; expected ' . $this->scheme . '://host[:port][/persistence_identifier]'); } $this->host = $matches[1]; - $this->port = (int) (isset($matches[3]) ? $matches[3] : 6379); - $this->persistent = isset($matches[5]) ? $matches[5] : ''; + $this->port = (int)($matches[3] ?? $this->port); + $this->persistent = $matches[5] ?? $this->persistent; } else { $this->host = $matches[2]; - $this->port = NULL; + $this->port = null; $this->scheme = 'unix'; - if (substr($this->host,0,1) != '/') { + if (substr($this->host, 0, 1) != '/') { throw new CredisException('Invalid unix socket format; expected unix:///path/to/redis.sock'); } } } - if ($this->port !== NULL && substr($this->host,0,1) == '/') { - $this->port = NULL; + if ($this->port !== null && substr($this->host, 0, 1) == '/') { + $this->port = null; $this->scheme = 'unix'; } if (!$this->scheme) { $this->scheme = 'tcp'; } } + /** - * @throws CredisException * @return Credis_Client + * @throws CredisException */ public function connect() { @@ -455,32 +523,56 @@ public function connect() return $this; } $this->close(true); - + $tlsOptions = $this->isTls ? $this->tlsOptions : []; if ($this->standalone) { $flags = STREAM_CLIENT_CONNECT; - $remote_socket = $this->port === NULL - ? $this->scheme.'://'.$this->host - : $this->scheme.'://'.$this->host.':'.$this->port; - if ($this->persistent && $this->port !== NULL) { + $remote_socket = $this->port === null + ? $this->scheme . '://' . $this->host + : $this->scheme . '://' . $this->host . ':' . $this->port; + if ($this->persistent && $this->port !== null) { // Persistent connections to UNIX sockets are not supported - $remote_socket .= '/'.$this->persistent; + $remote_socket .= '/' . $this->persistent; $flags = $flags | STREAM_CLIENT_PERSISTENT; } - $result = $this->redis = @stream_socket_client($remote_socket, $errno, $errstr, $this->timeout !== null ? $this->timeout : 2.5, $flags); - } - else { - if ( ! $this->redis) { - $this->redis = new Redis; + if ($this->isTls) { + $tlsOptions = array_merge($tlsOptions, [ + 'capture_peer_cert' => true, + 'capture_peer_cert_chain' => true, + 'capture_session_meta' => true, + ]); + } + + // passing $context as null errors before php 8.0 + $context = stream_context_create(['ssl' => $tlsOptions]); + + $result = $this->redis = @stream_socket_client($remote_socket, $errno, $errstr, $this->timeout !== null ? $this->timeout : 2.5, $flags, $context); + + if ($result && $this->isTls) { + $this->sslMeta = stream_context_get_options($context); } - $socketTimeout = $this->timeout ? $this->timeout : 0.0; - try - { - $result = $this->persistent - ? $this->redis->pconnect($this->host, $this->port, $socketTimeout, $this->persistent) - : $this->redis->connect($this->host, $this->port, $socketTimeout); + } else { + if (!$this->redis) { + $this->redis = new Redis(); } - catch(Exception $e) - { + $socketTimeout = $this->timeout ?: 0.0; + try { + if ($this->oldPhpRedis) { + $result = $this->persistent + ? $this->redis->pconnect($this->host, (int)$this->port, $socketTimeout, $this->persistent) + : $this->redis->connect($this->host, (int)$this->port, $socketTimeout); + } else { + // 7th argument is non-documented TLS options. But it only exists on the newer versions of phpredis + if ($tlsOptions) { + $context = ['stream' => $tlsOptions]; + } else { + $context = []; + } + /** @noinspection PhpMethodParametersCountMismatchInspection */ + $result = $this->persistent + ? $this->redis->pconnect($this->scheme . '://' . $this->host, (int)$this->port, $socketTimeout, $this->persistent, 0, 0.0, $context) + : $this->redis->connect($this->scheme . '://' . $this->host, (int)$this->port, $socketTimeout, null, 0, 0.0, $context); + } + } catch (Exception $e) { // Some applications will capture the php error that phpredis can sometimes generate and throw it as an Exception $result = false; $errno = 1; @@ -489,32 +581,39 @@ public function connect() } // Use recursion for connection retries - if ( ! $result) { + if (!$result) { $this->connectFailures++; if ($this->connectFailures <= $this->maxConnectRetries) { return $this->connect(); } $failures = $this->connectFailures; $this->connectFailures = 0; - throw new CredisException("Connection to Redis {$this->host}:{$this->port} failed after $failures failures." . (isset($errno) && isset($errstr) ? "Last Error : ({$errno}) {$errstr}" : "")); + throw new CredisException(sprintf( + "Connection to Redis%s %s://%s failed after %s failures.%s", + $this->standalone ? ' standalone' : '', + $this->scheme, + $this->host . ($this->port ? ':' . $this->port : ''), + $failures, + (isset($errno) && isset($errstr) ? "Last Error : ({$errno}) {$errstr}" : "") + )); } $this->connectFailures = 0; - $this->connected = TRUE; + $this->connected = true; // Set read timeout if ($this->readTimeout) { $this->setReadTimeout($this->readTimeout); } - - if($this->authPassword) { + if ($this->authPassword) { $this->auth($this->authPassword, $this->authUsername); } - if($this->selectedDb !== 0) { + if ($this->selectedDb !== 0) { $this->select($this->selectedDb); } return $this; } + /** * @return bool */ @@ -522,13 +621,14 @@ public function isConnected() { return $this->connected; } + /** * Set the read timeout for the connection. Use 0 to disable timeouts entirely (or use a very long timeout * if not supported). * - * @param int $timeout 0 (or -1) for no timeout, otherwise number of seconds - * @throws CredisException + * @param float $timeout 0 (or -1) for no timeout, otherwise number of seconds * @return Credis_Client + * @throws CredisException */ public function setReadTimeout($timeout) { @@ -539,13 +639,17 @@ public function setReadTimeout($timeout) if ($this->isConnected()) { if ($this->standalone) { $timeout = $timeout <= 0 ? 315360000 : $timeout; // Ten-year timeout - stream_set_blocking($this->redis, TRUE); - stream_set_timeout($this->redis, (int) floor($timeout), ($timeout - floor($timeout)) * 1000000); - } else if (defined('Redis::OPT_READ_TIMEOUT')) { + stream_set_blocking($this->redis, true); + stream_set_timeout($this->redis, (int)floor($timeout), ($timeout - floor($timeout)) * 1000000); + } elseif (defined('Redis::OPT_READ_TIMEOUT')) { // supported in phpredis 2.2.3 - // a timeout value of -1 means reads will not timeout + // a timeout value of -1 means reads will not time out $timeout = $timeout == 0 ? -1 : $timeout; - $this->redis->setOption(Redis::OPT_READ_TIMEOUT, $timeout); + try { + $this->redis->setOption(Redis::OPT_READ_TIMEOUT, $timeout); + } catch (RedisException $e) { + throw new CredisException($e->getMessage(), $e->getCode(), $e); + } } } return $this; @@ -554,10 +658,10 @@ public function setReadTimeout($timeout) /** * @return bool */ - public function close($force = FALSE) + public function close($force = false) { - $result = TRUE; - if ($this->redis && ($force || $this->connected && ! $this->persistent)) { + $result = true; + if ($this->redis && ($force || $this->connected && !$this->persistent)) { try { if (is_callable(array($this->redis, 'close'))) { $this->redis->close(); @@ -566,9 +670,10 @@ public function close($force = FALSE) $this->redis = null; } } catch (Exception $e) { - ; // Ignore exceptions on close + // Ignore exceptions on close + $result = false; } - $this->connected = $this->usePipeline = $this->isMulti = $this->isWatching = FALSE; + $this->connected = $this->usePipeline = $this->isMulti = $this->isWatching = false; } return $result; } @@ -584,16 +689,17 @@ public function close($force = FALSE) * @param string|callable|array $command * @param string|null $alias * @return $this + * @throws CredisException */ - public function renameCommand($command, $alias = NULL) + public function renameCommand($command, $alias = null) { - if ( ! $this->standalone) { + if (!$this->standalone) { $this->forceStandalone(); } - if ($alias === NULL) { + if ($alias === null) { $this->renamedCommands = $command; } else { - if ( ! $this->renamedCommands) { + if (!$this->renamedCommands) { $this->renamedCommands = array(); } $this->renamedCommands[$command] = $alias; @@ -610,12 +716,12 @@ public function getRenamedCommand($command) static $map; // Command renaming not enabled - if ($this->renamedCommands === NULL) { + if ($this->renamedCommands === null) { return $command; } // Initialize command map - if ($map === NULL) { + if ($map === null) { if (is_array($this->renamedCommands)) { $map = $this->renamedCommands; } else { @@ -624,17 +730,15 @@ public function getRenamedCommand($command) } // Generate and return cached result - if ( ! isset($map[$command])) { + if (!isset($map[$command])) { // String means all commands are hashed with salted md5 if (is_string($this->renamedCommands)) { - $map[$command] = md5($this->renamedCommands.$command); - } - // Would already be set in $map if it was intended to be renamed - else if (is_array($this->renamedCommands)) { + $map[$command] = md5($this->renamedCommands . $command); + } // Would already be set in $map if it was intended to be renamed + elseif (is_array($this->renamedCommands)) { return $command; - } - // User-supplied function - else if (is_callable($this->renamedCommands)) { + } // User-supplied function + elseif (is_callable($this->renamedCommands)) { $map[$command] = call_user_func($this->renamedCommands, $command); } } @@ -645,85 +749,110 @@ public function getRenamedCommand($command) * @param string $password * @param string|null $username * @return bool + * @throws CredisException */ public function auth($password, $username = null) { if ($username !== null) { $response = $this->__call('auth', array($username, $password)); + $this->authUsername = $username; } else { $response = $this->__call('auth', array($password)); } $this->authPassword = $password; - $this->authUsername = $username; return $response; } /** * @param int $index * @return bool + * @throws CredisException */ public function select($index) { $response = $this->__call('select', array($index)); - $this->selectedDb = (int) $index; + $this->selectedDb = (int)$index; return $response; } /** - * @param string|array $pattern + * @param string $caller + * @return void + * @throws CredisException + */ + protected function assertNotPipelineOrMulti($caller) + { + if ($this->standalone && ($this->isMulti || $this->usePipeline) || + // phpredis triggers a php fatal error, so do the check before + !$this->standalone && ($this->redis->getMode() === Redis::MULTI || $this->redis->getMode() === Redis::PIPELINE)) { + throw new CredisException('multi()/pipeline() mode can not be used with '.$caller); + } + } + + /** + * @param string|array ...$args * @return array + * @throws CredisException */ - public function pUnsubscribe() + public function pUnsubscribe(...$args) { - list($command, $channel, $subscribedChannels) = $this->__call('punsubscribe', func_get_args()); - $this->subscribed = $subscribedChannels > 0; - return array($command, $channel, $subscribedChannels); + list($command, $channel, $subscribedChannels) = $this->__call('punsubscribe', $args); + $this->subscribed = $subscribedChannels > 0; + return array($command, $channel, $subscribedChannels); } /** - * @param int $Iterator + * @param ?int $Iterator * @param string $pattern * @param int $count * @return bool|array + * @throws CredisException */ public function scan(&$Iterator, $pattern = null, $count = null) { + $this->assertNotPipelineOrMulti(__METHOD__); return $this->__call('scan', array(&$Iterator, $pattern, $count)); } /** - * @param int $Iterator - * @param string $field - * @param string $pattern - * @param int $count - * @return bool|array - */ - public function hscan(&$Iterator, $field, $pattern = null, $count = null) - { - return $this->__call('hscan', array($field, &$Iterator, $pattern, $count)); - } + * @param ?int $Iterator + * @param string $field + * @param string $pattern + * @param int $count + * @return bool|array + * @throws CredisException + */ + public function hscan(&$Iterator, $field, $pattern = null, $count = null) + { + $this->assertNotPipelineOrMulti(__METHOD__); + return $this->__call('hscan', array($field, &$Iterator, $pattern, $count)); + } /** - * @param int $Iterator + * @param ?int $Iterator * @param string $field * @param string $pattern - * @param int $Iterator + * @param ?int $count * @return bool|array + * @throws CredisException */ public function sscan(&$Iterator, $field, $pattern = null, $count = null) { + $this->assertNotPipelineOrMulti(__METHOD__); return $this->__call('sscan', array($field, &$Iterator, $pattern, $count)); } /** - * @param int $Iterator + * @param ?int $Iterator * @param string $field * @param string $pattern - * @param int $Iterator + * @param ?int $count * @return bool|array + * @throws CredisException */ public function zscan(&$Iterator, $field, $pattern = null, $count = null) { + $this->assertNotPipelineOrMulti(__METHOD__); return $this->__call('zscan', array($field, &$Iterator, $pattern, $count)); } @@ -735,7 +864,7 @@ public function zscan(&$Iterator, $field, $pattern = null, $count = null) */ public function pSubscribe($patterns, $callback) { - if ( ! $this->standalone) { + if (!$this->standalone) { return $this->__call('pSubscribe', array((array)$patterns, $callback)); } @@ -748,7 +877,7 @@ public function pSubscribe($patterns, $callback) list($command, $pattern, $status) = $this->__call('psubscribe', array($patterns)); } $this->subscribed = $status > 0; - if ( ! $status) { + if (!$status) { throw new CredisException('Invalid pSubscribe response.'); } } @@ -763,25 +892,26 @@ public function pSubscribe($patterns, $callback) } /** - * @param string|array $pattern + * @param string|array ...$args * @return array + * @throws CredisException */ - public function unsubscribe() + public function unsubscribe(...$args) { - list($command, $channel, $subscribedChannels) = $this->__call('unsubscribe', func_get_args()); - $this->subscribed = $subscribedChannels > 0; - return array($command, $channel, $subscribedChannels); + list($command, $channel, $subscribedChannels) = $this->__call('unsubscribe', $args); + $this->subscribed = $subscribedChannels > 0; + return array($command, $channel, $subscribedChannels); } /** * @param string|array $channels * @param $callback - * @throws CredisException * @return $this|array|bool|Credis_Client|mixed|null|string + * @throws CredisException */ public function subscribe($channels, $callback) { - if ( ! $this->standalone) { + if (!$this->standalone) { return $this->__call('subscribe', array((array)$channels, $callback)); } @@ -794,7 +924,7 @@ public function subscribe($channels, $callback) list($command, $channel, $status) = $this->__call('subscribe', array($channels)); } $this->subscribed = $status > 0; - if ( ! $status) { + if (!$status) { throw new CredisException('Invalid subscribe response.'); } } @@ -811,12 +941,33 @@ public function subscribe($channels, $callback) /** * @param string|null $name * @return string|Credis_Client + * @throws CredisException */ public function ping($name = null) { - return $this->__call('ping', $name ? array($name) : array()); + return $this->__call('ping', $name ? array($name) : array()); + } + + /** + * @param string $command + * @param array $args + * + * @return array|Credis_Client + * @throws CredisException + */ + public function rawCommand($command, array $args) + { + if ($this->standalone) { + return $this->__call($command, $args); + } else { + \array_unshift($args, $command); + return $this->__call('rawCommand', $args); + } } + /** + * @throws CredisException + */ public function __call($name, $args) { // Lazy connection @@ -825,26 +976,26 @@ public function __call($name, $args) $name = strtolower($name); // Send request via native PHP - if($this->standalone) - { + if ($this->standalone) { + // Early returns should verify how phpredis behaves! $trackedArgs = array(); switch ($name) { case 'eval': case 'evalsha': $script = array_shift($args); - $keys = (array) array_shift($args); - $eArgs = (array) array_shift($args); + $keys = (array)array_shift($args); + $eArgs = (array)array_shift($args); $args = array($script, count($keys), $keys, $eArgs); break; case 'zinterstore': case 'zunionstore': $dest = array_shift($args); - $keys = (array) array_shift($args); + $keys = (array)array_shift($args); $weights = array_shift($args); $aggregate = array_shift($args); $args = array($dest, count($keys), $keys); if ($weights) { - $args[] = (array) $weights; + $args[] = (array)$weights; } if ($aggregate) { $args[] = $aggregate; @@ -858,9 +1009,9 @@ public function __call($name, $args) } elseif (count($args) === 3 && is_array($args[2])) { $tmp_args = $args; $args = array($tmp_args[0], $tmp_args[1]); - foreach ($tmp_args[2] as $k=>$v) { + foreach ($tmp_args[2] as $k => $v) { if (is_string($k)) { - $args[] = array($k,$v); + $args[] = array($k, $v); } elseif (is_int($k)) { $args[] = $v; } @@ -870,18 +1021,17 @@ public function __call($name, $args) break; case 'scan': $trackedArgs = array(&$args[0]); - if (empty($trackedArgs[0])) - { + if ($trackedArgs[0] === null) { $trackedArgs[0] = 0; + } elseif ($trackedArgs[0] === 0) { + return false; } $eArgs = array($trackedArgs[0]); - if (!empty($args[1])) - { + if (!empty($args[1])) { $eArgs[] = 'MATCH'; $eArgs[] = $args[1]; } - if (!empty($args[2])) - { + if (!empty($args[2])) { $eArgs[] = 'COUNT'; $eArgs[] = $args[2]; } @@ -890,24 +1040,23 @@ public function __call($name, $args) case 'sscan': case 'zscan': case 'hscan': - $trackedArgs = array(&$args[1]); - if (empty($trackedArgs[0])) - { - $trackedArgs[0] = 0; - } - $eArgs = array($args[0],$trackedArgs[0]); - if (!empty($args[2])) - { - $eArgs[] = 'MATCH'; - $eArgs[] = $args[2]; - } - if (!empty($args[3])) - { - $eArgs[] = 'COUNT'; - $eArgs[] = $args[3]; - } - $args = $eArgs; - break; + $trackedArgs = array(&$args[1]); + if ($trackedArgs[0] === null) { + $trackedArgs[0] = 0; + } elseif ($trackedArgs[0] === 0) { + return false; + } + $eArgs = array($args[0], $trackedArgs[0]); + if (!empty($args[2])) { + $eArgs[] = 'MATCH'; + $eArgs[] = $args[2]; + } + if (!empty($args[3])) { + $eArgs[] = 'COUNT'; + $eArgs[] = $args[3]; + } + $args = $eArgs; + break; case 'zrangebyscore': case 'zrevrangebyscore': case 'zrange': @@ -926,17 +1075,17 @@ public function __call($name, $args) } break; case 'mget': - if (isset($args[0]) && is_array($args[0])) - { + if (isset($args[0]) && is_array($args[0])) { $args = array_values($args[0]); } + if (is_array($args) && count($args) === 0) { + return ($this->isMulti || $this->usePipeline) ? $this : false; + } break; case 'hmset': - if (isset($args[1]) && is_array($args[1])) - { + if (isset($args[1]) && is_array($args[1])) { $cArgs = array(); - foreach($args[1] as $id => $value) - { + foreach ($args[1] as $id => $value) { $cArgs[] = $id; $cArgs[] = $value; } @@ -951,121 +1100,150 @@ public function __call($name, $args) break; case 'hmget': // hmget needs to track the keys for rehydrating the results - if (isset($args[1])) - { + if (isset($args[1])) { $trackedArgs = $args[1]; } break; + case 'multi': + // calling multi() multiple times is a no-op + if ($this->isMulti) { + return $this; + } + break; } // Flatten arguments $args = self::_flattenArguments($args); // In pipeline mode - if($this->usePipeline) - { - if($name === 'pipeline') { + if ($this->usePipeline) { + if ($name === 'pipeline') { throw new CredisException('A pipeline is already in use and only one pipeline is supported.'); - } - else if($name === 'exec') { - if($this->isMulti) { - $this->commandNames[] = array($name, $trackedArgs); + } elseif ($name === 'exec') { + if ($this->isMulti) { + $this->commandNames[] = array($name, $trackedArgs, true); $this->commands .= self::_prepare_command(array($this->getRenamedCommand($name))); } - // Write request - if($this->commands) { - $this->write_command($this->commands); - } - $this->commands = NULL; - - // Read response - $queuedResponses = array(); - $response = array(); - foreach($this->commandNames as $command) { - list($name, $arguments) = $command; - $result = $this->read_reply($name, true); - if ($result !== null) - { - $result = $this->decode_reply($name, $result, $arguments); + try { + // Write request + if ($this->commands) { + $this->write_command($this->commands); } - else - { - $queuedResponses[] = $command; + + // Read response + $queuedResponses = array(); + $response = array(); + foreach ($this->commandNames as $command) { + list($name, $arguments, $requireDispatch) = $command; + if (!$requireDispatch) { + $queuedResponses[] = $command; + continue; + } + $result = $this->read_reply($name, true); + if ($result !== null) { + if ($name === 'multi') { + continue; + } + $result = $this->decode_reply($name, $result, $arguments); + $response[] = $result; + } else { + $queuedResponses[] = $command; + } } - $response[] = $result; - } - if($this->isMulti) { - $response = array_pop($response); - foreach($queuedResponses as $key => $command) - { - list($name, $arguments) = $command; - $response[$key] = $this->decode_reply($name, $response[$key], $arguments); + if ($this->isMulti) { + $execResponse = array_pop($response); + if (!empty($execResponse)) { + foreach ($queuedResponses as $key => $command) { + list($name, $arguments) = $command; + $response[] = $this->decode_reply($name, $execResponse[$key], $arguments); + } + } } + } catch (CredisException $e) { + // the connection on redis's side is likely in a bad state, force it closed to abort the pipeline/transaction + $this->close(true); + throw $e; + } finally { + $this->commands = $this->commandNames = null; + $this->isMulti = $this->usePipeline = false; } - - $this->commandNames = NULL; - $this->usePipeline = $this->isMulti = FALSE; return $response; - } - else if ($name === 'discard') - { - $this->commands = NULL; - $this->commandNames = NULL; - $this->usePipeline = $this->isMulti = FALSE; - } - else { - if($name === 'multi') { - $this->isMulti = TRUE; + } elseif ($name === 'discard') { + $this->commands = null; + $this->commandNames = null; + $this->usePipeline = $this->isMulti = false; + } else { + if ($name === 'multi') { + $this->isMulti = true; } array_unshift($args, $this->getRenamedCommand($name)); - $this->commandNames[] = array($name, $trackedArgs); + $this->commandNames[] = array($name, $trackedArgs, true); $this->commands .= self::_prepare_command($args); return $this; } } // Start pipeline mode - if($name === 'pipeline') - { - $this->usePipeline = TRUE; - $this->commandNames = array(); + if ($name === 'pipeline') { + $this->usePipeline = true; + if (!$this->isMulti) { + $this->commandNames = []; + } $this->commands = ''; return $this; } // If unwatching, allow reconnect with no error thrown - if($name === 'unwatch') { - $this->isWatching = FALSE; + if ($name === 'unwatch') { + $this->isWatching = false; } // Non-pipeline mode array_unshift($args, $this->getRenamedCommand($name)); $command = self::_prepare_command($args); - $this->write_command($command); - $response = $this->read_reply($name); - $response = $this->decode_reply($name, $response, $trackedArgs); + // transaction mode needs to track commands + if ($this->isMulti) { + try { + if ($name === 'exec' || $name === 'discard') { + try { + $this->write_command($command); + $response = $this->read_reply($name); + $response = $this->decode_reply($name, $response, $trackedArgs); + } finally { + $this->isMulti = false; + $this->commandNames = []; + } + } else { + $this->commandNames[] = array($name, $trackedArgs, false); + $this->write_command($command); + $response = $this->read_reply($name); + } + } catch (CredisException $e) { + // the connection on redis's side is likely in a bad state, force it closed to abort the transaction + $this->isMulti = false; + $this->commandNames = []; + $this->close(true); + throw $e; + } + } else { + $this->write_command($command); + $response = $this->read_reply($name); + $response = $this->decode_reply($name, $response, $trackedArgs); + } // Watch mode disables reconnect so error is thrown - if($name == 'watch') { - $this->isWatching = TRUE; - } - // Transaction mode - else if($this->isMulti && ($name == 'exec' || $name == 'discard')) { - $this->isMulti = FALSE; - } - // Started transaction - else if($this->isMulti || $name == 'multi') { - $this->isMulti = TRUE; + if ($name === 'watch') { + $this->isWatching = true; + } // Started transaction + elseif ($this->isMulti || $name === 'multi') { + $this->isMulti = true; $response = $this; } - } - - // Send request via phpredis client - else - { + } // Send request via phpredis client + else { // Tweak arguments - switch($name) { + switch ($name) { case 'get': // optimize common cases case 'set': case 'hget': @@ -1075,14 +1253,12 @@ public function __call($name, $args) case 'msetnx': case 'hmset': case 'hmget': - case 'del': case 'zrangebyscore': case 'zrevrangebyscore': - break; + break; case 'zrange': case 'zrevrange': - if (isset($args[3]) && is_array($args[3])) - { + if (isset($args[3]) && is_array($args[3])) { $cArgs = $args[3]; $args[3] = !empty($cArgs['withscores']); } @@ -1093,18 +1269,18 @@ public function __call($name, $args) $cArgs = array(); $cArgs[] = array_shift($args); // destination $cArgs[] = array_shift($args); // keys - if(isset($args[0]) and isset($args[0]['weights'])) { - $cArgs[] = (array) $args[0]['weights']; + if (isset($args[0]) and isset($args[0]['weights'])) { + $cArgs[] = (array)$args[0]['weights']; } else { $cArgs[] = null; } - if(isset($args[0]) and isset($args[0]['aggregate'])) { + if (isset($args[0]) and isset($args[0]['aggregate'])) { $cArgs[] = strtoupper($args[0]['aggregate']); } $args = $cArgs; break; case 'mget': - if(isset($args[0]) && ! is_array($args[0])) { + if (isset($args[0]) && !is_array($args[0])) { $args = array($args); } break; @@ -1139,6 +1315,10 @@ public function __call($name, $args) // allow phpredis to see the caller's reference //$param_ref =& $args[0]; break; + case 'auth': + // For phpredis pre-v5.3, the type signature is string, not array|string + $args = $this->oldPhpRedis ? $args : array($args); + break; default: // Flatten arguments $args = self::_flattenArguments($args); @@ -1146,34 +1326,31 @@ public function __call($name, $args) try { // Proxy pipeline mode to the phpredis library - if($name == 'pipeline' || $name == 'multi') { - if($this->isMulti) { - return $this; - } else { - $this->isMulti = TRUE; + if ($name == 'pipeline' || $name == 'multi') { + if (!$this->isMulti) { + $this->isMulti = true; $this->redisMulti = call_user_func_array(array($this->redis, $name), $args); - return $this; } - } - else if($name == 'exec' || $name == 'discard') { - $this->isMulti = FALSE; + return $this; + } elseif ($name == 'exec' || $name == 'discard') { + $this->isMulti = false; $response = $this->redisMulti->$name(); - $this->redisMulti = NULL; - #echo "> $name : ".substr(print_r($response, TRUE),0,100)."\n"; + $this->redisMulti = null; return $response; } // Use aliases to be compatible with phpredis wrapper - if(isset($this->wrapperMethods[$name])) { + if (isset($this->wrapperMethods[$name])) { $name = $this->wrapperMethods[$name]; } // Multi and pipeline return self for chaining - if($this->isMulti) { + if ($this->isMulti) { call_user_func_array(array($this->redisMulti, $name), $args); return $this; } + // Send request, retry one time when using persistent connections on the first request only $this->requests++; try { @@ -1187,13 +1364,16 @@ public function __call($name, $args) throw $e; } } - } - // Wrap exceptions - catch(RedisException $e) { + } // Wrap exceptions + catch (RedisException $e) { $code = 0; - if ( ! ($result = $this->redis->IsConnected())) { - $this->close(true); - $code = CredisException::CODE_DISCONNECTED; + try { + if (!($result = $this->redis->isConnected())) { + $this->close(true); + $code = CredisException::CODE_DISCONNECTED; + } + } catch (RedisException $e2) { + throw new CredisException($e2->getMessage(), $e2->getCode(), $e2); } throw new CredisException($e->getMessage(), $code, $e); } @@ -1201,83 +1381,93 @@ public function __call($name, $args) #echo "> $name : ".substr(print_r($response, TRUE),0,100)."\n"; // change return values where it is too difficult to minim in standalone mode - switch($name) - { - case 'type': - $typeMap = array( - self::TYPE_NONE, - self::TYPE_STRING, - self::TYPE_SET, - self::TYPE_LIST, - self::TYPE_ZSET, - self::TYPE_HASH, - ); - $response = $typeMap[$response]; - break; - - // Handle scripting errors - case 'eval': - case 'evalsha': - case 'script': - $error = $this->redis->getLastError(); - $this->redis->clearLastError(); - if ($error && substr($error,0,8) == 'NOSCRIPT') { - $response = NULL; - } else if ($error) { - throw new CredisException($error); - } - break; - case 'exists': - // smooth over phpredis-v4 vs earlier difference to match documented credis return results - $response = (int) $response; - break; - case 'ping': - if ($response) { - if ($response === true) { - $response = isset($args[0]) ? $args[0] : "PONG"; - } else if ($response[0] === '+') { - $response = substr($response, 1); - } - } - break; - default: - $error = $this->redis->getLastError(); - $this->redis->clearLastError(); - if ($error) { - throw new CredisException(rtrim($error)); - } - break; + try { + switch ($name) { + case 'type': + $typeMap = array( + self::TYPE_NONE, + self::TYPE_STRING, + self::TYPE_SET, + self::TYPE_LIST, + self::TYPE_ZSET, + self::TYPE_HASH, + ); + $response = $typeMap[$response]; + break; + + case 'eval': + case 'evalsha': + case 'script': + $error = $this->redis->getLastError(); + $this->redis->clearLastError(); + if ($error && substr($error, 0, 8) == 'NOSCRIPT') { + $response = null; + } elseif ($error) { + throw new CredisException($error); + } + break; + case 'exists': + // smooth over phpredis-v4 vs earlier difference to match documented credis return results + $response = (int)$response; + break; + case 'ping': + if ($response) { + if ($response === true) { + $response = $args[0] ?? "PONG"; + } elseif ($response[0] === '+') { + $response = substr($response, 1); + } + } + break; + case 'auth': + if ($response === true) { + $this->redis->clearLastError(); + } + // no break + default: + $error = $this->redis->getLastError(); + $this->redis->clearLastError(); + if ($error) { + throw new CredisException(rtrim($error)); + } + break; + } + } catch (RedisException $e) { + throw new CredisException($e->getMessage(), $e->getCode(), $e); } } return $response; } + /** + * @throws CredisException + */ protected function write_command($command) { // Reconnect on lost connection (Redis server "timeout" exceeded since last command) - if(feof($this->redis)) { + if (feof($this->redis)) { // If a watch or transaction was in progress and connection was lost, throw error rather than reconnect // since transaction/watch state will be lost. - if(($this->isMulti && ! $this->usePipeline) || $this->isWatching) { + if (($this->isMulti && !$this->usePipeline) || $this->isWatching) { $this->close(true); throw new CredisException('Lost connection to Redis server during watch or transaction.'); } $this->close(true); $this->connect(); - if($this->authPassword) { - $this->auth($this->authPassword, $this->authUsername); + if ($this->authPassword) { + $this->auth($this->authPassword); } - if($this->selectedDb != 0) { + if ($this->selectedDb != 0) { $this->select($this->selectedDb); } } $commandLen = strlen($command); - $lastFailed = FALSE; + $lastFailed = false; for ($written = 0; $written < $commandLen; $written += $fwrite) { $fwrite = fwrite($this->redis, substr($command, $written)); - if ($fwrite === FALSE || ($fwrite == 0 && $lastFailed)) { + if ($fwrite === false || ($fwrite == 0 && $lastFailed)) { $this->close(true); throw new CredisException('Failed to write entire command to stream'); } @@ -1285,10 +1475,13 @@ protected function write_command($command) } } + /** + * @throws CredisException + */ protected function read_reply($name = '', $returnQueued = false) { $reply = fgets($this->redis); - if($reply === FALSE) { + if ($reply === false) { $info = stream_get_meta_data($this->redis); $this->close(true); if ($info['timed_out']) { @@ -1297,87 +1490,89 @@ protected function read_reply($name = '', $returnQueued = false) throw new CredisException('Lost connection to Redis server.', CredisException::CODE_DISCONNECTED); } } - $reply = rtrim($reply, CRLF); + $reply = rtrim($reply, "\r\n"); #echo "> $name: $reply\n"; $replyType = substr($reply, 0, 1); switch ($replyType) { /* Error reply */ case '-': - if($this->isMulti || $this->usePipeline) { - $response = FALSE; - } else if ($name == 'evalsha' && substr($reply,0,9) == '-NOSCRIPT') { - $response = NULL; + if ($this->isMulti || $this->usePipeline) { + $response = false; + } elseif ($name == 'evalsha' && substr($reply, 0, 9) == '-NOSCRIPT') { + $response = null; } else { - throw new CredisException(substr($reply,0,4) == '-ERR' ? 'ERR '.substr($reply, 5) : substr($reply,1)); + throw new CredisException(substr($reply, 0, 4) == '-ERR' ? 'ERR ' . substr($reply, 5) : substr($reply, 1)); } break; - /* Inline reply */ + /* Inline reply */ case '+': $response = substr($reply, 1); - if($response == 'OK') { - return TRUE; + if ($response == 'OK') { + return true; } - if($response == 'QUEUED') { + if ($response == 'QUEUED') { return $returnQueued ? null : true; } break; - /* Bulk reply */ + /* Bulk reply */ case '$': - if ($reply == '$-1') return FALSE; - $size = (int) substr($reply, 1); + if ($reply == '$-1') { + return false; + } + $size = (int)substr($reply, 1); $response = stream_get_contents($this->redis, $size + 2); - if( ! $response) { + if (!$response) { $this->close(true); throw new CredisException('Error reading reply.'); } $response = substr($response, 0, $size); break; - /* Multi-bulk reply */ + /* Multi-bulk reply */ case '*': $count = substr($reply, 1); - if ($count == '-1') return FALSE; + if ($count == '-1') { + return false; + } $response = array(); for ($i = 0; $i < $count; $i++) { - $response[] = $this->read_reply(); + $response[] = $this->read_reply(); } break; - /* Integer reply */ + /* Integer reply */ case ':': $response = intval(substr($reply, 1)); break; default: - throw new CredisException('Invalid response: '.print_r($reply, TRUE)); - break; + throw new CredisException('Invalid response: ' . print_r($reply, true)); } return $response; } - protected function decode_reply($name, $response, array &$arguments = array() ) + /** + * @throws CredisException + */ + protected function decode_reply($name, $response, array &$arguments = array()) { // Smooth over differences between phpredis and standalone response - switch ($name) - { + switch ($name) { case '': // Minor optimization for multi-bulk replies break; case 'config': case 'hgetall': $keys = $values = array(); - while ($response) - { + while ($response) { $keys[] = array_shift($response); $values[] = array_shift($response); } $response = count($keys) ? array_combine($keys, $values) : array(); break; case 'info': - $lines = explode(CRLF, trim($response, CRLF)); + $lines = explode("\r\n", trim($response, "\r\n")); $response = array(); - foreach ($lines as $line) - { - if (!$line || substr($line, 0, 1) == '#') - { + foreach ($lines as $line) { + if (!$line || substr($line, 0, 1) == '#') { continue; } list($key, $value) = explode(':', $line, 2); @@ -1385,17 +1580,16 @@ protected function decode_reply($name, $response, array &$arguments = array() ) } break; case 'ttl': - if ($response === -1) - { + if ($response === -1) { $response = false; } break; case 'hmget': - if (count($arguments) != count($response)) - { + if (count($arguments) != count($response)) { throw new CredisException( 'hmget arguments and response do not match: ' . print_r($arguments, true) . ' ' . print_r( - $response, true + $response, + true ) ); } @@ -1412,12 +1606,10 @@ protected function decode_reply($name, $response, array &$arguments = array() ) case 'zscan': $arguments[0] = intval(array_shift($response)); $response = empty($response[0]) ? array() : $response[0]; - if (!empty($response) && is_array($response)) - { + if (!empty($response) && is_array($response)) { $count = count($response); $out = array(); - for ($i = 0; $i < $count; $i += 2) - { + for ($i = 0; $i < $count; $i += 2) { $out[$response[$i]] = $response[$i + 1]; } $response = $out; @@ -1427,19 +1619,14 @@ protected function decode_reply($name, $response, array &$arguments = array() ) case 'zrevrangebyscore': case 'zrange': case 'zrevrange': - if (in_array('withscores', $arguments, true)) - { + if (in_array('withscores', $arguments, true)) { // Map array of values into key=>score list like phpRedis does $item = null; $out = array(); - foreach ($response as $value) - { - if ($item == null) - { + foreach ($response as $value) { + if ($item == null) { $item = $value; - } - else - { + } else { // 2nd value is the score $out[$item] = (float)$value; $item = null; @@ -1461,12 +1648,12 @@ protected function decode_reply($name, $response, array &$arguments = array() ) */ private static function _prepare_command($args) { - return sprintf('*%d%s%s%s', count($args), CRLF, implode(CRLF, array_map(array('Credis_Client', '_map'), $args)), CRLF); + return sprintf('*%d%s%s%s', count($args), "\r\n", implode("\r\n", array_map([static::class, '_map'], $args)), "\r\n"); } private static function _map($arg) { - return sprintf('$%d%s%s', strlen($arg), CRLF, $arg); + return sprintf('$%d%s%s', strlen((string)$arg), "\r\n", $arg); } /** @@ -1477,7 +1664,8 @@ private static function _map($arg) * becomes * array('zrangebyscore', '-inf', 123, 'limit', '0', '1') * - * @param array $in + * @param array $arguments + * @param array $out * @return array */ private static function _flattenArguments(array $arguments, &$out = array()) diff --git a/libs/credis/Cluster.php b/libs/credis/Cluster.php old mode 100755 new mode 100644 index eda43b8..4034203 --- a/libs/credis/Cluster.php +++ b/libs/credis/Cluster.php @@ -1,4 +1,5 @@ hostname, - * 'port' => port, - * 'db' => db, - * 'password' => password, - * 'timeout' => timeout, - * 'alias' => alias, - * 'persistent' => persistence_identifier, - * 'master' => master - * 'write_only'=> true/false - * ) - * - * @param array $servers The Redis servers in the cluster. - * @param int $replicas - * @param bool $standAlone - * @throws CredisException - */ - public function __construct($servers, $replicas = 128, $standAlone = false) - { - $this->clients = array(); - $this->masterClient = null; - $this->aliases = array(); - $this->ring = array(); - $this->replicas = (int)$replicas; - $client = null; - foreach ($servers as $server) + /** + * Creates a connection to the Redis Cluster on cluser named {@link $clusterName} or seeds {@link $clusterSeeds}. + * + * @param string|null $clusterName Name of the cluster as configured in redis.ini + * @param array|null $clusterSeeds Hosts & ports of the cluster; eg: ['redis-node-1:6379', 'redis-node-2:6379'] + * @param float|null $timeout Timeout period in seconds + * @param float|null $readTimeout Timeout period in seconds + * @param bool $persistentBool Flag to establish persistent connection + * @param string|null $password The authentication password of the Redis server + * @param string|null $username The authentication username of the Redis server + * @param array|null $tlsOptions If array, then uses TLS for non-seed connections; if null, no TLS for non-seed + * @throws CredisException + */ + public function __construct($clusterName = null, array $clusterSeeds = [], $timeout = null, $readTimeout = null, $persistentBool = false, $password = null, $username = null, $tlsOptions = null) { - if(is_array($server)){ - $client = new Credis_Client( - $server['host'], - $server['port'], - isset($server['timeout']) ? $server['timeout'] : 2.5, - isset($server['persistent']) ? $server['persistent'] : '', - isset($server['db']) ? $server['db'] : 0, - isset($server['password']) ? $server['password'] : null - ); - if (isset($server['alias'])) { - $this->aliases[$server['alias']] = $client; - } - if(isset($server['master']) && $server['master'] === true){ - $this->masterClient = $client; - if(isset($server['write_only']) && $server['write_only'] === true){ - continue; - } - } - } elseif($server instanceof Credis_Client){ - $client = $server; - } else { - throw new CredisException('Server should either be an array or an instance of Credis_Client'); - } - if($standAlone) { - $client->forceStandalone(); - } - $this->clients[] = $client; - for ($replica = 0; $replica <= $this->replicas; $replica++) { - $md5num = hexdec(substr(md5($client->getHost().':'.$client->getPort().'-'.$replica),0,7)); - $this->ring[$md5num] = count($this->clients)-1; - } - } - ksort($this->ring, SORT_NUMERIC); - $this->nodes = array_keys($this->ring); - $this->dont_hash = array_flip(array( - 'RANDOMKEY', 'DBSIZE', 'PIPELINE', 'EXEC', - 'SELECT', 'MOVE', 'FLUSHDB', 'FLUSHALL', - 'SAVE', 'BGSAVE', 'LASTSAVE', 'SHUTDOWN', - 'INFO', 'MONITOR', 'SLAVEOF' - )); - if($this->masterClient !== null && count($this->clients()) == 0){ - $this->clients[] = $this->masterClient; - for ($replica = 0; $replica <= $this->replicas; $replica++) { - $md5num = hexdec(substr(md5($this->masterClient->getHost().':'.$this->masterClient->getHost().'-'.$replica),0,7)); - $this->ring[$md5num] = count($this->clients)-1; + if (!class_exists(\RedisCluster::class)) { + throw new \Exception("Credis_Cluster depends on RedisCluster class from phpredis extension. " + . " Please verify that phpredis extension is installed and enabled"); } - $this->nodes = array_keys($this->ring); + $this->clusterName = $clusterName; + $this->clusterSeeds = $clusterSeeds; + $this->scheme = null; + $this->timeout = $timeout; + $this->readTimeout = $readTimeout; + $this->persistentBool = $persistentBool; + $this->standalone = false; + $this->authPassword = $password; + $this->authUsername = $username; + $this->selectedDb = 0; // Note: Clusters don't have db, but it's in superclass + $this->tlsOptions = $tlsOptions; + // PHP Redis extension support TLS/ACL AUTH since 5.3.0 // Note: Do we need this in Credis_ClusterClient? + $this->oldPhpRedis = (bool)version_compare(phpversion('redis'), '5.3.0', '<'); } - } - /** - * @param Credis_Client $masterClient - * @param bool $writeOnly - * @return Credis_Cluster - */ - public function setMasterClient(Credis_Client $masterClient, $writeOnly=false) - { - if(!$masterClient instanceof Credis_Client){ - throw new CredisException('Master client should be an instance of Credis_Client'); - } - $this->masterClient = $masterClient; - if (!isset($this->aliases['master'])) { - $this->aliases['master'] = $masterClient; - } - if(!$writeOnly){ - $this->clients[] = $this->masterClient; - for ($replica = 0; $replica <= $this->replicas; $replica++) { - $md5num = hexdec(substr(md5($this->masterClient->getHost().':'.$this->masterClient->getHost().'-'.$replica),0,7)); - $this->ring[$md5num] = count($this->clients)-1; + /** + * @inheritDoc + */ + public function connect() + { + if ($this->connected) { + return $this; } - $this->nodes = array_keys($this->ring); + $this->close(true); + if (!$this->redis) { + $this->redis = new RedisCluster( + $this->clusterName, + $this->clusterSeeds, + $this->timeout ?? 0, + $this->readTimeout ?? 0, + $this->persistentBool, // Note: This can't be $this->persistent, because it is string + ['user' => $this->authUsername, 'pass' => $this->authPassword], + // Note: RedisCluster won't use TLS for non-seed connections if this is null + $this->tlsOptions + ); + $this->connectFailures = 0; + $this->connected = true; + return $this; + } + return $this; + } + + /** + * @return string|null + */ + public function getClusterName() + { + return $this->clusterName; } - return $this; - } - /** - * Get a client by index or alias. - * - * @param string|int $alias - * @throws CredisException - * @return Credis_Client - */ - public function client($alias) - { - if (is_int($alias) && isset($this->clients[$alias])) { - return $this->clients[$alias]; + + /** + * @return array|null + */ + public function getClusterSeeds() + { + return $this->clusterSeeds; } - else if (isset($this->aliases[$alias])) { - return $this->aliases[$alias]; + + /** + * @return bool + */ + public function getPersistenceBool() + { + return $this->persistentBool; } - throw new CredisException("Client $alias does not exist."); - } - /** - * Get an array of all clients - * - * @return array|Credis_Client[] - */ - public function clients() - { - return $this->clients; - } + /* + * Gets list of masters from RedisCluster + */ + public function getClusterMasters(): array + { + $this->connect(); + return $this->redis->_masters(); + } - /** - * Execute a command on all clients - * - * @return array - */ - public function all() - { - $args = func_get_args(); - $name = array_shift($args); - $results = array(); - foreach($this->clients as $client) { - $results[] = call_user_func_array([$client, $name], $args); + /** + * @inheritDoc + * + * Runs PING on all the "master" nodes. + */ + public function ping($message = null) + { + $this->connect(); + foreach ($this->getClusterMasters() as $master) { + $response = $this->redis->ping($master, $message); + if (($response !== true) && (!is_string($response)) && ($response !== $this->redis)) { + return $output; + } + } + if ($response === $this->redis) { + return $this; + } + if ($response) { + if ($response === true) { + $response = $message ?? "PONG"; + } elseif ($response[0] === '+') { + $response = substr($response, 1); + } + } + return $response; } - return $results; - } - /** - * Get the client that the key would hash to. - * - * @param string $key - * @return \Credis_Client - */ - public function byHash($key) - { - return $this->clients[$this->hash($key)]; - } + /** + * @inheritDoc + * + * Runs FLUSHDB on all the "master" nodes. + */ + public function flushDb(...$args) + { + $this->connect(); + foreach ($this->getClusterMasters() as $master) { + $output = $this->redis->flushDb($master, ...$args); + } + return $output; + } - /** - * @param int $index - * @return void - */ - public function select($index) - { - $this->selectedDb = (int) $index; - } - /** - * Execute a Redis command on the cluster with automatic consistent hashing and read/write splitting - * - * @param string $name - * @param array $args - * @return mixed - */ - public function __call($name, $args) - { - if($this->masterClient !== null && !$this->isReadOnlyCommand($name)){ - $client = $this->masterClient; - }elseif (count($this->clients()) == 1 || isset($this->dont_hash[strtoupper($name)]) || !isset($args[0])) { - $client = $this->clients[0]; + /** + * @inheritDoc + * + * Runs FLUSHALL on all the "master" nodes. + */ + public function flushAll(...$args) + { + $this->connect(); + foreach ($this->getClusterMasters() as $master) { + $output = $this->redis->flushAll($master, ...$args); + } + return $output; } - else { - $hashKey = $args[0]; - if (is_array($hashKey)) { - $hashKey = join('|', $hashKey); - } - $client = $this->byHash($hashKey); + + /** + * To specify the node, the first argument will either be a key which maps to a slot which maps to a node; or it + * can be an array of ['host': port] for a node. + * + * @see flushAll + */ + public function flushAllForNode($node, ...$args) + { + $this->connect(); + return $this->redis->flushAll($node, ...$args); } - // Ensure that current client is working on the same database as expected. - if ($client->getSelectedDb() != $this->selectedDb) { - $client->select($this->selectedDb); + + /** + * To specify the node, the first argument will either be a key which maps to a slot which maps to a node; or it + * can be an array of ['host': port] for a node. + * + * @see flushDb + */ + public function flushDbForNode($node, ...$args) + { + $this->connect(); + return $this->redis->flushDb($node, ...$args); } - return call_user_func_array([$client, $name], $args); - } - /** - * Get client index for a key by searching ring with binary search - * - * @param string $key The key to hash - * @return int The index of the client object associated with the hash of the key - */ - public function hash($key) - { - $needle = hexdec(substr(md5($key),0,7)); - $server = $min = 0; - $max = count($this->nodes) - 1; - while ($max >= $min) { - $position = (int) (($min + $max) / 2); - $server = $this->nodes[$position]; - if ($needle < $server) { - $max = $position - 1; - } - else if ($needle > $server) { - $min = $position + 1; - } - else { - break; - } + /** + * To specify the node, the first argument will either be a key which maps to a slot which maps to a node; or it + * can be an array of ['host': port] for a node. + * + * @see ping + */ + public function pingForNode($node, ...$args) + { + $this->connect(); + $response = $this->redis->ping($node, ...$args); + if ($response === $this->redis) { + return $this; + } + if ($response) { + if ($response === true) { + $response = $message ?? "PONG"; + } elseif ($response[0] === '+') { + $response = substr($response, 1); + } + } + return $response; } - return $this->ring[$server]; - } - public function isReadOnlyCommand($command) - { - static $readOnlyCommands = array( - 'DBSIZE' => true, - 'INFO' => true, - 'MONITOR' => true, - 'EXISTS' => true, - 'TYPE' => true, - 'KEYS' => true, - 'SCAN' => true, - 'RANDOMKEY' => true, - 'TTL' => true, - 'GET' => true, - 'MGET' => true, - 'SUBSTR' => true, - 'STRLEN' => true, - 'GETRANGE' => true, - 'GETBIT' => true, - 'LLEN' => true, - 'LRANGE' => true, - 'LINDEX' => true, - 'SCARD' => true, - 'SISMEMBER' => true, - 'SINTER' => true, - 'SUNION' => true, - 'SDIFF' => true, - 'SMEMBERS' => true, - 'SSCAN' => true, - 'SRANDMEMBER' => true, - 'ZRANGE' => true, - 'ZREVRANGE' => true, - 'ZRANGEBYSCORE' => true, - 'ZREVRANGEBYSCORE' => true, - 'ZCARD' => true, - 'ZSCORE' => true, - 'ZCOUNT' => true, - 'ZRANK' => true, - 'ZREVRANK' => true, - 'ZSCAN' => true, - 'HGET' => true, - 'HMGET' => true, - 'HEXISTS' => true, - 'HLEN' => true, - 'HKEYS' => true, - 'HVALS' => true, - 'HGETALL' => true, - 'HSCAN' => true, - 'PING' => true, - 'AUTH' => true, - 'SELECT' => true, - 'ECHO' => true, - 'QUIT' => true, - 'OBJECT' => true, - 'BITCOUNT' => true, - 'TIME' => true, - 'SORT' => true, - ); - return array_key_exists(strtoupper($command), $readOnlyCommands); - } + /** + * To specify the node, the first argument will either be a key which maps to a slot which maps to a node; or it + * can be an array of ['host': port] for a node. + * + * @see save + */ + public function saveForNode($node, ...$args) + { + $this->connect(); + return $this->redis->save($node, ...$args); + } } - diff --git a/libs/credis/LICENSE b/libs/credis/LICENSE old mode 100755 new mode 100644 diff --git a/libs/credis/Module.php b/libs/credis/Module.php index 70b9ba9..f85a699 100644 --- a/libs/credis/Module.php +++ b/libs/credis/Module.php @@ -47,7 +47,7 @@ public function __destruct() */ public function setModule($moduleName) { - $this->moduleName = (string) $moduleName; + $this->moduleName = (string)$moduleName; return $this; } diff --git a/libs/credis/README.markdown b/libs/credis/README.markdown old mode 100755 new mode 100644 index 2e3bdc1..89e339e --- a/libs/credis/README.markdown +++ b/libs/credis/README.markdown @@ -1,4 +1,4 @@ -[![Build Status](https://travis-ci.org/colinmollenhour/credis.svg?branch=master)](https://travis-ci.org/colinmollenhour/credis) +![Build Status](https://github.com/colinmollenhour/credis/actions/workflows/ci.yml/badge.svg) # Credis @@ -26,6 +26,15 @@ Credis_Client also supports transparent command renaming. Write code using the o client will send the aliased commands to the server transparently. Specify the renamed commands using a prefix for md5, a callable function, individual aliases, or an array map of aliases. See "Redis Security":http://redis.io/topics/security for more info. +## Contributing + +Please be sure to add tests to cover and new or changed functionality and run the PHP-CS-Fixer to format the code. + +```shell +composer require "friendsofphp/php-cs-fixer:^3.13" --dev --no-update -n +composer format +``` + ## Supported connection string formats ```php @@ -44,6 +53,14 @@ $redis = new Credis_Client(/* connection string */); `tls://host[:port][/persistence_identifier]` +or + +`tlsv1.2://host[:port][/persistence_identifier]` + +Before php 7.2, `tls://` only supports TLSv1.0, either `ssl://` or `tlsv1.2` can be used to force TLSv1.2 support. + +Recent versions of redis do not support the protocols/cyphers that older versions of php default to, which may result in cryptic connection failures. + #### Enable transport level security (TLS) Use TLS connection string `tls://127.0.0.1:6379` instead of TCP connection `tcp://127.0.0.1:6379` string in order to enable transport level security. @@ -61,9 +78,8 @@ $particles = $redis->lrange('particles', 0, -1); ## Clustering your servers -Credis also includes a way for developers to fully utilize the scalability of Redis with multiple servers and [consistent hashing](http://en.wikipedia.org/wiki/Consistent_hashing). -Using the [Credis_Cluster](Cluster.php) class, you can use Credis the same way, except that keys will be hashed across multiple servers. -Here is how to set up a cluster: +Credis also includes a way for developers to fully utilize the [scalability of Redis cluster](https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/) by using Credis_Cluster which is an adapter for the RedisCluster class from [the Redis extension for PHP](https://github.com/phpredis/phpredis). This also works on [AWS ElastiCatch clusters](https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/Clusters.html). +This feature requires the PHP extension for its functionality. Here is an example how to set up a cluster: ### Basic clustering example ```php @@ -71,136 +87,31 @@ Here is how to set up a cluster: require 'Credis/Client.php'; require 'Credis/Cluster.php'; -$cluster = new Credis_Cluster(array( - array('host' => '127.0.0.1', 'port' => 6379, 'alias'=>'alpha'), - array('host' => '127.0.0.1', 'port' => 6380, 'alias'=>'beta') -)); -$cluster->set('key','value'); -echo "Alpha: ".$cluster->client('alpha')->get('key').PHP_EOL; -echo "Beta: ".$cluster->client('beta')->get('key').PHP_EOL; -``` - -### Explicit definition of replicas - -The consistent hashing strategy stores keys on a so called "ring". The position of each key is relative to the position of its target node. The target node that has the closest position will be the selected node for that specific key. - -To avoid an uneven distribution of keys (especially on small clusters), it is common to duplicate target nodes. Based on the number of replicas, each target node will exist *n times* on the "ring". - -The following example explicitly sets the number of replicas to 5. Both Redis instances will have 5 copies. The default value is 128. - -```php - '127.0.0.1', 'port' => 6379, 'alias'=>'alpha'), - array('host' => '127.0.0.1', 'port' => 6380, 'alias'=>'beta') - ), 5 + null, // $clusterName // Optional. Name from redis.ini. See https://github.com/phpredis/phpredis/blob/develop/cluster.md + ['redis-node-1:6379', 'redis-node-2:6379', 'redis-node-3:6379'], // $clusterSeeds // don't need all nodes, as it pulls that info from one randomly + null, // $timeout + null, // $readTimeout + false, //$persistentBool + 'TopSecretPassword', // $password + null, //$username + null //$tlsOptions ); $cluster->set('key','value'); -echo "Alpha: ".$cluster->client('alpha')->get('key').PHP_EOL; -echo "Beta: ".$cluster->client('beta')->get('key').PHP_EOL; -``` - -## Master/slave replication - -The [Credis_Cluster](Cluster.php) class can also be used for [master/slave replication](http://redis.io/topics/replication). -Credis_Cluster will automatically perform *read/write splitting* and send the write requests exclusively to the master server. -Read requests will be handled by all servers unless you set the *write_only* flag to true in the connection string of the master server. - -### Redis server settings for master/slave replication - -Setting up master/slave replication is simple and only requires adding the following line to the config of the slave server: - -``` -slaveof 127.0.0.1 6379 +echo "Get: ".$cluster->get('key').PHP_EOL; ``` +The Credis_Cluster constructor can either take a cluster name (from redis.ini) or a seed of cluster nodes (An array of strings which can be hostnames or IP address, followed by ports). RedisCluster gets cluster information from one of the seeds at random, so we don't need to pass it all the nodes, and don't need to worry if new nodes are added to cluster. +Many methods of Credis_Cluster are compatible with Credis_Client, but there are some differences. -### Basic master/slave example -```php - '127.0.0.1', 'port' => 6379, 'alias'=>'master', 'master'=>true), - array('host' => '127.0.0.1', 'port' => 6380, 'alias'=>'slave') -)); -$cluster->set('key','value'); -echo $cluster->get('key').PHP_EOL; -echo $cluster->client('slave')->get('key').PHP_EOL; - -$cluster->client('master')->set('key2','value'); -echo $cluster->client('slave')->get('key2').PHP_EOL; -``` +### Differences between the Credis_Client and Credis_Cluster classes -### No read on master - -The following example illustrates how to disable reading on the master server. This will cause the master server only to be used for writing. -This should only happen when you have enough write calls to create a certain load on the master server. Otherwise this is an inefficient usage of server resources. - -```php - '127.0.0.1', 'port' => 6379, 'alias'=>'master', 'master'=>true, 'write_only'=>true), - array('host' => '127.0.0.1', 'port' => 6380, 'alias'=>'slave') -)); -$cluster->set('key','value'); -echo $cluster->get('key').PHP_EOL; -``` -## Automatic failover with Sentinel - -[Redis Sentinel](http://redis.io/topics/sentinel) is a system that can monitor Redis instances. You register master servers and Sentinel automatically detects its slaves. - -When a master server dies, Sentinel will make sure one of the slaves is promoted to be the new master. This autofailover mechanism will also demote failed masters to avoid data inconsistency. - -The [Credis_Sentinel](Sentinel.php) class interacts with the *Redis Sentinel* instance(s) and acts as a proxy. Sentinel will automatically create [Credis_Cluster](Cluster.php) objects and will set the master and slaves accordingly. - -Sentinel uses the same protocol as Redis. In the example below we register the Sentinel server running on port *26379* and assign it to the [Credis_Sentinel](Sentinel.php) object. -We then ask Sentinel the hostname and port for the master server known as *mymaster*. By calling the *getCluster* method we immediately get a [Credis_Cluster](Cluster.php) object that allows us to perform basic Redis calls. - -```php -getMasterAddressByName('mymaster'); -$cluster = $sentinel->getCluster('mymaster'); - -echo 'Writing to master: '.$masterAddress[0].' on port '.$masterAddress[1].PHP_EOL; -$cluster->set('key','value'); -echo $cluster->get('key').PHP_EOL; -``` -### Additional parameters - -Because [Credis_Sentinel](Sentinel.php) will create [Credis_Cluster](Cluster.php) objects using the *"getCluster"* or *"createCluster"* methods, additional parameters can be passed. - -First of all there's the *"write_only"* flag. You can also define the selected database and the number of replicas. And finally there's a *"selectRandomSlave"* option. - -The *"selectRandomSlave"* flag is used in setups for masters that have multiple slaves. The Credis_Sentinel will either select one random slave to be used when creating the Credis_Cluster object or to pass them all and use the built-in hashing. - -The example below shows how to use these 3 options. It selects database 2, sets the number of replicas to 10, it doesn't select a random slave and doesn't allow reading on the master server. - -```php -getCluster('mymaster',2,10,false,true); -$cluster->set('key','value'); -echo $cluster->get('key').PHP_EOL; -``` +* RedisCluster currently has limitations like not supporting pipeline or multi. This may be added in the future. See [here](https://github.com/phpredis/phpredis/blob/develop/cluster.md) for details. +* Many methods require an additional parameter to specify which node to run on, and only run on that node, such as saveForNode(), flushDbForNode(), and pingForNode(). To specify the node, the first argument will either be a key which maps to a slot which maps to a node; or it can be an array of ['host': port] for a node. +* Redis clusters do not support select(), as they only have a single database. +* RedisCluster currently has buggy/broken behaviour for pSubscribe and script. This appears to be a bug and hopefully will be fixed in the future. -## About +### Note about tlsOptions for Credis_Cluster +Because of weirdness in the behaviour of the $tlsOptions parameter of Credis_Cluster, when a seed is defined with a URL that starts with tls:// or ssl://, if $tlsOptions is null, then it will still try to connect without TLS, and it will fail. This odd behaviour is because the connections to the nodes are gotten from the CLUSTER SLOTS command and those hostnames or IP address do not get prefixed with tls:// or ssl://, and it uses the existance of $tlsOptions array for determining which type of connection to make. If you need TLS connection, the $tlsOptions value MUST be either an empty array, or an array with values. If you want the connections to be made without TLS, then the $tlsOptions array MUST be null. © 2011 [Colin Mollenhour](http://colin.mollenhour.com) © 2009 [Justin Poliey](http://justinpoliey.com) diff --git a/libs/credis/Sentinel.php b/libs/credis/Sentinel.php old mode 100755 new mode 100644 index 71c531d..4cd86de --- a/libs/credis/Sentinel.php +++ b/libs/credis/Sentinel.php @@ -1,14 +1,11 @@ * @license http://www.opensource.org/licenses/mit-license.php The MIT License * @package Credis_Sentinel @@ -24,6 +21,7 @@ class Credis_Sentinel /** * Contains an active instance of Credis_Cluster per master pool + * @deprecated no longer used * @var array */ protected $_cluster = array(); @@ -52,21 +50,31 @@ class Credis_Sentinel * @var string */ protected $_password = ''; - /** - * @var null + * Store the AUTH username used by Credis_Client instances (Redis v6+) + * @var string + */ + protected $_username = ''; + /** + * @var null|float */ - private $_timeout; - + protected $_timeout; /** * @var string */ - private $_persistent; - + protected $_persistent; /** * @var int */ - private $_db; + protected $_db; + /** + * @var string|null + */ + protected $_replicaCmd = null; + /** + * @var string|null + */ + protected $_redisVersion = null; /** * Connect with a Sentinel node. Sentinel will do the master and slave discovery @@ -75,17 +83,15 @@ class Credis_Sentinel * @param string $password (deprecated - use setClientPassword) * @throws CredisException */ - public function __construct(Credis_Client $client, $password = NULL) + public function __construct(Credis_Client $client, $password = null, $username = null) { - if(!$client instanceof Credis_Client){ - throw new CredisException('Sentinel client should be an instance of Credis_Client'); - } $client->forceStandalone(); // SENTINEL command not currently supported by phpredis - $this->_client = $client; - $this->_password = $password; - $this->_timeout = NULL; + $this->_client = $client; + $this->_password = $password; + $this->_username = $username; + $this->_timeout = null; $this->_persistent = ''; - $this->_db = 0; + $this->_db = 0; } /** @@ -136,6 +142,37 @@ public function setClientPassword($password) return $this; } + /** + * @param null|string $username + * @return $this + */ + public function setClientUsername($username) + { + $this->_username = $username; + return $this; + } + + /** + * @param null|string $replicaCmd + * @return $this + */ + public function setReplicaCommand($replicaCmd) + { + $this->_replicaCmd = $replicaCmd; + return $this; + } + + public function detectRedisVersion() + { + if ($this->_redisVersion !== null && $this->_replicaCmd !== null) { + return; + } + $serverInfo = $this->info('server'); + $this->_redisVersion = $serverInfo['redis_version']; + // Redis v7+ renames the replica command to 'replicas' instead of 'slaves' + $this->_replicaCmd = version_compare($this->_redisVersion, '7.0.0', '>=') ? 'replicas' : 'slaves'; + } + /** * @return Credis_Sentinel * @deprecated @@ -156,10 +193,10 @@ public function forceStandalone() public function createMasterClient($name) { $master = $this->getMasterAddressByName($name); - if(!isset($master[0]) || !isset($master[1])){ + if (!isset($master[0]) || !isset($master[1])) { throw new CredisException('Master not found'); } - return new Credis_Client($master[0], $master[1], $this->_timeout, $this->_persistent, $this->_db, $this->_password); + return new Credis_Client($master[0], $master[1], $this->_timeout, $this->_persistent, $this->_db, $this->_password, $this->_username); } /** @@ -169,7 +206,7 @@ public function createMasterClient($name) */ public function getMasterClient($name) { - if(!isset($this->_master[$name])){ + if (!isset($this->_master[$name])) { $this->_master[$name] = $this->createMasterClient($name); } return $this->_master[$name]; @@ -185,13 +222,25 @@ public function getMasterClient($name) public function createSlaveClients($name) { $slaves = $this->slaves($name); - $workingSlaves = array(); - foreach($slaves as $slave) { - if(!isset($slave[9])){ + $workingSlaves = []; + foreach ($slaves as $slaveRaw) { + $replica = []; + for ($i = 0; $i < count($slaveRaw) - 1; $i += 2) { + $replica[$slaveRaw[$i]] = $slaveRaw[$i + 1]; + } + + $flags = $replica['flags'] ?? null; + if ($flags === null) { throw new CredisException('Can\' retrieve slave status'); } - if(!strstr($slave[9],'s_down') && !strstr($slave[9],'disconnected')) { - $workingSlaves[] = new Credis_Client($slave[3], $slave[5], $this->_timeout, $this->_persistent, $this->_db, $this->_password); + + if (($replica['master-link-status'] ?? '') === 'err') { + continue; + } + + $flags = explode(',', $flags); + if (!array_intersect($flags, ['s_down', 'o_down', 'disconnected'])) { + $workingSlaves[] = new Credis_Client($replica['ip'], $replica['port'], $this->_timeout, $this->_persistent, $this->_db, $this->_password, $this->_username); } } return $workingSlaves; @@ -204,73 +253,12 @@ public function createSlaveClients($name) */ public function getSlaveClients($name) { - if(!isset($this->_slaves[$name])){ + if (!isset($this->_slaves[$name])) { $this->_slaves[$name] = $this->createSlaveClients($name); } return $this->_slaves[$name]; } - /** - * Returns a Redis cluster object containing a random slave and the master - * When $selectRandomSlave is true, only one random slave is passed. - * When $selectRandomSlave is false, all clients are passed and hashing is applied in Credis_Cluster - * When $writeOnly is false, the master server will also be used for read commands. - * - * @param string $name - * @param int $db - * @param int $replicas - * @param bool $selectRandomSlave - * @param bool $writeOnly - * @return Credis_Cluster - * @throws CredisException - * @deprecated - */ - public function createCluster($name, $db=0, $replicas=128, $selectRandomSlave=true, $writeOnly=false) - { - $clients = array(); - $workingClients = array(); - $master = $this->master($name); - if(strstr($master[9],'s_down') || strstr($master[9],'disconnected')) { - throw new CredisException('The master is down'); - } - $slaves = $this->slaves($name); - foreach($slaves as $slave){ - if(!strstr($slave[9],'s_down') && !strstr($slave[9],'disconnected')) { - $workingClients[] = array('host'=>$slave[3],'port'=>$slave[5],'master'=>false,'db'=>$db,'password'=>$this->_password); - } - } - if(count($workingClients)>0){ - if($selectRandomSlave){ - if(!$writeOnly){ - $workingClients[] = array('host'=>$master[3],'port'=>$master[5],'master'=>false,'db'=>$db,'password'=>$this->_password); - } - $clients[] = $workingClients[rand(0,count($workingClients)-1)]; - } else { - $clients = $workingClients; - } - } - $clients[] = array('host'=>$master[3],'port'=>$master[5], 'db'=>$db ,'master'=>true,'write_only'=>$writeOnly,'password'=>$this->_password); - return new Credis_Cluster($clients,$replicas,$this->_standAlone); - } - - /** - * If a Credis_Cluster object exists, return it. Otherwise create one and return it. - * @param string $name - * @param int $db - * @param int $replicas - * @param bool $selectRandomSlave - * @param bool $writeOnly - * @return Credis_Cluster - * @deprecated - */ - public function getCluster($name, $db=0, $replicas=128, $selectRandomSlave=true, $writeOnly=false) - { - if(!isset($this->_cluster[$name])){ - $this->_cluster[$name] = $this->createCluster($name, $db, $replicas, $selectRandomSlave, $writeOnly); - } - return $this->_cluster[$name]; - } - /** * Catch-all method * @param string $name @@ -279,8 +267,8 @@ public function getCluster($name, $db=0, $replicas=128, $selectRandomSlave=true, */ public function __call($name, $args) { - array_unshift($args,$name); - return call_user_func(array($this->_client,'sentinel'),$args); + array_unshift($args, $name); + return call_user_func(array($this->_client, 'sentinel'), $args); } /** @@ -292,8 +280,7 @@ public function __call($name, $args) */ public function info($section = null) { - if ($section) - { + if ($section) { return $this->_client->info($section); } return $this->_client->info(); @@ -315,7 +302,10 @@ public function masters() */ public function slaves($name) { - return $this->_client->sentinel('slaves',$name); + if ($this->_replicaCmd === null) { + $this->detectRedisVersion(); + } + return $this->_client->sentinel($this->_replicaCmd, $name); } /** @@ -325,7 +315,7 @@ public function slaves($name) */ public function master($name) { - return $this->_client->sentinel('master',$name); + return $this->_client->sentinel('master', $name); } /** @@ -335,7 +325,7 @@ public function master($name) */ public function getMasterAddressByName($name) { - return $this->_client->sentinel('get-master-addr-by-name',$name); + return $this->_client->sentinel('get-master-addr-by-name', $name); } /** @@ -354,7 +344,7 @@ public function ping() */ public function failover($name) { - return $this->_client->sentinel('failover',$name); + return $this->_client->sentinel('failover', $name); } /** diff --git a/libs/credis/composer.json b/libs/credis/composer.json old mode 100755 new mode 100644 index 9223e13..ac418a6 --- a/libs/credis/composer.json +++ b/libs/credis/composer.json @@ -10,8 +10,19 @@ "email": "colin@mollenhour.com" } ], + "scripts": { + "format": "./vendor/bin/php-cs-fixer fix" + }, "require": { - "php": ">=5.4.0" + "php": ">=7.4.0" + }, + "config": { + "audit": { + "block-insecure": false + } + }, + "suggest": { + "ext-redis": "Improved performance for communicating with redis" }, "autoload": { "classmap": [