Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

Differences Between: [Versions 310 and 403] [Versions 311 and 403] [Versions 39 and 403] [Versions 400 and 403] [Versions 401 and 403] [Versions 402 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       * @var string Suffix used on key name (for hash) to store the TTL sorted list
  58       */
  59      const TTL_SUFFIX = '_ttl';
  60  
  61      /**
  62       * @var int Number of items to delete from cache in one batch when expiring old TTL data.
  63       */
  64      const TTL_EXPIRE_BATCH = 10000;
  65  
  66      /**
  67       * Name of this store.
  68       *
  69       * @var string
  70       */
  71      protected $name;
  72  
  73      /**
  74       * The definition hash, used for hash key
  75       *
  76       * @var string
  77       */
  78      protected $hash;
  79  
  80      /**
  81       * Flag for readiness!
  82       *
  83       * @var boolean
  84       */
  85      protected $isready = false;
  86  
  87      /**
  88       * Cache definition for this store.
  89       *
  90       * @var cache_definition
  91       */
  92      protected $definition = null;
  93  
  94      /**
  95       * Connection to Redis for this store.
  96       *
  97       * @var Redis
  98       */
  99      protected $redis;
 100  
 101      /**
 102       * Serializer for this store.
 103       *
 104       * @var int
 105       */
 106      protected $serializer = Redis::SERIALIZER_PHP;
 107  
 108      /**
 109       * Compressor for this store.
 110       *
 111       * @var int
 112       */
 113      protected $compressor = self::COMPRESSOR_NONE;
 114  
 115      /**
 116       * Bytes read or written by last call to set()/get() or set_many()/get_many().
 117       *
 118       * @var int
 119       */
 120      protected $lastiobytes = 0;
 121  
 122      /** @var int Maximum number of seconds to wait for a lock before giving up. */
 123      protected $lockwait = 60;
 124  
 125      /** @var int Timeout before lock is automatically released (in case of crashes) */
 126      protected $locktimeout = 600;
 127  
 128      /** @var ?array Array of current locks, or null if we haven't registered shutdown function */
 129      protected $currentlocks = null;
 130  
 131      /**
 132       * Determines if the requirements for this type of store are met.
 133       *
 134       * @return bool
 135       */
 136      public static function are_requirements_met() {
 137          return class_exists('Redis');
 138      }
 139  
 140      /**
 141       * Determines if this type of store supports a given mode.
 142       *
 143       * @param int $mode
 144       * @return bool
 145       */
 146      public static function is_supported_mode($mode) {
 147          return ($mode === self::MODE_APPLICATION || $mode === self::MODE_SESSION);
 148      }
 149  
 150      /**
 151       * Get the features of this type of cache store.
 152       *
 153       * @param array $configuration
 154       * @return int
 155       */
 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;
 162      }
 163  
 164      /**
 165       * Get the supported modes of this type of cache store.
 166       *
 167       * @param array $configuration
 168       * @return int
 169       */
 170      public static function get_supported_modes(array $configuration = array()) {
 171          return self::MODE_APPLICATION + self::MODE_SESSION;
 172      }
 173  
 174      /**
 175       * Constructs an instance of this type of store.
 176       *
 177       * @param string $name
 178       * @param array $configuration
 179       */
 180      public function __construct($name, array $configuration = array()) {
 181          $this->name = $name;
 182  
 183          if (!array_key_exists('server', $configuration) || empty($configuration['server'])) {
 184              return;
 185          }
 186          if (array_key_exists('serializer', $configuration)) {
 187              $this->serializer = (int)$configuration['serializer'];
 188          }
 189          if (array_key_exists('compressor', $configuration)) {
 190              $this->compressor = (int)$configuration['compressor'];
 191          }
 192          if (array_key_exists('lockwait', $configuration)) {
 193              $this->lockwait = (int)$configuration['lockwait'];
 194          }
 195          if (array_key_exists('locktimeout', $configuration)) {
 196              $this->locktimeout = (int)$configuration['locktimeout'];
 197          }
 198          $this->redis = $this->new_redis($configuration);
 199      }
 200  
 201      /**
 202       * Create a new Redis instance and
 203       * connect to the server.
 204       *
 205       * @param array $configuration The server configuration
 206       * @return Redis
 207       */
 208      protected function new_redis(array $configuration): \Redis {
 209          global $CFG;
 210  
 211          $redis = new Redis();
 212  
 213          $server = $configuration['server'];
 214          $encrypt = (bool) ($configuration['encryption'] ?? false);
 215          $password = !empty($configuration['password']) ? $configuration['password'] : '';
 216          $prefix = !empty($configuration['prefix']) ? $configuration['prefix'] : '';
 217          // Check if it isn't a Unix socket to set default port.
 218          $port = null;
 219          $opts = [];
 220          if ($server[0] === '/') {
 221              $port = 0;
 222          } else {
 223              $port = 6379; // No Unix socket so set default port.
 224              if (strpos($server, ':')) { // Check for custom port.
 225                  list($server, $port) = explode(':', $server);
 226              }
 227  
 228              // We can encrypt if we aren't unix socket.
 229              if ($encrypt) {
 230                  $server = 'tls://' . $server;
 231                  if (empty($configuration['cafile'])) {
 232                      $sslopts = [
 233                          'verify_peer' => false,
 234                          'verify_peer_name' => false,
 235                      ];
 236                  } else {
 237                      $sslopts = ['cafile' => $configuration['cafile']];
 238                  }
 239                  $opts['stream'] = $sslopts;
 240              }
 241          }
 242  
 243          try {
 244              if ($redis->connect($server, $port, 1, null, 100, 1, $opts)) {
 245  
 246                  if (!empty($password)) {
 247                      $redis->auth($password);
 248                  }
 249                  // If using compressor, serialisation will be done at cachestore level, not php-redis.
 250                  if ($this->compressor == self::COMPRESSOR_NONE) {
 251                      $redis->setOption(Redis::OPT_SERIALIZER, $this->serializer);
 252                  }
 253                  if (!empty($prefix)) {
 254                      $redis->setOption(Redis::OPT_PREFIX, $prefix);
 255                  }
 256                  if ($encrypt && !$redis->ping()) {
 257                      /*
 258                       * In case of a TLS connection, if phpredis client does not
 259                       * communicate immediately with the server the connection hangs.
 260                       * See https://github.com/phpredis/phpredis/issues/2332 .
 261                       */
 262                      throw new \RedisException("Ping failed");
 263                  }
 264                  $this->isready = true;
 265              } else {
 266                  $this->isready = false;
 267              }
 268          } catch (\RedisException $e) {
 269              debugging("redis $server: $e", DEBUG_NORMAL);
 270              $this->isready = false;
 271          }
 272  
 273          return $redis;
 274      }
 275  
 276      /**
 277       * See if we can ping Redis server
 278       *
 279       * @param Redis $redis
 280       * @return bool
 281       */
 282      protected function ping(Redis $redis) {
 283          try {
 284              if ($redis->ping() === false) {
 285                  return false;
 286              }
 287          } catch (Exception $e) {
 288              return false;
 289          }
 290          return true;
 291      }
 292  
 293      /**
 294       * Get the name of the store.
 295       *
 296       * @return string
 297       */
 298      public function my_name() {
 299          return $this->name;
 300      }
 301  
 302      /**
 303       * Initialize the store.
 304       *
 305       * @param cache_definition $definition
 306       * @return bool
 307       */
 308      public function initialise(cache_definition $definition) {
 309          $this->definition = $definition;
 310          $this->hash       = $definition->generate_definition_hash();
 311          return true;
 312      }
 313  
 314      /**
 315       * Determine if the store is initialized.
 316       *
 317       * @return bool
 318       */
 319      public function is_initialised() {
 320          return ($this->definition !== null);
 321      }
 322  
 323      /**
 324       * Determine if the store is ready for use.
 325       *
 326       * @return bool
 327       */
 328      public function is_ready() {
 329          return $this->isready;
 330      }
 331  
 332      /**
 333       * Get the value associated with a given key.
 334       *
 335       * @param string $key The key to get the value of.
 336       * @return mixed The value of the key, or false if there is no value associated with the key.
 337       */
 338      public function get($key) {
 339          $value = $this->redis->hGet($this->hash, $key);
 340  
 341          if ($this->compressor == self::COMPRESSOR_NONE) {
 342              return $value;
 343          }
 344  
 345          // When using compression, values are always strings, so strlen will work.
 346          $this->lastiobytes = strlen($value);
 347  
 348          return $this->uncompress($value);
 349      }
 350  
 351      /**
 352       * Get the values associated with a list of keys.
 353       *
 354       * @param array $keys The keys to get the values of.
 355       * @return array An array of the values of the given keys.
 356       */
 357      public function get_many($keys) {
 358          $values = $this->redis->hMGet($this->hash, $keys);
 359  
 360          if ($this->compressor == self::COMPRESSOR_NONE) {
 361              return $values;
 362          }
 363  
 364          $this->lastiobytes = 0;
 365          foreach ($values as &$value) {
 366              $this->lastiobytes += strlen($value);
 367              $value = $this->uncompress($value);
 368          }
 369  
 370          return $values;
 371      }
 372  
 373      /**
 374       * Gets the number of bytes read from or written to cache as a result of the last action.
 375       *
 376       * If compression is not enabled, this function always returns IO_BYTES_NOT_SUPPORTED. The reason is that
 377       * when compression is not enabled, data sent to the cache is not serialized, and we would
 378       * need to serialize it to compute the size, which would have a significant performance cost.
 379       *
 380       * @return int Bytes read or written
 381       * @since Moodle 4.0
 382       */
 383      public function get_last_io_bytes(): int {
 384          if ($this->compressor != self::COMPRESSOR_NONE) {
 385              return $this->lastiobytes;
 386          } else {
 387              // Not supported unless compression is on.
 388              return parent::get_last_io_bytes();
 389          }
 390      }
 391  
 392      /**
 393       * Set the value of a key.
 394       *
 395       * @param string $key The key to set the value of.
 396       * @param mixed $value The value.
 397       * @return bool True if the operation succeeded, false otherwise.
 398       */
 399      public function set($key, $value) {
 400          if ($this->compressor != self::COMPRESSOR_NONE) {
 401              $value = $this->compress($value);
 402              $this->lastiobytes = strlen($value);
 403          }
 404  
 405          if ($this->redis->hSet($this->hash, $key, $value) === false) {
 406              return false;
 407          }
 408          if ($this->definition->get_ttl()) {
 409              // When TTL is enabled, we also store the key name in a list sorted by the current time.
 410              $this->redis->zAdd($this->hash . self::TTL_SUFFIX, [], self::get_time(), $key);
 411              // The return value to the zAdd function never indicates whether the operation succeeded
 412              // (it returns zero when there was no error if the item is already in the list) so we
 413              // ignore it.
 414          }
 415          return true;
 416      }
 417  
 418      /**
 419       * Set the values of many keys.
 420       *
 421       * @param array $keyvaluearray An array of key/value pairs. Each item in the array is an associative array
 422       *      with two keys, 'key' and 'value'.
 423       * @return int The number of key/value pairs successfuly set.
 424       */
 425      public function set_many(array $keyvaluearray) {
 426          $pairs = [];
 427          $usettl = false;
 428          if ($this->definition->get_ttl()) {
 429              $usettl = true;
 430              $ttlparams = [];
 431              $now = self::get_time();
 432          }
 433  
 434          $this->lastiobytes = 0;
 435          foreach ($keyvaluearray as $pair) {
 436              $key = $pair['key'];
 437              if ($this->compressor != self::COMPRESSOR_NONE) {
 438                  $pairs[$key] = $this->compress($pair['value']);
 439                  $this->lastiobytes += strlen($pairs[$key]);
 440              } else {
 441                  $pairs[$key] = $pair['value'];
 442              }
 443              if ($usettl) {
 444                  // When TTL is enabled, we also store the key names in a list sorted by the current
 445                  // time.
 446                  $ttlparams[] = $now;
 447                  $ttlparams[] = $key;
 448              }
 449          }
 450          if ($usettl) {
 451              // Store all the key values with current time.
 452              $this->redis->zAdd($this->hash . self::TTL_SUFFIX, [], ...$ttlparams);
 453              // The return value to the zAdd function never indicates whether the operation succeeded
 454              // (it returns zero when there was no error if the item is already in the list) so we
 455              // ignore it.
 456          }
 457          if ($this->redis->hMSet($this->hash, $pairs)) {
 458              return count($pairs);
 459          }
 460          return 0;
 461      }
 462  
 463      /**
 464       * Delete the given key.
 465       *
 466       * @param string $key The key to delete.
 467       * @return bool True if the delete operation succeeds, false otherwise.
 468       */
 469      public function delete($key) {
 470          $ok = true;
 471          if (!$this->redis->hDel($this->hash, $key)) {
 472              $ok = false;
 473          }
 474          if ($this->definition->get_ttl()) {
 475              // When TTL is enabled, also remove the key from the TTL list.
 476              $this->redis->zRem($this->hash . self::TTL_SUFFIX, $key);
 477          }
 478          return $ok;
 479      }
 480  
 481      /**
 482       * Delete many keys.
 483       *
 484       * @param array $keys The keys to delete.
 485       * @return int The number of keys successfully deleted.
 486       */
 487      public function delete_many(array $keys) {
 488          // If there are no keys to delete, do nothing.
 489          if (!$keys) {
 490              return 0;
 491          }
 492          $count = $this->redis->hDel($this->hash, ...$keys);
 493          if ($this->definition->get_ttl()) {
 494              // When TTL is enabled, also remove the keys from the TTL list.
 495              $this->redis->zRem($this->hash . self::TTL_SUFFIX, ...$keys);
 496          }
 497          return $count;
 498      }
 499  
 500      /**
 501       * Purges all keys from the store.
 502       *
 503       * @return bool
 504       */
 505      public function purge() {
 506          if ($this->definition->get_ttl()) {
 507              // Purge the TTL list as well.
 508              $this->redis->del($this->hash . self::TTL_SUFFIX);
 509              // According to documentation, there is no error return for the 'del' command (it
 510              // only returns the number of keys deleted, which could be 0 or 1 in this case) so we
 511              // do not need to check the return value.
 512          }
 513          return ($this->redis->del($this->hash) !== false);
 514      }
 515  
 516      /**
 517       * Cleans up after an instance of the store.
 518       */
 519      public function instance_deleted() {
 520          $this->redis->close();
 521          unset($this->redis);
 522      }
 523  
 524      /**
 525       * Determines if the store has a given key.
 526       *
 527       * @see cache_is_key_aware
 528       * @param string $key The key to check for.
 529       * @return bool True if the key exists, false if it does not.
 530       */
 531      public function has($key) {
 532          return !empty($this->redis->hExists($this->hash, $key));
 533      }
 534  
 535      /**
 536       * Determines if the store has any of the keys in a list.
 537       *
 538       * @see cache_is_key_aware
 539       * @param array $keys The keys to check for.
 540       * @return bool True if any of the keys are found, false none of the keys are found.
 541       */
 542      public function has_any(array $keys) {
 543          foreach ($keys as $key) {
 544              if ($this->has($key)) {
 545                  return true;
 546              }
 547          }
 548          return false;
 549      }
 550  
 551      /**
 552       * Determines if the store has all of the keys in a list.
 553       *
 554       * @see cache_is_key_aware
 555       * @param array $keys The keys to check for.
 556       * @return bool True if all of the keys are found, false otherwise.
 557       */
 558      public function has_all(array $keys) {
 559          foreach ($keys as $key) {
 560              if (!$this->has($key)) {
 561                  return false;
 562              }
 563          }
 564          return true;
 565      }
 566  
 567      /**
 568       * Tries to acquire a lock with a given name.
 569       *
 570       * @see cache_is_lockable
 571       * @param string $key Name of the lock to acquire.
 572       * @param string $ownerid Information to identify owner of lock if acquired.
 573       * @return bool True if the lock was acquired, false if it was not.
 574       */
 575      public function acquire_lock($key, $ownerid) {
 576          $timelimit = time() + $this->lockwait;
 577          do {
 578              // If the key doesn't already exist, grab it and return true.
 579              if ($this->redis->setnx($key, $ownerid)) {
 580                  // Ensure Redis deletes the key after a bit in case something goes wrong.
 581                  $this->redis->expire($key, $this->locktimeout);
 582                  // If we haven't got it already, better register a shutdown function.
 583                  if ($this->currentlocks === null) {
 584                      core_shutdown_manager::register_function([$this, 'shutdown_release_locks']);
 585                      $this->currentlocks = [];
 586                  }
 587                  $this->currentlocks[$key] = $ownerid;
 588                  return true;
 589              }
 590              // Wait 1 second then retry.
 591              sleep(1);
 592          } while (time() < $timelimit);
 593          return false;
 594      }
 595  
 596      /**
 597       * Releases any locks when the system shuts down, in case there is a crash or somebody forgets
 598       * to use 'try-finally'.
 599       *
 600       * Do not call this function manually (except from unit test).
 601       */
 602      public function shutdown_release_locks() {
 603          foreach ($this->currentlocks as $key => $ownerid) {
 604              debugging('Automatically releasing Redis cache lock: ' . $key . ' (' . $ownerid .
 605                      ') - did somebody forget to call release_lock()?', DEBUG_DEVELOPER);
 606              $this->release_lock($key, $ownerid);
 607          }
 608      }
 609  
 610      /**
 611       * Checks a lock with a given name and owner information.
 612       *
 613       * @see cache_is_lockable
 614       * @param string $key Name of the lock to check.
 615       * @param string $ownerid Owner information to check existing lock against.
 616       * @return mixed True if the lock exists and the owner information matches, null if the lock does not
 617       *      exist, and false otherwise.
 618       */
 619      public function check_lock_state($key, $ownerid) {
 620          $result = $this->redis->get($key);
 621          if ($result === (string)$ownerid) {
 622              return true;
 623          }
 624          if ($result === false) {
 625              return null;
 626          }
 627          return false;
 628      }
 629  
 630      /**
 631       * Finds all of the keys being used by this cache store instance.
 632       *
 633       * @return array of all keys in the hash as a numbered array.
 634       */
 635      public function find_all() {
 636          return $this->redis->hKeys($this->hash);
 637      }
 638  
 639      /**
 640       * Finds all of the keys whose keys start with the given prefix.
 641       *
 642       * @param string $prefix
 643       *
 644       * @return array List of keys that match this prefix.
 645       */
 646      public function find_by_prefix($prefix) {
 647          $return = [];
 648          foreach ($this->find_all() as $key) {
 649              if (strpos($key, $prefix) === 0) {
 650                  $return[] = $key;
 651              }
 652          }
 653          return $return;
 654      }
 655  
 656      /**
 657       * Releases a given lock if the owner information matches.
 658       *
 659       * @see cache_is_lockable
 660       * @param string $key Name of the lock to release.
 661       * @param string $ownerid Owner information to use.
 662       * @return bool True if the lock is released, false if it is not.
 663       */
 664      public function release_lock($key, $ownerid) {
 665          if ($this->check_lock_state($key, $ownerid)) {
 666              unset($this->currentlocks[$key]);
 667              return ($this->redis->del($key) !== false);
 668          }
 669          return false;
 670      }
 671  
 672      /**
 673       * Runs TTL expiry process for this cache.
 674       *
 675       * This is not part of the standard cache API and is intended for use by the scheduled task
 676       * \cachestore_redis\ttl.
 677       *
 678       * @return array Various keys with information about how the expiry went
 679       */
 680      public function expire_ttl(): array {
 681          $ttl = $this->definition->get_ttl();
 682          if (!$ttl) {
 683              throw new \coding_exception('Cache definition ' . $this->definition->get_id() . ' does not use TTL');
 684          }
 685          $limit = self::get_time() - $ttl;
 686          $count = 0;
 687          $batches = 0;
 688          $timebefore = microtime(true);
 689          $memorybefore = $this->store_total_size();
 690          do {
 691              $keys = $this->redis->zRangeByScore($this->hash . self::TTL_SUFFIX, 0, $limit,
 692                      ['limit' => [0, self::TTL_EXPIRE_BATCH]]);
 693              $this->delete_many($keys);
 694              $count += count($keys);
 695              $batches++;
 696          } while (count($keys) === self::TTL_EXPIRE_BATCH);
 697          $memoryafter = $this->store_total_size();
 698          $timeafter = microtime(true);
 699  
 700          $result = ['keys' => $count, 'batches' => $batches, 'time' => $timeafter - $timebefore];
 701          if ($memorybefore !== null) {
 702              $result['memory'] = $memorybefore - $memoryafter;
 703          }
 704          return $result;
 705      }
 706  
 707      /**
 708       * Gets the current time for TTL functionality. This wrapper makes it easier to unit-test
 709       * the TTL behaviour.
 710       *
 711       * @return int Current time
 712       */
 713      protected static function get_time(): int {
 714          global $CFG;
 715          if (PHPUNIT_TEST && !empty($CFG->phpunit_cachestore_redis_time)) {
 716              return $CFG->phpunit_cachestore_redis_time;
 717          }
 718          return time();
 719      }
 720  
 721      /**
 722       * Sets the current time (within unit test) for TTL functionality.
 723       *
 724       * This setting is stored in $CFG so will be automatically reset if you use resetAfterTest.
 725       *
 726       * @param int $time Current time (set 0 to start using real time).
 727       */
 728      public static function set_phpunit_time(int $time = 0): void {
 729          global $CFG;
 730          if (!PHPUNIT_TEST) {
 731              throw new \coding_exception('Function only available during unit test');
 732          }
 733          if ($time) {
 734              $CFG->phpunit_cachestore_redis_time = $time;
 735          } else {
 736              unset($CFG->phpunit_cachestore_redis_time);
 737          }
 738      }
 739  
 740      /**
 741       * Estimates the stored size, taking into account whether compression is turned on.
 742       *
 743       * @param mixed $key Key name
 744       * @param mixed $value Value
 745       * @return int Approximate stored size
 746       */
 747      public function estimate_stored_size($key, $value): int {
 748          if ($this->compressor == self::COMPRESSOR_NONE) {
 749              // If uncompressed, use default estimate.
 750              return parent::estimate_stored_size($key, $value);
 751          } else {
 752              // If compressed, compress value.
 753              return strlen($this->serialize($key)) + strlen($this->compress($value));
 754          }
 755      }
 756  
 757      /**
 758       * Gets Redis reported memory usage.
 759       *
 760       * @return int|null Memory used by Redis or null if we don't know
 761       */
 762      public function store_total_size(): ?int {
 763          try {
 764              $details = $this->redis->info('MEMORY');
 765          } catch (\RedisException $e) {
 766              return null;
 767          }
 768          if (empty($details['used_memory'])) {
 769              return null;
 770          } else {
 771              return (int)$details['used_memory'];
 772          }
 773      }
 774  
 775      /**
 776       * Creates a configuration array from given 'add instance' form data.
 777       *
 778       * @see cache_is_configurable
 779       * @param stdClass $data
 780       * @return array
 781       */
 782      public static function config_get_configuration_array($data) {
 783          return array(
 784              'server' => $data->server,
 785              'prefix' => $data->prefix,
 786              'password' => $data->password,
 787              'serializer' => $data->serializer,
 788              'compressor' => $data->compressor,
 789              'encryption' => $data->encryption,
 790              'cafile' => $data->cafile,
 791          );
 792      }
 793  
 794      /**
 795       * Sets form data from a configuration array.
 796       *
 797       * @see cache_is_configurable
 798       * @param moodleform $editform
 799       * @param array $config
 800       */
 801      public static function config_set_edit_form_data(moodleform $editform, array $config) {
 802          $data = array();
 803          $data['server'] = $config['server'];
 804          $data['prefix'] = !empty($config['prefix']) ? $config['prefix'] : '';
 805          $data['password'] = !empty($config['password']) ? $config['password'] : '';
 806          if (!empty($config['serializer'])) {
 807              $data['serializer'] = $config['serializer'];
 808          }
 809          if (!empty($config['compressor'])) {
 810              $data['compressor'] = $config['compressor'];
 811          }
 812          if (!empty($config['encryption'])) {
 813              $data['encryption'] = $config['encryption'];
 814          }
 815          if (!empty($config['cafile'])) {
 816              $data['cafile'] = $config['cafile'];
 817          }
 818          $editform->set_data($data);
 819      }
 820  
 821  
 822      /**
 823       * Creates an instance of the store for testing.
 824       *
 825       * @param cache_definition $definition
 826       * @return mixed An instance of the store, or false if an instance cannot be created.
 827       */
 828      public static function initialise_test_instance(cache_definition $definition) {
 829          if (!self::are_requirements_met()) {
 830              return false;
 831          }
 832          $config = get_config('cachestore_redis');
 833          if (empty($config->test_server)) {
 834              return false;
 835          }
 836          $configuration = array('server' => $config->test_server);
 837          if (!empty($config->test_serializer)) {
 838              $configuration['serializer'] = $config->test_serializer;
 839          }
 840          if (!empty($config->test_password)) {
 841              $configuration['password'] = $config->test_password;
 842          }
 843          if (!empty($config->test_encryption)) {
 844              $configuration['encryption'] = $config->test_encryption;
 845          }
 846          if (!empty($config->test_cafile)) {
 847              $configuration['cafile'] = $config->test_cafile;
 848          }
 849          // Make it possible to test TTL performance by hacking a copy of the cache definition.
 850          if (!empty($config->test_ttl)) {
 851              $definition = clone $definition;
 852              $property = (new ReflectionClass($definition))->getProperty('ttl');
 853              $property->setAccessible(true);
 854              $property->setValue($definition, 999);
 855          }
 856          $cache = new cachestore_redis('Redis test', $configuration);
 857          $cache->initialise($definition);
 858  
 859          return $cache;
 860      }
 861  
 862      /**
 863       * Return configuration to use when unit testing.
 864       *
 865       * @return array
 866       */
 867      public static function unit_test_configuration() {
 868          global $DB;
 869  
 870          if (!self::are_requirements_met() || !self::ready_to_be_used_for_testing()) {
 871              throw new moodle_exception('TEST_CACHESTORE_REDIS_TESTSERVERS not configured, unable to create test configuration');
 872          }
 873  
 874          return ['server' => TEST_CACHESTORE_REDIS_TESTSERVERS,
 875                  'prefix' => $DB->get_prefix(),
 876                  'encryption' => defined('TEST_CACHESTORE_REDIS_ENCRYPT') && TEST_CACHESTORE_REDIS_ENCRYPT,
 877          ];
 878      }
 879  
 880      /**
 881       * Returns true if this cache store instance is both suitable for testing, and ready for testing.
 882       *
 883       * When TEST_CACHESTORE_REDIS_TESTSERVERS is set, then we are ready to be use d for testing.
 884       *
 885       * @return bool
 886       */
 887      public static function ready_to_be_used_for_testing() {
 888          return defined('TEST_CACHESTORE_REDIS_TESTSERVERS');
 889      }
 890  
 891      /**
 892       * Gets an array of options to use as the serialiser.
 893       * @return array
 894       */
 895      public static function config_get_serializer_options() {
 896          $options = array(
 897              Redis::SERIALIZER_PHP => get_string('serializer_php', 'cachestore_redis')
 898          );
 899  
 900          if (defined('Redis::SERIALIZER_IGBINARY')) {
 901              $options[Redis::SERIALIZER_IGBINARY] = get_string('serializer_igbinary', 'cachestore_redis');
 902          }
 903          return $options;
 904      }
 905  
 906      /**
 907       * Gets an array of options to use as the compressor.
 908       *
 909       * @return array
 910       */
 911      public static function config_get_compressor_options() {
 912          $arr = [
 913              self::COMPRESSOR_NONE     => get_string('compressor_none', 'cachestore_redis'),
 914              self::COMPRESSOR_PHP_GZIP => get_string('compressor_php_gzip', 'cachestore_redis'),
 915          ];
 916  
 917          // Check if the Zstandard PHP extension is installed.
 918          if (extension_loaded('zstd')) {
 919              $arr[self::COMPRESSOR_PHP_ZSTD] = get_string('compressor_php_zstd', 'cachestore_redis');
 920          }
 921  
 922          return $arr;
 923      }
 924  
 925      /**
 926       * Compress the given value, serializing it first.
 927       *
 928       * @param mixed $value
 929       * @return string
 930       */
 931      private function compress($value) {
 932          $value = $this->serialize($value);
 933  
 934          switch ($this->compressor) {
 935              case self::COMPRESSOR_NONE:
 936                  return $value;
 937  
 938              case self::COMPRESSOR_PHP_GZIP:
 939                  return gzencode($value);
 940  
 941              case self::COMPRESSOR_PHP_ZSTD:
 942                  return zstd_compress($value);
 943  
 944              default:
 945                  debugging("Invalid compressor: {$this->compressor}");
 946                  return $value;
 947          }
 948      }
 949  
 950      /**
 951       * Uncompresses (deflates) the data, unserialising it afterwards.
 952       *
 953       * @param string $value
 954       * @return mixed
 955       */
 956      private function uncompress($value) {
 957          if ($value === false) {
 958              return false;
 959          }
 960  
 961          switch ($this->compressor) {
 962              case self::COMPRESSOR_NONE:
 963                  break;
 964              case self::COMPRESSOR_PHP_GZIP:
 965                  $value = gzdecode($value);
 966                  break;
 967              case self::COMPRESSOR_PHP_ZSTD:
 968                  $value = zstd_uncompress($value);
 969                  break;
 970              default:
 971                  debugging("Invalid compressor: {$this->compressor}");
 972          }
 973  
 974          return $this->unserialize($value);
 975      }
 976  
 977      /**
 978       * Serializes the data according to the configured serializer.
 979       *
 980       * @param mixed $value
 981       * @return string
 982       */
 983      private function serialize($value) {
 984          switch ($this->serializer) {
 985              case Redis::SERIALIZER_NONE:
 986                  return $value;
 987              case Redis::SERIALIZER_PHP:
 988                  return serialize($value);
 989              case defined('Redis::SERIALIZER_IGBINARY') && Redis::SERIALIZER_IGBINARY:
 990                  return igbinary_serialize($value);
 991              default:
 992                  debugging("Invalid serializer: {$this->serializer}");
 993                  return $value;
 994          }
 995      }
 996  
 997      /**
 998       * Unserializes the data according to the configured serializer
 999       *
1000       * @param string $value
1001       * @return mixed
1002       */
1003      private function unserialize($value) {
1004          switch ($this->serializer) {
1005              case Redis::SERIALIZER_NONE:
1006                  return $value;
1007              case Redis::SERIALIZER_PHP:
1008                  return unserialize($value);
1009              case defined('Redis::SERIALIZER_IGBINARY') && Redis::SERIALIZER_IGBINARY:
1010                  return igbinary_unserialize($value);
1011              default:
1012                  debugging("Invalid serializer: {$this->serializer}");
1013                  return $value;
1014          }
1015      }
1016  }