2 // This file is part of Moodle - http://moodle.org/
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
18 * Redis Cache Store - Main library
20 * @package cachestore_redis
21 * @copyright 2013 Adam Durana
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25 defined('MOODLE_INTERNAL') ||
die();
30 * To allow separation of definitions in Moodle and faster purging, each cache
31 * is implemented as a Redis hash. That is a trade-off between having functionality of TTL
32 * and being able to manage many caches in a single redis instance. Given the recommendation
33 * not to use TTL if at all possible and the benefits of having many stores in Redis using the
34 * hash configuration, the hash implementation has been used.
36 * @copyright 2013 Adam Durana
37 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
39 class cachestore_redis
extends cache_store
implements cache_is_key_aware
, cache_is_lockable
,
40 cache_is_configurable
, cache_is_searchable
{
44 const COMPRESSOR_NONE
= 0;
47 * Compressor: PHP GZip.
49 const COMPRESSOR_PHP_GZIP
= 1;
52 * Compressor: PHP Zstandard.
54 const COMPRESSOR_PHP_ZSTD
= 2;
57 * @var string Suffix used on key name (for hash) to store the TTL sorted list
59 const TTL_SUFFIX
= '_ttl';
62 * @var int Number of items to delete from cache in one batch when expiring old TTL data.
64 const TTL_EXPIRE_BATCH
= 10000;
74 * The definition hash, used for hash key
85 protected $isready = false;
88 * Cache definition for this store.
90 * @var cache_definition
92 protected $definition = null;
95 * Connection to Redis for this store.
102 * Serializer for this store.
106 protected $serializer = Redis
::SERIALIZER_PHP
;
109 * Compressor for this store.
113 protected $compressor = self
::COMPRESSOR_NONE
;
116 * Bytes read or written by last call to set()/get() or set_many()/get_many().
120 protected $lastiobytes = 0;
122 /** @var int Maximum number of seconds to wait for a lock before giving up. */
123 protected $lockwait = 60;
125 /** @var int Timeout before lock is automatically released (in case of crashes) */
126 protected $locktimeout = 600;
128 /** @var ?array Array of current locks, or null if we haven't registered shutdown function */
129 protected $currentlocks = null;
132 * Determines if the requirements for this type of store are met.
136 public static function are_requirements_met() {
137 return class_exists('Redis');
141 * Determines if this type of store supports a given mode.
146 public static function is_supported_mode($mode) {
147 return ($mode === self
::MODE_APPLICATION ||
$mode === self
::MODE_SESSION
);
151 * Get the features of this type of cache store.
153 * @param array $configuration
156 public static function get_supported_features(array $configuration = array()) {
157 // Although this plugin now supports TTL I did not add SUPPORTS_NATIVE_TTL here, because
158 // doing so would cause Moodle to stop adding a 'TTL wrapper' to data items which enforces
159 // the precise specified TTL. Unless the scheduled task is set to run rather frequently,
160 // this could cause change in behaviour. Maybe later this should be reconsidered...
161 return self
::SUPPORTS_DATA_GUARANTEE + self
::DEREFERENCES_OBJECTS + self
::IS_SEARCHABLE
;
165 * Get the supported modes of this type of cache store.
167 * @param array $configuration
170 public static function get_supported_modes(array $configuration = array()) {
171 return self
::MODE_APPLICATION + self
::MODE_SESSION
;
175 * Constructs an instance of this type of store.
177 * @param string $name
178 * @param array $configuration
180 public function __construct($name, array $configuration = array()) {
183 if (!array_key_exists('server', $configuration) ||
empty($configuration['server'])) {
186 if (array_key_exists('serializer', $configuration)) {
187 $this->serializer
= (int)$configuration['serializer'];
189 if (array_key_exists('compressor', $configuration)) {
190 $this->compressor
= (int)$configuration['compressor'];
192 $password = !empty($configuration['password']) ?
$configuration['password'] : '';
193 $prefix = !empty($configuration['prefix']) ?
$configuration['prefix'] : '';
194 if (array_key_exists('lockwait', $configuration)) {
195 $this->lockwait
= (int)$configuration['lockwait'];
197 if (array_key_exists('locktimeout', $configuration)) {
198 $this->locktimeout
= (int)$configuration['locktimeout'];
200 $this->redis
= $this->new_redis($configuration['server'], $prefix, $password);
204 * Create a new Redis instance and
205 * connect to the server.
207 * @param string $server The server connection string
208 * @param string $prefix The key prefix
209 * @param string $password The server connection password
212 protected function new_redis($server, $prefix = '', $password = '') {
213 $redis = new Redis();
214 // Check if it isn't a Unix socket to set default port.
215 $port = ($server[0] === '/') ?
null : 6379;
216 if (strpos($server, ':')) {
217 $serverconf = explode(':', $server);
218 $server = $serverconf[0];
219 $port = $serverconf[1];
223 if ($redis->connect($server, $port)) {
224 if (!empty($password)) {
225 $redis->auth($password);
227 // If using compressor, serialisation will be done at cachestore level, not php-redis.
228 if ($this->compressor
== self
::COMPRESSOR_NONE
) {
229 $redis->setOption(Redis
::OPT_SERIALIZER
, $this->serializer
);
231 if (!empty($prefix)) {
232 $redis->setOption(Redis
::OPT_PREFIX
, $prefix);
234 $this->isready
= true;
236 $this->isready
= false;
238 } catch (\RedisException
$e) {
239 $this->isready
= false;
246 * See if we can ping Redis server
248 * @param Redis $redis
251 protected function ping(Redis
$redis) {
253 if ($redis->ping() === false) {
256 } catch (Exception
$e) {
263 * Get the name of the store.
267 public function my_name() {
272 * Initialize the store.
274 * @param cache_definition $definition
277 public function initialise(cache_definition
$definition) {
278 $this->definition
= $definition;
279 $this->hash
= $definition->generate_definition_hash();
284 * Determine if the store is initialized.
288 public function is_initialised() {
289 return ($this->definition
!== null);
293 * Determine if the store is ready for use.
297 public function is_ready() {
298 return $this->isready
;
302 * Get the value associated with a given key.
304 * @param string $key The key to get the value of.
305 * @return mixed The value of the key, or false if there is no value associated with the key.
307 public function get($key) {
308 $value = $this->redis
->hGet($this->hash
, $key);
310 if ($this->compressor
== self
::COMPRESSOR_NONE
) {
314 // When using compression, values are always strings, so strlen will work.
315 $this->lastiobytes
= strlen($value);
317 return $this->uncompress($value);
321 * Get the values associated with a list of keys.
323 * @param array $keys The keys to get the values of.
324 * @return array An array of the values of the given keys.
326 public function get_many($keys) {
327 $values = $this->redis
->hMGet($this->hash
, $keys);
329 if ($this->compressor
== self
::COMPRESSOR_NONE
) {
333 $this->lastiobytes
= 0;
334 foreach ($values as &$value) {
335 $this->lastiobytes +
= strlen($value);
336 $value = $this->uncompress($value);
343 * Gets the number of bytes read from or written to cache as a result of the last action.
345 * If compression is not enabled, this function always returns IO_BYTES_NOT_SUPPORTED. The reason is that
346 * when compression is not enabled, data sent to the cache is not serialized, and we would
347 * need to serialize it to compute the size, which would have a significant performance cost.
349 * @return int Bytes read or written
352 public function get_last_io_bytes(): int {
353 if ($this->compressor
!= self
::COMPRESSOR_NONE
) {
354 return $this->lastiobytes
;
356 // Not supported unless compression is on.
357 return parent
::get_last_io_bytes();
362 * Set the value of a key.
364 * @param string $key The key to set the value of.
365 * @param mixed $value The value.
366 * @return bool True if the operation succeeded, false otherwise.
368 public function set($key, $value) {
369 if ($this->compressor
!= self
::COMPRESSOR_NONE
) {
370 $value = $this->compress($value);
371 $this->lastiobytes
= strlen($value);
374 if ($this->redis
->hSet($this->hash
, $key, $value) === false) {
377 if ($this->definition
->get_ttl()) {
378 // When TTL is enabled, we also store the key name in a list sorted by the current time.
379 $this->redis
->zAdd($this->hash
. self
::TTL_SUFFIX
, [], self
::get_time(), $key);
380 // The return value to the zAdd function never indicates whether the operation succeeded
381 // (it returns zero when there was no error if the item is already in the list) so we
388 * Set the values of many keys.
390 * @param array $keyvaluearray An array of key/value pairs. Each item in the array is an associative array
391 * with two keys, 'key' and 'value'.
392 * @return int The number of key/value pairs successfuly set.
394 public function set_many(array $keyvaluearray) {
397 if ($this->definition
->get_ttl()) {
400 $now = self
::get_time();
403 $this->lastiobytes
= 0;
404 foreach ($keyvaluearray as $pair) {
406 if ($this->compressor
!= self
::COMPRESSOR_NONE
) {
407 $pairs[$key] = $this->compress($pair['value']);
408 $this->lastiobytes +
= strlen($pairs[$key]);
410 $pairs[$key] = $pair['value'];
413 // When TTL is enabled, we also store the key names in a list sorted by the current
420 // Store all the key values with current time.
421 $this->redis
->zAdd($this->hash
. self
::TTL_SUFFIX
, [], ...$ttlparams);
422 // The return value to the zAdd function never indicates whether the operation succeeded
423 // (it returns zero when there was no error if the item is already in the list) so we
426 if ($this->redis
->hMSet($this->hash
, $pairs)) {
427 return count($pairs);
433 * Delete the given key.
435 * @param string $key The key to delete.
436 * @return bool True if the delete operation succeeds, false otherwise.
438 public function delete($key) {
440 if (!$this->redis
->hDel($this->hash
, $key)) {
443 if ($this->definition
->get_ttl()) {
444 // When TTL is enabled, also remove the key from the TTL list.
445 $this->redis
->zRem($this->hash
. self
::TTL_SUFFIX
, $key);
453 * @param array $keys The keys to delete.
454 * @return int The number of keys successfully deleted.
456 public function delete_many(array $keys) {
457 // If there are no keys to delete, do nothing.
461 $count = $this->redis
->hDel($this->hash
, ...$keys);
462 if ($this->definition
->get_ttl()) {
463 // When TTL is enabled, also remove the keys from the TTL list.
464 $this->redis
->zRem($this->hash
. self
::TTL_SUFFIX
, ...$keys);
470 * Purges all keys from the store.
474 public function purge() {
475 if ($this->definition
->get_ttl()) {
476 // Purge the TTL list as well.
477 $this->redis
->del($this->hash
. self
::TTL_SUFFIX
);
478 // According to documentation, there is no error return for the 'del' command (it
479 // only returns the number of keys deleted, which could be 0 or 1 in this case) so we
480 // do not need to check the return value.
482 return ($this->redis
->del($this->hash
) !== false);
486 * Cleans up after an instance of the store.
488 public function instance_deleted() {
489 $this->redis
->close();
494 * Determines if the store has a given key.
496 * @see cache_is_key_aware
497 * @param string $key The key to check for.
498 * @return bool True if the key exists, false if it does not.
500 public function has($key) {
501 return !empty($this->redis
->hExists($this->hash
, $key));
505 * Determines if the store has any of the keys in a list.
507 * @see cache_is_key_aware
508 * @param array $keys The keys to check for.
509 * @return bool True if any of the keys are found, false none of the keys are found.
511 public function has_any(array $keys) {
512 foreach ($keys as $key) {
513 if ($this->has($key)) {
521 * Determines if the store has all of the keys in a list.
523 * @see cache_is_key_aware
524 * @param array $keys The keys to check for.
525 * @return bool True if all of the keys are found, false otherwise.
527 public function has_all(array $keys) {
528 foreach ($keys as $key) {
529 if (!$this->has($key)) {
537 * Tries to acquire a lock with a given name.
539 * @see cache_is_lockable
540 * @param string $key Name of the lock to acquire.
541 * @param string $ownerid Information to identify owner of lock if acquired.
542 * @return bool True if the lock was acquired, false if it was not.
544 public function acquire_lock($key, $ownerid) {
545 $timelimit = time() +
$this->lockwait
;
547 // If the key doesn't already exist, grab it and return true.
548 if ($this->redis
->setnx($key, $ownerid)) {
549 // Ensure Redis deletes the key after a bit in case something goes wrong.
550 $this->redis
->expire($key, $this->locktimeout
);
551 // If we haven't got it already, better register a shutdown function.
552 if ($this->currentlocks
=== null) {
553 core_shutdown_manager
::register_function([$this, 'shutdown_release_locks']);
554 $this->currentlocks
= [];
556 $this->currentlocks
[$key] = $ownerid;
559 // Wait 1 second then retry.
561 } while (time() < $timelimit);
566 * Releases any locks when the system shuts down, in case there is a crash or somebody forgets
567 * to use 'try-finally'.
569 * Do not call this function manually (except from unit test).
571 public function shutdown_release_locks() {
572 foreach ($this->currentlocks
as $key => $ownerid) {
573 debugging('Automatically releasing Redis cache lock: ' . $key . ' (' . $ownerid .
574 ') - did somebody forget to call release_lock()?', DEBUG_DEVELOPER
);
575 $this->release_lock($key, $ownerid);
580 * Checks a lock with a given name and owner information.
582 * @see cache_is_lockable
583 * @param string $key Name of the lock to check.
584 * @param string $ownerid Owner information to check existing lock against.
585 * @return mixed True if the lock exists and the owner information matches, null if the lock does not
586 * exist, and false otherwise.
588 public function check_lock_state($key, $ownerid) {
589 $result = $this->redis
->get($key);
590 if ($result === (string)$ownerid) {
593 if ($result === false) {
600 * Finds all of the keys being used by this cache store instance.
602 * @return array of all keys in the hash as a numbered array.
604 public function find_all() {
605 return $this->redis
->hKeys($this->hash
);
609 * Finds all of the keys whose keys start with the given prefix.
611 * @param string $prefix
613 * @return array List of keys that match this prefix.
615 public function find_by_prefix($prefix) {
617 foreach ($this->find_all() as $key) {
618 if (strpos($key, $prefix) === 0) {
626 * Releases a given lock if the owner information matches.
628 * @see cache_is_lockable
629 * @param string $key Name of the lock to release.
630 * @param string $ownerid Owner information to use.
631 * @return bool True if the lock is released, false if it is not.
633 public function release_lock($key, $ownerid) {
634 if ($this->check_lock_state($key, $ownerid)) {
635 unset($this->currentlocks
[$key]);
636 return ($this->redis
->del($key) !== false);
642 * Runs TTL expiry process for this cache.
644 * This is not part of the standard cache API and is intended for use by the scheduled task
645 * \cachestore_redis\ttl.
647 * @return array Various keys with information about how the expiry went
649 public function expire_ttl(): array {
650 $ttl = $this->definition
->get_ttl();
652 throw new \
coding_exception('Cache definition ' . $this->definition
->get_id() . ' does not use TTL');
654 $limit = self
::get_time() - $ttl;
657 $timebefore = microtime(true);
658 $memorybefore = $this->store_total_size();
660 $keys = $this->redis
->zRangeByScore($this->hash
. self
::TTL_SUFFIX
, 0, $limit,
661 ['limit' => [0, self
::TTL_EXPIRE_BATCH
]]);
662 $this->delete_many($keys);
663 $count +
= count($keys);
665 } while (count($keys) === self
::TTL_EXPIRE_BATCH
);
666 $memoryafter = $this->store_total_size();
667 $timeafter = microtime(true);
669 $result = ['keys' => $count, 'batches' => $batches, 'time' => $timeafter - $timebefore];
670 if ($memorybefore !== null) {
671 $result['memory'] = $memorybefore - $memoryafter;
677 * Gets the current time for TTL functionality. This wrapper makes it easier to unit-test
680 * @return int Current time
682 protected static function get_time(): int {
684 if (PHPUNIT_TEST
&& !empty($CFG->phpunit_cachestore_redis_time
)) {
685 return $CFG->phpunit_cachestore_redis_time
;
691 * Sets the current time (within unit test) for TTL functionality.
693 * This setting is stored in $CFG so will be automatically reset if you use resetAfterTest.
695 * @param int $time Current time (set 0 to start using real time).
697 public static function set_phpunit_time(int $time = 0): void
{
700 throw new \
coding_exception('Function only available during unit test');
703 $CFG->phpunit_cachestore_redis_time
= $time;
705 unset($CFG->phpunit_cachestore_redis_time
);
710 * Estimates the stored size, taking into account whether compression is turned on.
712 * @param mixed $key Key name
713 * @param mixed $value Value
714 * @return int Approximate stored size
716 public function estimate_stored_size($key, $value): int {
717 if ($this->compressor
== self
::COMPRESSOR_NONE
) {
718 // If uncompressed, use default estimate.
719 return parent
::estimate_stored_size($key, $value);
721 // If compressed, compress value.
722 return strlen($this->serialize($key)) +
strlen($this->compress($value));
727 * Gets Redis reported memory usage.
729 * @return int|null Memory used by Redis or null if we don't know
731 public function store_total_size(): ?
int {
733 $details = $this->redis
->info('MEMORY');
734 } catch (\RedisException
$e) {
737 if (empty($details['used_memory'])) {
740 return (int)$details['used_memory'];
745 * Creates a configuration array from given 'add instance' form data.
747 * @see cache_is_configurable
748 * @param stdClass $data
751 public static function config_get_configuration_array($data) {
753 'server' => $data->server
,
754 'prefix' => $data->prefix
,
755 'password' => $data->password
,
756 'serializer' => $data->serializer
,
757 'compressor' => $data->compressor
,
762 * Sets form data from a configuration array.
764 * @see cache_is_configurable
765 * @param moodleform $editform
766 * @param array $config
768 public static function config_set_edit_form_data(moodleform
$editform, array $config) {
770 $data['server'] = $config['server'];
771 $data['prefix'] = !empty($config['prefix']) ?
$config['prefix'] : '';
772 $data['password'] = !empty($config['password']) ?
$config['password'] : '';
773 if (!empty($config['serializer'])) {
774 $data['serializer'] = $config['serializer'];
776 if (!empty($config['compressor'])) {
777 $data['compressor'] = $config['compressor'];
779 $editform->set_data($data);
784 * Creates an instance of the store for testing.
786 * @param cache_definition $definition
787 * @return mixed An instance of the store, or false if an instance cannot be created.
789 public static function initialise_test_instance(cache_definition
$definition) {
790 if (!self
::are_requirements_met()) {
793 $config = get_config('cachestore_redis');
794 if (empty($config->test_server
)) {
797 $configuration = array('server' => $config->test_server
);
798 if (!empty($config->test_serializer
)) {
799 $configuration['serializer'] = $config->test_serializer
;
801 if (!empty($config->test_password
)) {
802 $configuration['password'] = $config->test_password
;
804 // Make it possible to test TTL performance by hacking a copy of the cache definition.
805 if (!empty($config->test_ttl
)) {
806 $definition = clone $definition;
807 $property = (new ReflectionClass($definition))->getProperty('ttl');
808 $property->setAccessible(true);
809 $property->setValue($definition, 999);
811 $cache = new cachestore_redis('Redis test', $configuration);
812 $cache->initialise($definition);
818 * Return configuration to use when unit testing.
822 public static function unit_test_configuration() {
825 if (!self
::are_requirements_met() ||
!self
::ready_to_be_used_for_testing()) {
826 throw new moodle_exception('TEST_CACHESTORE_REDIS_TESTSERVERS not configured, unable to create test configuration');
829 return ['server' => TEST_CACHESTORE_REDIS_TESTSERVERS
,
830 'prefix' => $DB->get_prefix(),
835 * Returns true if this cache store instance is both suitable for testing, and ready for testing.
837 * When TEST_CACHESTORE_REDIS_TESTSERVERS is set, then we are ready to be use d for testing.
841 public static function ready_to_be_used_for_testing() {
842 return defined('TEST_CACHESTORE_REDIS_TESTSERVERS');
846 * Gets an array of options to use as the serialiser.
849 public static function config_get_serializer_options() {
851 Redis
::SERIALIZER_PHP
=> get_string('serializer_php', 'cachestore_redis')
854 if (defined('Redis::SERIALIZER_IGBINARY')) {
855 $options[Redis
::SERIALIZER_IGBINARY
] = get_string('serializer_igbinary', 'cachestore_redis');
861 * Gets an array of options to use as the compressor.
865 public static function config_get_compressor_options() {
867 self
::COMPRESSOR_NONE
=> get_string('compressor_none', 'cachestore_redis'),
868 self
::COMPRESSOR_PHP_GZIP
=> get_string('compressor_php_gzip', 'cachestore_redis'),
871 // Check if the Zstandard PHP extension is installed.
872 if (extension_loaded('zstd')) {
873 $arr[self
::COMPRESSOR_PHP_ZSTD
] = get_string('compressor_php_zstd', 'cachestore_redis');
880 * Compress the given value, serializing it first.
882 * @param mixed $value
885 private function compress($value) {
886 $value = $this->serialize($value);
888 switch ($this->compressor
) {
889 case self
::COMPRESSOR_NONE
:
892 case self
::COMPRESSOR_PHP_GZIP
:
893 return gzencode($value);
895 case self
::COMPRESSOR_PHP_ZSTD
:
896 return zstd_compress($value);
899 debugging("Invalid compressor: {$this->compressor}");
905 * Uncompresses (deflates) the data, unserialising it afterwards.
907 * @param string $value
910 private function uncompress($value) {
911 if ($value === false) {
915 switch ($this->compressor
) {
916 case self
::COMPRESSOR_NONE
:
918 case self
::COMPRESSOR_PHP_GZIP
:
919 $value = gzdecode($value);
921 case self
::COMPRESSOR_PHP_ZSTD
:
922 $value = zstd_uncompress($value);
925 debugging("Invalid compressor: {$this->compressor}");
928 return $this->unserialize($value);
932 * Serializes the data according to the configured serializer.
934 * @param mixed $value
937 private function serialize($value) {
938 switch ($this->serializer
) {
939 case Redis
::SERIALIZER_NONE
:
941 case Redis
::SERIALIZER_PHP
:
942 return serialize($value);
943 case defined('Redis::SERIALIZER_IGBINARY') && Redis
::SERIALIZER_IGBINARY
:
944 return igbinary_serialize($value);
946 debugging("Invalid serializer: {$this->serializer}");
952 * Unserializes the data according to the configured serializer
954 * @param string $value
957 private function unserialize($value) {
958 switch ($this->serializer
) {
959 case Redis
::SERIALIZER_NONE
:
961 case Redis
::SERIALIZER_PHP
:
962 return unserialize($value);
963 case defined('Redis::SERIALIZER_IGBINARY') && Redis
::SERIALIZER_IGBINARY
:
964 return igbinary_unserialize($value);
966 debugging("Invalid serializer: {$this->serializer}");