diff --git a/README.md b/README.md index af783a8..8593e13 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ Redis clients ==================== -This package provides support for three different Redis clients. +This package provides support for several different Redis clients. * PhpRedis + * PhpRedisCluster * Predis + * PredisCluster * Relay (See configuration recommendations for in-memory cache) By default, the first available client will be used in that order, to configure @@ -90,6 +92,30 @@ needs to be configured for that. Additional configuration and features. =============== +Use other backends as failover +------------------------------------- + +You can tell Redis to fall back to database caching (or an alternative) if no +connection can be made. This works for cache bins, lock, persistent lock, flood, +and cache tag invalidation services. There is NO failover for service container +storage. Include example.failover.services.yml rather than example.services.yml, +and add the following lines to settings.php: + + # Use failover services when Redis is not available. + $settings['container_yamls'][] = 'modules/redis/example.failover.services.yml'; + $settings['redis.failover'] = TRUE; + +You can also override the service being used as a failover for cache bins. The +default is to use the 'cache.backend.database' service. + + # Use another cache backend as failover, in this case PHP file-based caching. + # NOTE: This is only an example, and not a recommended configuration. + $settings['redis.failover.cache_service'] = 'cache.backend.php'; + +To override the failover service used for other services, make your own version +of example.failover.services.yml with modified service definitions, or do the +same in a site-specific services.yml file. + Lock Backend ------------ diff --git a/src/Cache/CacheBackendFactory.php b/src/Cache/CacheBackendFactory.php index 4efa1c6..7d38f5e 100644 --- a/src/Cache/CacheBackendFactory.php +++ b/src/Cache/CacheBackendFactory.php @@ -5,6 +5,7 @@ namespace Drupal\redis\Cache; use Drupal\Component\Serialization\SerializationInterface; use Drupal\Core\Cache\CacheFactoryInterface; use Drupal\Core\Cache\CacheTagsChecksumInterface; +use Drupal\Core\Site\Settings; use Drupal\redis\ClientFactory; /** @@ -61,8 +62,20 @@ class CacheBackendFactory implements CacheFactoryInterface { */ public function get($bin) { if (!isset($this->bins[$bin])) { - $class_name = $this->clientFactory->getClass(ClientFactory::REDIS_IMPL_CACHE); - $this->bins[$bin] = new $class_name($bin, $this->clientFactory->getClient(), $this->checksumProvider, $this->serializer); + $client = $this->clientFactory->getClient(); + + if ($client === FALSE && Settings::get('redis.failover', FALSE)) { + $cache_failover = Settings::get('redis.failover.cache_service', 'cache.backend.database'); + if ($warning_message = Settings::get('redis.failover.warning_message', FALSE)) { + \Drupal::messenger()->addWarning($warning_message); + } + $this->bins[$bin] = \Drupal::service($cache_failover)->get($bin); + } + else { + $class_name = $this->clientFactory->getClass(ClientFactory::REDIS_IMPL_CACHE); + $this->bins[$bin] = new $class_name($bin, $client, $this->checksumProvider, $this->serializer); + } + } return $this->bins[$bin]; } diff --git a/src/Cache/RedisCacheTagsChecksum.php b/src/Cache/RedisCacheTagsChecksum.php index 2da8a3e..88d7103 100644 --- a/src/Cache/RedisCacheTagsChecksum.php +++ b/src/Cache/RedisCacheTagsChecksum.php @@ -64,7 +64,12 @@ class RedisCacheTagsChecksum implements CacheTagsChecksumInterface, CacheTagsInv } $multi->exec(); } - elseif ($this->clientType === 'Predis') { + elseif ($this->clientType === 'PhpRedisCluster') { + foreach ($keys as $key) { + $this->client->incr($key); + } + } + elseif ($this->clientType === 'Predis' || $this->clientType === 'PredisCluster') { $pipe = $this->client->pipeline(); foreach ($keys as $key) { diff --git a/src/Client/PhpRedis.php b/src/Client/PhpRedis.php index 7fe1141..180ce5d 100644 --- a/src/Client/PhpRedis.php +++ b/src/Client/PhpRedis.php @@ -25,11 +25,20 @@ class PhpRedis implements ClientInterface { } } - if ($persistent) { - $client->pconnect($host, $port); + try { + if ($persistent) { + $client->pconnect($host, $port); + } + else { + $client->connect($host, $port); + } } - else { - $client->connect($host, $port); + catch (\RedisException $e) { + if (Settings::get('redis.failover', FALSE)) { + return FALSE; + } + + throw $e; } if (isset($password)) { diff --git a/src/Client/Predis.php b/src/Client/Predis.php index 9d5d444..3568619 100644 --- a/src/Client/Predis.php +++ b/src/Client/Predis.php @@ -2,6 +2,7 @@ namespace Drupal\redis\Client; +use Drupal\Core\Site\Settings; use Drupal\redis\ClientInterface; use Predis\Client; @@ -11,6 +12,9 @@ use Predis\Client; */ class Predis implements ClientInterface { + /** + * {@inheritdoc} + */ public function getClient($host = NULL, $port = NULL, $base = NULL, $password = NULL, $replicationHosts = [], $persistent = FALSE) { $connectionInfo = [ 'password' => $password, @@ -54,8 +58,19 @@ class Predis implements ClientInterface { else { $client = new Client($connectionInfo); } - return $client; + try { + $client->connect(); + } + catch (\Exception $e) { + if (Settings::get('redis.failover', FALSE)) { + return FALSE; + } + + throw $e; + } + + return $client; } public function getName() { diff --git a/src/ClientInterface.php b/src/ClientInterface.php index 2ecd895..ea5eefa 100644 --- a/src/ClientInterface.php +++ b/src/ClientInterface.php @@ -9,8 +9,9 @@ interface ClientInterface { /** * Get the connected client instance. * - * @return mixed - * Real client depends from the library behind. + * @return mixed|false + * Real client depends from the library behind. FALSE if the client could + * not connect. */ public function getClient($host = NULL, $port = NULL, $base = NULL, $password = NULL, $replicationHosts = [], $persistent = FALSE); diff --git a/src/Controller/ReportController.php b/src/Controller/ReportController.php index a30ea99..197ab8c 100755 --- a/src/Controller/ReportController.php +++ b/src/Controller/ReportController.php @@ -2,13 +2,14 @@ namespace Drupal\redis\Controller; -use Drupal\Core\StringTranslation\ByteSizeMarkup; -use Predis\Client; +use Drupal\Component\Utility\Unicode; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Datetime\DateFormatterInterface; +use Drupal\Core\StringTranslation\ByteSizeMarkup; use Drupal\Core\Url; use Drupal\redis\ClientFactory; use Drupal\redis\RedisPrefixTrait; +use Predis\Client; use Predis\Collection\Iterator\Keyspace; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -24,7 +25,7 @@ class ReportController extends ControllerBase { /** * The redis client. * - * @var \Redis|\Relay\Relay|\Predis\Client|false + * @var \Redis|\Relay\Relay|\Predis\Client|\RedisCluster|false */ protected $redis; @@ -89,7 +90,7 @@ class ReportController extends ControllerBase { $start = microtime(TRUE); - $info = $this->redis->info(); + $info = $this->info(); $prefix_length = strlen($this->getPrefix()) + 1; @@ -172,18 +173,18 @@ class ReportController extends ControllerBase { /** @var array|false $memory_config */ $memory_config = $this->redis->config('get', 'maxmemory*'); // Redis default for maxmemory is 0 for "unlimited" (ie system limit). - if (!empty($memory_config['maxmemory'])) { + if (!empty($info['maxmemory'])) { $memory_value = $this->t('@used_memory / @max_memory (@used_percentage%), maxmemory policy: @policy', [ '@used_memory' => $info['used_memory_human'] ?? $info['Memory']['used_memory_human'], - '@max_memory' => static::formatSize($memory_config['maxmemory']), - '@used_percentage' => (int) (($info['used_memory'] ?? $info['Memory']['used_memory']) / $memory_config['maxmemory'] * 100), - '@policy' => $memory_config['maxmemory-policy'], + '@max_memory' => static::formatSize($info['maxmemory']), + '@used_percentage' => (int) ($info['used_memory'] / $info['maxmemory'] * 100), + '@policy' => $info['maxmemory_policy'] ?? '', ]); } else { $memory_value = $this->t('@used_memory / unlimited, maxmemory policy: @policy', [ - '@used_memory' => $info['used_memory_human'] ?? $info['Memory']['used_memory_human'], - '@policy' => $memory_config['maxmemory-policy'] ?? '', + '@used_memory' => $info['used_memory_human'], + '@policy' => $info['maxmemory_policy'], ]); } @@ -194,15 +195,19 @@ class ReportController extends ControllerBase { ], 'version' => [ 'title' => $this->t('Version'), - 'value' => $info['redis_version'] ?? $info['Server']['redis_version'], + 'value' => $info['redis_version'], + ], + 'mode' => [ + 'title' => $this->t('Mode'), + 'value' => Unicode::ucfirst($info['redis_mode']), ], 'clients' => [ 'title' => $this->t('Connected clients'), - 'value' => $info['connected_clients'] ?? $info['Clients']['connected_clients'], + 'value' => $info['connected_clients'], ], 'dbsize' => [ 'title' => $this->t('Keys'), - 'value' => $this->redis->dbSize(), + 'value' => $info['db_size'], ], 'memory' => [ 'title' => $this->t('Memory'), @@ -210,17 +215,17 @@ class ReportController extends ControllerBase { ], 'uptime' => [ 'title' => $this->t('Uptime'), - 'value' => $this->dateFormatter->formatInterval($info['uptime_in_seconds'] ?? $info['Server']['uptime_in_seconds']), + 'value' => $this->dateFormatter->formatInterval($info['uptime_in_seconds']), ], 'read_write' => [ 'title' => $this->t('Read/Write'), 'value' => $this->t('@read read (@percent_read%), @write written (@percent_write%), @commands commands in @connections connections.', [ - '@read' => static::formatSize($info['total_net_output_bytes'] ?? $info['Stats']['total_net_output_bytes']), - '@percent_read' => round(100 / (($info['total_net_output_bytes'] ?? $info['Stats']['total_net_output_bytes']) + ($info['total_net_input_bytes'] ?? $info['Stats']['total_net_input_bytes'])) * ($info['total_net_output_bytes'] ?? $info['Stats']['total_net_output_bytes'])), - '@write' => static::formatSize($info['total_net_input_bytes'] ?? $info['Stats']['total_net_input_bytes']), - '@percent_write' => round(100 / (($info['total_net_output_bytes'] ?? $info['Stats']['total_net_output_bytes']) + ($info['total_net_input_bytes'] ?? $info['Stats']['total_net_input_bytes'])) * ($info['total_net_input_bytes'] ?? $info['Stats']['total_net_input_bytes'])), - '@commands' => $info['total_commands_processed'] ?? $info['Stats']['total_commands_processed'], - '@connections' => $info['total_connections_received'] ?? $info['Stats']['total_connections_received'], + '@read' => static::formatSize($info['total_net_output_bytes']), + '@percent_read' => round(100 / ($info['total_net_output_bytes'] + $info['total_net_input_bytes']) * ($info['total_net_output_bytes'])), + '@write' => static::formatSize($info['total_net_input_bytes']), + '@percent_write' => round(100 / ($info['total_net_output_bytes'] + $info['total_net_input_bytes']) * ($info['total_net_input_bytes'])), + '@commands' => $info['total_commands_processed'], + '@connections' => $info['total_connections_received'], ]), ], 'per_bin' => [ @@ -246,12 +251,17 @@ class ReportController extends ControllerBase { ], 'time_spent' => [ 'title' => $this->t('Time spent'), - 'value' => ['#markup' => $this->t('@count keys in @time seconds.', ['@count' => $i, '@time' => round(($end - $start), 4)])], + 'value' => [ + '#markup' => $this->t('@count keys in @time seconds.', [ + '@count' => $i, + '@time' => round(($end - $start), 4) + ]) + ], ], ]; // Warnings/hints. - if (!empty($memory_config['maxmemory-policy']) && $memory_config['maxmemory-policy'] == 'noeviction') { + if (!empty($memory_config['maxmemory-policy']) && $info['maxmemory_policy'] == 'noeviction') { $redis_url = Url::fromUri('https://redis.io/topics/lru-cache', [ 'fragment' => 'eviction-policies', 'attributes' => [ @@ -318,11 +328,65 @@ class ReportController extends ControllerBase { yield from $keys; } } + elseif ($this->redis instanceof \RedisCluster) { + $master = current($this->redis->_masters()); + while ($keys = $this->redis->scan($it, $master, $this->getPrefix() . '*', $count)) { + yield from $keys; + } + } elseif ($this->redis instanceof Client) { yield from new Keyspace($this->redis, $match, $count); } } + /** + * Wrapper to get various statistical information from Redis. + * + * @return array + * Redis info. + */ + protected function info() { + $normalized_info = []; + if ($this->redis instanceof \RedisCluster) { + $master = current($this->redis->_masters()); + $info = $this->redis->info($master); + } + else { + $info = $this->redis->info(); + } + + $normalized_info['redis_version'] = $info['redis_version'] ?? $info['Server']['redis_version']; + $normalized_info['redis_mode'] = $info['redis_mode'] ?? $info['Server']['redis_mode']; + $normalized_info['connected_clients'] = $info['connected_clients'] ?? $info['Clients']['connected_clients']; + if ($this->redis instanceof \RedisCluster) { + $master = current($this->redis->_masters()); + $normalized_info['db_size'] = $this->redis->dbSize($master); + } + else { + $normalized_info['db_size'] = $this->redis->dbSize(); + } + $normalized_info['used_memory'] = $info['used_memory'] ?? $info['Memory']['used_memory']; + $normalized_info['used_memory_human'] = $info['used_memory_human'] ?? $info['Memory']['used_memory_human']; + + if (empty($info['maxmemory_policy'])) { + $memory_config = $this->redis->config('get', 'maxmemory*'); + $normalized_info['maxmemory_policy'] = $memory_config['maxmemory-policy']; + $normalized_info['maxmemory'] = $memory_config['maxmemory']; + } + else { + $normalized_info['maxmemory_policy'] = $info['maxmemory_policy']; + $normalized_info['maxmemory'] = $info['maxmemory']; + } + + $normalized_info['uptime_in_seconds'] = $info['uptime_in_seconds'] ?? $info['Server']['uptime_in_seconds']; + $normalized_info['total_net_output_bytes'] = $info['total_net_output_bytes'] ?? $info['Stats']['total_net_output_bytes']; + $normalized_info['total_net_input_bytes'] = $info['total_net_input_bytes'] ?? $info['Stats']['total_net_input_bytes']; + $normalized_info['total_commands_processed'] = $info['total_commands_processed'] ?? $info['Stats']['total_commands_processed']; + $normalized_info['total_connections_received'] = $info['total_connections_received'] ?? $info['Stats']['total_connections_received']; + + return $normalized_info; + } + /** * Generates a string representation for the given byte count. * diff --git a/src/Flood/FloodFactory.php b/src/Flood/FloodFactory.php index 65e169d..f270108 100644 --- a/src/Flood/FloodFactory.php +++ b/src/Flood/FloodFactory.php @@ -2,6 +2,7 @@ namespace Drupal\redis\Flood; +use Drupal\Core\Site\Settings; use Drupal\redis\ClientFactory; use Symfony\Component\HttpFoundation\RequestStack; @@ -44,6 +45,14 @@ class FloodFactory { */ public function get() { $class_name = $this->clientFactory->getClass(ClientFactory::REDIS_IMPL_FLOOD); + + if (Settings::get('redis.failover', FALSE)) { + $client = $this->clientFactory->getClient(); + if ($client === FALSE) { + return \Drupal::service('flood.failover'); + } + } + return new $class_name($this->clientFactory, $this->requestStack); } } diff --git a/src/Lock/LockFactory.php b/src/Lock/LockFactory.php index 6e8c8b1..b05f0b5 100644 --- a/src/Lock/LockFactory.php +++ b/src/Lock/LockFactory.php @@ -2,6 +2,7 @@ namespace Drupal\redis\Lock; +use Drupal\Core\Site\Settings; use Drupal\redis\ClientFactory; /** @@ -32,6 +33,17 @@ class LockFactory { */ public function get($persistent = FALSE) { $class_name = $this->clientFactory->getClass($persistent ? ClientFactory::REDIS_IMPL_PERSISTENT_LOCK : ClientFactory::REDIS_IMPL_LOCK); + + if (Settings::get('redis.failover', FALSE)) { + $client = $this->clientFactory->getClient(); + if ($client === FALSE) { + if ($persistent === TRUE) { + return \Drupal::service('lock.persistent.failover'); + } + return \Drupal::service('lock.failover'); + } + } + return new $class_name($this->clientFactory); } } diff --git a/tests/src/Traits/RedisTestInterfaceTrait.php b/tests/src/Traits/RedisTestInterfaceTrait.php index dcbec57..f6d1452 100644 --- a/tests/src/Traits/RedisTestInterfaceTrait.php +++ b/tests/src/Traits/RedisTestInterfaceTrait.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\redis\Traits; use Drupal\Core\Site\Settings; +use Symfony\Component\DependencyInjection\Reference; trait RedisTestInterfaceTrait { @@ -23,6 +24,36 @@ trait RedisTestInterfaceTrait { new Settings($settings); } + /** + * Sets up intentionally invalid settings for testing failover functionality. + */ + public function setUpFailoverSettings() { + // Write redis_interface settings manually. + $redis_interface = self::getRedisInterfaceEnv(); + $settings = Settings::getAll(); + $settings['redis.connection']['interface'] = $redis_interface; + + $settings['redis.connection']['host'] = 'intentionally-invalid'; + $settings['redis.failover'] = TRUE; + + new Settings($settings); + } + + /** + * Replaces the checksum service with the redis failover implementation. + */ + public function setUpFailoverChecksumServices($container) { + if ($container->has('redis.factory')) { + $container->register('cache_tags.invalidator.checksum', 'Drupal\redis\Cache\RedisCacheTagsChecksumProxy') + ->addArgument(new Reference('redis.factory')) + ->addTag('cache_tags_invalidator'); + $container->register('cache_tags.invalidator.checksum.redis', 'Drupal\redis\Cache\RedisCacheTagsChecksum') + ->addArgument(new Reference('redis.factory')); + $container->register('cache_tags.invalidator.checksum.failover', 'Drupal\Core\Cache\DatabaseCacheTagsChecksum') + ->addArgument(new Reference('database')); + } + } + /** * Uses an env variable to set the redis client to use for this test. */