Completed
Branch master (939199)
by
unknown
39:35
created

includes/libs/objectcache/RedisBagOStuff.php (3 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
/**
3
 * Object caching using Redis (http://redis.io/).
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
 */
22
23
/**
24
 * Redis-based caching module for redis server >= 2.6.12
25
 *
26
 * @note: avoid use of Redis::MULTI transactions for twemproxy support
27
 */
28
class RedisBagOStuff extends BagOStuff {
29
	/** @var RedisConnectionPool */
30
	protected $redisPool;
31
	/** @var array List of server names */
32
	protected $servers;
33
	/** @var array Map of (tag => server name) */
34
	protected $serverTagMap;
35
	/** @var bool */
36
	protected $automaticFailover;
37
38
	/**
39
	 * Construct a RedisBagOStuff object. Parameters are:
40
	 *
41
	 *   - servers: An array of server names. A server name may be a hostname,
42
	 *     a hostname/port combination or the absolute path of a UNIX socket.
43
	 *     If a hostname is specified but no port, the standard port number
44
	 *     6379 will be used. Arrays keys can be used to specify the tag to
45
	 *     hash on in place of the host/port. Required.
46
	 *
47
	 *   - connectTimeout: The timeout for new connections, in seconds. Optional,
48
	 *     default is 1 second.
49
	 *
50
	 *   - persistent: Set this to true to allow connections to persist across
51
	 *     multiple web requests. False by default.
52
	 *
53
	 *   - password: The authentication password, will be sent to Redis in
54
	 *     clear text. Optional, if it is unspecified, no AUTH command will be
55
	 *     sent.
56
	 *
57
	 *   - automaticFailover: If this is false, then each key will be mapped to
58
	 *     a single server, and if that server is down, any requests for that key
59
	 *     will fail. If this is true, a connection failure will cause the client
60
	 *     to immediately try the next server in the list (as determined by a
61
	 *     consistent hashing algorithm). True by default. This has the
62
	 *     potential to create consistency issues if a server is slow enough to
63
	 *     flap, for example if it is in swap death.
64
	 * @param array $params
65
	 */
66
	function __construct( $params ) {
67
		parent::__construct( $params );
68
		$redisConf = [ 'serializer' => 'none' ]; // manage that in this class
69
		foreach ( [ 'connectTimeout', 'persistent', 'password' ] as $opt ) {
70
			if ( isset( $params[$opt] ) ) {
71
				$redisConf[$opt] = $params[$opt];
72
			}
73
		}
74
		$this->redisPool = RedisConnectionPool::singleton( $redisConf );
75
76
		$this->servers = $params['servers'];
77
		foreach ( $this->servers as $key => $server ) {
78
			$this->serverTagMap[is_int( $key ) ? $server : $key] = $server;
79
		}
80
81
		if ( isset( $params['automaticFailover'] ) ) {
82
			$this->automaticFailover = $params['automaticFailover'];
83
		} else {
84
			$this->automaticFailover = true;
85
		}
86
87
		$this->attrMap[self::ATTR_SYNCWRITES] = self::QOS_SYNCWRITES_NONE;
88
	}
89
90 View Code Duplication
	protected function doGet( $key, $flags = 0 ) {
91
		list( $server, $conn ) = $this->getConnection( $key );
92
		if ( !$conn ) {
93
			return false;
94
		}
95
		try {
96
			$value = $conn->get( $key );
97
			$result = $this->unserialize( $value );
98
		} catch ( RedisException $e ) {
99
			$result = false;
100
			$this->handleException( $conn, $e );
101
		}
102
103
		$this->logRequest( 'get', $key, $server, $result );
104
		return $result;
105
	}
106
107 View Code Duplication
	public function set( $key, $value, $expiry = 0, $flags = 0 ) {
108
		list( $server, $conn ) = $this->getConnection( $key );
109
		if ( !$conn ) {
110
			return false;
111
		}
112
		$expiry = $this->convertToRelative( $expiry );
113
		try {
114
			if ( $expiry ) {
115
				$result = $conn->setex( $key, $expiry, $this->serialize( $value ) );
116
			} else {
117
				// No expiry, that is very different from zero expiry in Redis
118
				$result = $conn->set( $key, $this->serialize( $value ) );
119
			}
120
		} catch ( RedisException $e ) {
121
			$result = false;
122
			$this->handleException( $conn, $e );
123
		}
124
125
		$this->logRequest( 'set', $key, $server, $result );
126
		return $result;
127
	}
128
129
	public function delete( $key ) {
130
		list( $server, $conn ) = $this->getConnection( $key );
131
		if ( !$conn ) {
132
			return false;
133
		}
134
		try {
135
			$conn->delete( $key );
136
			// Return true even if the key didn't exist
137
			$result = true;
138
		} catch ( RedisException $e ) {
139
			$result = false;
140
			$this->handleException( $conn, $e );
141
		}
142
143
		$this->logRequest( 'delete', $key, $server, $result );
144
		return $result;
145
	}
146
147
	public function getMulti( array $keys, $flags = 0 ) {
148
		$batches = [];
149
		$conns = [];
150 View Code Duplication
		foreach ( $keys as $key ) {
151
			list( $server, $conn ) = $this->getConnection( $key );
152
			if ( !$conn ) {
153
				continue;
154
			}
155
			$conns[$server] = $conn;
156
			$batches[$server][] = $key;
157
		}
158
		$result = [];
159
		foreach ( $batches as $server => $batchKeys ) {
160
			$conn = $conns[$server];
161
			try {
162
				$conn->multi( Redis::PIPELINE );
163
				foreach ( $batchKeys as $key ) {
164
					$conn->get( $key );
165
				}
166
				$batchResult = $conn->exec();
167
				if ( $batchResult === false ) {
168
					$this->debug( "multi request to $server failed" );
169
					continue;
170
				}
171
				foreach ( $batchResult as $i => $value ) {
172
					if ( $value !== false ) {
173
						$result[$batchKeys[$i]] = $this->unserialize( $value );
174
					}
175
				}
176
			} catch ( RedisException $e ) {
177
				$this->handleException( $conn, $e );
178
			}
179
		}
180
181
		$this->debug( "getMulti for " . count( $keys ) . " keys " .
182
			"returned " . count( $result ) . " results" );
183
		return $result;
184
	}
185
186
	/**
187
	 * @param array $data
188
	 * @param int $expiry
189
	 * @return bool
190
	 */
191
	public function setMulti( array $data, $expiry = 0 ) {
192
		$batches = [];
193
		$conns = [];
194 View Code Duplication
		foreach ( $data as $key => $value ) {
195
			list( $server, $conn ) = $this->getConnection( $key );
196
			if ( !$conn ) {
197
				continue;
198
			}
199
			$conns[$server] = $conn;
200
			$batches[$server][] = $key;
201
		}
202
203
		$expiry = $this->convertToRelative( $expiry );
204
		$result = true;
205
		foreach ( $batches as $server => $batchKeys ) {
206
			$conn = $conns[$server];
207
			try {
208
				$conn->multi( Redis::PIPELINE );
209
				foreach ( $batchKeys as $key ) {
210
					if ( $expiry ) {
211
						$conn->setex( $key, $expiry, $this->serialize( $data[$key] ) );
212
					} else {
213
						$conn->set( $key, $this->serialize( $data[$key] ) );
214
					}
215
				}
216
				$batchResult = $conn->exec();
217
				if ( $batchResult === false ) {
218
					$this->debug( "setMulti request to $server failed" );
219
					continue;
220
				}
221
				foreach ( $batchResult as $value ) {
222
					if ( $value === false ) {
223
						$result = false;
224
					}
225
				}
226
			} catch ( RedisException $e ) {
227
				$this->handleException( $server, $conn, $e );
228
				$result = false;
229
			}
230
		}
231
232
		return $result;
233
	}
234
235 View Code Duplication
	public function add( $key, $value, $expiry = 0 ) {
236
		list( $server, $conn ) = $this->getConnection( $key );
237
		if ( !$conn ) {
238
			return false;
239
		}
240
		$expiry = $this->convertToRelative( $expiry );
241
		try {
242
			if ( $expiry ) {
243
				$result = $conn->set(
244
					$key,
245
					$this->serialize( $value ),
246
					[ 'nx', 'ex' => $expiry ]
247
				);
248
			} else {
249
				$result = $conn->setnx( $key, $this->serialize( $value ) );
250
			}
251
		} catch ( RedisException $e ) {
252
			$result = false;
253
			$this->handleException( $conn, $e );
254
		}
255
256
		$this->logRequest( 'add', $key, $server, $result );
257
		return $result;
258
	}
259
260
	/**
261
	 * Non-atomic implementation of incr().
262
	 *
263
	 * Probably all callers actually want incr() to atomically initialise
264
	 * values to zero if they don't exist, as provided by the Redis INCR
265
	 * command. But we are constrained by the memcached-like interface to
266
	 * return null in that case. Once the key exists, further increments are
267
	 * atomic.
268
	 * @param string $key Key to increase
269
	 * @param int $value Value to add to $key (Default 1)
270
	 * @return int|bool New value or false on failure
271
	 */
272 View Code Duplication
	public function incr( $key, $value = 1 ) {
273
		list( $server, $conn ) = $this->getConnection( $key );
274
		if ( !$conn ) {
275
			return false;
276
		}
277
		try {
278
			if ( !$conn->exists( $key ) ) {
279
				return null;
280
			}
281
			// @FIXME: on races, the key may have a 0 TTL
282
			$result = $conn->incrBy( $key, $value );
283
		} catch ( RedisException $e ) {
284
			$result = false;
285
			$this->handleException( $conn, $e );
286
		}
287
288
		$this->logRequest( 'incr', $key, $server, $result );
289
		return $result;
290
	}
291
292 View Code Duplication
	public function changeTTL( $key, $expiry = 0 ) {
293
		list( $server, $conn ) = $this->getConnection( $key );
294
		if ( !$conn ) {
295
			return false;
296
		}
297
298
		$expiry = $this->convertToRelative( $expiry );
299
		try {
300
			$result = $conn->expire( $key, $expiry );
301
		} catch ( RedisException $e ) {
0 ignored issues
show
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...
302
			$result = false;
303
			$this->handleException( $conn, $e );
304
		}
305
306
		$this->logRequest( 'expire', $key, $server, $result );
307
		return $result;
308
	}
309
310
	public function modifySimpleRelayEvent( array $event ) {
311
		if ( array_key_exists( 'val', $event ) ) {
312
			$event['val'] = serialize( $event['val'] ); // this class uses PHP serialization
313
		}
314
315
		return $event;
316
	}
317
318
	/**
319
	 * @param mixed $data
320
	 * @return string
321
	 */
322
	protected function serialize( $data ) {
323
		// Serialize anything but integers so INCR/DECR work
324
		// Do not store integer-like strings as integers to avoid type confusion (bug 60563)
325
		return is_int( $data ) ? $data : serialize( $data );
326
	}
327
328
	/**
329
	 * @param string $data
330
	 * @return mixed
331
	 */
332
	protected function unserialize( $data ) {
333
		$int = intval( $data );
334
		return $data === (string)$int ? $int : unserialize( $data );
335
	}
336
337
	/**
338
	 * Get a Redis object with a connection suitable for fetching the specified key
339
	 * @param string $key
340
	 * @return array (server, RedisConnRef) or (false, false)
341
	 */
342
	protected function getConnection( $key ) {
343
		$candidates = array_keys( $this->serverTagMap );
344
345
		if ( count( $this->servers ) > 1 ) {
346
			ArrayUtils::consistentHashSort( $candidates, $key, '/' );
347
			if ( !$this->automaticFailover ) {
348
				$candidates = array_slice( $candidates, 0, 1 );
349
			}
350
		}
351
352
		while ( ( $tag = array_shift( $candidates ) ) !== null ) {
353
			$server = $this->serverTagMap[$tag];
354
			$conn = $this->redisPool->getConnection( $server, $this->logger );
355
			if ( !$conn ) {
356
				continue;
357
			}
358
359
			// If automatic failover is enabled, check that the server's link
360
			// to its master (if any) is up -- but only if there are other
361
			// viable candidates left to consider. Also, getMasterLinkStatus()
362
			// does not work with twemproxy, though $candidates will be empty
363
			// by now in such cases.
364
			if ( $this->automaticFailover && $candidates ) {
365
				try {
366
					if ( $this->getMasterLinkStatus( $conn ) === 'down' ) {
367
						// If the master cannot be reached, fail-over to the next server.
368
						// If masters are in data-center A, and replica DBs in data-center B,
369
						// this helps avoid the case were fail-over happens in A but not
370
						// to the corresponding server in B (e.g. read/write mismatch).
371
						continue;
372
					}
373
				} catch ( RedisException $e ) {
0 ignored issues
show
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...
374
					// Server is not accepting commands
375
					$this->handleException( $conn, $e );
376
					continue;
377
				}
378
			}
379
380
			return [ $server, $conn ];
381
		}
382
383
		$this->setLastError( BagOStuff::ERR_UNREACHABLE );
384
385
		return [ false, false ];
386
	}
387
388
	/**
389
	 * Check the master link status of a Redis server that is configured as a replica DB.
390
	 * @param RedisConnRef $conn
391
	 * @return string|null Master link status (either 'up' or 'down'), or null
392
	 *  if the server is not a replica DB.
393
	 */
394
	protected function getMasterLinkStatus( RedisConnRef $conn ) {
395
		$info = $conn->info();
0 ignored issues
show
Documentation Bug introduced by
The method info does not exist on object<RedisConnRef>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
396
		return isset( $info['master_link_status'] )
397
			? $info['master_link_status']
398
			: null;
399
	}
400
401
	/**
402
	 * Log a fatal error
403
	 * @param string $msg
404
	 */
405
	protected function logError( $msg ) {
406
		$this->logger->error( "Redis error: $msg" );
407
	}
408
409
	/**
410
	 * The redis extension throws an exception in response to various read, write
411
	 * and protocol errors. Sometimes it also closes the connection, sometimes
412
	 * not. The safest response for us is to explicitly destroy the connection
413
	 * object and let it be reopened during the next request.
414
	 * @param RedisConnRef $conn
415
	 * @param Exception $e
416
	 */
417
	protected function handleException( RedisConnRef $conn, $e ) {
418
		$this->setLastError( BagOStuff::ERR_UNEXPECTED );
419
		$this->redisPool->handleError( $conn, $e );
420
	}
421
422
	/**
423
	 * Send information about a single request to the debug log
424
	 * @param string $method
425
	 * @param string $key
426
	 * @param string $server
427
	 * @param bool $result
428
	 */
429
	public function logRequest( $method, $key, $server, $result ) {
430
		$this->debug( "$method $key on $server: " .
431
			( $result === false ? "failure" : "success" ) );
432
	}
433
}
434