Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 145 |
|
0.00% |
0 / 12 |
CRAP | |
0.00% |
0 / 1 |
RedisConnectionPool | |
0.00% |
0 / 144 |
|
0.00% |
0 / 12 |
3306 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
56 | |||
setLogger | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
applyDefaultConfig | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
30 | |||
singleton | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
destroySingletons | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getConnection | |
0.00% |
0 / 65 |
|
0.00% |
0 / 1 |
342 | |||
freeConnection | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
closeExcessIdleConections | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
42 | |||
handleError | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
20 | |||
reauthenticateConnection | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
resetTimeout | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
__destruct | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
20 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | */ |
20 | |
21 | namespace Wikimedia\ObjectCache; |
22 | |
23 | use Exception; |
24 | use InvalidArgumentException; |
25 | use Psr\Log\LoggerAwareInterface; |
26 | use Psr\Log\LoggerInterface; |
27 | use Psr\Log\NullLogger; |
28 | use Redis; |
29 | use RedisException; |
30 | use RuntimeException; |
31 | |
32 | /** |
33 | * Manage one or more Redis client connection. |
34 | * |
35 | * This can be used to get RedisConnRef objects that automatically reuses |
36 | * connections internally after the calling function has returned (and thus |
37 | * your RedisConnRef instance leaves scope/destructs). |
38 | * |
39 | * This provides an easy way to cache connection handles that may also have state, |
40 | * such as a handle does between multi() and exec(), and without hoarding connections. |
41 | * The wrappers use PHP magic methods so that calling functions on them calls the |
42 | * function of the actual Redis object handle. |
43 | * |
44 | * @ingroup Cache |
45 | * @since 1.21 |
46 | */ |
47 | class RedisConnectionPool implements LoggerAwareInterface { |
48 | /** @var int Connection timeout in seconds */ |
49 | protected $connectTimeout; |
50 | /** @var string Read timeout in seconds */ |
51 | protected $readTimeout; |
52 | /** @var string Plaintext auth password */ |
53 | protected $password; |
54 | /** @var bool Whether connections persist */ |
55 | protected $persistent; |
56 | /** @var int Serializer to use (Redis::SERIALIZER_*) */ |
57 | protected $serializer; |
58 | /** @var string ID for persistent connections */ |
59 | protected $id; |
60 | |
61 | /** @var int Current idle pool size */ |
62 | protected $idlePoolSize = 0; |
63 | |
64 | /** |
65 | * @var array (server name => ((connection info array),...) |
66 | * @phan-var array<string,array{conn:Redis,free:bool}[]> |
67 | */ |
68 | protected $connections = []; |
69 | /** @var array (server name => UNIX timestamp) */ |
70 | protected $downServers = []; |
71 | |
72 | /** @var array (pool ID => RedisConnectionPool) */ |
73 | protected static $instances = []; |
74 | |
75 | /** integer; seconds to cache servers as "down". */ |
76 | private const SERVER_DOWN_TTL = 30; |
77 | |
78 | /** |
79 | * @var LoggerInterface |
80 | */ |
81 | protected $logger; |
82 | |
83 | /** |
84 | * @param array $options |
85 | * @param string $id |
86 | * @throws Exception |
87 | */ |
88 | protected function __construct( array $options, $id ) { |
89 | if ( !class_exists( Redis::class ) ) { |
90 | throw new RuntimeException( |
91 | __CLASS__ . ' requires a Redis client library. ' . |
92 | 'See https://www.mediawiki.org/wiki/Redis#Setup' ); |
93 | } |
94 | $this->logger = $options['logger'] ?? new NullLogger(); |
95 | $this->connectTimeout = $options['connectTimeout']; |
96 | $this->readTimeout = $options['readTimeout']; |
97 | $this->persistent = $options['persistent']; |
98 | $this->password = $options['password']; |
99 | if ( !isset( $options['serializer'] ) || $options['serializer'] === 'php' ) { |
100 | $this->serializer = Redis::SERIALIZER_PHP; |
101 | } elseif ( $options['serializer'] === 'igbinary' ) { |
102 | if ( !defined( 'Redis::SERIALIZER_IGBINARY' ) ) { |
103 | throw new InvalidArgumentException( |
104 | __CLASS__ . ': configured serializer "igbinary" not available' ); |
105 | } |
106 | $this->serializer = Redis::SERIALIZER_IGBINARY; |
107 | } elseif ( $options['serializer'] === 'none' ) { |
108 | $this->serializer = Redis::SERIALIZER_NONE; |
109 | } else { |
110 | throw new InvalidArgumentException( "Invalid serializer specified." ); |
111 | } |
112 | $this->id = $id; |
113 | } |
114 | |
115 | public function setLogger( LoggerInterface $logger ) { |
116 | $this->logger = $logger; |
117 | } |
118 | |
119 | /** |
120 | * @param array $options |
121 | * @return array |
122 | */ |
123 | protected static function applyDefaultConfig( array $options ) { |
124 | if ( !isset( $options['connectTimeout'] ) ) { |
125 | $options['connectTimeout'] = 1; |
126 | } |
127 | if ( !isset( $options['readTimeout'] ) ) { |
128 | $options['readTimeout'] = 1; |
129 | } |
130 | if ( !isset( $options['persistent'] ) ) { |
131 | $options['persistent'] = false; |
132 | } |
133 | if ( !isset( $options['password'] ) ) { |
134 | $options['password'] = null; |
135 | } |
136 | |
137 | return $options; |
138 | } |
139 | |
140 | /** |
141 | * @param array $options |
142 | * $options include: |
143 | * - connectTimeout : The timeout for new connections, in seconds. |
144 | * Optional, default is 1 second. |
145 | * - readTimeout : The timeout for operation reads, in seconds. |
146 | * Commands like BLPOP can fail if told to wait longer than this. |
147 | * Optional, default is 1 second. |
148 | * - persistent : Set this to true to allow connections to persist across |
149 | * multiple web requests. False by default. |
150 | * - password : The authentication password, will be sent to Redis in clear text. |
151 | * Optional, if it is unspecified, no AUTH command will be sent. |
152 | * - serializer : Set to "php", "igbinary", or "none". Default is "php". |
153 | * @return RedisConnectionPool |
154 | */ |
155 | public static function singleton( array $options ) { |
156 | $options = self::applyDefaultConfig( $options ); |
157 | // Map the options to a unique hash... |
158 | ksort( $options ); // normalize to avoid pool fragmentation |
159 | $id = sha1( serialize( $options ) ); |
160 | // Initialize the object at the hash as needed... |
161 | if ( !isset( self::$instances[$id] ) ) { |
162 | self::$instances[$id] = new self( $options, $id ); |
163 | } |
164 | |
165 | return self::$instances[$id]; |
166 | } |
167 | |
168 | /** |
169 | * Destroy all singleton() instances |
170 | * @since 1.27 |
171 | */ |
172 | public static function destroySingletons() { |
173 | self::$instances = []; |
174 | } |
175 | |
176 | /** |
177 | * Get a connection to a redis server. Based on code in RedisBagOStuff.php. |
178 | * |
179 | * @param string $server A hostname/port combination or the absolute path of a UNIX socket. |
180 | * If a hostname is specified but no port, port 6379 will be used. |
181 | * @param LoggerInterface|null $logger PSR-3 logger instance. [optional] |
182 | * @return RedisConnRef|Redis|false Returns false on failure |
183 | * @throws InvalidArgumentException |
184 | */ |
185 | public function getConnection( $server, ?LoggerInterface $logger = null ) { |
186 | // The above @return also documents 'Redis' for convenience with IDEs. |
187 | // RedisConnRef uses PHP magic methods, which wouldn't be recognised. |
188 | |
189 | $logger = $logger ?: $this->logger; |
190 | // Check the listing "dead" servers which have had a connection errors. |
191 | // Servers are marked dead for a limited period of time, to |
192 | // avoid excessive overhead from repeated connection timeouts. |
193 | if ( isset( $this->downServers[$server] ) ) { |
194 | $now = time(); |
195 | if ( $now > $this->downServers[$server] ) { |
196 | // Dead time expired |
197 | unset( $this->downServers[$server] ); |
198 | } else { |
199 | // Server is dead |
200 | $logger->debug( |
201 | 'Server "{redis_server}" is marked down for another ' . |
202 | ( $this->downServers[$server] - $now ) . ' seconds', |
203 | [ 'redis_server' => $server ] |
204 | ); |
205 | |
206 | return false; |
207 | } |
208 | } |
209 | |
210 | // Check if a connection is already free for use |
211 | if ( isset( $this->connections[$server] ) ) { |
212 | foreach ( $this->connections[$server] as &$connection ) { |
213 | if ( $connection['free'] ) { |
214 | $connection['free'] = false; |
215 | --$this->idlePoolSize; |
216 | |
217 | return new RedisConnRef( |
218 | $this, $server, $connection['conn'], $logger |
219 | ); |
220 | } |
221 | } |
222 | } |
223 | |
224 | if ( !$server ) { |
225 | throw new InvalidArgumentException( |
226 | __CLASS__ . ": invalid configured server \"$server\"" ); |
227 | } elseif ( substr( $server, 0, 1 ) === '/' ) { |
228 | // UNIX domain socket |
229 | // These are required by the redis extension to start with a slash, but |
230 | // we still need to set the port to a special value to make it work. |
231 | $host = $server; |
232 | $port = 0; |
233 | } else { |
234 | // TCP connection |
235 | if ( preg_match( '/^\[(.+)\]:(\d+)$/', $server, $m ) ) { |
236 | // (ip, port) |
237 | [ $host, $port ] = [ $m[1], (int)$m[2] ]; |
238 | } elseif ( preg_match( '/^((?:[\w]+\:\/\/)?[^:]+):(\d+)$/', $server, $m ) ) { |
239 | // (ip, uri or path, port) |
240 | [ $host, $port ] = [ $m[1], (int)$m[2] ]; |
241 | if ( |
242 | substr( $host, 0, 6 ) === 'tls://' |
243 | && version_compare( phpversion( 'redis' ), '5.0.0' ) < 0 |
244 | ) { |
245 | throw new RuntimeException( |
246 | 'A newer version of the Redis client library is required to use TLS. ' . |
247 | 'See https://www.mediawiki.org/wiki/Redis#Setup' ); |
248 | } |
249 | } else { |
250 | // (ip or path, port) |
251 | [ $host, $port ] = [ $server, 6379 ]; |
252 | } |
253 | } |
254 | |
255 | $conn = new Redis(); |
256 | try { |
257 | if ( $this->persistent ) { |
258 | $result = $conn->pconnect( $host, $port, $this->connectTimeout, $this->id ); |
259 | } else { |
260 | $result = $conn->connect( $host, $port, $this->connectTimeout ); |
261 | } |
262 | if ( !$result ) { |
263 | $logger->error( |
264 | 'Could not connect to server "{redis_server}"', |
265 | [ 'redis_server' => $server ] |
266 | ); |
267 | // Mark server down for some time to avoid further timeouts |
268 | $this->downServers[$server] = time() + self::SERVER_DOWN_TTL; |
269 | |
270 | return false; |
271 | } |
272 | if ( ( $this->password !== null ) && !$conn->auth( $this->password ) ) { |
273 | $logger->error( |
274 | 'Authentication error connecting to "{redis_server}"', |
275 | [ 'redis_server' => $server ] |
276 | ); |
277 | } |
278 | } catch ( RedisException $e ) { |
279 | $this->downServers[$server] = time() + self::SERVER_DOWN_TTL; |
280 | $logger->error( |
281 | 'Redis exception connecting to "{redis_server}"', |
282 | [ |
283 | 'redis_server' => $server, |
284 | 'exception' => $e, |
285 | ] |
286 | ); |
287 | |
288 | return false; |
289 | } |
290 | |
291 | $conn->setOption( Redis::OPT_READ_TIMEOUT, $this->readTimeout ); |
292 | $conn->setOption( Redis::OPT_SERIALIZER, $this->serializer ); |
293 | $this->connections[$server][] = [ 'conn' => $conn, 'free' => false ]; |
294 | |
295 | return new RedisConnRef( $this, $server, $conn, $logger ); |
296 | } |
297 | |
298 | /** |
299 | * Mark a connection to a server as free to return to the pool |
300 | * |
301 | * @param string $server |
302 | * @param Redis $conn |
303 | * @return bool |
304 | */ |
305 | public function freeConnection( $server, Redis $conn ) { |
306 | $found = false; |
307 | |
308 | foreach ( $this->connections[$server] as &$connection ) { |
309 | if ( $connection['conn'] === $conn && !$connection['free'] ) { |
310 | $connection['free'] = true; |
311 | ++$this->idlePoolSize; |
312 | break; |
313 | } |
314 | } |
315 | |
316 | $this->closeExcessIdleConections(); |
317 | |
318 | return $found; |
319 | } |
320 | |
321 | /** |
322 | * Close any extra idle connections if there are more than the limit |
323 | */ |
324 | protected function closeExcessIdleConections() { |
325 | if ( $this->idlePoolSize <= count( $this->connections ) ) { |
326 | return; // nothing to do (no more connections than servers) |
327 | } |
328 | |
329 | foreach ( $this->connections as &$serverConnections ) { |
330 | foreach ( $serverConnections as $key => &$connection ) { |
331 | if ( $connection['free'] ) { |
332 | unset( $serverConnections[$key] ); |
333 | if ( --$this->idlePoolSize <= count( $this->connections ) ) { |
334 | return; // done (no more connections than servers) |
335 | } |
336 | } |
337 | } |
338 | } |
339 | } |
340 | |
341 | /** |
342 | * The redis extension throws an exception in response to various read, write |
343 | * and protocol errors. Sometimes it also closes the connection, sometimes |
344 | * not. The safest response for us is to explicitly destroy the connection |
345 | * object and let it be reopened during the next request. |
346 | * |
347 | * @param RedisConnRef $cref |
348 | * @param RedisException $e |
349 | */ |
350 | public function handleError( RedisConnRef $cref, RedisException $e ) { |
351 | $server = $cref->getServer(); |
352 | $this->logger->error( |
353 | 'Redis exception on server "{redis_server}"', |
354 | [ |
355 | 'redis_server' => $server, |
356 | 'exception' => $e, |
357 | ] |
358 | ); |
359 | foreach ( $this->connections[$server] as $key => $connection ) { |
360 | if ( $cref->isConnIdentical( $connection['conn'] ) ) { |
361 | $this->idlePoolSize -= $connection['free'] ? 1 : 0; |
362 | unset( $this->connections[$server][$key] ); |
363 | break; |
364 | } |
365 | } |
366 | } |
367 | |
368 | /** |
369 | * Re-send an AUTH request to the redis server (useful after disconnects). |
370 | * |
371 | * This works around an upstream bug in phpredis. phpredis hides disconnects by transparently |
372 | * reconnecting, but it neglects to re-authenticate the new connection. To the user of the |
373 | * phpredis client API this manifests as a seemingly random tendency of connections to lose |
374 | * their authentication status. |
375 | * |
376 | * This method is for internal use only. |
377 | * |
378 | * @see https://github.com/nicolasff/phpredis/issues/403 |
379 | * |
380 | * @param string $server |
381 | * @param Redis $conn |
382 | * @return bool Success |
383 | */ |
384 | public function reauthenticateConnection( $server, Redis $conn ) { |
385 | if ( $this->password !== null && !$conn->auth( $this->password ) ) { |
386 | $this->logger->error( |
387 | 'Authentication error connecting to "{redis_server}"', |
388 | [ 'redis_server' => $server ] |
389 | ); |
390 | |
391 | return false; |
392 | } |
393 | |
394 | return true; |
395 | } |
396 | |
397 | /** |
398 | * Adjust or reset the connection handle read timeout value |
399 | * |
400 | * @param Redis $conn |
401 | * @param int|null $timeout Optional |
402 | */ |
403 | public function resetTimeout( Redis $conn, $timeout = null ) { |
404 | $conn->setOption( Redis::OPT_READ_TIMEOUT, $timeout ?: $this->readTimeout ); |
405 | } |
406 | |
407 | /** |
408 | * Make sure connections are closed |
409 | */ |
410 | public function __destruct() { |
411 | foreach ( $this->connections as &$serverConnections ) { |
412 | foreach ( $serverConnections as &$connection ) { |
413 | try { |
414 | /** @var Redis $conn */ |
415 | $conn = $connection['conn']; |
416 | $conn->close(); |
417 | } catch ( RedisException $e ) { |
418 | // The destructor can be called on shutdown when random parts of the system |
419 | // have been destructed already, causing weird errors. Ignore them. |
420 | } |
421 | } |
422 | } |
423 | } |
424 | } |
425 | |
426 | /** @deprecated class alias since 1.43 */ |
427 | class_alias( RedisConnectionPool::class, 'RedisConnectionPool' ); |