Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

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