MediaWiki  master
RedisConnectionPool.php
Go to the documentation of this file.
1 <?php
24 use Psr\Log\LoggerAwareInterface;
25 use Psr\Log\LoggerInterface;
26 use Psr\Log\NullLogger;
27 
41 class RedisConnectionPool implements LoggerAwareInterface {
43  protected $connectTimeout;
45  protected $readTimeout;
47  protected $password;
49  protected $persistent;
51  protected $serializer;
53  protected $id;
54 
56  protected $idlePoolSize = 0;
57 
62  protected $connections = [];
64  protected $downServers = [];
65 
67  protected static $instances = [];
68 
70  private const SERVER_DOWN_TTL = 30;
71 
75  protected $logger;
76 
82  protected function __construct( array $options, $id ) {
83  if ( !class_exists( Redis::class ) ) {
84  throw new RuntimeException(
85  __CLASS__ . ' requires a Redis client library. ' .
86  'See https://www.mediawiki.org/wiki/Redis#Setup' );
87  }
88  $this->logger = $options['logger'] ?? new NullLogger();
89  $this->connectTimeout = $options['connectTimeout'];
90  $this->readTimeout = $options['readTimeout'];
91  $this->persistent = $options['persistent'];
92  $this->password = $options['password'];
93  if ( !isset( $options['serializer'] ) || $options['serializer'] === 'php' ) {
94  $this->serializer = Redis::SERIALIZER_PHP;
95  } elseif ( $options['serializer'] === 'igbinary' ) {
96  if ( !defined( 'Redis::SERIALIZER_IGBINARY' ) ) {
97  throw new InvalidArgumentException(
98  __CLASS__ . ': configured serializer "igbinary" not available' );
99  }
100  $this->serializer = Redis::SERIALIZER_IGBINARY;
101  } elseif ( $options['serializer'] === 'none' ) {
102  $this->serializer = Redis::SERIALIZER_NONE;
103  } else {
104  throw new InvalidArgumentException( "Invalid serializer specified." );
105  }
106  $this->id = $id;
107  }
108 
109  public function setLogger( LoggerInterface $logger ) {
110  $this->logger = $logger;
111  }
112 
117  protected static function applyDefaultConfig( array $options ) {
118  if ( !isset( $options['connectTimeout'] ) ) {
119  $options['connectTimeout'] = 1;
120  }
121  if ( !isset( $options['readTimeout'] ) ) {
122  $options['readTimeout'] = 1;
123  }
124  if ( !isset( $options['persistent'] ) ) {
125  $options['persistent'] = false;
126  }
127  if ( !isset( $options['password'] ) ) {
128  $options['password'] = null;
129  }
130 
131  return $options;
132  }
133 
149  public static function singleton( array $options ) {
150  $options = self::applyDefaultConfig( $options );
151  // Map the options to a unique hash...
152  ksort( $options ); // normalize to avoid pool fragmentation
153  $id = sha1( serialize( $options ) );
154  // Initialize the object at the hash as needed...
155  if ( !isset( self::$instances[$id] ) ) {
156  self::$instances[$id] = new self( $options, $id );
157  }
158 
159  return self::$instances[$id];
160  }
161 
166  public static function destroySingletons() {
167  self::$instances = [];
168  }
169 
179  public function getConnection( $server, LoggerInterface $logger = null ) {
180  // The above @return also documents 'Redis' for convenience with IDEs.
181  // RedisConnRef uses PHP magic methods, which wouldn't be recognised.
182 
184  // Check the listing "dead" servers which have had a connection errors.
185  // Servers are marked dead for a limited period of time, to
186  // avoid excessive overhead from repeated connection timeouts.
187  if ( isset( $this->downServers[$server] ) ) {
188  $now = time();
189  if ( $now > $this->downServers[$server] ) {
190  // Dead time expired
191  unset( $this->downServers[$server] );
192  } else {
193  // Server is dead
194  $logger->debug(
195  'Server "{redis_server}" is marked down for another ' .
196  ( $this->downServers[$server] - $now ) . 'seconds',
197  [ 'redis_server' => $server ]
198  );
199 
200  return false;
201  }
202  }
203 
204  // Check if a connection is already free for use
205  if ( isset( $this->connections[$server] ) ) {
206  foreach ( $this->connections[$server] as &$connection ) {
207  if ( $connection['free'] ) {
208  $connection['free'] = false;
210 
211  return new RedisConnRef(
212  $this, $server, $connection['conn'], $logger
213  );
214  }
215  }
216  }
217 
218  if ( !$server ) {
219  throw new InvalidArgumentException(
220  __CLASS__ . ": invalid configured server \"$server\"" );
221  } elseif ( substr( $server, 0, 1 ) === '/' ) {
222  // UNIX domain socket
223  // These are required by the redis extension to start with a slash, but
224  // we still need to set the port to a special value to make it work.
225  $host = $server;
226  $port = 0;
227  } else {
228  // TCP connection
229  if ( preg_match( '/^\[(.+)\]:(\d+)$/', $server, $m ) ) {
230  // (ip, port)
231  list( $host, $port ) = [ $m[1], (int)$m[2] ];
232  } elseif ( preg_match( '/^((?:[\w]+\:\/\/)?[^:]+):(\d+)$/', $server, $m ) ) {
233  // (ip, uri or path, port)
234  list( $host, $port ) = [ $m[1], (int)$m[2] ];
235  if (
236  substr( $host, 0, 6 ) === 'tls://'
237  && version_compare( phpversion( 'redis' ), '5.0.0' ) < 0
238  ) {
239  throw new RuntimeException(
240  'A newer version of the Redis client library is required to use TLS. ' .
241  'See https://www.mediawiki.org/wiki/Redis#Setup' );
242  }
243  } else {
244  // (ip or path, port)
245  list( $host, $port ) = [ $server, 6379 ];
246  }
247  }
248 
249  $conn = new Redis();
250  try {
251  if ( $this->persistent ) {
252  $result = $conn->pconnect( $host, $port, $this->connectTimeout, $this->id );
253  } else {
254  $result = $conn->connect( $host, $port, $this->connectTimeout );
255  }
256  if ( !$result ) {
257  $logger->error(
258  'Could not connect to server "{redis_server}"',
259  [ 'redis_server' => $server ]
260  );
261  // Mark server down for some time to avoid further timeouts
262  $this->downServers[$server] = time() + self::SERVER_DOWN_TTL;
263 
264  return false;
265  }
266  if ( ( $this->password !== null ) && !$conn->auth( $this->password ) ) {
267  $logger->error(
268  'Authentication error connecting to "{redis_server}"',
269  [ 'redis_server' => $server ]
270  );
271  }
272  } catch ( RedisException $e ) {
273  $this->downServers[$server] = time() + self::SERVER_DOWN_TTL;
274  $logger->error(
275  'Redis exception connecting to "{redis_server}"',
276  [
277  'redis_server' => $server,
278  'exception' => $e,
279  ]
280  );
281 
282  return false;
283  }
284 
285  $conn->setOption( Redis::OPT_READ_TIMEOUT, $this->readTimeout );
286  $conn->setOption( Redis::OPT_SERIALIZER, $this->serializer );
287  $this->connections[$server][] = [ 'conn' => $conn, 'free' => false ];
288 
289  return new RedisConnRef( $this, $server, $conn, $logger );
290  }
291 
299  public function freeConnection( $server, Redis $conn ) {
300  $found = false;
301 
302  foreach ( $this->connections[$server] as &$connection ) {
303  if ( $connection['conn'] === $conn && !$connection['free'] ) {
304  $connection['free'] = true;
306  break;
307  }
308  }
309 
310  $this->closeExcessIdleConections();
311 
312  return $found;
313  }
314 
318  protected function closeExcessIdleConections() {
319  if ( $this->idlePoolSize <= count( $this->connections ) ) {
320  return; // nothing to do (no more connections than servers)
321  }
322 
323  foreach ( $this->connections as &$serverConnections ) {
324  foreach ( $serverConnections as $key => &$connection ) {
325  if ( $connection['free'] ) {
326  unset( $serverConnections[$key] );
327  if ( --$this->idlePoolSize <= count( $this->connections ) ) {
328  return; // done (no more connections than servers)
329  }
330  }
331  }
332  }
333  }
334 
344  public function handleError( RedisConnRef $cref, RedisException $e ) {
345  $server = $cref->getServer();
346  $this->logger->error(
347  'Redis exception on server "{redis_server}"',
348  [
349  'redis_server' => $server,
350  'exception' => $e,
351  ]
352  );
353  foreach ( $this->connections[$server] as $key => $connection ) {
354  if ( $cref->isConnIdentical( $connection['conn'] ) ) {
355  $this->idlePoolSize -= $connection['free'] ? 1 : 0;
356  unset( $this->connections[$server][$key] );
357  break;
358  }
359  }
360  }
361 
378  public function reauthenticateConnection( $server, Redis $conn ) {
379  if ( $this->password !== null && !$conn->auth( $this->password ) ) {
380  $this->logger->error(
381  'Authentication error connecting to "{redis_server}"',
382  [ 'redis_server' => $server ]
383  );
384 
385  return false;
386  }
387 
388  return true;
389  }
390 
397  public function resetTimeout( Redis $conn, $timeout = null ) {
398  $conn->setOption( Redis::OPT_READ_TIMEOUT, $timeout ?: $this->readTimeout );
399  }
400 
404  public function __destruct() {
405  foreach ( $this->connections as &$serverConnections ) {
406  foreach ( $serverConnections as &$connection ) {
407  try {
409  $conn = $connection['conn'];
410  $conn->close();
411  } catch ( RedisException $e ) {
412  // The destructor can be called on shutdown when random parts of the system
413  // have been destructed already, causing weird errors. Ignore them.
414  }
415  }
416  }
417  }
418 }
serialize()
Helper class to handle automatically marking connections as reusable (via RAII pattern)
isConnIdentical(Redis $conn)
Helper class to manage Redis connections.
static applyDefaultConfig(array $options)
__construct(array $options, $id)
int $serializer
Serializer to use (Redis::SERIALIZER_*)
reauthenticateConnection( $server, Redis $conn)
Re-send an AUTH request to the redis server (useful after disconnects).
array $connections
(server name => ((connection info array),...)
getConnection( $server, LoggerInterface $logger=null)
Get a connection to a redis server.
__destruct()
Make sure connections are closed.
string $id
ID for persistent connections.
closeExcessIdleConections()
Close any extra idle connections if there are more than the limit.
handleError(RedisConnRef $cref, RedisException $e)
The redis extension throws an exception in response to various read, write and protocol errors.
static destroySingletons()
Destroy all singleton() instances.
freeConnection( $server, Redis $conn)
Mark a connection to a server as free to return to the pool.
static array $instances
(pool ID => RedisConnectionPool)
resetTimeout(Redis $conn, $timeout=null)
Adjust or reset the connection handle read timeout value.
string $password
Plaintext auth password.
int $idlePoolSize
Current idle pool size.
bool $persistent
Whether connections persist.
array $downServers
(server name => UNIX timestamp)
string $readTimeout
Read timeout in seconds.
static singleton(array $options)
setLogger(LoggerInterface $logger)
int $connectTimeout
Connection timeout in seconds.