RedisConnectionPool::handleException()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 3
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * Redis client connection pooling manager.
4
 *
5
 * This program is free software; you can redistribute it and/or modify
6
 * it under the terms of the GNU General Public License as published by
7
 * the Free Software Foundation; either version 2 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU General Public License along
16
 * with this program; if not, write to the Free Software Foundation, Inc.,
17
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
 * http://www.gnu.org/copyleft/gpl.html
19
 *
20
 * @file
21
 * @defgroup Redis Redis
22
 * @author Aaron Schulz
23
 */
24
25
use Psr\Log\LoggerAwareInterface;
26
use Psr\Log\LoggerInterface;
27
28
/**
29
 * Helper class to manage Redis connections.
30
 *
31
 * This can be used to get handle wrappers that free the handle when the wrapper
32
 * leaves scope. The maximum number of free handles (connections) is configurable.
33
 * This provides an easy way to cache connection handles that may also have state,
34
 * such as a handle does between multi() and exec(), and without hoarding connections.
35
 * The wrappers use PHP magic methods so that calling functions on them calls the
36
 * function of the actual Redis object handle.
37
 *
38
 * @ingroup Redis
39
 * @since 1.21
40
 */
41
class RedisConnectionPool implements LoggerAwareInterface {
42
	/** @var string Connection timeout in seconds */
43
	protected $connectTimeout;
44
	/** @var string Read timeout in seconds */
45
	protected $readTimeout;
46
	/** @var string Plaintext auth password */
47
	protected $password;
48
	/** @var bool Whether connections persist */
49
	protected $persistent;
50
	/** @var int Serializer to use (Redis::SERIALIZER_*) */
51
	protected $serializer;
52
53
	/** @var int Current idle pool size */
54
	protected $idlePoolSize = 0;
55
56
	/** @var array (server name => ((connection info array),...) */
57
	protected $connections = [];
58
	/** @var array (server name => UNIX timestamp) */
59
	protected $downServers = [];
60
61
	/** @var array (pool ID => RedisConnectionPool) */
62
	protected static $instances = [];
63
64
	/** integer; seconds to cache servers as "down". */
65
	const SERVER_DOWN_TTL = 30;
66
67
	/**
68
	 * @var LoggerInterface
69
	 */
70
	protected $logger;
71
72
	/**
73
	 * @param array $options
74
	 * @throws Exception
75
	 */
76
	protected function __construct( array $options ) {
77
		if ( !class_exists( 'Redis' ) ) {
78
			throw new RuntimeException(
79
				__CLASS__ . ' requires a Redis client library. ' .
80
				'See https://www.mediawiki.org/wiki/Redis#Setup' );
81
		}
82
		$this->logger = isset( $options['logger'] )
83
			? $options['logger']
84
			: new \Psr\Log\NullLogger();
85
		$this->connectTimeout = $options['connectTimeout'];
86
		$this->readTimeout = $options['readTimeout'];
87
		$this->persistent = $options['persistent'];
88
		$this->password = $options['password'];
89
		if ( !isset( $options['serializer'] ) || $options['serializer'] === 'php' ) {
90
			$this->serializer = Redis::SERIALIZER_PHP;
91
		} elseif ( $options['serializer'] === 'igbinary' ) {
92
			$this->serializer = Redis::SERIALIZER_IGBINARY;
93
		} elseif ( $options['serializer'] === 'none' ) {
94
			$this->serializer = Redis::SERIALIZER_NONE;
95
		} else {
96
			throw new InvalidArgumentException( "Invalid serializer specified." );
97
		}
98
	}
99
100
	/**
101
	 * @param LoggerInterface $logger
102
	 * @return null
103
	 */
104
	public function setLogger( LoggerInterface $logger ) {
105
		$this->logger = $logger;
106
	}
107
108
	/**
109
	 * @param array $options
110
	 * @return array
111
	 */
112
	protected static function applyDefaultConfig( array $options ) {
113
		if ( !isset( $options['connectTimeout'] ) ) {
114
			$options['connectTimeout'] = 1;
115
		}
116
		if ( !isset( $options['readTimeout'] ) ) {
117
			$options['readTimeout'] = 1;
118
		}
119
		if ( !isset( $options['persistent'] ) ) {
120
			$options['persistent'] = false;
121
		}
122
		if ( !isset( $options['password'] ) ) {
123
			$options['password'] = null;
124
		}
125
126
		return $options;
127
	}
128
129
	/**
130
	 * @param array $options
131
	 * $options include:
132
	 *   - connectTimeout : The timeout for new connections, in seconds.
133
	 *                      Optional, default is 1 second.
134
	 *   - readTimeout    : The timeout for operation reads, in seconds.
135
	 *                      Commands like BLPOP can fail if told to wait longer than this.
136
	 *                      Optional, default is 1 second.
137
	 *   - persistent     : Set this to true to allow connections to persist across
138
	 *                      multiple web requests. False by default.
139
	 *   - password       : The authentication password, will be sent to Redis in clear text.
140
	 *                      Optional, if it is unspecified, no AUTH command will be sent.
141
	 *   - serializer     : Set to "php", "igbinary", or "none". Default is "php".
142
	 * @return RedisConnectionPool
143
	 */
144
	public static function singleton( array $options ) {
145
		$options = self::applyDefaultConfig( $options );
146
		// Map the options to a unique hash...
147
		ksort( $options ); // normalize to avoid pool fragmentation
148
		$id = sha1( serialize( $options ) );
149
		// Initialize the object at the hash as needed...
150
		if ( !isset( self::$instances[$id] ) ) {
151
			self::$instances[$id] = new self( $options );
152
		}
153
154
		return self::$instances[$id];
155
	}
156
157
	/**
158
	 * Destroy all singleton() instances
159
	 * @since 1.27
160
	 */
161
	public static function destroySingletons() {
162
		self::$instances = [];
163
	}
164
165
	/**
166
	 * Get a connection to a redis server. Based on code in RedisBagOStuff.php.
167
	 *
168
	 * @param string $server A hostname/port combination or the absolute path of a UNIX socket.
169
	 *                       If a hostname is specified but no port, port 6379 will be used.
170
	 * @param LoggerInterface $logger PSR-3 logger intance. [optional]
171
	 * @return RedisConnRef|bool Returns false on failure
172
	 * @throws MWException
173
	 */
174
	public function getConnection( $server, LoggerInterface $logger = null ) {
175
		$logger = $logger ?: $this->logger;
176
		// Check the listing "dead" servers which have had a connection errors.
177
		// Servers are marked dead for a limited period of time, to
178
		// avoid excessive overhead from repeated connection timeouts.
179
		if ( isset( $this->downServers[$server] ) ) {
180
			$now = time();
181
			if ( $now > $this->downServers[$server] ) {
182
				// Dead time expired
183
				unset( $this->downServers[$server] );
184
			} else {
185
				// Server is dead
186
				$logger->debug(
187
					'Server "{redis_server}" is marked down for another ' .
188
					( $this->downServers[$server] - $now ) . 'seconds',
189
					[ 'redis_server' => $server ]
190
				);
191
192
				return false;
193
			}
194
		}
195
196
		// Check if a connection is already free for use
197
		if ( isset( $this->connections[$server] ) ) {
198
			foreach ( $this->connections[$server] as &$connection ) {
199
				if ( $connection['free'] ) {
200
					$connection['free'] = false;
201
					--$this->idlePoolSize;
202
203
					return new RedisConnRef(
204
						$this, $server, $connection['conn'], $logger
205
					);
206
				}
207
			}
208
		}
209
210
		if ( !$server ) {
211
			throw new InvalidArgumentException(
212
				__CLASS__ . ": invalid configured server \"$server\"" );
213
		} elseif ( substr( $server, 0, 1 ) === '/' ) {
214
			// UNIX domain socket
215
			// These are required by the redis extension to start with a slash, but
216
			// we still need to set the port to a special value to make it work.
217
			$host = $server;
218
			$port = 0;
219
		} else {
220
			// TCP connection
221
			if ( preg_match( '/^\[(.+)\]:(\d+)$/', $server, $m ) ) {
222
				list( $host, $port ) = [ $m[1], (int)$m[2] ]; // (ip, port)
223
			} elseif ( preg_match( '/^([^:]+):(\d+)$/', $server, $m ) ) {
224
				list( $host, $port ) = [ $m[1], (int)$m[2] ]; // (ip or path, port)
225
			} else {
226
				list( $host, $port ) = [ $server, 6379 ]; // (ip or path, port)
227
			}
228
		}
229
230
		$conn = new Redis();
231
		try {
232
			if ( $this->persistent ) {
233
				$result = $conn->pconnect( $host, $port, $this->connectTimeout );
234
			} else {
235
				$result = $conn->connect( $host, $port, $this->connectTimeout );
236
			}
237
			if ( !$result ) {
238
				$logger->error(
239
					'Could not connect to server "{redis_server}"',
240
					[ 'redis_server' => $server ]
241
				);
242
				// Mark server down for some time to avoid further timeouts
243
				$this->downServers[$server] = time() + self::SERVER_DOWN_TTL;
244
245
				return false;
246
			}
247 View Code Duplication
			if ( $this->password !== null ) {
248
				if ( !$conn->auth( $this->password ) ) {
249
					$logger->error(
250
						'Authentication error connecting to "{redis_server}"',
251
						[ 'redis_server' => $server ]
252
					);
253
				}
254
			}
255
		} catch ( RedisException $e ) {
0 ignored issues
show
Bug introduced by
The class RedisException does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
256
			$this->downServers[$server] = time() + self::SERVER_DOWN_TTL;
257
			$logger->error(
258
				'Redis exception connecting to "{redis_server}"',
259
				[
260
					'redis_server' => $server,
261
					'exception' => $e,
262
				]
263
			);
264
265
			return false;
266
		}
267
268
		if ( $conn ) {
269
			$conn->setOption( Redis::OPT_READ_TIMEOUT, $this->readTimeout );
270
			$conn->setOption( Redis::OPT_SERIALIZER, $this->serializer );
271
			$this->connections[$server][] = [ 'conn' => $conn, 'free' => false ];
272
273
			return new RedisConnRef( $this, $server, $conn, $logger );
274
		} else {
275
			return false;
276
		}
277
	}
278
279
	/**
280
	 * Mark a connection to a server as free to return to the pool
281
	 *
282
	 * @param string $server
283
	 * @param Redis $conn
284
	 * @return bool
285
	 */
286
	public function freeConnection( $server, Redis $conn ) {
287
		$found = false;
288
289
		foreach ( $this->connections[$server] as &$connection ) {
290
			if ( $connection['conn'] === $conn && !$connection['free'] ) {
291
				$connection['free'] = true;
292
				++$this->idlePoolSize;
293
				break;
294
			}
295
		}
296
297
		$this->closeExcessIdleConections();
298
299
		return $found;
300
	}
301
302
	/**
303
	 * Close any extra idle connections if there are more than the limit
304
	 */
305
	protected function closeExcessIdleConections() {
306
		if ( $this->idlePoolSize <= count( $this->connections ) ) {
307
			return; // nothing to do (no more connections than servers)
308
		}
309
310
		foreach ( $this->connections as &$serverConnections ) {
311
			foreach ( $serverConnections as $key => &$connection ) {
312
				if ( $connection['free'] ) {
313
					unset( $serverConnections[$key] );
314
					if ( --$this->idlePoolSize <= count( $this->connections ) ) {
315
						return; // done (no more connections than servers)
316
					}
317
				}
318
			}
319
		}
320
	}
321
322
	/**
323
	 * The redis extension throws an exception in response to various read, write
324
	 * and protocol errors. Sometimes it also closes the connection, sometimes
325
	 * not. The safest response for us is to explicitly destroy the connection
326
	 * object and let it be reopened during the next request.
327
	 *
328
	 * @param string $server
329
	 * @param RedisConnRef $cref
330
	 * @param RedisException $e
331
	 * @deprecated since 1.23
332
	 */
333
	public function handleException( $server, RedisConnRef $cref, RedisException $e ) {
334
		$this->handleError( $cref, $e );
335
	}
336
337
	/**
338
	 * The redis extension throws an exception in response to various read, write
339
	 * and protocol errors. Sometimes it also closes the connection, sometimes
340
	 * not. The safest response for us is to explicitly destroy the connection
341
	 * object and let it be reopened during the next request.
342
	 *
343
	 * @param RedisConnRef $cref
344
	 * @param RedisException $e
345
	 */
346
	public function handleError( RedisConnRef $cref, RedisException $e ) {
347
		$server = $cref->getServer();
348
		$this->logger->error(
349
			'Redis exception on server "{redis_server}"',
350
			[
351
				'redis_server' => $server,
352
				'exception' => $e,
353
			]
354
		);
355
		foreach ( $this->connections[$server] as $key => $connection ) {
356
			if ( $cref->isConnIdentical( $connection['conn'] ) ) {
357
				$this->idlePoolSize -= $connection['free'] ? 1 : 0;
358
				unset( $this->connections[$server][$key] );
359
				break;
360
			}
361
		}
362
	}
363
364
	/**
365
	 * Re-send an AUTH request to the redis server (useful after disconnects).
366
	 *
367
	 * This works around an upstream bug in phpredis. phpredis hides disconnects by transparently
368
	 * reconnecting, but it neglects to re-authenticate the new connection. To the user of the
369
	 * phpredis client API this manifests as a seemingly random tendency of connections to lose
370
	 * their authentication status.
371
	 *
372
	 * This method is for internal use only.
373
	 *
374
	 * @see https://github.com/nicolasff/phpredis/issues/403
375
	 *
376
	 * @param string $server
377
	 * @param Redis $conn
378
	 * @return bool Success
379
	 */
380
	public function reauthenticateConnection( $server, Redis $conn ) {
381 View Code Duplication
		if ( $this->password !== null ) {
382
			if ( !$conn->auth( $this->password ) ) {
383
				$this->logger->error(
384
					'Authentication error connecting to "{redis_server}"',
385
					[ 'redis_server' => $server ]
386
				);
387
388
				return false;
389
			}
390
		}
391
392
		return true;
393
	}
394
395
	/**
396
	 * Adjust or reset the connection handle read timeout value
397
	 *
398
	 * @param Redis $conn
399
	 * @param int $timeout Optional
400
	 */
401
	public function resetTimeout( Redis $conn, $timeout = null ) {
402
		$conn->setOption( Redis::OPT_READ_TIMEOUT, $timeout ?: $this->readTimeout );
403
	}
404
405
	/**
406
	 * Make sure connections are closed for sanity
407
	 */
408
	function __destruct() {
409
		foreach ( $this->connections as $server => &$serverConnections ) {
410
			foreach ( $serverConnections as $key => &$connection ) {
411
				/** @var Redis $conn */
412
				$conn = $connection['conn'];
413
				$conn->close();
414
			}
415
		}
416
	}
417
}
418