Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

Differences Between: [Versions 311 and 400] [Versions 311 and 401] [Versions 311 and 402] [Versions 311 and 403]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   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.
   8  //
   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.
  13  //
  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/>.
  16  
  17  /**
  18   * Redis Cache Store - Main library
  19   *
  20   * @package   cachestore_redis
  21   * @copyright 2013 Adam Durana
  22   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  defined('MOODLE_INTERNAL') || die();
  26  
  27  /**
  28   * Redis Cache Store
  29   *
  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.
  35   *
  36   * @copyright   2013 Adam Durana
  37   * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  38   */
  39  class cachestore_redis extends cache_store implements cache_is_key_aware, cache_is_lockable,
  40          cache_is_configurable, cache_is_searchable {
  41      /**
  42       * Compressor: none.
  43       */
  44      const COMPRESSOR_NONE = 0;
  45  
  46      /**
  47       * Compressor: PHP GZip.
  48       */
  49      const COMPRESSOR_PHP_GZIP = 1;
  50  
  51      /**
  52       * Compressor: PHP Zstandard.
  53       */
  54      const COMPRESSOR_PHP_ZSTD = 2;
  55  
  56      /**
  57       * Name of this store.
  58       *
  59       * @var string
  60       */
  61      protected $name;
  62  
  63      /**
  64       * The definition hash, used for hash key
  65       *
  66       * @var string
  67       */
  68      protected $hash;
  69  
  70      /**
  71       * Flag for readiness!
  72       *
  73       * @var boolean
  74       */
  75      protected $isready = false;
  76  
  77      /**
  78       * Cache definition for this store.
  79       *
  80       * @var cache_definition
  81       */
  82      protected $definition = null;
  83  
  84      /**
  85       * Connection to Redis for this store.
  86       *
  87       * @var Redis
  88       */
  89      protected $redis;
  90  
  91      /**
  92       * Serializer for this store.
  93       *
  94       * @var int
  95       */
  96      protected $serializer = Redis::SERIALIZER_PHP;
  97  
  98      /**
  99       * Compressor for this store.
 100       *
 101       * @var int
 102       */
 103      protected $compressor = self::COMPRESSOR_NONE;
 104  
 105      /**
 106       * Determines if the requirements for this type of store are met.
 107       *
 108       * @return bool
 109       */
 110      public static function are_requirements_met() {
 111          return class_exists('Redis');
 112      }
 113  
 114      /**
 115       * Determines if this type of store supports a given mode.
 116       *
 117       * @param int $mode
 118       * @return bool
 119       */
 120      public static function is_supported_mode($mode) {
 121          return ($mode === self::MODE_APPLICATION || $mode === self::MODE_SESSION);
 122      }
 123  
 124      /**
 125       * Get the features of this type of cache store.
 126       *
 127       * @param array $configuration
 128       * @return int
 129       */
 130      public static function get_supported_features(array $configuration = array()) {
 131          return self::SUPPORTS_DATA_GUARANTEE + self::DEREFERENCES_OBJECTS + self::IS_SEARCHABLE;
 132      }
 133  
 134      /**
 135       * Get the supported modes of this type of cache store.
 136       *
 137       * @param array $configuration
 138       * @return int
 139       */
 140      public static function get_supported_modes(array $configuration = array()) {
 141          return self::MODE_APPLICATION + self::MODE_SESSION;
 142      }
 143  
 144      /**
 145       * Constructs an instance of this type of store.
 146       *
 147       * @param string $name
 148       * @param array $configuration
 149       */
 150      public function __construct($name, array $configuration = array()) {
 151          $this->name = $name;
 152  
 153          if (!array_key_exists('server', $configuration) || empty($configuration['server'])) {
 154              return;
 155          }
 156          if (array_key_exists('serializer', $configuration)) {
 157              $this->serializer = (int)$configuration['serializer'];
 158          }
 159          if (array_key_exists('compressor', $configuration)) {
 160              $this->compressor = (int)$configuration['compressor'];
 161          }
 162          $password = !empty($configuration['password']) ? $configuration['password'] : '';
 163          $prefix = !empty($configuration['prefix']) ? $configuration['prefix'] : '';
 164          $this->redis = $this->new_redis($configuration['server'], $prefix, $password);
 165      }
 166  
 167      /**
 168       * Create a new Redis instance and
 169       * connect to the server.
 170       *
 171       * @param string $server The server connection string
 172       * @param string $prefix The key prefix
 173       * @param string $password The server connection password
 174       * @return Redis
 175       */
 176      protected function new_redis($server, $prefix = '', $password = '') {
 177          $redis = new Redis();
 178          // Check if it isn't a Unix socket to set default port.
 179          $port = ($server[0] === '/') ? null : 6379;
 180          if (strpos($server, ':')) {
 181              $serverconf = explode(':', $server);
 182              $server = $serverconf[0];
 183              $port = $serverconf[1];
 184          }
 185  
 186          try {
 187              if ($redis->connect($server, $port)) {
 188                  if (!empty($password)) {
 189                      $redis->auth($password);
 190                  }
 191                  // If using compressor, serialisation will be done at cachestore level, not php-redis.
 192                  if ($this->compressor == self::COMPRESSOR_NONE) {
 193                      $redis->setOption(Redis::OPT_SERIALIZER, $this->serializer);
 194                  }
 195                  if (!empty($prefix)) {
 196                      $redis->setOption(Redis::OPT_PREFIX, $prefix);
 197                  }
 198                  // Database setting option...
 199                  $this->isready = $this->ping($redis);
 200              } else {
 201                  $this->isready = false;
 202              }
 203          } catch (\RedisException $e) {
 204              $this->isready = false;
 205          }
 206  
 207          return $redis;
 208      }
 209  
 210      /**
 211       * See if we can ping Redis server
 212       *
 213       * @param Redis $redis
 214       * @return bool
 215       */
 216      protected function ping(Redis $redis) {
 217          try {
 218              if ($redis->ping() === false) {
 219                  return false;
 220              }
 221          } catch (Exception $e) {
 222              return false;
 223          }
 224          return true;
 225      }
 226  
 227      /**
 228       * Get the name of the store.
 229       *
 230       * @return string
 231       */
 232      public function my_name() {
 233          return $this->name;
 234      }
 235  
 236      /**
 237       * Initialize the store.
 238       *
 239       * @param cache_definition $definition
 240       * @return bool
 241       */
 242      public function initialise(cache_definition $definition) {
 243          $this->definition = $definition;
 244          $this->hash       = $definition->generate_definition_hash();
 245          return true;
 246      }
 247  
 248      /**
 249       * Determine if the store is initialized.
 250       *
 251       * @return bool
 252       */
 253      public function is_initialised() {
 254          return ($this->definition !== null);
 255      }
 256  
 257      /**
 258       * Determine if the store is ready for use.
 259       *
 260       * @return bool
 261       */
 262      public function is_ready() {
 263          return $this->isready;
 264      }
 265  
 266      /**
 267       * Get the value associated with a given key.
 268       *
 269       * @param string $key The key to get the value of.
 270       * @return mixed The value of the key, or false if there is no value associated with the key.
 271       */
 272      public function get($key) {
 273          $value = $this->redis->hGet($this->hash, $key);
 274  
 275          if ($this->compressor == self::COMPRESSOR_NONE) {
 276              return $value;
 277          }
 278  
 279          return $this->uncompress($value);
 280      }
 281  
 282      /**
 283       * Get the values associated with a list of keys.
 284       *
 285       * @param array $keys The keys to get the values of.
 286       * @return array An array of the values of the given keys.
 287       */
 288      public function get_many($keys) {
 289          $values = $this->redis->hMGet($this->hash, $keys);
 290  
 291          if ($this->compressor == self::COMPRESSOR_NONE) {
 292              return $values;
 293          }
 294  
 295          foreach ($values as &$value) {
 296              $value = $this->uncompress($value);
 297          }
 298  
 299          return $values;
 300      }
 301  
 302      /**
 303       * Set the value of a key.
 304       *
 305       * @param string $key The key to set the value of.
 306       * @param mixed $value The value.
 307       * @return bool True if the operation succeeded, false otherwise.
 308       */
 309      public function set($key, $value) {
 310          if ($this->compressor != self::COMPRESSOR_NONE) {
 311              $value = $this->compress($value);
 312          }
 313  
 314          return ($this->redis->hSet($this->hash, $key, $value) !== false);
 315      }
 316  
 317      /**
 318       * Set the values of many keys.
 319       *
 320       * @param array $keyvaluearray An array of key/value pairs. Each item in the array is an associative array
 321       *      with two keys, 'key' and 'value'.
 322       * @return int The number of key/value pairs successfuly set.
 323       */
 324      public function set_many(array $keyvaluearray) {
 325          $pairs = [];
 326          foreach ($keyvaluearray as $pair) {
 327              $key = $pair['key'];
 328              if ($this->compressor != self::COMPRESSOR_NONE) {
 329                  $pairs[$key] = $this->compress($pair['value']);
 330              } else {
 331                  $pairs[$key] = $pair['value'];
 332              }
 333          }
 334          if ($this->redis->hMSet($this->hash, $pairs)) {
 335              return count($pairs);
 336          }
 337          return 0;
 338      }
 339  
 340      /**
 341       * Delete the given key.
 342       *
 343       * @param string $key The key to delete.
 344       * @return bool True if the delete operation succeeds, false otherwise.
 345       */
 346      public function delete($key) {
 347          return ($this->redis->hDel($this->hash, $key) > 0);
 348      }
 349  
 350      /**
 351       * Delete many keys.
 352       *
 353       * @param array $keys The keys to delete.
 354       * @return int The number of keys successfully deleted.
 355       */
 356      public function delete_many(array $keys) {
 357          // Redis needs the hash as the first argument, so we have to put it at the start of the array.
 358          array_unshift($keys, $this->hash);
 359          return call_user_func_array(array($this->redis, 'hDel'), $keys);
 360      }
 361  
 362      /**
 363       * Purges all keys from the store.
 364       *
 365       * @return bool
 366       */
 367      public function purge() {
 368          return ($this->redis->del($this->hash) !== false);
 369      }
 370  
 371      /**
 372       * Cleans up after an instance of the store.
 373       */
 374      public function instance_deleted() {
 375          $this->purge();
 376          $this->redis->close();
 377          unset($this->redis);
 378      }
 379  
 380      /**
 381       * Determines if the store has a given key.
 382       *
 383       * @see cache_is_key_aware
 384       * @param string $key The key to check for.
 385       * @return bool True if the key exists, false if it does not.
 386       */
 387      public function has($key) {
 388          return !empty($this->redis->hExists($this->hash, $key));
 389      }
 390  
 391      /**
 392       * Determines if the store has any of the keys in a list.
 393       *
 394       * @see cache_is_key_aware
 395       * @param array $keys The keys to check for.
 396       * @return bool True if any of the keys are found, false none of the keys are found.
 397       */
 398      public function has_any(array $keys) {
 399          foreach ($keys as $key) {
 400              if ($this->has($key)) {
 401                  return true;
 402              }
 403          }
 404          return false;
 405      }
 406  
 407      /**
 408       * Determines if the store has all of the keys in a list.
 409       *
 410       * @see cache_is_key_aware
 411       * @param array $keys The keys to check for.
 412       * @return bool True if all of the keys are found, false otherwise.
 413       */
 414      public function has_all(array $keys) {
 415          foreach ($keys as $key) {
 416              if (!$this->has($key)) {
 417                  return false;
 418              }
 419          }
 420          return true;
 421      }
 422  
 423      /**
 424       * Tries to acquire a lock with a given name.
 425       *
 426       * @see cache_is_lockable
 427       * @param string $key Name of the lock to acquire.
 428       * @param string $ownerid Information to identify owner of lock if acquired.
 429       * @return bool True if the lock was acquired, false if it was not.
 430       */
 431      public function acquire_lock($key, $ownerid) {
 432          return $this->redis->setnx($key, $ownerid);
 433      }
 434  
 435      /**
 436       * Checks a lock with a given name and owner information.
 437       *
 438       * @see cache_is_lockable
 439       * @param string $key Name of the lock to check.
 440       * @param string $ownerid Owner information to check existing lock against.
 441       * @return mixed True if the lock exists and the owner information matches, null if the lock does not
 442       *      exist, and false otherwise.
 443       */
 444      public function check_lock_state($key, $ownerid) {
 445          $result = $this->redis->get($key);
 446          if ($result === $ownerid) {
 447              return true;
 448          }
 449          if ($result === false) {
 450              return null;
 451          }
 452          return false;
 453      }
 454  
 455      /**
 456       * Finds all of the keys being used by this cache store instance.
 457       *
 458       * @return array of all keys in the hash as a numbered array.
 459       */
 460      public function find_all() {
 461          return $this->redis->hKeys($this->hash);
 462      }
 463  
 464      /**
 465       * Finds all of the keys whose keys start with the given prefix.
 466       *
 467       * @param string $prefix
 468       *
 469       * @return array List of keys that match this prefix.
 470       */
 471      public function find_by_prefix($prefix) {
 472          $return = [];
 473          foreach ($this->find_all() as $key) {
 474              if (strpos($key, $prefix) === 0) {
 475                  $return[] = $key;
 476              }
 477          }
 478          return $return;
 479      }
 480  
 481      /**
 482       * Releases a given lock if the owner information matches.
 483       *
 484       * @see cache_is_lockable
 485       * @param string $key Name of the lock to release.
 486       * @param string $ownerid Owner information to use.
 487       * @return bool True if the lock is released, false if it is not.
 488       */
 489      public function release_lock($key, $ownerid) {
 490          if ($this->check_lock_state($key, $ownerid)) {
 491              return ($this->redis->del($key) !== false);
 492          }
 493          return false;
 494      }
 495  
 496      /**
 497       * Creates a configuration array from given 'add instance' form data.
 498       *
 499       * @see cache_is_configurable
 500       * @param stdClass $data
 501       * @return array
 502       */
 503      public static function config_get_configuration_array($data) {
 504          return array(
 505              'server' => $data->server,
 506              'prefix' => $data->prefix,
 507              'password' => $data->password,
 508              'serializer' => $data->serializer,
 509              'compressor' => $data->compressor,
 510          );
 511      }
 512  
 513      /**
 514       * Sets form data from a configuration array.
 515       *
 516       * @see cache_is_configurable
 517       * @param moodleform $editform
 518       * @param array $config
 519       */
 520      public static function config_set_edit_form_data(moodleform $editform, array $config) {
 521          $data = array();
 522          $data['server'] = $config['server'];
 523          $data['prefix'] = !empty($config['prefix']) ? $config['prefix'] : '';
 524          $data['password'] = !empty($config['password']) ? $config['password'] : '';
 525          if (!empty($config['serializer'])) {
 526              $data['serializer'] = $config['serializer'];
 527          }
 528          if (!empty($config['compressor'])) {
 529              $data['compressor'] = $config['compressor'];
 530          }
 531          $editform->set_data($data);
 532      }
 533  
 534  
 535      /**
 536       * Creates an instance of the store for testing.
 537       *
 538       * @param cache_definition $definition
 539       * @return mixed An instance of the store, or false if an instance cannot be created.
 540       */
 541      public static function initialise_test_instance(cache_definition $definition) {
 542          if (!self::are_requirements_met()) {
 543              return false;
 544          }
 545          $config = get_config('cachestore_redis');
 546          if (empty($config->test_server)) {
 547              return false;
 548          }
 549          $configuration = array('server' => $config->test_server);
 550          if (!empty($config->test_serializer)) {
 551              $configuration['serializer'] = $config->test_serializer;
 552          }
 553          if (!empty($config->test_password)) {
 554              $configuration['password'] = $config->test_password;
 555          }
 556          $cache = new cachestore_redis('Redis test', $configuration);
 557          $cache->initialise($definition);
 558  
 559          return $cache;
 560      }
 561  
 562      /**
 563       * Return configuration to use when unit testing.
 564       *
 565       * @return array
 566       */
 567      public static function unit_test_configuration() {
 568          global $DB;
 569  
 570          if (!self::are_requirements_met() || !self::ready_to_be_used_for_testing()) {
 571              throw new moodle_exception('TEST_CACHESTORE_REDIS_TESTSERVERS not configured, unable to create test configuration');
 572          }
 573  
 574          return ['server' => TEST_CACHESTORE_REDIS_TESTSERVERS,
 575                  'prefix' => $DB->get_prefix(),
 576          ];
 577      }
 578  
 579      /**
 580       * Returns true if this cache store instance is both suitable for testing, and ready for testing.
 581       *
 582       * When TEST_CACHESTORE_REDIS_TESTSERVERS is set, then we are ready to be use d for testing.
 583       *
 584       * @return bool
 585       */
 586      public static function ready_to_be_used_for_testing() {
 587          return defined('TEST_CACHESTORE_REDIS_TESTSERVERS');
 588      }
 589  
 590      /**
 591       * Gets an array of options to use as the serialiser.
 592       * @return array
 593       */
 594      public static function config_get_serializer_options() {
 595          $options = array(
 596              Redis::SERIALIZER_PHP => get_string('serializer_php', 'cachestore_redis')
 597          );
 598  
 599          if (defined('Redis::SERIALIZER_IGBINARY')) {
 600              $options[Redis::SERIALIZER_IGBINARY] = get_string('serializer_igbinary', 'cachestore_redis');
 601          }
 602          return $options;
 603      }
 604  
 605      /**
 606       * Gets an array of options to use as the compressor.
 607       *
 608       * @return array
 609       */
 610      public static function config_get_compressor_options() {
 611          $arr = [
 612              self::COMPRESSOR_NONE     => get_string('compressor_none', 'cachestore_redis'),
 613              self::COMPRESSOR_PHP_GZIP => get_string('compressor_php_gzip', 'cachestore_redis'),
 614          ];
 615  
 616          // Check if the Zstandard PHP extension is installed.
 617          if (extension_loaded('zstd')) {
 618              $arr[self::COMPRESSOR_PHP_ZSTD] = get_string('compressor_php_zstd', 'cachestore_redis');
 619          }
 620  
 621          return $arr;
 622      }
 623  
 624      /**
 625       * Compress the given value, serializing it first.
 626       *
 627       * @param mixed $value
 628       * @return string
 629       */
 630      private function compress($value) {
 631          $value = $this->serialize($value);
 632  
 633          switch ($this->compressor) {
 634              case self::COMPRESSOR_NONE:
 635                  return $value;
 636  
 637              case self::COMPRESSOR_PHP_GZIP:
 638                  return gzencode($value);
 639  
 640              case self::COMPRESSOR_PHP_ZSTD:
 641                  return zstd_compress($value);
 642  
 643              default:
 644                  debugging("Invalid compressor: {$this->compressor}");
 645                  return $value;
 646          }
 647      }
 648  
 649      /**
 650       * Uncompresses (deflates) the data, unserialising it afterwards.
 651       *
 652       * @param string $value
 653       * @return mixed
 654       */
 655      private function uncompress($value) {
 656          if ($value === false) {
 657              return false;
 658          }
 659  
 660          switch ($this->compressor) {
 661              case self::COMPRESSOR_NONE:
 662                  break;
 663              case self::COMPRESSOR_PHP_GZIP:
 664                  $value = gzdecode($value);
 665                  break;
 666              case self::COMPRESSOR_PHP_ZSTD:
 667                  $value = zstd_uncompress($value);
 668                  break;
 669              default:
 670                  debugging("Invalid compressor: {$this->compressor}");
 671          }
 672  
 673          return $this->unserialize($value);
 674      }
 675  
 676      /**
 677       * Serializes the data according to the configured serializer.
 678       *
 679       * @param mixed $value
 680       * @return string
 681       */
 682      private function serialize($value) {
 683          switch ($this->serializer) {
 684              case Redis::SERIALIZER_NONE:
 685                  return $value;
 686              case Redis::SERIALIZER_PHP:
 687                  return serialize($value);
 688              case defined('Redis::SERIALIZER_IGBINARY') && Redis::SERIALIZER_IGBINARY:
 689                  return igbinary_serialize($value);
 690              default:
 691                  debugging("Invalid serializer: {$this->serializer}");
 692                  return $value;
 693          }
 694      }
 695  
 696      /**
 697       * Unserializes the data according to the configured serializer
 698       *
 699       * @param string $value
 700       * @return mixed
 701       */
 702      private function unserialize($value) {
 703          switch ($this->serializer) {
 704              case Redis::SERIALIZER_NONE:
 705                  return $value;
 706              case Redis::SERIALIZER_PHP:
 707                  return unserialize($value);
 708              case defined('Redis::SERIALIZER_IGBINARY') && Redis::SERIALIZER_IGBINARY:
 709                  return igbinary_unserialize($value);
 710              default:
 711                  debugging("Invalid serializer: {$this->serializer}");
 712                  return $value;
 713          }
 714      }
 715  }