Proyectos de Subversion Moodle

Rev

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