diff --git a/README.PhpRedisCluster.txt b/README.PhpRedisCluster.txt new file mode 100644 index 0000000..0a335b9 --- /dev/null +++ b/README.PhpRedisCluster.txt @@ -0,0 +1,49 @@ +See README.md file. + +See README.PhpRedis.txt file because PhpRedisCluster requires the same PHP Redis extension. +However the extension version should be >=3.0.0. +Extension info can be found here: https://github.com/phpredis/phpredis + +See RedisCluster() class documentation: +https://github.com/phpredis/phpredis/blob/develop/cluster.markdown#readme + +Example settings.php configuration for using the PhpRedisCluster: + + .... + $settings['redis.connection']['interface'] = 'PhpRedisCluster'; + $settings['redis.connection']['seeds'] = ['192.168.0.1:6379', '192.168.100.100:6379']; + $settings['redis.connection']['read_timeout'] = 1.5; + $settings['redis.connection']['timeout'] = 2; + + // You can also use some additional parameters for PhpRedisCluster as: + // cluster_name - use if set in php.ini e.g. + // $settings['redis.connection']['cluster_name'] = 'redis_cluster'; + // persistent - persistent connections to each node e.g. + // $settings['redis.connection']['persistent'] = FALSE; + + // Set the Drupal's default cache backend. + $settings['cache']['default'] = 'cache.backend.redis'; + + // Always set the fast backend for bootstrap, discover and config, otherwise + // this gets lost when redis is enabled. + $settings['cache']['bins']['bootstrap'] = 'cache.backend.chainedfast'; + $settings['cache']['bins']['discovery'] = 'cache.backend.chainedfast'; + $settings['cache']['bins']['config'] = 'cache.backend.chainedfast'; + +Also, in your project services.yml file you should change the service for +"cache_tags.invalidator.checksum" to use Drupal\redis\Cache\PhpRedisClusterCacheTagsChecksum class. + + ..... + services: + # Cache tag checksum backend. Used by redis and most other cache backend + # to deal with cache tag invalidations. + cache_tags.invalidator.checksum: + class: Drupal\redis\Cache\PhpRedisClusterCacheTagsChecksum + arguments: ['@redis.factory'] + tags: + - { name: cache_tags_invalidator } + ..... + +You can copy/paste the example.services.yml in your settings folder, override the value for +cache_tags.invalidator.checksum service and include the yml file in your settings.php as shown in the +examples in README.md diff --git a/README.PredisCluster.txt b/README.PredisCluster.txt new file mode 100644 index 0000000..5765d02 --- /dev/null +++ b/README.PredisCluster.txt @@ -0,0 +1,32 @@ +See README.md file. + +Sample configuration. + +settings.php + + $settings['redis.connection']['interface'] = 'PredisCluster'; + $settings['redis.connection']['hosts'] = ['tcp://0.0.0.1:6379', 'tcp://0.0.0.2:6379', 'tcp://0.0.0.3:6379']; + $settings['cache']['bins']['bootstrap'] = 'cache.backend.chainedfast'; + $settings['cache']['bins']['discovery'] = 'cache.backend.chainedfast'; + $settings['cache']['bins']['config'] = 'cache.backend.chainedfast'; + $settings['cache']['default'] = 'cache.backend.redis'; + $settings['container_yamls'][] = 'redis.services.yml'; + +redis.services.yml + + services: + cache_tags.invalidator.checksum: + class: Drupal\redis\Cache\PredisClusterCacheTagsChecksum + arguments: ['@redis.factory'] + tags: + - { name: cache_tags_invalidator } + lock: + class: Drupal\Core\Lock\LockBackendInterface + factory: ['@redis.lock.factory', get] + lock.persistent: + class: Drupal\Core\Lock\LockBackendInterface + factory: ['@redis.lock.factory', get] + arguments: [true] + flood: + class: Drupal\Core\Flood\FloodInterface + factory: ['@redis.flood.factory', get] diff --git a/src/Cache/PhpRedisCluster.php b/src/Cache/PhpRedisCluster.php new file mode 100644 index 0000000..fd3a458 --- /dev/null +++ b/src/Cache/PhpRedisCluster.php @@ -0,0 +1,136 @@ +client = $client; + $this->checksumProvider = $checksum_provider; + } + + /** + * {@inheritdoc} + */ + public function getMultiple(&$cids, $allow_invalid = FALSE) { + // Avoid an error when there are no cache ids. + if (empty($cids)) { + return []; + } + + $return = []; + + // Build the list of keys to fetch. + $keys = array_map([$this, 'getKey'], $cids); + + // Optimize for the common case when only a single cache entry needs to + // be fetched, no pipeline is needed then. + if (count($keys) > 1) { + foreach ($keys as $key) { + $result[] = $this->client->hGetAll($key); + } + } + else { + $key = reset($keys); + try { + $result = [$this->client->hGetAll($key)]; + } + catch (\RedisClusterException $e) { + if (\Drupal::hasService('logger.factory')) { + \Drupal::logger('redis')->critical('Redis cluster exception for key %key', [ + '%key' => $key, + ]); + } + throw $e; + } + } + + // Loop over the cid values to ensure numeric indexes. + foreach (array_values($cids) as $index => $key) { + // Check if a valid result was returned from Redis. + if (isset($result[$index]) && is_array($result[$index])) { + // Check expiration and invalidation and convert into an object. + $item = $this->expandEntry($result[$index], $allow_invalid); + if ($item) { + $return[$item->cid] = $item; + } + } + } + + // Remove fetched cids from the list. + $cids = array_diff($cids, array_keys($return)); + + return $return; + } + + /** + * {@inheritdoc} + */ + public function set($cid, $data, $expire = Cache::PERMANENT, array $tags = []) { + + $ttl = $this->getExpiration($expire); + + $key = $this->getKey($cid); + + // If the item is already expired, delete it. + if ($ttl <= 0) { + $this->delete($key); + } + + // Build the cache item and save it as a hash array. + $entry = $this->createEntryHash($cid, $data, $expire, $tags); + $this->client->hMset($key, $entry); + $this->client->expire($key, $ttl); + } + + /** + * {@inheritdoc} + */ + public function doDeleteMultiple(array $cids) { + $keys = array_map([$this, 'getKey'], $cids); + $this->client->del($keys); + } + +} diff --git a/src/Cache/PhpRedisClusterCacheTagsChecksum.php b/src/Cache/PhpRedisClusterCacheTagsChecksum.php new file mode 100644 index 0000000..89f1765 --- /dev/null +++ b/src/Cache/PhpRedisClusterCacheTagsChecksum.php @@ -0,0 +1,79 @@ +client = $factory->getClient(); + } + + /** + * {@inheritdoc} + */ + public function doInvalidateTags(array $tags) { + foreach (array_map([$this, 'getTagKey'], $tags) as $key) { + $this->client->incr($key); + } + } + + /** + * Return the key for the given cache tag. + * + * @param string $tag + * The cache tag. + * + * @return string + * The prefixed cache tag. + */ + protected function getTagKey($tag) { + return $this->getPrefix() . ':cachetags:' . $tag; + } + + /** + * {@inheritdoc} + */ + protected function getTagInvalidationCounts(array $tags) { + $keys = array_map([$this, 'getTagKey'], $tags); + // The mget command returns the values as an array with numeric keys, + // combine it with the tags array to get the expected return value and run + // it through intval() to convert to integers and FALSE to 0. + $values = $this->client->mget($keys); + return $values ? array_map('intval', array_combine($tags, $values)) : []; + } + + /** + * {@inheritdoc} + */ + protected function getDatabaseConnection() { + // This is not injected to avoid a dependency on the database in the + // critical path. It is only needed during cache tag invalidations. + return \Drupal::database(); + } + +} diff --git a/src/Cache/PredisCluster.php b/src/Cache/PredisCluster.php new file mode 100644 index 0000000..f67a608 --- /dev/null +++ b/src/Cache/PredisCluster.php @@ -0,0 +1,30 @@ +invalidatedTags[$tag])) { + continue; + } + $this->invalidatedTags[$tag] = TRUE; + unset($this->tagCache[$tag]); + $keys_to_increment[] = $this->getTagKey($tag); + } + if ($keys_to_increment) { + $pipe = $this->client->pipeline(); + foreach ($keys_to_increment as $key) { + $pipe->incr($key); + } + $pipe->execute(); + } + } + +} diff --git a/src/Client/PhpRedisCluster.php b/src/Client/PhpRedisCluster.php new file mode 100644 index 0000000..cfcabd5 --- /dev/null +++ b/src/Client/PhpRedisCluster.php @@ -0,0 +1,134 @@ +initSettings(); + + $client = new \RedisCluster($this->getClusterName(), $this->getSeeds(), $this->getTimeout(), $this->getReadTimeout(), $this->getPersistent(), $this->getPassword()); + + return $client; + } + + /** + * Initialize the settings. + */ + private function initSettings() { + $this->settings = Settings::get('redis.connection', []); + } + + /** + * Get the cluster name if configured. + * + * @return string|null + * Cluster name or NULL if not configured. + */ + private function getClusterName() { + if (isset($this->settings['cluster_name'])) { + return $this->settings['cluster_name']; + } + + return NULL; + } + + /** + * Get the seeds for the cluster connection. + * + * @return array + * An array of hosts. + */ + private function getSeeds() { + if (isset($this->settings['seeds'])) { + return $this->settings['seeds']; + } + + return [implode(':', [$this->settings['host'], $this->settings['port']])]; + } + + /** + * Get the configured timeout. + * + * @return float + * Configured timeout or self::DEFAULT_TIMEOUT + */ + private function getTimeout() { + if (isset($this->settings['timeout'])) { + return $this->settings['timeout']; + } + + return self::DEFAULT_TIMEOUT; + } + + /** + * Get the configured read timeout. + * + * @return float + * Configured timeout or self::DEFAULT_READ_TIMEOUT + */ + private function getReadTimeout() { + if (isset($this->settings['read_timeout'])) { + return $this->settings['read_timeout']; + } + + return self::DEFAULT_READ_TIMEOUT; + } + + /** + * Get the persistent flag for the RedisCluster option. + * + * @return bool + * Return the persistent + */ + private function getPersistent() { + if (isset($this->settings['persistent'])) { + return $this->settings['persistent']; + } + + return FALSE; + } + + /** + * Get the cluster password if configured. + * + * @return string|null + * Cluster password or NULL if not configured. + */ + private function getPassword() { + if (isset($this->settings['password'])) { + return $this->settings['password']; + } + + return NULL; + } + +} diff --git a/src/Client/PredisCluster.php b/src/Client/PredisCluster.php new file mode 100644 index 0000000..2c4f053 --- /dev/null +++ b/src/Client/PredisCluster.php @@ -0,0 +1,35 @@ + 'redis']; + + $client = new Client($parameters, $options); + return $client; + + } + + /** + * {@inheritdoc} + */ + public function getName() { + return 'PredisCluster'; + } + +} diff --git a/src/Controller/ReportController.php b/src/Controller/ReportController.php index b021220..570f9db 100755 --- a/src/Controller/ReportController.php +++ b/src/Controller/ReportController.php @@ -2,12 +2,13 @@ namespace Drupal\redis\Controller; -use Predis\Client; +use Drupal\Component\Utility\Unicode; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Datetime\DateFormatterInterface; 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; @@ -23,7 +24,7 @@ class ReportController extends ControllerBase { /** * The redis client. * - * @var \Redis|\Predis\Client|false + * @var \Redis|\RedisCluster|Predis\Client|false */ protected $redis; @@ -88,7 +89,7 @@ class ReportController extends ControllerBase { $start = microtime(TRUE); - $info = $this->redis->info(); + $info = $this->info(); $prefix_length = strlen($this->getPrefix()) + 1; @@ -168,20 +169,19 @@ class ReportController extends ControllerBase { } $end = microtime(TRUE); - $memory_config = $this->redis->config('get', 'maxmemory*'); - if ($memory_config['maxmemory']) { + if ($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' => format_size($memory_config['maxmemory']), - '@used_percentage' => (int) ($info['used_memory'] ?? $info['Memory']['used_memory'] / $memory_config['maxmemory'] * 100), - '@policy' => $memory_config['maxmemory-policy'], + '@max_memory' => format_size($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'], ]); } @@ -192,15 +192,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'), @@ -208,17 +212,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' => format_size($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' => format_size($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' => format_size($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' => format_size($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' => [ @@ -244,12 +248,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 ($memory_config['maxmemory-policy'] == 'noeviction') { + if ($info['maxmemory_policy'] == 'noeviction') { $redis_url = Url::fromUri('https://redis.io/topics/lru-cache', [ 'fragment' => 'eviction-policies', 'attributes' => [ @@ -297,9 +306,63 @@ 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; + } + } diff --git a/src/Controller/ReportController.php.orig b/src/Controller/ReportController.php.orig new file mode 100755 index 0000000..b021220 --- /dev/null +++ b/src/Controller/ReportController.php.orig @@ -0,0 +1,305 @@ +redis = $client_factory->getClient(); + } + else { + $this->redis = FALSE; + } + + $this->dateFormatter = $date_formatter; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static($container->get('redis.factory'), $container->get('date.formatter')); + } + + /** + * Redis report overview. + */ + public function overview() { + + include_once DRUPAL_ROOT . '/core/includes/install.inc'; + + $build['report'] = [ + '#type' => 'status_report', + '#requirements' => [], + ]; + + if ($this->redis === FALSE) { + + $build['report']['#requirements'] = [ + 'client' => [ + 'title' => 'Redis', + 'value' => t('Not connected.'), + 'severity_status' => 'error', + 'description' => t('No Redis client connected. Verify cache settings.'), + ], + ]; + + return $build; + } + + $start = microtime(TRUE); + + $info = $this->redis->info(); + + $prefix_length = strlen($this->getPrefix()) + 1; + + $entries_per_bin = array_fill_keys(\Drupal::getContainer()->getParameter('cache_bins'), 0); + + $required_cached_contexts = \Drupal::getContainer()->getParameter('renderer.config')['required_cache_contexts']; + + $render_cache_totals = []; + $render_cache_contexts = []; + $cache_tags = []; + $i = 0; + $cache_tags_max = FALSE; + foreach ($this->scan($this->getPrefix() . '*') as $key) { + $i++; + $second_colon_pos = mb_strpos($key, ':', $prefix_length); + if ($second_colon_pos !== FALSE) { + $bin = mb_substr($key, $prefix_length, $second_colon_pos - $prefix_length); + if (isset($entries_per_bin[$bin])) { + $entries_per_bin[$bin]++; + } + + if ($bin == 'render') { + $cache_key = mb_substr($key, $second_colon_pos + 1); + + $first_context = mb_strpos($cache_key, '['); + if ($first_context) { + $cache_key_only = mb_substr($cache_key, 0, $first_context - 1); + if (!isset($render_cache_totals[$cache_key_only])) { + $render_cache_totals[$cache_key_only] = 1; + } + else { + $render_cache_totals[$cache_key_only]++; + } + + if (preg_match_all('/\[([a-z0-9:_.]+)\]=([^:]*)/', $cache_key, $matches)) { + foreach ($matches[1] as $index => $context) { + $render_cache_contexts[$cache_key_only][$context][$matches[2][$index]] = $matches[2][$index]; + } + } + } + } + elseif ($bin == 'cachetags') { + $cache_tag = mb_substr($key, $second_colon_pos + 1); + // @todo: Make the max configurable or allow ot override it through + // a query parameter. + if (count($cache_tags) < 50000) { + $cache_tags[$cache_tag] = $this->redis->get($key); + } + else { + $cache_tags_max = TRUE; + } + } + } + + // Do not process more than 100k cache keys. + // @todo Adjust this after more testing or move to a separate page. + } + + arsort($entries_per_bin); + arsort($render_cache_totals); + arsort($cache_tags); + + $per_bin_string = ''; + foreach ($entries_per_bin as $bin => $entries) { + $per_bin_string .= "$bin: $entries
"; + } + + $render_cache_string = ''; + foreach (array_slice($render_cache_totals, 0, 50) as $cache_key => $total) { + $contexts = implode(', ', array_diff(array_keys($render_cache_contexts[$cache_key]), $required_cached_contexts)); + $render_cache_string .= $contexts ? "$cache_key: $total ($contexts)
" : "$cache_key: $total
"; + } + + $cache_tags_string = ''; + foreach (array_slice($cache_tags, 0, 50) as $cache_tag => $invalidations) { + $cache_tags_string .= "$cache_tag: $invalidations
"; + } + + $end = microtime(TRUE); + $memory_config = $this->redis->config('get', 'maxmemory*'); + + if ($memory_config['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' => format_size($memory_config['maxmemory']), + '@used_percentage' => (int) ($info['used_memory'] ?? $info['Memory']['used_memory'] / $memory_config['maxmemory'] * 100), + '@policy' => $memory_config['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'], + ]); + } + + $requirements = [ + 'client' => [ + 'title' => $this->t('Client'), + 'value' => t("Connected, using the @name client.", ['@name' => ClientFactory::getClientName()]), + ], + 'version' => [ + 'title' => $this->t('Version'), + 'value' => $info['redis_version'] ?? $info['Server']['redis_version'], + ], + 'clients' => [ + 'title' => $this->t('Connected clients'), + 'value' => $info['connected_clients'] ?? $info['Clients']['connected_clients'], + ], + 'dbsize' => [ + 'title' => $this->t('Keys'), + 'value' => $this->redis->dbSize(), + ], + 'memory' => [ + 'title' => $this->t('Memory'), + 'value' => $memory_value, + ], + 'uptime' => [ + 'title' => $this->t('Uptime'), + 'value' => $this->dateFormatter->formatInterval($info['uptime_in_seconds'] ?? $info['Server']['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' => format_size($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' => format_size($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'], + ]), + ], + 'per_bin' => [ + 'title' => $this->t('Keys per cache bin'), + 'value' => ['#markup' => $per_bin_string], + ], + 'render_cache' => [ + 'title' => $this->t('Render cache entries with most variations'), + 'value' => ['#markup' => $render_cache_string], + ], + 'cache_tags' => [ + 'title' => $this->t('Most invalidated cache tags'), + 'value' => ['#markup' => $cache_tags_string], + ], + 'cache_tag_totals' => [ + 'title' => $this->t('Total cache tag invalidations'), + 'value' => [ + '#markup' => $this->t('@count tags with @invalidations invalidations.', [ + '@count' => count($cache_tags), + '@invalidations' => array_sum($cache_tags), + ]), + ], + ], + 'time_spent' => [ + 'title' => $this->t('Time spent'), + 'value' => ['#markup' => $this->t('@count keys in @time seconds.', ['@count' => $i, '@time' => round(($end - $start), 4)])], + ], + ]; + + // Warnings/hints. + if ($memory_config['maxmemory-policy'] == 'noeviction') { + $redis_url = Url::fromUri('https://redis.io/topics/lru-cache', [ + 'fragment' => 'eviction-policies', + 'attributes' => [ + 'target' => '_blank', + ], + ]); + $requirements['memory']['severity_status'] = 'warning'; + $requirements['memory']['description'] = $this->t('It is recommended to configure the maxmemory policy to e.g. volatile-lru, see Redis documentation.', [ + ':documentation_url' => $redis_url->toString(), + ]); + } + if (count($cache_tags) == 0) { + $requirements['cache_tag_totals']['severity_status'] = 'warning'; + $requirements['cache_tag_totals']['description'] = $this->t('No cache tags found, make sure that the redis cache tag checksum service is used. See example.services.yml on root of this module.'); + unset($requirements['cache_tags']); + } + + if ($cache_tags_max) { + $requirements['max_cache_tags'] = [ + 'severity_status' => 'warning', + 'title' => $this->t('Cache tags limit reached'), + 'value' => ['#markup' => $this->t('Cache tag count incomplete, only counted @count cache tags.', ['@count' => count($cache_tags)])], + ]; + } + + $build['report']['#requirements'] = $requirements; + + return $build; + } + + /** + * Wrapper to SCAN through matching redis keys. + * + * @param string $match + * The MATCH pattern. + * @param int $count + * Count of keys per iteration (only a suggestion to Redis). + * + * @return \Generator + */ + protected function scan($match, $count = 10000) { + $it = NULL; + if ($this->redis instanceof \Redis) { + while ($keys = $this->redis->scan($it, $this->getPrefix() . '*', $count)) { + yield from $keys; + } + } + elseif ($this->redis instanceof Client) { + yield from new Keyspace($this->redis, $match, $count); + } + } + +} diff --git a/src/Controller/ReportController.php.rej b/src/Controller/ReportController.php.rej new file mode 100644 index 0000000..4b4b2ad --- /dev/null +++ b/src/Controller/ReportController.php.rej @@ -0,0 +1,90 @@ +*************** +*** 2,7 **** + + namespace Drupal\redis\Controller; + + use Drupal\Core\Controller\ControllerBase; + use Drupal\Core\Datetime\DateFormatterInterface; + use Drupal\Core\Url; +--- 2,8 ---- + + namespace Drupal\redis\Controller; + ++ use Drupal\Component\Utility\Unicode; + use Drupal\Core\Controller\ControllerBase; + use Drupal\Core\Datetime\DateFormatterInterface; + use Drupal\Core\Url; +*************** +*** 297,305 **** + yield from $keys; + } + } + elseif ($this->redis instanceof \Predis\Client) { + yield from new Keyspace($this->redis, $match, $count); + } + } + + } +--- 306,367 ---- + 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 \Predis\Client) { + yield from new Keyspace($this->redis, $match, $count); + } + } + + /** + * Wrapper to get various statistical information from Redis + * + * @return array + */ + 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; + } + + } diff --git a/src/Flood/PhpRedisCluster.php b/src/Flood/PhpRedisCluster.php new file mode 100644 index 0000000..2552924 --- /dev/null +++ b/src/Flood/PhpRedisCluster.php @@ -0,0 +1,13 @@ +client = $factory->getClient(); + // __destruct() is causing problems with garbage collections, register a + // shutdown function instead. + drupal_register_shutdown_function([$this, 'releaseAll']); + } + + /** + * Generate a redis key name for the current lock name. + * + * @param string $name + * Lock name. + * + * @return string + * The redis key for the given lock. + */ + protected function getKey($name) { + return $this->getPrefix() . ':lock:' . $name; + } + + /** + * {@inheritdoc} + */ + public function acquire($name, $timeout = 30.0) { + $key = $this->getKey($name); + $id = $this->getLockId(); + + // Insure that the timeout is at least 1 ms. + $timeout = max($timeout, 0.001); + + // If we already have the lock, check for its owner and attempt a new EXPIRE + // command on it. + if (isset($this->locks[$name])) { + + // Create a new transaction, for atomicity. + $this->client->watch($key); + + // Global tells us we are the owner, but in real life it could have + // expired and another process could have taken it, check that. + if ($this->client->get($key) != $id) { + // Explicit UNWATCH we are not going to run the MULTI/EXEC block. + $this->client->unwatch(); + unset($this->locks[$name]); + return FALSE; + } + + $result = $this->client->psetex($key, (int) ($timeout * 1000), $id); + + // If the set failed, someone else wrote the key, we failed to acquire + // the lock. + if (FALSE === $result) { + unset($this->locks[$name]); + // Explicit transaction release which also frees the WATCH'ed key. + $this->client->discard(); + return FALSE; + } + + return ($this->locks[$name] = TRUE); + } + else { + // Use a SET with microsecond expiration and the NX flag, which will only + // succeed if the key does not exist yet. + $result = $this->client->set($key, $id, ['nx', 'px' => (int) ($timeout * 1000)]); + + // If the result is FALSE, we failed to acquire the lock. + if (FALSE === $result) { + return FALSE; + } + + // Register the lock. + return ($this->locks[$name] = TRUE); + } + } + + /** + * {@inheritdoc} + */ + public function lockMayBeAvailable($name) { + $key = $this->getKey($name); + $value = $this->client->get($key); + return FALSE === $value; + } + + /** + * {@inheritdoc} + */ + public function release($name) { + $key = $this->getKey($name); + $id = $this->getLockId(); + + unset($this->locks[$name]); + + // Ensure the lock deletion is an atomic transaction. If another thread + // manages to removes all lock, we can not alter it anymore else we will + // release the lock for the other thread and cause race conditions. + $this->client->watch($key); + + if ($this->client->get($key) == $id) { + $this->client->del($key); + } + else { + $this->client->unwatch(); + } + } + + /** + * {@inheritdoc} + */ + public function releaseAll($lock_id = NULL) { + // We can afford to deal with a slow algorithm here, this should not happen + // on normal run because we should have removed manually all our locks. + foreach ($this->locks as $name => $foo) { + $this->release($name); + } + } + +} diff --git a/src/Lock/PredisCluster.php b/src/Lock/PredisCluster.php new file mode 100644 index 0000000..4d8ab17 --- /dev/null +++ b/src/Lock/PredisCluster.php @@ -0,0 +1,10 @@ +client = $factory->getClient(); + // Set the lockId to a fixed string to make the lock ID the same across + // multiple requests. The lock ID is used as a page token to relate all the + // locks set during a request to each other. + // @see \Drupal\Core\Lock\LockBackendInterface::getLockId() + $this->lockId = 'persistent'; + } + +} diff --git a/src/PersistentLock/PredisCluster.php b/src/PersistentLock/PredisCluster.php new file mode 100644 index 0000000..4f38b00 --- /dev/null +++ b/src/PersistentLock/PredisCluster.php @@ -0,0 +1,10 @@ +