diff --git a/cache/stores/redis/README.md b/cache/stores/redis/README.md new file mode 100755 index 0000000000000..5fa50f602adca --- /dev/null +++ b/cache/stores/redis/README.md @@ -0,0 +1,6 @@ +Redis Cache Store for Moodle +============================ + +A Moodle cache store plugin for [Redis](http://redis.io). + +This plugin requires the [PhpRedis](https://github.com/nicolasff/phpredis) extension. The PhpRedis extension can be installed via PECL with `pecl install redis`. diff --git a/cache/stores/redis/addinstanceform.php b/cache/stores/redis/addinstanceform.php new file mode 100755 index 0000000000000..537cbef1a3444 --- /dev/null +++ b/cache/stores/redis/addinstanceform.php @@ -0,0 +1,52 @@ +. + +/** + * Redis Cache Store - Add instance form + * + * @package cachestore_redis + * @copyright 2013 Adam Durana + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot.'/cache/forms.php'); + +/** + * Form for adding instance of Redis Cache Store. + * + * @copyright 2013 Adam Durana + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class cachestore_redis_addinstance_form extends cachestore_addinstance_form { + /** + * Builds the form for creating an instance. + */ + protected function configuration_definition() { + $form = $this->_form; + + $form->addElement('text', 'server', get_string('server', 'cachestore_redis'), array('size' => 24)); + $form->setType('server', PARAM_TEXT); + $form->addHelpButton('server', 'server', 'cachestore_redis'); + $form->addRule('server', get_string('required'), 'required'); + + $form->addElement('text', 'prefix', get_string('prefix', 'cachestore_redis'), array('size' => 16)); + $form->setType('prefix', PARAM_TEXT); // We set to text but we have a rule to limit to alphanumext. + $form->addHelpButton('prefix', 'prefix', 'cachestore_redis'); + $form->addRule('prefix', get_string('prefixinvalid', 'cachestore_redis'), 'regex', '#^[a-zA-Z0-9\-_]+$#'); + } +} \ No newline at end of file diff --git a/cache/stores/redis/lang/en/cachestore_redis.php b/cache/stores/redis/lang/en/cachestore_redis.php new file mode 100755 index 0000000000000..2526bed989f18 --- /dev/null +++ b/cache/stores/redis/lang/en/cachestore_redis.php @@ -0,0 +1,36 @@ +. + +/** + * Redis Cache Store - English language strings + * + * @package cachestore_redis + * @copyright 2013 Adam Durana + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$string['pluginname'] = 'Redis'; +$string['prefix'] = 'Key prefix'; +$string['prefix_help'] = 'This prefix is used for all key names on the Redis server. +* If you only have one Moodle instance using this server, you can leave this value default. +* Due to key length restrictions, a maximum of 5 characters is permitted.'; +$string['prefixinvalid'] = 'Invalid prefix. You can only use a-z A-Z 0-9-_.'; +$string['test_server'] = 'Test Server'; +$string['test_server_desc'] = 'Redis server to use for testing.'; +$string['server'] = 'Server'; +$string['server_help'] = 'This sets the hostname or IP address of the Redis server to use.'; \ No newline at end of file diff --git a/cache/stores/redis/lib.php b/cache/stores/redis/lib.php new file mode 100755 index 0000000000000..16ad4c298e285 --- /dev/null +++ b/cache/stores/redis/lib.php @@ -0,0 +1,494 @@ +. + +/** + * Redis Cache Store - Main library + * + * @package cachestore_redis + * @copyright 2013 Adam Durana + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Redis Cache Store + * + * To allow separation of definitions in Moodle and faster purging, each cache + * is implemented as a Redis hash. That is a trade-off between having functionality of TTL + * and being able to manage many caches in a single redis instance. Given the recommendation + * not to use TTL if at all possible and the benefits of having many stores in Redis using the + * hash configuration, the hash implementation has been used. + * + * @copyright 2013 Adam Durana + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class cachestore_redis extends cache_store implements cache_is_key_aware, cache_is_lockable, + cache_is_configurable, cache_is_searchable { + /** + * Name of this store. + * + * @var string + */ + protected $name; + + /** + * The definition hash, used for hash key + * + * @var string + */ + protected $hash; + + /** + * Flag for readiness! + * + * @var boolean + */ + protected $isready = false; + + /** + * Cache definition for this store. + * + * @var cache_definition + */ + protected $definition = null; + + /** + * Connection to Redis for this store. + * + * @var Redis + */ + protected $redis; + + /** + * Determines if the requirements for this type of store are met. + * + * @return bool + */ + public static function are_requirements_met() { + return class_exists('Redis'); + } + + /** + * Determines if this type of store supports a given mode. + * + * @param int $mode + * @return bool + */ + public static function is_supported_mode($mode) { + return ($mode === self::MODE_APPLICATION || $mode === self::MODE_SESSION); + } + + /** + * Get the features of this type of cache store. + * + * @param array $configuration + * @return int + */ + public static function get_supported_features(array $configuration = array()) { + return self::SUPPORTS_DATA_GUARANTEE + self::DEREFERENCES_OBJECTS + self::IS_SEARCHABLE; + } + + /** + * Get the supported modes of this type of cache store. + * + * @param array $configuration + * @return int + */ + public static function get_supported_modes(array $configuration = array()) { + return self::MODE_APPLICATION + self::MODE_SESSION; + } + + /** + * Constructs an instance of this type of store. + * + * @param string $name + * @param array $configuration + */ + public function __construct($name, array $configuration = array()) { + $this->name = $name; + + if (!array_key_exists('server', $configuration) || empty($configuration['server'])) { + return; + } + $prefix = !empty($configuration['prefix']) ? $configuration['prefix'] : ''; + $this->redis = $this->new_redis($configuration['server'], $prefix); + } + + /** + * Create a new Redis instance and + * connect to the server. + * + * @param string $server The server connection string + * @param string $prefix The key prefix + * @return Redis + */ + protected function new_redis($server, $prefix = '') { + $redis = new Redis(); + $port = null; + if (strpos($server, ':')) { + $serverconf = explode(':', $server); + $server = $serverconf[0]; + $port = $serverconf[1]; + } + if ($redis->connect($server, $port)) { + $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP); + if (!empty($prefix)) { + $redis->setOption(Redis::OPT_PREFIX, $prefix); + } + // Database setting option... + $this->isready = $this->ping($redis); + } else { + $this->isready = false; + } + return $redis; + } + + /** + * See if we can ping Redis server + * + * @param Redis $redis + * @return bool + */ + protected function ping(Redis $redis) { + try { + if ($redis->ping() === false) { + return false; + } + } catch (Exception $e) { + return false; + } + return true; + } + + /** + * Get the name of the store. + * + * @return string + */ + public function my_name() { + return $this->name; + } + + /** + * Initialize the store. + * + * @param cache_definition $definition + * @return bool + */ + public function initialise(cache_definition $definition) { + $this->definition = $definition; + $this->hash = $definition->generate_definition_hash(); + return true; + } + + /** + * Determine if the store is initialized. + * + * @return bool + */ + public function is_initialised() { + return ($this->definition !== null); + } + + /** + * Determine if the store is ready for use. + * + * @return bool + */ + public function is_ready() { + return $this->isready; + } + + /** + * Get the value associated with a given key. + * + * @param string $key The key to get the value of. + * @return mixed The value of the key, or false if there is no value associated with the key. + */ + public function get($key) { + return $this->redis->hGet($this->hash, $key); + } + + /** + * Get the values associated with a list of keys. + * + * @param array $keys The keys to get the values of. + * @return array An array of the values of the given keys. + */ + public function get_many($keys) { + return $this->redis->hMGet($this->hash, $keys); + } + + /** + * Set the value of a key. + * + * @param string $key The key to set the value of. + * @param mixed $value The value. + * @return bool True if the operation succeeded, false otherwise. + */ + public function set($key, $value) { + return ($this->redis->hSet($this->hash, $key, $value) !== false); + } + + /** + * Set the values of many keys. + * + * @param array $keyvaluearray An array of key/value pairs. Each item in the array is an associative array + * with two keys, 'key' and 'value'. + * @return int The number of key/value pairs successfuly set. + */ + public function set_many(array $keyvaluearray) { + $pairs = []; + foreach ($keyvaluearray as $pair) { + $pairs[$pair['key']] = $pair['value']; + } + if ($this->redis->hMSet($this->hash, $pairs)) { + return count($pairs); + } + return 0; + } + + /** + * Delete the given key. + * + * @param string $key The key to delete. + * @return bool True if the delete operation succeeds, false otherwise. + */ + public function delete($key) { + return ($this->redis->hDel($this->hash, $key) > 0); + } + + /** + * Delete many keys. + * + * @param array $keys The keys to delete. + * @return int The number of keys successfully deleted. + */ + public function delete_many(array $keys) { + // Redis needs the hash as the first argument, so we have to put it at the start of the array. + array_unshift($keys, $this->hash); + return call_user_func_array(array($this->redis, 'hDel'), $keys); + } + + /** + * Purges all keys from the store. + * + * @return bool + */ + public function purge() { + return ($this->redis->del($this->hash) !== false); + } + + /** + * Cleans up after an instance of the store. + */ + public function instance_deleted() { + $this->purge(); + $this->redis->close(); + unset($this->redis); + } + + /** + * Determines if the store has a given key. + * + * @see cache_is_key_aware + * @param string $key The key to check for. + * @return bool True if the key exists, false if it does not. + */ + public function has($key) { + return $this->redis->hExists($this->hash, $key); + } + + /** + * Determines if the store has any of the keys in a list. + * + * @see cache_is_key_aware + * @param array $keys The keys to check for. + * @return bool True if any of the keys are found, false none of the keys are found. + */ + public function has_any(array $keys) { + foreach ($keys as $key) { + if ($this->has($key)) { + return true; + } + } + return false; + } + + /** + * Determines if the store has all of the keys in a list. + * + * @see cache_is_key_aware + * @param array $keys The keys to check for. + * @return bool True if all of the keys are found, false otherwise. + */ + public function has_all(array $keys) { + foreach ($keys as $key) { + if (!$this->has($key)) { + return false; + } + } + return true; + } + + /** + * Tries to acquire a lock with a given name. + * + * @see cache_is_lockable + * @param string $key Name of the lock to acquire. + * @param string $ownerid Information to identify owner of lock if acquired. + * @return bool True if the lock was acquired, false if it was not. + */ + public function acquire_lock($key, $ownerid) { + return $this->redis->setnx($key, $ownerid); + } + + /** + * Checks a lock with a given name and owner information. + * + * @see cache_is_lockable + * @param string $key Name of the lock to check. + * @param string $ownerid Owner information to check existing lock against. + * @return mixed True if the lock exists and the owner information matches, null if the lock does not + * exist, and false otherwise. + */ + public function check_lock_state($key, $ownerid) { + $result = $this->redis->get($key); + if ($result === $ownerid) { + return true; + } + if ($result === false) { + return null; + } + return false; + } + + /** + * Finds all of the keys being used by this cache store instance. + * + * @return array of all keys in the hash as a numbered array. + */ + public function find_all() { + return $this->redis->hKeys($this->hash); + } + + /** + * Finds all of the keys whose keys start with the given prefix. + * + * @param string $prefix + * + * @return array List of keys that match this prefix. + */ + public function find_by_prefix($prefix) { + $return = []; + foreach ($this->find_all() as $key) { + if (strpos($key, $prefix) === 0) { + $return[] = $key; + } + } + return $return; + } + + /** + * Releases a given lock if the owner information matches. + * + * @see cache_is_lockable + * @param string $key Name of the lock to release. + * @param string $ownerid Owner information to use. + * @return bool True if the lock is released, false if it is not. + */ + public function release_lock($key, $ownerid) { + if ($this->check_lock_state($key, $ownerid)) { + return ($this->redis->del($key) !== false); + } + return false; + } + + /** + * Creates a configuration array from given 'add instance' form data. + * + * @see cache_is_configurable + * @param stdClass $data + * @return array + */ + public static function config_get_configuration_array($data) { + return array('server' => $data->server, 'prefix' => $data->prefix); + } + + /** + * Sets form data from a configuration array. + * + * @see cache_is_configurable + * @param moodleform $editform + * @param array $config + */ + public static function config_set_edit_form_data(moodleform $editform, array $config) { + $data = array(); + $data['server'] = $config['server']; + $data['prefix'] = !empty($config['prefix']) ? $config['prefix'] : ''; + $editform->set_data($data); + } + + + /** + * Creates an instance of the store for testing. + * + * @param cache_definition $definition + * @return mixed An instance of the store, or false if an instance cannot be created. + */ + public static function initialise_test_instance(cache_definition $definition) { + if (!self::are_requirements_met()) { + return false; + } + $config = get_config('cachestore_redis'); + if (empty($config->test_server)) { + return false; + } + $cache = new cachestore_redis('Redis test', ['server' => $config->test_server]); + $cache->initialise($definition); + + return $cache; + } + + /** + * Return configuration to use when unit testing. + * + * @return array + */ + public static function unit_test_configuration() { + global $DB; + + if (!self::are_requirements_met() || !self::ready_to_be_used_for_testing()) { + throw new moodle_exception('TEST_CACHESTORE_REDIS_TESTSERVERS not configured, unable to create test configuration'); + } + + return ['server' => TEST_CACHESTORE_REDIS_TESTSERVERS, + 'prefix' => $DB->get_prefix(), + ]; + } + + /** + * Returns true if this cache store instance is both suitable for testing, and ready for testing. + * + * When TEST_CACHESTORE_REDIS_TESTSERVERS is set, then we are ready to be use d for testing. + * + * @return bool + */ + public static function ready_to_be_used_for_testing() { + return defined('TEST_CACHESTORE_REDIS_TESTSERVERS'); + } +} diff --git a/cache/stores/redis/settings.php b/cache/stores/redis/settings.php new file mode 100755 index 0000000000000..1d10cf81e0ae9 --- /dev/null +++ b/cache/stores/redis/settings.php @@ -0,0 +1,36 @@ +. + +/** + * Redis Cache Store - Settings + * + * @package cachestore_redis + * @copyright 2013 Adam Durana + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +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 + ) +); diff --git a/cache/stores/redis/tests/redis_test.php b/cache/stores/redis/tests/redis_test.php new file mode 100644 index 0000000000000..1fa3861b5c782 --- /dev/null +++ b/cache/stores/redis/tests/redis_test.php @@ -0,0 +1,126 @@ +. + +/** + * Redis cache 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 Copyright (c) 2015 Moodlerooms Inc. (http://www.moodlerooms.com) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__.'/../../../tests/fixtures/stores.php'); +require_once(__DIR__.'/../lib.php'); + +/** + * Redis cache test. + * + * @package cachestore_redis + * @copyright Copyright (c) 2015 Moodlerooms Inc. (http://www.moodlerooms.com) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class cachestore_redis_test extends cachestore_tests { + /** + * @var cachestore_redis + */ + protected $store; + + /** + * Returns the MongoDB class name + * + * @return string + */ + protected function get_class_name() { + return 'cachestore_redis'; + } + + public function setUp() { + 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() { + 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() { + /** @var cache_definition $definition */ + $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; + + if (!$store) { + $this->markTestSkipped(); + } + + return $store; + } + + public function test_has() { + $store = $this->create_cachestore_redis(); + + $this->assertTrue($store->set('foo', 'bar')); + $this->assertTrue($store->has('foo')); + $this->assertFalse($store->has('bat')); + } + + public function test_has_any() { + $store = $this->create_cachestore_redis(); + + $this->assertTrue($store->set('foo', 'bar')); + $this->assertTrue($store->has_any(array('bat', 'foo'))); + $this->assertFalse($store->has_any(array('bat', 'baz'))); + } + + public function test_has_all() { + $store = $this->create_cachestore_redis(); + + $this->assertTrue($store->set('foo', 'bar')); + $this->assertTrue($store->set('bat', 'baz')); + $this->assertTrue($store->has_all(array('foo', 'bat'))); + $this->assertFalse($store->has_all(array('foo', 'bat', 'this'))); + } + + public function test_lock() { + $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')); + } +} \ No newline at end of file diff --git a/cache/stores/redis/version.php b/cache/stores/redis/version.php new file mode 100755 index 0000000000000..af51d62d7f1b2 --- /dev/null +++ b/cache/stores/redis/version.php @@ -0,0 +1,31 @@ +. + +/** + * Redis Cache Store - Version information + * + * @package cachestore_redis + * @copyright 2013 Adam Durana + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->version = 2016012600; +$plugin->requires = 2015111604; // Requires this Moodle version (3.0.4). +$plugin->maturity = MATURITY_STABLE; +$plugin->component = 'cachestore_redis'; +$plugin->release = '3.0.4 (Build: 20160509)'; diff --git a/cache/tests/fixtures/stores.php b/cache/tests/fixtures/stores.php index 7048a7d120e66..4f9c757319285 100644 --- a/cache/tests/fixtures/stores.php +++ b/cache/tests/fixtures/stores.php @@ -106,12 +106,25 @@ public function run_tests(cache_store $instance) { $this->assertTrue($instance->set('test1', 'test1')); $this->assertTrue($instance->set('test2', 'test2')); $this->assertTrue($instance->set('test3', '3')); + $this->assertTrue($instance->set('other3', '3')); // Test get with a string. $this->assertSame('test1', $instance->get('test1')); $this->assertSame('test2', $instance->get('test2')); $this->assertSame('3', $instance->get('test3')); + // Test find and find with prefix if this class implements the searchable interface. + if ($instance->is_searchable()) { + // Extra settings here ignore the return order of the array. + $this->assertEquals(['test3', 'test1', 'test2', 'other3'], $instance->find_all(), '', 0, 1, true); + + // Extra settings here ignore the return order of the array. + $this->assertEquals(['test2', 'test1', 'test3'], $instance->find_by_prefix('test'), '', 0, 1, true); + $this->assertEquals(['test2'], $instance->find_by_prefix('test2')); + $this->assertEquals(['other3'], $instance->find_by_prefix('other')); + $this->assertEquals([], $instance->find_by_prefix('nothere')); + } + // Test set with an int. $this->assertTrue($instance->set('test1', 1)); $this->assertTrue($instance->set('test2', 2)); diff --git a/lib/classes/plugin_manager.php b/lib/classes/plugin_manager.php index 047ad738b9032..9f56863e77949 100644 --- a/lib/classes/plugin_manager.php +++ b/lib/classes/plugin_manager.php @@ -1744,7 +1744,7 @@ public static function standard_plugins_list($type) { ), 'cachestore' => array( - 'file', 'memcache', 'memcached', 'mongodb', 'session', 'static', 'apcu' + 'file', 'memcache', 'memcached', 'mongodb', 'session', 'static', 'apcu', 'redis' ), 'calendartype' => array(