Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
44.34% covered (danger)
44.34%
47 / 106
59.26% covered (warning)
59.26%
16 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
common_persistence_PhpRedisDriver
44.34% covered (danger)
44.34%
47 / 106
59.26% covered (warning)
59.26%
16 / 27
576.63
0.00% covered (danger)
0.00%
0 / 1
 connect
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 connectionSet
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
56
 connectToSingleNode
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 connectToCluster
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 callWithRetry
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
5.58
 set
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 exists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 del
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hmSet
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hExists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hSet
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hGet
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hDel
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hGetAll
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 keys
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 incr
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 decr
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 scan
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
7.46
 mGet
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mDel
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 mSet
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getConnection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPrefix
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 prefixKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 prefixKeys
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 reconnectOnException
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3/**
4 * This program is free software; you can redistribute it and/or
5 * modify it under the terms of the GNU General Public License
6 * as published by the Free Software Foundation; under version 2
7 * of the License (non-upgradable).
8 *
9 * This program 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 this program; if not, write to the Free Software
16 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
17 *
18 * Copyright (c) 2013-2023 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT);
19 *
20 * @author Lionel Lecaque  <lionel@taotesting.com>
21 * @license GPLv2
22 * @package
23 * phpcs:disable Squiz.Classes.ValidClassName
24 */
25
26class common_persistence_PhpRedisDriver implements common_persistence_AdvKvDriver, common_persistence_KeyValue_Nx
27{
28    public const DEFAULT_PORT = 6379;
29    public const DEFAULT_ATTEMPT = 3;
30    public const DEFAULT_TIMEOUT = 5; // in seconds
31    public const RETRY_DELAY = 500000; // Eq to 0.5s
32
33    private const DEFAULT_PREFIX_SEPARATOR = ':';
34
35    /**
36     * @var Redis
37     */
38    private $connection;
39
40    /**
41     * @var $params
42     */
43    private $params;
44
45    /**
46     * store connection params and try to connect
47     * @see common_persistence_Driver::connect()
48     */
49    public function connect($key, array $params)
50    {
51        $this->params = $params;
52        $this->connectionSet($params);
53
54        return new common_persistence_AdvKeyValuePersistence($params, $this);
55    }
56
57    /**
58     * create a new connection using stored parameters
59     * @param array $params
60     * @throws common_exception_Error
61     */
62    public function connectionSet(array $params)
63    {
64        if (!isset($params['host'])) {
65            throw new common_exception_Error('Missing host information for Redis driver');
66        }
67
68        $port = isset($params['port']) ? $params['port'] : self::DEFAULT_PORT;
69        $timeout = isset($params['timeout']) ? $params['timeout'] : self::DEFAULT_TIMEOUT;
70        $persist = isset($params['pconnect']) ? $params['pconnect'] : true;
71        $this->params['attempt'] = isset($params['attempt']) ? $params['attempt'] : self::DEFAULT_ATTEMPT;
72
73        if (is_array($params['host'])) {
74            $this->connectToCluster($params['host'], $timeout, $persist);
75        } else {
76            $this->connectToSingleNode($params['host'], $port, $timeout, $persist);
77        }
78    }
79
80    private function connectToSingleNode(string $host, int $port, int $timeout, bool $persist)
81    {
82        $this->connection = new Redis();
83        if ($this->connection == false) {
84            throw new common_exception_Error("Redis php module not found");
85        }
86        if ($persist) {
87            $this->connection->pconnect($host, $port, $timeout);
88        } else {
89            $this->connection->connect($host, $port, $timeout);
90        }
91        if (isset($this->params['database_index'])) {
92            if (!$this->connection->select($this->params['database_index'])) {
93                $this->connection->close();
94                throw new common_exception_Error(
95                    "Failed to select Redis database"
96                );
97            }
98        }
99    }
100
101    private function connectToCluster(array $host, int $timeout, bool $persist)
102    {
103        if (isset($this->params['database_index'])) {
104            throw new common_exception_Error(
105                "Redis Cluster can only support a single database, 'database_index' parameter is invalid."
106            );
107        }
108        $this->connection = new RedisCluster(null, $host, $timeout, null, $persist);
109    }
110
111    /**
112     * @param $method
113     * @param array $params
114     * @param $retry
115     * @param int $attempt
116     * @return mixed
117     * @throws Exception
118     */
119    protected function callWithRetry($method, array $params, $attempt = 1)
120    {
121
122        $success = false;
123        $lastException = null;
124        $result = false;
125
126        $retry = (int)$this->params['attempt'];
127
128        while (!$success && $attempt <= $retry) {
129            try {
130                $result = call_user_func_array([$this->connection, $method], $params);
131                $success = true;
132            } catch (Exception $e) {
133                $lastException = $e;
134
135                $this->reconnectOnException($lastException, $method, $attempt, $retry);
136            }
137            $attempt++;
138        }
139
140        if (!$success) {
141            throw $lastException;
142        }
143
144        return $result;
145    }
146
147    /**
148     * (non-PHPdoc)
149     * @see common_persistence_KvDriver::set()
150     */
151    public function set($key, $value, $ttl = null, $nx = false)
152    {
153        $options = [];
154        if (!is_null($ttl)) {
155            $options['ex'] = $ttl;
156        }
157        if ($nx) {
158            $options[] = 'nx';
159        }
160        return $this->callWithRetry('set', [$this->prefixKey($key), $value, $options]);
161    }
162
163    public function get($key)
164    {
165        return $this->callWithRetry('get', [$this->prefixKey($key)]);
166    }
167
168    public function exists($key)
169    {
170        return (bool)$this->callWithRetry('exists', [$this->prefixKey($key)]);
171    }
172
173    public function del($key)
174    {
175        return $this->callWithRetry('del', [$this->prefixKey($key)]);
176    }
177
178    //O(N) where N is the number of fields being set.
179    public function hmSet($key, $fields)
180    {
181        return $this->callWithRetry('hmSet', [$this->prefixKey($key), $fields]);
182    }
183
184    //Time complexity: O(1)
185    public function hExists($key, $field)
186    {
187        return (bool)$this->callWithRetry('hExists', [$this->prefixKey($key), $field]);
188    }
189
190    //Time complexity: O(1)
191    public function hSet($key, $field, $value)
192    {
193        return $this->callWithRetry('hSet', [$this->prefixKey($key), $field, $value]);
194    }
195
196    //Time complexity: O(1)
197    public function hGet($key, $field)
198    {
199        return $this->callWithRetry('hGet', [$this->prefixKey($key), $field]);
200    }
201
202    public function hDel($key, $field): bool
203    {
204        return (bool)$this->callWithRetry('hDel', [$this->prefixKey($key), $field]);
205    }
206
207    //Time complexity: O(N) where N is the size of the hash.
208    public function hGetAll($key)
209    {
210        return $this->callWithRetry('hGetAll', [$this->prefixKey($key)]);
211    }
212
213    //Time complexity: O(N)
214    public function keys($pattern)
215    {
216        return $this->callWithRetry('keys', [$this->prefixKey($pattern)]);
217    }
218
219    //Time complexity: O(1)
220    public function incr($key)
221    {
222        return $this->callWithRetry('incr', [$this->prefixKey($key)]);
223    }
224
225    //Time complexity: O(1)
226    public function decr($key)
227    {
228        return $this->callWithRetry('decr', [$this->prefixKey($key)]);
229    }
230
231    /**
232     * @throws RedisException
233     * @throws common_exception_Error
234     */
235    public function scan(int &$iterator = null, string $pattern = null, int $count = 1000): array
236    {
237        $retry = (int)$this->params['attempt'];
238        $attempt = 0;
239
240        while ($attempt <= $retry) {
241            try {
242                return $this->connection->scan($iterator, $this->prefixKey($pattern), $count);
243            } catch (Exception $exception) {
244                $this->reconnectOnException($exception, 'scan', $attempt, $retry);
245            }
246
247            $attempt++;
248        }
249
250        if (isset($exception)) {
251            throw $exception;
252        }
253
254        return [];
255    }
256
257    /**
258     * @return array|bool
259     */
260    public function mGet(array $keys)
261    {
262        return $this->callWithRetry('mGet', [$this->prefixKeys($keys)]);
263    }
264
265    /**
266     * @return bool|mixed
267     */
268    public function mDel(array $keys)
269    {
270        return $this->callWithRetry('del', [$this->prefixKeys($keys)]);
271    }
272
273    /**
274     * @return bool|mixed
275     */
276    public function mSet(array $keyValues)
277    {
278        return $this->callWithRetry('mSet', [$this->prefixKeys($keyValues, true)]);
279    }
280
281    /**
282     * @return Redis
283     */
284    public function getConnection()
285    {
286        return $this->connection;
287    }
288
289    protected function getPrefix(array $params): ?string
290    {
291        $prefix = null;
292
293        if (!empty($this->params['prefix'])) {
294            $prefix = $this->params['prefix'];
295        }
296
297        return $prefix;
298    }
299
300    /**
301     * @param string|int|null $key
302     * @return string|int|null
303     */
304    private function prefixKey($key)
305    {
306        $prefix = $this->getPrefix($this->params);
307
308        if ($prefix === null) {
309            return $key;
310        }
311
312        return $prefix . ($this->params['prefixSeparator'] ?? self::DEFAULT_PREFIX_SEPARATOR) . $key;
313    }
314
315    private function prefixKeys(array $keys, bool $keyValueMode = false): array
316    {
317        if ($this->getPrefix($this->params) !== null) {
318            $prefixedKeys = [];
319            foreach (array_values($keys) as $i => $element) {
320                if ($keyValueMode) {
321                    $prefixedKeys[] = $i % 2 == 0 ? $this->prefixKey($element) : $element;
322                } else {
323                    $prefixedKeys[] = $this->prefixKey($element);
324                }
325            }
326
327            return $prefixedKeys;
328        }
329
330        return $keys;
331    }
332
333    /**
334     * @return void
335     * @throws RedisException
336     * @throws common_exception_Error
337     */
338    private function reconnectOnException(Exception $exception, string $method, int $attempt, int $retry): void
339    {
340        common_Logger::d(
341            sprintf(
342                'Redis %s failed %s/%s:  %s',
343                $method,
344                $attempt,
345                $retry,
346                $exception->getMessage(),
347            )
348        );
349
350        if ($exception->getMessage() == 'Failed to AUTH connection' && isset($this->params['password'])) {
351            common_Logger::d('Authenticating Redis connection');
352
353            $this->connection->auth($this->params['password']);
354        }
355
356        $delay = rand(self::RETRY_DELAY, self::RETRY_DELAY * 2);
357
358        usleep($delay);
359
360        $this->connectionSet($this->params);
361    }
362}