MediaWiki master
RedisConnectionPool.php
Go to the documentation of this file.
1<?php
21namespace Wikimedia\ObjectCache;
22
23use Exception;
24use InvalidArgumentException;
25use Psr\Log\LoggerAwareInterface;
26use Psr\Log\LoggerInterface;
27use Psr\Log\NullLogger;
28use Redis;
29use RedisException;
30use RuntimeException;
31
47class RedisConnectionPool implements LoggerAwareInterface {
49 protected $connectTimeout;
51 protected $readTimeout;
53 protected $password;
55 protected $prefix;
57 protected $persistent;
59 protected $serializer;
61 protected $id;
62
64 protected $idlePoolSize = 0;
65
70 protected $connections = [];
72 protected $downServers = [];
73
75 protected static $instances = [];
76
78 private const SERVER_DOWN_TTL = 30;
79
83 protected $logger;
84
90 protected function __construct( array $options, $id ) {
91 if ( !class_exists( Redis::class ) ) {
92 throw new RuntimeException(
93 __CLASS__ . ' requires a Redis client library. ' .
94 'See https://www.mediawiki.org/wiki/Redis#Setup' );
95 }
96 $this->logger = $options['logger'] ?? new NullLogger();
97 $this->connectTimeout = $options['connectTimeout'];
98 $this->readTimeout = $options['readTimeout'];
99 $this->persistent = $options['persistent'];
100 $this->password = $options['password'];
101 $this->prefix = $options['prefix'];
102 if ( !isset( $options['serializer'] ) || $options['serializer'] === 'php' ) {
103 $this->serializer = Redis::SERIALIZER_PHP;
104 } elseif ( $options['serializer'] === 'igbinary' ) {
105 if ( !defined( 'Redis::SERIALIZER_IGBINARY' ) ) {
106 throw new InvalidArgumentException(
107 __CLASS__ . ': configured serializer "igbinary" not available' );
108 }
109 $this->serializer = Redis::SERIALIZER_IGBINARY;
110 } elseif ( $options['serializer'] === 'none' ) {
111 $this->serializer = Redis::SERIALIZER_NONE;
112 } else {
113 throw new InvalidArgumentException( "Invalid serializer specified." );
114 }
115 $this->id = $id;
116 }
117
118 public function setLogger( LoggerInterface $logger ) {
119 $this->logger = $logger;
120 }
121
126 protected static function applyDefaultConfig( array $options ) {
127 if ( !isset( $options['connectTimeout'] ) ) {
128 $options['connectTimeout'] = 1;
129 }
130 if ( !isset( $options['readTimeout'] ) ) {
131 $options['readTimeout'] = 1;
132 }
133 if ( !isset( $options['persistent'] ) ) {
134 $options['persistent'] = false;
135 }
136 if ( !isset( $options['password'] ) ) {
137 $options['password'] = null;
138 }
139 if ( !isset( $options['prefix'] ) ) {
140 $options['prefix'] = null;
141 }
142
143 return $options;
144 }
145
161 public static function singleton( array $options ) {
162 $options = self::applyDefaultConfig( $options );
163 // Map the options to a unique hash...
164 ksort( $options ); // normalize to avoid pool fragmentation
165 $id = sha1( serialize( $options ) );
166 // Initialize the object at the hash as needed...
167 if ( !isset( self::$instances[$id] ) ) {
168 self::$instances[$id] = new self( $options, $id );
169 }
170
171 return self::$instances[$id];
172 }
173
178 public static function destroySingletons() {
179 self::$instances = [];
180 }
181
191 public function getConnection( $server, ?LoggerInterface $logger = null ) {
192 // The above @return also documents 'Redis' for convenience with IDEs.
193 // RedisConnRef uses PHP magic methods, which wouldn't be recognised.
194
196 // Check the listing "dead" servers which have had a connection errors.
197 // Servers are marked dead for a limited period of time, to
198 // avoid excessive overhead from repeated connection timeouts.
199 if ( isset( $this->downServers[$server] ) ) {
200 $now = time();
201 if ( $now > $this->downServers[$server] ) {
202 // Dead time expired
203 unset( $this->downServers[$server] );
204 } else {
205 // Server is dead
206 $logger->debug(
207 'Server "{redis_server}" is marked down for another ' .
208 ( $this->downServers[$server] - $now ) . ' seconds',
209 [ 'redis_server' => $server ]
210 );
211
212 return false;
213 }
214 }
215
216 // Check if a connection is already free for use
217 if ( isset( $this->connections[$server] ) ) {
218 foreach ( $this->connections[$server] as &$connection ) {
219 if ( $connection['free'] ) {
220 $connection['free'] = false;
222
223 return new RedisConnRef(
224 $this, $server, $connection['conn'], $logger
225 );
226 }
227 }
228 }
229
230 if ( !$server ) {
231 throw new InvalidArgumentException(
232 __CLASS__ . ": invalid configured server \"$server\"" );
233 } elseif ( substr( $server, 0, 1 ) === '/' ) {
234 // UNIX domain socket
235 // These are required by the redis extension to start with a slash, but
236 // we still need to set the port to a special value to make it work.
237 $host = $server;
238 $port = 0;
239 } else {
240 // TCP connection
241 if ( preg_match( '/^\[(.+)\]:(\d+)$/', $server, $m ) ) {
242 // (ip, port)
243 [ $host, $port ] = [ $m[1], (int)$m[2] ];
244 } elseif ( preg_match( '/^((?:[\w]+\:\/\/)?[^:]+):(\d+)$/', $server, $m ) ) {
245 // (ip, uri or path, port)
246 [ $host, $port ] = [ $m[1], (int)$m[2] ];
247 if (
248 substr( $host, 0, 6 ) === 'tls://'
249 && version_compare( phpversion( 'redis' ), '5.0.0' ) < 0
250 ) {
251 throw new RuntimeException(
252 'A newer version of the Redis client library is required to use TLS. ' .
253 'See https://www.mediawiki.org/wiki/Redis#Setup' );
254 }
255 } else {
256 // (ip or path, port)
257 [ $host, $port ] = [ $server, 6379 ];
258 }
259 }
260
261 $conn = new Redis();
262 try {
263 if ( $this->persistent ) {
264 $result = $conn->pconnect( $host, $port, $this->connectTimeout, $this->id );
265 } else {
266 $result = $conn->connect( $host, $port, $this->connectTimeout );
267 }
268 if ( !$result ) {
269 $logger->error(
270 'Could not connect to server "{redis_server}"',
271 [ 'redis_server' => $server ]
272 );
273 // Mark server down for some time to avoid further timeouts
274 $this->downServers[$server] = time() + self::SERVER_DOWN_TTL;
275
276 return false;
277 }
278 if ( ( $this->password !== null ) && !$conn->auth( $this->password ) ) {
279 $logger->error(
280 'Authentication error connecting to "{redis_server}"',
281 [ 'redis_server' => $server ]
282 );
283 }
284 } catch ( RedisException $e ) {
285 $this->downServers[$server] = time() + self::SERVER_DOWN_TTL;
286 $logger->error(
287 'Redis exception connecting to "{redis_server}"',
288 [
289 'redis_server' => $server,
290 'exception' => $e,
291 ]
292 );
293
294 return false;
295 }
296
297 if ( $this->prefix !== null ) {
298 $conn->setOption( Redis::OPT_PREFIX, $this->prefix );
299 }
300
301 $conn->setOption( Redis::OPT_READ_TIMEOUT, $this->readTimeout );
302 $conn->setOption( Redis::OPT_SERIALIZER, $this->serializer );
303 $this->connections[$server][] = [ 'conn' => $conn, 'free' => false ];
304
305 return new RedisConnRef( $this, $server, $conn, $logger );
306 }
307
315 public function freeConnection( $server, Redis $conn ) {
316 $found = false;
317
318 foreach ( $this->connections[$server] as &$connection ) {
319 if ( $connection['conn'] === $conn && !$connection['free'] ) {
320 $connection['free'] = true;
322 break;
323 }
324 }
325
327
328 return $found;
329 }
330
334 protected function closeExcessIdleConections() {
335 if ( $this->idlePoolSize <= count( $this->connections ) ) {
336 return; // nothing to do (no more connections than servers)
337 }
338
339 foreach ( $this->connections as &$serverConnections ) {
340 foreach ( $serverConnections as $key => &$connection ) {
341 if ( $connection['free'] ) {
342 unset( $serverConnections[$key] );
343 if ( --$this->idlePoolSize <= count( $this->connections ) ) {
344 return; // done (no more connections than servers)
345 }
346 }
347 }
348 }
349 }
350
360 public function handleError( RedisConnRef $cref, RedisException $e ) {
361 $server = $cref->getServer();
362 $this->logger->error(
363 'Redis exception on server "{redis_server}"',
364 [
365 'redis_server' => $server,
366 'exception' => $e,
367 ]
368 );
369 foreach ( $this->connections[$server] as $key => $connection ) {
370 if ( $cref->isConnIdentical( $connection['conn'] ) ) {
371 $this->idlePoolSize -= $connection['free'] ? 1 : 0;
372 unset( $this->connections[$server][$key] );
373 break;
374 }
375 }
376 }
377
394 public function reauthenticateConnection( $server, Redis $conn ) {
395 if ( $this->password !== null && !$conn->auth( $this->password ) ) {
396 $this->logger->error(
397 'Authentication error connecting to "{redis_server}"',
398 [ 'redis_server' => $server ]
399 );
400
401 return false;
402 }
403
404 return true;
405 }
406
413 public function resetTimeout( Redis $conn, $timeout = null ) {
414 $conn->setOption( Redis::OPT_READ_TIMEOUT, $timeout ?: $this->readTimeout );
415 }
416
420 public function __destruct() {
421 foreach ( $this->connections as &$serverConnections ) {
422 foreach ( $serverConnections as &$connection ) {
423 try {
425 $conn = $connection['conn'];
426 $conn->close();
427 } catch ( RedisException $e ) {
428 // The destructor can be called on shutdown when random parts of the system
429 // have been destructed already, causing weird errors. Ignore them.
430 }
431 }
432 }
433 }
434}
435
437class_alias( RedisConnectionPool::class, 'RedisConnectionPool' );
Wrapper class for Redis connections that automatically reuses connections (via RAII pattern)
Manage one or more Redis client connection.
int $connectTimeout
Connection timeout in seconds.
string $readTimeout
Read timeout in seconds.
static destroySingletons()
Destroy all singleton() instances.
bool $persistent
Whether connections persist.
__destruct()
Make sure connections are closed.
array $downServers
(server name => UNIX timestamp)
freeConnection( $server, Redis $conn)
Mark a connection to a server as free to return to the pool.
string $id
ID for persistent connections.
array $connections
(server name => ((connection info array),...)
handleError(RedisConnRef $cref, RedisException $e)
The redis extension throws an exception in response to various read, write and protocol errors.
resetTimeout(Redis $conn, $timeout=null)
Adjust or reset the connection handle read timeout value.
getConnection( $server, ?LoggerInterface $logger=null)
Get a connection to a redis server.
reauthenticateConnection( $server, Redis $conn)
Re-send an AUTH request to the redis server (useful after disconnects).
closeExcessIdleConections()
Close any extra idle connections if there are more than the limit.
static array $instances
(pool ID => RedisConnectionPool)
string string[] null $password
Plaintext auth password or array containing username and password.
int $serializer
Serializer to use (Redis::SERIALIZER_*)
string null $prefix
Key prefix automatically added to all operations.