This commit is contained in:
Ilya Tregubov 2024-03-25 14:23:53 +08:00
commit d1a34c7d23
11 changed files with 729 additions and 141 deletions

View File

@ -873,4 +873,13 @@ class cache_helper {
public static function result_found($value): bool {
return $value !== false;
}
/**
* Checks whether the cluster mode is available in PHP.
*
* @return bool Return true if the PHP supports redis cluster, otherwise false.
*/
public static function is_cluster_available(): bool {
return class_exists('RedisCluster');
}
}

View File

@ -24,7 +24,7 @@
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot.'/cache/forms.php');
require_once($CFG->dirroot . '/cache/forms.php');
/**
* Form for adding instance of Redis Cache Store.
@ -39,7 +39,12 @@ class cachestore_redis_addinstance_form extends cachestore_addinstance_form {
protected function configuration_definition() {
$form = $this->_form;
$form->addElement('text', 'server', get_string('server', 'cachestore_redis'), array('size' => 24));
$form->addElement('advcheckbox', 'clustermode', get_string('clustermode', 'cachestore_redis'), '',
cache_helper::is_cluster_available() ? '' : 'disabled');
$form->addHelpButton('clustermode', 'clustermode', 'cachestore_redis');
$form->setType('clustermode', PARAM_BOOL);
$form->addElement('textarea', 'server', get_string('server', 'cachestore_redis'), ['cols' => 6, 'rows' => 10]);
$form->setType('server', PARAM_TEXT);
$form->addHelpButton('server', 'server', 'cachestore_redis');
$form->addRule('server', get_string('required'), 'required');

View File

@ -24,13 +24,18 @@
defined('MOODLE_INTERNAL') || die();
$string['ca_file'] = 'CA file path';
$string['ca_file_help'] = 'Location of Certificate Authority file on local filesystem';
$string['clustermode'] = 'Cluster Mode';
$string['clustermode_help'] = 'Enabling it will run the Redis cluster function, allowing your server to serve multiple servers to handle concurrent requests simultaneously.';
$string['clustermodeunavailable'] = 'Redis Cluster is currently unavailable. Please ensure that the PHP Redis extension supports Redis Cluster functionality.';
$string['compressor_none'] = 'No compression.';
$string['compressor_php_gzip'] = 'Use gzip compression.';
$string['compressor_php_zstd'] = 'Use Zstandard compression.';
$string['encrypt_connection'] = 'Use TLS encryption.';
$string['encrypt_connection_help'] = 'Use TLS to connect to Redis. Do not use \'tls://\' in the hostname for Redis, use this option instead.';
$string['ca_file'] = 'CA file path';
$string['ca_file_help'] = 'Location of Certificate Authority file on local filesystem';
$string['password'] = 'Password';
$string['password_help'] = 'This sets the password of the Redis server.';
$string['pluginname'] = 'Redis';
$string['prefix'] = 'Key prefix';
$string['prefix_help'] = 'This prefix is used for all key names on the Redis server.
@ -41,8 +46,8 @@ $string['privacy:metadata:redis'] = 'The Redis cachestore plugin stores data bri
$string['privacy:metadata:redis:data'] = 'The various data stored in the cache';
$string['serializer_igbinary'] = 'The igbinary serializer.';
$string['serializer_php'] = 'The default PHP serializer.';
$string['server'] = 'Server';
$string['server_help'] = 'This sets the hostname, IP address or Unix socket path of the Redis server to use.
$string['server'] = 'Server(s)';
$string['server_help'] = 'Redis server to use for testing.
Some example values:
@ -52,12 +57,20 @@ Some example values:
* 1.2.3.4:1234 - To connect to a Redis server by IP address with a specific port.
* unix:///var/redis.sock - To connect to a Redis server using a Unix socket.
* /var/redis.sock - To connect to a Redis server using a Unix socket (alternative format).
* If cluster mode is enabled, please specify servers separated by a new line:<br>
172.23.0.11<br>
172.23.0.12<br>
172.23.0.13<br>
Refer to the above examples to write a server.
See <a href="https://redis.io/docs/reference/clients/#accepting-client-connections" target="_new">Accepting Client Connections</a> and <a href="https://redis.io/resources/clients/#php" target="_new">Redis PHP clients</a> for more information.
';
$string['password'] = 'Password';
$string['password_help'] = 'This sets the password of the Redis server.';
See <a href="https://redis.io/docs/reference/clients/#accepting-client-connections" target="_new">Accepting Client Connections</a> and <a href="https://redis.io/resources/clients/#php" target="_new">Redis PHP clients</a> for more information.';
$string['task_ttl'] = 'Free up memory used by expired entries in Redis caches';
$string['test_clustermode'] = 'Cluster Mode';
$string['test_clustermode_desc'] = 'Enable Test in Redis cluster mode.';
$string['test_password'] = 'Test server password';
$string['test_password_desc'] = 'Redis test server password.';
$string['test_serializer'] = 'Serializer';
$string['test_serializer_desc'] = 'Serializer to use for testing.';
$string['test_server'] = 'Test server';
$string['test_server_desc'] = 'Redis server to use for testing.
@ -69,17 +82,18 @@ Some example values:
* 1.2.3.4:1234 - To connect to a Redis server by IP address with a specific port.
* unix:///var/redis.sock - To connect to a Redis server using a Unix socket.
* /var/redis.sock - To connect to a Redis server using a Unix socket (alternative format).
* If cluster mode is enabled, please specify servers separated by a new line:<br>
172.23.0.11<br>
172.23.0.12<br>
172.23.0.13<br>
Refer to the above examples to write a server.
See <a href="https://redis.io/docs/reference/clients/#accepting-client-connections" target="_new">Accepting Client Connections</a> and <a href="https://redis.io/resources/clients/#php" target="_new">Redis PHP clients</a> for more information.';
$string['test_password'] = 'Test server password';
$string['test_password_desc'] = 'Redis test server password.';
$string['test_serializer'] = 'Serializer';
$string['test_serializer_desc'] = 'Serializer to use for testing.';
$string['test_ttl'] = 'Testing TTL';
$string['test_ttl_desc'] = 'Run the performance test using a cache that requires TTL (slower sets).';
$string['usecompressor'] = 'Use compressor';
$string['usecompressor_help'] = 'Specifies the compressor to use after serializing. It is done at Moodle Cache API level, not at php-redis level.';
$string['useserializer'] = 'Use serializer';
$string['useserializer_help'] = 'Specifies the serializer to use for serializing.
The valid serializers are Redis::SERIALIZER_PHP or Redis::SERIALIZER_IGBINARY.
The latter is supported only when phpredis is configured with --enable-redis-igbinary option and the igbinary extension is loaded.';
$string['usecompressor'] = 'Use compressor';
$string['usecompressor_help'] = 'Specifies the compressor to use after serializing. It is done at Moodle Cache API level, not at php-redis level.';

View File

@ -94,7 +94,7 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_
/**
* Connection to Redis for this store.
*
* @var Redis
* @var Redis|RedisCluster
*/
protected $redis;
@ -177,7 +177,10 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_
* @param string $name
* @param array $configuration
*/
public function __construct($name, array $configuration = array()) {
public function __construct(
$name,
array $configuration = [],
) {
$this->name = $name;
if (!array_key_exists('server', $configuration) || empty($configuration['server'])) {
@ -199,75 +202,112 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_
}
/**
* Create a new Redis instance and
* connect to the server.
* Create a new Redis or RedisCluster instance and connect to the server.
*
* @param array $configuration The server configuration
* @return Redis
* @param array $configuration The redis instance configuration.
* @return Redis|RedisCluster|null
*/
protected function new_redis(array $configuration): \Redis {
global $CFG;
$redis = new Redis();
$server = $configuration['server'];
protected function new_redis(array $configuration): Redis|RedisCluster|null {
$encrypt = (bool) ($configuration['encryption'] ?? false);
$clustermode = (bool) ($configuration['clustermode'] ?? false);
$password = !empty($configuration['password']) ? $configuration['password'] : '';
$prefix = !empty($configuration['prefix']) ? $configuration['prefix'] : '';
// Check if it isn't a Unix socket to set default port.
$port = null;
$opts = [];
// Unix sockets can start with / or with unix://.
if ($server[0] === '/' || strpos($server, 'unix://') === 0) {
$port = 0;
} else {
$port = 6379; // No Unix socket so set default port.
if (strpos($server, ':')) { // Check for custom port.
list($server, $port) = explode(':', $server);
}
// We can encrypt if we aren't unix socket.
if ($encrypt) {
$server = 'tls://' . $server;
if (empty($configuration['cafile'])) {
$sslopts = [
'verify_peer' => false,
'verify_peer_name' => false,
];
// Set Redis server(s).
$servers = explode("\n", $configuration['server']);
$trimmedservers = [];
// print_r($configuration);
// print_r($servers);
foreach ($servers as $server) {
$server = strtolower(trim($server));
if (!empty($server)) {
if ($server[0] === '/' || str_starts_with($server, 'unix://')) {
$port = 0;
$trimmedservers[] = $server;
} else {
$sslopts = ['cafile' => $configuration['cafile']];
$port = 6379; // No Unix socket so set default port.
if (strpos($server, ':')) { // Check for custom port.
list($server, $port) = explode(':', $server);
}
if (!$clustermode && $encrypt) {
$server = 'tls://' . $server;
}
$trimmedservers[] = $server.':'.$port;
}
// We only need the first record for the single redis.
if (!$clustermode) {
break;
}
$opts['stream'] = $sslopts;
}
}
try {
if ($redis->connect($server, $port, 1, null, 100, 1, $opts)) {
// TLS/SSL Configuration.
$exceptionclass = $clustermode ? 'RedisClusterException' : 'RedisException';
$opts = [];
if ($encrypt) {
$opts = empty($configuration['cafile']) ?
['verify_peer' => false, 'verify_peer_name' => false] :
['cafile' => $configuration['cafile']];
// For a single (non-cluster) Redis, the TLS/SSL config must be added to the 'stream' key.
if (!$clustermode) {
$opts['stream'] = $opts;
}
}
// Connect to redis.
$redis = null;
// print_r($trimmedservers);
// exit;
try {
// Create a $redis object of a RedisCluster or Redis class.
if ($clustermode) {
$redis = new RedisCluster(
name: null,
seeds: $trimmedservers,
timeout: 1,
read_timeout: 1,
persistent: true,
auth: $password,
context: !empty($opts) ? $opts : null,
);
} else {
// We only need the first record for the single redis.
list($server, $port) = explode(':', $trimmedservers[0]);
$redis = new Redis();
$redis->connect(
host: $server,
port: $port,
timeout: 1,
retry_interval: 100,
read_timeout: 1,
context: $opts,
);
if (!empty($password)) {
$redis->auth($password);
}
// If using compressor, serialisation will be done at cachestore level, not php-redis.
if ($this->compressor == self::COMPRESSOR_NONE) {
$redis->setOption(Redis::OPT_SERIALIZER, $this->serializer);
}
if (!empty($prefix)) {
$redis->setOption(Redis::OPT_PREFIX, $prefix);
}
if ($encrypt && !$redis->ping()) {
/*
* In case of a TLS connection, if phpredis client does not
* communicate immediately with the server the connection hangs.
* See https://github.com/phpredis/phpredis/issues/2332 .
*/
throw new \RedisException("Ping failed");
}
$this->isready = true;
} else {
$this->isready = false;
}
} catch (\RedisException $e) {
debugging("redis $server: $e", DEBUG_NORMAL);
// In case of a TLS connection,
// if phpredis client does not communicate immediately with the server the connection hangs.
// See https://github.com/phpredis/phpredis/issues/2332.
if ($encrypt && !$redis->ping('Ping')) {
throw new $exceptionclass("Ping failed");
}
// If using compressor, serialisation will be done at cachestore level, not php-redis.
if ($this->compressor === self::COMPRESSOR_NONE) {
$redis->setOption(Redis::OPT_SERIALIZER, $this->serializer);
}
// Set the prefix.
$prefix = !empty($configuration['prefix']) ? $configuration['prefix'] : '';
if (!empty($prefix)) {
$redis->setOption(Redis::OPT_PREFIX, $prefix);
}
$this->isready = true;
} catch (RedisException | RedisClusterException $e) {
$server = $clustermode ? implode(',', $trimmedservers) : $trimmedservers[0].':'.$port;
debugging("Failed to connect to Redis at {$server}, the error returned was: {$e->getMessage()}");
$this->isready = false;
}
@ -277,10 +317,10 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_
/**
* See if we can ping Redis server
*
* @param Redis $redis
* @param RedisCluster|Redis $redis
* @return bool
*/
protected function ping(Redis $redis) {
protected function ping(RedisCluster|Redis $redis): bool {
try {
if ($redis->ping() === false) {
return false;
@ -763,7 +803,7 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_
public function store_total_size(): ?int {
try {
$details = $this->redis->info('MEMORY');
} catch (\RedisException $e) {
} catch (RedisException $e) {
return null;
}
if (empty($details['used_memory'])) {
@ -789,6 +829,7 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_
'compressor' => $data->compressor,
'encryption' => $data->encryption,
'cafile' => $data->cafile,
'clustermode' => $data->clustermode,
);
}
@ -816,6 +857,9 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_
if (!empty($config['cafile'])) {
$data['cafile'] = $config['cafile'];
}
if (!empty($config['clustermode'])) {
$data['clustermode'] = $config['clustermode'];
}
$editform->set_data($data);
}
@ -847,6 +891,9 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_
if (!empty($config->test_cafile)) {
$configuration['cafile'] = $config->test_cafile;
}
if (!empty($config->test_clustermode)) {
$configuration['clustermode'] = $config->test_clustermode;
}
// Make it possible to test TTL performance by hacking a copy of the cache definition.
if (!empty($config->test_ttl)) {
$definition = clone $definition;

View File

@ -25,15 +25,26 @@
defined('MOODLE_INTERNAL') || die();
$settings->add(
new admin_setting_configtext(
'cachestore_redis/test_server',
get_string('test_server', 'cachestore_redis'),
get_string('test_server_desc', 'cachestore_redis'),
'',
PARAM_TEXT,
16
new admin_setting_configcheckbox(
name: 'cachestore_redis/test_clustermode',
visiblename: get_string('clustermode', 'cachestore_redis'),
description: cache_helper::is_cluster_available() ?
get_string('clustermode_help', 'cachestore_redis') :
get_string('clustermodeunavailable', 'cachestore_redis'),
defaultsetting: 0,
)
);
$settings->add(
new admin_setting_configtextarea(
name: 'cachestore_redis/test_server',
visiblename: get_string('test_server', 'cachestore_redis'),
description: get_string('test_server_desc', 'cachestore_redis'),
defaultsetting: '',
paramtype: PARAM_TEXT,
)
);
$settings->add(new admin_setting_configcheckbox(
'cachestore_redis/test_encryption',
get_string('encrypt_connection', 'cachestore_redis'),

View File

@ -0,0 +1,193 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace cachestore_redis;
use cache_definition;
use cache_store;
use cachestore_redis;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/../../../tests/fixtures/stores.php');
require_once(__DIR__ . '/../lib.php');
/**
* Redis cluster test.
*
* If you wish to use these unit tests all you need to do is add the following definition to
* your config.php file:
*
* define('TEST_CACHESTORE_REDIS_SERVERSCLUSTER', 'localhost:7000,localhost:7001');
* define('TEST_CACHESTORE_REDIS_ENCRYPTCLUSTER', true);
* define('TEST_CACHESTORE_REDIS_AUTHCLUSTER', 'foobared');
* define('TEST_CACHESTORE_REDIS_CASCLUSTER', '/cafile/dir/ca.crt');
*
* @package cachestore_redis
* @author Daniel Thee Roperto <daniel.roperto@catalyst-au.net>
* @copyright 2017 Catalyst IT Australia {@link http://www.catalyst-au.net}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*
* @coversDefaultClass \cachestore_redis
*/
class cachestore_cluster_redis_test extends \advanced_testcase {
/**
* Create a cache store for testing the Redis cluster.
*
* @param string|null $seed The redis cluster servers.
* @return cachestore_redis The created cache store instance.
*/
public function create_store(?string $seed = null): cachestore_redis {
global $DB;
$definition = cache_definition::load_adhoc(
mode: cache_store::MODE_APPLICATION,
component: 'cachestore_redis',
area: 'phpunit_test',
);
$servers = $seed ?? str_replace(",", "\n", TEST_CACHESTORE_REDIS_SERVERSCLUSTER);
$config = [
'server' => $servers,
'prefix' => $DB->get_prefix(),
'clustermode' => true,
];
if (defined('TEST_CACHESTORE_REDIS_ENCRYPTCLUSTER') && TEST_CACHESTORE_REDIS_ENCRYPTCLUSTER === true) {
$config['encryption'] = true;
}
if (defined('TEST_CACHESTORE_REDIS_AUTHCLUSTER') && TEST_CACHESTORE_REDIS_AUTHCLUSTER) {
$config['password'] = TEST_CACHESTORE_REDIS_AUTHCLUSTER;
}
if (defined('TEST_CACHESTORE_REDIS_CASCLUSTER') && TEST_CACHESTORE_REDIS_CASCLUSTER) {
$config['cafile'] = TEST_CACHESTORE_REDIS_CASCLUSTER;
}
$store = new cachestore_redis('TestCluster', $config);
$store->initialise($definition);
$store->purge();
return $store;
}
/**
* Set up the test environment.
*/
public function setUp(): void {
if (!cachestore_redis::are_requirements_met()) {
$this->markTestSkipped('Could not test cachestore_redis with cluster, missing requirements.');
} else if (!\cache_helper::is_cluster_available()) {
$this->markTestSkipped('Could not test cachestore_redis with cluster, class RedisCluster is not available.');
} else if (!defined('TEST_CACHESTORE_REDIS_SERVERSCLUSTER')) {
$this->markTestSkipped('Could not test cachestore_redis with cluster, missing configuration. ' .
"Example: define('TEST_CACHESTORE_REDIS_SERVERSCLUSTER', " .
"'localhost:7000,localhost:7001,localhost:7002');");
}
}
/**
* Test if the cache store can be created successfully.
*
* @covers ::is_ready
*/
public function test_it_can_create(): void {
$store = $this->create_store();
$this->assertNotNull($store);
$this->assertTrue($store->is_ready());
}
/**
* Test if the cache store trims server names correctly.
*
* @covers ::new_redis
*/
public function test_it_trims_server_names(): void {
// Add a time before and spaces after the first server. Also adds a blank line before second server.
$servers = explode(',', TEST_CACHESTORE_REDIS_SERVERSCLUSTER);
$servers[0] = "\t" . $servers[0] . " \n";
$servers = implode("\n", $servers);
$store = $this->create_store($servers);
$this->assertTrue($store->is_ready());
}
/**
* Test if the cache store can successfully set and get a value.
*
* @covers ::set
* @covers ::get
*/
public function test_it_can_setget(): void {
$store = $this->create_store();
$store->set('the key', 'the value');
$actual = $store->get('the key');
$this->assertSame('the value', $actual);
}
/**
* Test if the cache store can successfully set and get multiple values.
*
* @covers ::set_many
* @covers ::get_many
*/
public function test_it_can_setget_many(): void {
$store = $this->create_store();
// Create values.
$values = [];
$keys = [];
$expected = [];
for ($i = 0; $i < 10; $i++) {
$key = "getkey_{$i}";
$value = "getvalue #{$i}";
$keys[] = $key;
$values[] = [
'key' => $key,
'value' => $value,
];
$expected[$key] = $value;
}
$store->set_many($values);
$actual = $store->get_many($keys);
$this->assertSame($expected, $actual);
}
/**
* Test if the cache store is marked as not ready if it fails to connect.
*
* @covers ::is_ready
*/
public function test_it_is_marked_not_ready_if_failed_to_connect(): void {
global $DB;
$config = [
'server' => "abc:123",
'prefix' => $DB->get_prefix(),
'clustermode' => true,
];
$store = new cachestore_redis('TestCluster', $config);
$debugging = $this->getDebuggingMessages();
// Failed to connect should show a debugging message.
$this->assertCount(1, \phpunit_util::get_debugging_messages() );
$this->assertStringContainsString('Couldn\'t map cluster keyspace using any provided seed', $debugging[0]->message);
$this->resetDebugging();
$this->assertFalse($store->is_ready());
}
}

View File

@ -0,0 +1,154 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace cachestore_redis;
use cache_definition;
use cache_store;
use cachestore_redis;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__.'/../../../tests/fixtures/stores.php');
require_once(__DIR__.'/../lib.php');
/**
* Redis cache store test.
*
* If you wish to use these unit tests all you need to do is add the following definition to
* your config.php file.
*
* define('TEST_CACHESTORE_REDIS_TESTSERVERS', '127.0.0.1');
*
* @package cachestore_redis
* @copyright (c) 2015 Moodlerooms Inc. (http://www.moodlerooms.com)
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*
* @coversDefaultClass \cachestore_redis
*/
class cachestore_redis_test extends \cachestore_tests {
/** @var cachestore_redis $store Redis Cache Store. */
protected $store;
/**
* Returns the class name.
*
* @return string
*/
protected function get_class_name(): string {
return 'cachestore_redis';
}
public function setUp(): void {
if (!cachestore_redis::are_requirements_met() || !defined('TEST_CACHESTORE_REDIS_TESTSERVERS')) {
$this->markTestSkipped('Could not test cachestore_redis. Requirements are not met.');
}
parent::setUp();
}
protected function tearDown(): void {
parent::tearDown();
if ($this->store instanceof cachestore_redis) {
$this->store->purge();
}
}
/**
* Creates the required cachestore for the tests to run against Redis.
*
* @return cachestore_redis
*/
protected function create_cachestore_redis(): cachestore_redis {
$definition = cache_definition::load_adhoc(cache_store::MODE_APPLICATION, 'cachestore_redis', 'phpunit_test');
$store = new cachestore_redis('Test', cachestore_redis::unit_test_configuration());
$store->initialise($definition);
$this->store = $store;
$store->purge();
return $store;
}
/**
* Test methods for various operations (set and has) in the cachestore_redis class.
*
* @covers ::set
* @covers ::has
*/
public function test_has(): void {
$store = $this->create_cachestore_redis();
$this->assertTrue($store->set('foo', 'bar'));
$this->assertTrue($store->has('foo'));
$this->assertFalse($store->has('bat'));
}
/**
* Test methods for the 'has_any' operation in the cachestore_redis class.
*
* @covers ::set
* @covers ::has_any
*/
public function test_has_any(): void {
$store = $this->create_cachestore_redis();
$this->assertTrue($store->set('foo', 'bar'));
$this->assertTrue($store->has_any(['bat', 'foo']));
$this->assertFalse($store->has_any(['bat', 'baz']));
}
/**
* PHPUnit test methods for the 'has_all' operation in the cachestore_redis class.
*
* @covers ::set
* @covers ::has_all
*/
public function test_has_all(): void {
$store = $this->create_cachestore_redis();
$this->assertTrue($store->set('foo', 'bar'));
$this->assertTrue($store->set('bat', 'baz'));
$this->assertTrue($store->has_all(['foo', 'bat']));
$this->assertFalse($store->has_all(['foo', 'bat', 'this']));
}
/**
* Test methods for the 'lock' operations in the cachestore_redis class.
*
* @covers ::acquire_lock
* @covers ::check_lock_state
* @covers ::release_lock
*/
public function test_lock(): void {
$store = $this->create_cachestore_redis();
$this->assertTrue($store->acquire_lock('lock', '123'));
$this->assertTrue($store->check_lock_state('lock', '123'));
$this->assertFalse($store->check_lock_state('lock', '321'));
$this->assertNull($store->check_lock_state('notalock', '123'));
$this->assertFalse($store->release_lock('lock', '321'));
$this->assertTrue($store->release_lock('lock', '123'));
}
/**
* Test method to check if the cachestore_redis instance is ready after connecting.
*
* @covers ::is_ready
*/
public function test_it_is_ready_after_connecting(): void {
$store = $this->create_cachestore_redis();
$this::assertTrue($store->is_ready());
}
}

View File

@ -346,7 +346,9 @@ $CFG->admin = 'admin';
//
// Redis session handler (requires redis server and redis extension):
// $CFG->session_handler_class = '\core\session\redis';
// $CFG->session_redis_host = '127.0.0.1';
// $CFG->session_redis_host = '127.0.0.1'; or... // If there is only one host, use the single Redis connection.
// $CFG->session_redis_host = '127.0.0.1:7000,127.0.0.1:7001'; // If there are multiple hosts (separated by a comma),
// // use the Redis cluster connection.
// Use TLS to connect to Redis. An array of SSL context options. Usually:
// $CFG->session_redis_encrypt = ['cafile' => '/path/to/ca.crt']; or...
// $CFG->session_redis_encrypt = ['verify_peer' => false, 'verify_peer_name' => false];

View File

@ -25,6 +25,7 @@
namespace core\session;
use RedisException;
use RedisClusterException;
use SessionHandlerInterface;
/**
@ -52,8 +53,8 @@ class redis extends handler implements SessionHandlerInterface {
*/
const COMPRESSION_ZSTD = 'zstd';
/** @var string $host save_path string */
protected $host = '';
/** @var array $host save_path string */
protected array $host = [];
/** @var int $port The port to connect to */
protected $port = 6379;
/** @var array $sslopts SSL options, if applicable */
@ -83,7 +84,7 @@ class redis extends handler implements SessionHandlerInterface {
*/
protected $lockexpire;
/** @var Redis Connection */
/** @var Redis|RedisCluster Connection */
protected $connection = null;
/** @var array $locks List of currently held locks by this page. */
@ -92,6 +93,12 @@ class redis extends handler implements SessionHandlerInterface {
/** @var int $timeout How long sessions live before expiring. */
protected $timeout;
/** @var bool $clustermode Redis in cluster mode. */
protected bool $clustermode = false;
/** @var int Maximum number of retries for cache store operations. */
const MAX_RETRIES = 5;
/**
* Create new instance of handler.
*/
@ -99,7 +106,10 @@ class redis extends handler implements SessionHandlerInterface {
global $CFG;
if (isset($CFG->session_redis_host)) {
$this->host = $CFG->session_redis_host;
// If there is only one host, use the single Redis connection.
// If there are multiple hosts (separated by a comma), use the Redis cluster connection.
$this->host = array_filter(array_map('trim', explode(',', $CFG->session_redis_host)));
$this->clustermode = count($this->host) > 1;
}
if (isset($CFG->session_redis_port)) {
@ -107,7 +117,9 @@ class redis extends handler implements SessionHandlerInterface {
}
if (isset($CFG->session_redis_encrypt) && $CFG->session_redis_encrypt) {
$this->host = 'tls://' . $this->host;
if (!$this->clustermode) {
$this->host[0] = 'tls://' . $this->host[0];
}
$this->sslopts = $CFG->session_redis_encrypt;
}
@ -197,86 +209,120 @@ class redis extends handler implements SessionHandlerInterface {
// The session handler requires a version of Redis with the SETEX command (at least 2.0).
$version = phpversion('Redis');
if (!$version or version_compare($version, '2.0') <= 0) {
if (!$version || version_compare($version, '2.0') <= 0) {
throw new exception('sessionhandlerproblem', 'error', '', null, 'redis extension version must be at least 2.0');
}
$this->connection = new \Redis();
$encrypt = (bool) ($this->sslopts ?? false);
// Set Redis server(s).
$trimmedservers = [];
foreach ($this->host as $host) {
$server = strtolower(trim($host));
if (!empty($server)) {
if ($server[0] === '/' || str_starts_with($server, 'unix://')) {
$port = 0;
$trimmedservers[] = $server;
} else {
$port = 6379; // No Unix socket so set default port.
if (strpos($server, ':')) { // Check for custom port.
list($server, $port) = explode(':', $server);
}
$trimmedservers[] = $server.':'.$port;
}
$result = session_set_save_handler($this);
// We only need the first record for the single redis.
if (!$this->clustermode) {
break;
}
}
}
if (!$result) {
throw new exception('redissessionhandlerproblem', 'error');
// TLS/SSL Configuration.
$opts = [];
if ($encrypt) {
if ($this->clustermode) {
$opts = $this->sslopts;
} else {
// For a single (non-cluster) Redis, the TLS/SSL config must be added to the 'stream' key.
$opts['stream'] = $this->sslopts;
}
}
// MDL-59866: Add retries for connections (up to 5 times) to make sure it goes through.
$counter = 1;
$maxnumberofretries = 5;
$opts = [];
if ($this->sslopts) {
// Do not set $opts['stream'] = [], breaks connect().
$opts['stream'] = $this->sslopts;
}
while ($counter <= $maxnumberofretries) {
$exceptionclass = $this->clustermode ? 'RedisClusterException' : 'RedisException';
while ($counter <= self::MAX_RETRIES) {
$this->connection = null;
// Make a connection to Redis server(s).
try {
$delay = rand(100, 500);
// One second timeout was chosen as it is long for connection, but short enough for a user to be patient.
if (!$this->connection->connect($this->host, $this->port, 1, null, $delay, 1, $opts)) {
throw new RedisException('Unable to connect to host.');
}
if ($this->auth !== '') {
if (!$this->connection->auth($this->auth)) {
throw new RedisException('Unable to authenticate.');
// Create a $redis object of a RedisCluster or Redis class.
if ($this->clustermode) {
$this->connection = new \RedisCluster(
name: null,
seeds: $trimmedservers,
timeout: 1,
read_timeout: 1,
persistent: true,
auth: $this->auth,
context: !empty($opts) ? $opts : null,
);
} else {
$delay = rand(100, 500);
list($server, $port) = explode(':', $trimmedservers[0]);
$this->connection = new \Redis();
$this->connection->connect(
host: $server,
port: $this->port ?? $port,
timeout: 1,
retry_interval: $delay,
read_timeout: 1,
context: $opts,
);
if ($this->auth !== '' && !$this->connection->auth($this->auth)) {
throw new $exceptionclass('Unable to authenticate.');
}
}
if (!$this->connection->setOption(\Redis::OPT_SERIALIZER, $this->serializer)) {
throw new RedisException('Unable to set Redis PHP Serializer option.');
throw new $exceptionclass('Unable to set the Redis PHP Serializer option.');
}
if ($this->prefix !== '') {
// Use custom prefix on sessions.
if (!$this->connection->setOption(\Redis::OPT_PREFIX, $this->prefix)) {
throw new RedisException('Unable to set Redis Prefix option.');
throw new $exceptionclass('Unable to set the Redis Prefix option.');
}
}
if ($this->sslopts && !$this->connection->ping()) {
/*
* In case of a TLS connection, if phpredis client does not
* communicate immediately with the server the connection hangs.
* See https://github.com/phpredis/phpredis/issues/2332 .
*/
throw new \RedisException("Ping failed");
if ($this->sslopts && !$this->connection->ping('Ping')) {
// In case of a TLS connection,
// if phpredis client does not communicate immediately with the server the connection hangs.
// See https://github.com/phpredis/phpredis/issues/2332.
throw new $exceptionclass("Ping failed");
}
if ($this->database !== 0) {
if (!$this->connection->select($this->database)) {
throw new RedisException('Unable to select Redis database '.$this->database.'.');
throw new $exceptionclass('Unable to select the Redis database ' . $this->database . '.');
}
}
return true;
} catch (RedisException $e) {
$logstring = "Failed to connect (try {$counter} out of {$maxnumberofretries}) to redis ";
$logstring .= "at {$this->host}:{$this->port}, error returned was: {$e->getMessage()}";
} catch (RedisException | RedisClusterException $e) {
$redishost = $this->clustermode ? implode(',', $this->host) : $this->host[0].':'.$this->port ?? $port;
$logstring = "Failed to connect (try {$counter} out of " . self::MAX_RETRIES . ") to Redis ";
$logstring .= "at ". $redishost .", the error returned was: {$e->getMessage()}";
debugging($logstring);
}
$counter++;
// Introduce a random sleep between 100ms and 500ms.
usleep(rand(100000, 500000));
}
// We have exhausted our retries, time to give up.
if (isset($logstring)) {
throw new RedisException($logstring);
// We have exhausted our retries; it's time to give up.
throw new $exceptionclass($logstring);
}
$result = session_set_save_handler($this);
if (!$result) {
throw new exception('redissessionhandlerproblem', 'error');
}
}
@ -305,7 +351,7 @@ class redis extends handler implements SessionHandlerInterface {
}
unset($this->locks[$id]);
}
} catch (RedisException $e) {
} catch (RedisException | RedisClusterException $e) {
error_log('Failed talking to redis: '.$e->getMessage());
return false;
}
@ -336,7 +382,7 @@ class redis extends handler implements SessionHandlerInterface {
return '';
}
$this->connection->expire($id, $this->timeout);
} catch (RedisException $e) {
} catch (RedisException | RedisClusterException $e) {
error_log('Failed talking to redis: '.$e->getMessage());
throw $e;
}
@ -421,7 +467,7 @@ class redis extends handler implements SessionHandlerInterface {
$data = $this->compress($data);
$this->connection->setex($id, $this->timeout, $data);
} catch (RedisException $e) {
} catch (RedisException | RedisClusterException $e) {
error_log('Failed talking to redis: '.$e->getMessage());
return false;
}
@ -439,7 +485,7 @@ class redis extends handler implements SessionHandlerInterface {
try {
$this->connection->del($id);
$this->unlock_session($id);
} catch (RedisException $e) {
} catch (RedisException | RedisClusterException $e) {
error_log('Failed talking to redis: '.$e->getMessage());
return false;
}
@ -582,7 +628,7 @@ class redis extends handler implements SessionHandlerInterface {
try {
return !empty($this->connection->exists($sid));
} catch (RedisException $e) {
} catch (RedisException | RedisClusterException $e) {
return false;
}
}

View File

@ -0,0 +1,107 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core;
use core\session\redis as redis_session;
use RedisClusterException;
/**
* Unit tests for Redis cluster in the core/session/redis.php.
*
* NOTE: in order to execute this test you need to set up
* Redis cluster server and add configuration a constant
* to config.php or phpunit.xml configuration file:
*
* define('TEST_SESSION_REDIS_HOSTCLUSTER', '127.0.0.1:7000,127.0.0.1:7001,127.0.0.1:7002');
* define('TEST_SESSION_REDIS_AUTHCLUSTER', 'foobared');
*
* define('TEST_SESSION_REDIS_ENCRYPTCLUSTER', ['verify_peer' => false, 'verify_peer_name' => false]);
* OR
* define('TEST_SESSION_REDIS_ENCRYPTCLUSTER', ['cafile' => '/cafile/dir/ca.crt']);
*
* @package core
* @copyright 2024 Meirza <meirza.arson@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @coversDefaultClass \core\session\redis
*/
class session_redis_cluster_test extends \advanced_testcase {
/**
* Set up the test environment.
*/
public function setUp(): void {
global $CFG;
if (!\cache_helper::is_cluster_available()) {
$this->markTestSkipped('Could not test core_session with cluster, class RedisCluster is not available.');
} else if (!defined('TEST_SESSION_REDIS_HOSTCLUSTER')) {
$this->markTestSkipped('Could not test session_redis_cluster_test with cluster, missing configuration. ' .
"Example: define('TEST_SESSION_REDIS_HOSTCLUSTER', " .
"'localhost:7000,localhost:7001,localhost:7002');");
}
$this->resetAfterTest();
$CFG->session_redis_host = TEST_SESSION_REDIS_HOSTCLUSTER;
if (defined('TEST_SESSION_REDIS_ENCRYPTCLUSTER') && TEST_SESSION_REDIS_ENCRYPTCLUSTER) {
$CFG->session_redis_encrypt = TEST_SESSION_REDIS_ENCRYPTCLUSTER;
}
if (defined('TEST_SESSION_REDIS_AUTHCLUSTER') && TEST_SESSION_REDIS_AUTHCLUSTER) {
$CFG->session_redis_auth = TEST_SESSION_REDIS_AUTHCLUSTER;
}
}
/**
* Tests compression for session read and write operations.
*
* It covers the behavior of session read and write operations under different compression configurations.
*
* @covers ::read
* @covers ::write
*/
public function test_read_and_write(): void {
$rediscluster = new redis_session();
$rediscluster->init();
$this->assertTrue($rediscluster->write('sess1', 'DATA'));
$this->assertSame('DATA', $rediscluster->read('sess1'));
$this->assertTrue($rediscluster->close());
}
/**
* Tests the behavior when connection attempts to Redis cluster are exceeded.
*
* It sets up the environment to simulate multiple failed connection attempts and
* checks if the expected exception message is received.
*
* @covers ::init
*/
public function test_exception_when_connection_attempts_exceeded(): void {
global $CFG;
$CFG->session_redis_host = '127.0.0.1:1111111,127.0.0.1:1111112,127.0.0.1:1111113';
$actual = '';
$rediscluster = new redis_session();
try {
$rediscluster->init();
} catch (RedisClusterException $e) {
$actual = $e->getMessage();
}
$expected = "Failed to connect (try 5 out of 5) to Redis at";
$this->assertDebuggingCalledCount(5);
$this->assertStringContainsString($expected, $actual);
}
}

View File

@ -348,7 +348,7 @@ class session_redis_test extends \advanced_testcase {
if ($this->encrypted) {
$host = "tls://$host";
}
$expected = "Failed to connect (try 5 out of 5) to redis at $host:111111";
$expected = "Failed to connect (try 5 out of 5) to Redis at $host:111111";
$this->assertDebuggingCalledCount(5);
$this->assertStringContainsString($expected, $actual);
}
@ -368,6 +368,6 @@ class session_redis_test extends \advanced_testcase {
$sess = new \core\session\redis();
$prop = new \ReflectionProperty(\core\session\redis::class, 'host');
$this->assertEquals('tls://' . TEST_SESSION_REDIS_HOST, $prop->getValue($sess));
$this->assertEquals('tls://' . TEST_SESSION_REDIS_HOST, $prop->getValue($sess)[0]);
}
}