Merge "Exclude redirects from Special:Fewestrevisions"
[lhc/web/wiklou.git] / includes / libs / objectcache / RedisBagOStuff.php
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 * @ingroup Cache
29 * @ingroup Redis
30 */
31 class RedisBagOStuff extends MediumSpecificBagOStuff {
32 /** @var RedisConnectionPool */
33 protected $redisPool;
34 /** @var array List of server names */
35 protected $servers;
36 /** @var array Map of (tag => server name) */
37 protected $serverTagMap;
38 /** @var bool */
39 protected $automaticFailover;
40
41 /**
42 * Construct a RedisBagOStuff object. Parameters are:
43 *
44 * - servers: An array of server names. A server name may be a hostname,
45 * a hostname/port combination or the absolute path of a UNIX socket.
46 * If a hostname is specified but no port, the standard port number
47 * 6379 will be used. Arrays keys can be used to specify the tag to
48 * hash on in place of the host/port. Required.
49 *
50 * - connectTimeout: The timeout for new connections, in seconds. Optional,
51 * default is 1 second.
52 *
53 * - persistent: Set this to true to allow connections to persist across
54 * multiple web requests. False by default.
55 *
56 * - password: The authentication password, will be sent to Redis in
57 * clear text. Optional, if it is unspecified, no AUTH command will be
58 * sent.
59 *
60 * - automaticFailover: If this is false, then each key will be mapped to
61 * a single server, and if that server is down, any requests for that key
62 * will fail. If this is true, a connection failure will cause the client
63 * to immediately try the next server in the list (as determined by a
64 * consistent hashing algorithm). True by default. This has the
65 * potential to create consistency issues if a server is slow enough to
66 * flap, for example if it is in swap death.
67 * @param array $params
68 */
69 function __construct( $params ) {
70 parent::__construct( $params );
71 $redisConf = [ 'serializer' => 'none' ]; // manage that in this class
72 foreach ( [ 'connectTimeout', 'persistent', 'password' ] as $opt ) {
73 if ( isset( $params[$opt] ) ) {
74 $redisConf[$opt] = $params[$opt];
75 }
76 }
77 $this->redisPool = RedisConnectionPool::singleton( $redisConf );
78
79 $this->servers = $params['servers'];
80 foreach ( $this->servers as $key => $server ) {
81 $this->serverTagMap[is_int( $key ) ? $server : $key] = $server;
82 }
83
84 $this->automaticFailover = $params['automaticFailover'] ?? true;
85
86 $this->attrMap[self::ATTR_SYNCWRITES] = self::QOS_SYNCWRITES_NONE;
87 }
88
89 protected function doGet( $key, $flags = 0, &$casToken = null ) {
90 $casToken = null;
91
92 $conn = $this->getConnection( $key );
93 if ( !$conn ) {
94 return false;
95 }
96
97 $e = null;
98 try {
99 $value = $conn->get( $key );
100 $casToken = $value;
101 $result = $this->unserialize( $value );
102 } catch ( RedisException $e ) {
103 $result = false;
104 $this->handleException( $conn, $e );
105 }
106
107 $this->logRequest( 'get', $key, $conn->getServer(), $e );
108
109 return $result;
110 }
111
112 protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
113 $conn = $this->getConnection( $key );
114 if ( !$conn ) {
115 return false;
116 }
117
118 $ttl = $this->convertToRelative( $exptime );
119
120 $e = null;
121 try {
122 if ( $ttl ) {
123 $result = $conn->setex( $key, $ttl, $this->serialize( $value ) );
124 } else {
125 $result = $conn->set( $key, $this->serialize( $value ) );
126 }
127 } catch ( RedisException $e ) {
128 $result = false;
129 $this->handleException( $conn, $e );
130 }
131
132 $this->logRequest( 'set', $key, $conn->getServer(), $e );
133
134 return $result;
135 }
136
137 protected function doDelete( $key, $flags = 0 ) {
138 $conn = $this->getConnection( $key );
139 if ( !$conn ) {
140 return false;
141 }
142
143 $e = null;
144 try {
145 // Note that redis does not return false if the key was not there
146 $result = ( $conn->delete( $key ) !== false );
147 } catch ( RedisException $e ) {
148 $result = false;
149 $this->handleException( $conn, $e );
150 }
151
152 $this->logRequest( 'delete', $key, $conn->getServer(), $e );
153
154 return $result;
155 }
156
157 protected function doGetMulti( array $keys, $flags = 0 ) {
158 /** @var RedisConnRef[]|Redis[] $conns */
159 $conns = [];
160 $batches = [];
161 foreach ( $keys as $key ) {
162 $conn = $this->getConnection( $key );
163 if ( $conn ) {
164 $server = $conn->getServer();
165 $conns[$server] = $conn;
166 $batches[$server][] = $key;
167 }
168 }
169
170 $result = [];
171 foreach ( $batches as $server => $batchKeys ) {
172 $conn = $conns[$server];
173
174 $e = null;
175 try {
176 // Avoid mget() to reduce CPU hogging from a single request
177 $conn->multi( Redis::PIPELINE );
178 foreach ( $batchKeys as $key ) {
179 $conn->get( $key );
180 }
181 $batchResult = $conn->exec();
182 if ( $batchResult === false ) {
183 $this->logRequest( 'get', implode( ',', $batchKeys ), $server, true );
184 continue;
185 }
186
187 foreach ( $batchResult as $i => $value ) {
188 if ( $value !== false ) {
189 $result[$batchKeys[$i]] = $this->unserialize( $value );
190 }
191 }
192 } catch ( RedisException $e ) {
193 $this->handleException( $conn, $e );
194 }
195
196 $this->logRequest( 'get', implode( ',', $batchKeys ), $server, $e );
197 }
198
199 return $result;
200 }
201
202 protected function doSetMulti( array $data, $exptime = 0, $flags = 0 ) {
203 /** @var RedisConnRef[]|Redis[] $conns */
204 $conns = [];
205 $batches = [];
206 foreach ( $data as $key => $value ) {
207 $conn = $this->getConnection( $key );
208 if ( $conn ) {
209 $server = $conn->getServer();
210 $conns[$server] = $conn;
211 $batches[$server][] = $key;
212 }
213 }
214
215 $ttl = $this->convertToRelative( $exptime );
216 $op = $ttl ? 'setex' : 'set';
217
218 $result = true;
219 foreach ( $batches as $server => $batchKeys ) {
220 $conn = $conns[$server];
221
222 $e = null;
223 try {
224 // Avoid mset() to reduce CPU hogging from a single request
225 $conn->multi( Redis::PIPELINE );
226 foreach ( $batchKeys as $key ) {
227 if ( $ttl ) {
228 $conn->setex( $key, $ttl, $this->serialize( $data[$key] ) );
229 } else {
230 $conn->set( $key, $this->serialize( $data[$key] ) );
231 }
232 }
233 $batchResult = $conn->exec();
234 if ( $batchResult === false ) {
235 $this->logRequest( $op, implode( ',', $batchKeys ), $server, true );
236 continue;
237 }
238 $result = $result && !in_array( false, $batchResult, true );
239 } catch ( RedisException $e ) {
240 $this->handleException( $conn, $e );
241 $result = false;
242 }
243
244 $this->logRequest( $op, implode( ',', $batchKeys ), $server, $e );
245 }
246
247 return $result;
248 }
249
250 protected function doDeleteMulti( array $keys, $flags = 0 ) {
251 /** @var RedisConnRef[]|Redis[] $conns */
252 $conns = [];
253 $batches = [];
254 foreach ( $keys as $key ) {
255 $conn = $this->getConnection( $key );
256 if ( $conn ) {
257 $server = $conn->getServer();
258 $conns[$server] = $conn;
259 $batches[$server][] = $key;
260 }
261 }
262
263 $result = true;
264 foreach ( $batches as $server => $batchKeys ) {
265 $conn = $conns[$server];
266
267 $e = null;
268 try {
269 // Avoid delete() with array to reduce CPU hogging from a single request
270 $conn->multi( Redis::PIPELINE );
271 foreach ( $batchKeys as $key ) {
272 $conn->delete( $key );
273 }
274 $batchResult = $conn->exec();
275 if ( $batchResult === false ) {
276 $this->logRequest( 'delete', implode( ',', $batchKeys ), $server, true );
277 continue;
278 }
279 // Note that redis does not return false if the key was not there
280 $result = $result && !in_array( false, $batchResult, true );
281 } catch ( RedisException $e ) {
282 $this->handleException( $conn, $e );
283 $result = false;
284 }
285
286 $this->logRequest( 'delete', implode( ',', $batchKeys ), $server, $e );
287 }
288
289 return $result;
290 }
291
292 public function changeTTLMulti( array $keys, $exptime, $flags = 0 ) {
293 /** @var RedisConnRef[]|Redis[] $conns */
294 $conns = [];
295 $batches = [];
296 foreach ( $keys as $key ) {
297 $conn = $this->getConnection( $key );
298 if ( $conn ) {
299 $server = $conn->getServer();
300 $conns[$server] = $conn;
301 $batches[$server][] = $key;
302 }
303 }
304
305 $relative = $this->expiryIsRelative( $exptime );
306 $op = ( $exptime == 0 ) ? 'persist' : ( $relative ? 'expire' : 'expireAt' );
307
308 $result = true;
309 foreach ( $batches as $server => $batchKeys ) {
310 $conn = $conns[$server];
311
312 $e = null;
313 try {
314 $conn->multi( Redis::PIPELINE );
315 foreach ( $batchKeys as $key ) {
316 if ( $exptime == 0 ) {
317 $conn->persist( $key );
318 } elseif ( $relative ) {
319 $conn->expire( $key, $this->convertToRelative( $exptime ) );
320 } else {
321 $conn->expireAt( $key, $this->convertToExpiry( $exptime ) );
322 }
323 }
324 $batchResult = $conn->exec();
325 if ( $batchResult === false ) {
326 $this->logRequest( $op, implode( ',', $batchKeys ), $server, true );
327 continue;
328 }
329 $result = in_array( false, $batchResult, true ) ? false : $result;
330 } catch ( RedisException $e ) {
331 $this->handleException( $conn, $e );
332 $result = false;
333 }
334
335 $this->logRequest( $op, implode( ',', $batchKeys ), $server, $e );
336 }
337
338 return $result;
339 }
340
341 public function add( $key, $value, $expiry = 0, $flags = 0 ) {
342 $conn = $this->getConnection( $key );
343 if ( !$conn ) {
344 return false;
345 }
346
347 $ttl = $this->convertToRelative( $expiry );
348 try {
349 $result = $conn->set(
350 $key,
351 $this->serialize( $value ),
352 $ttl ? [ 'nx', 'ex' => $ttl ] : [ 'nx' ]
353 );
354 } catch ( RedisException $e ) {
355 $result = false;
356 $this->handleException( $conn, $e );
357 }
358
359 $this->logRequest( 'add', $key, $conn->getServer(), $result );
360
361 return $result;
362 }
363
364 public function incr( $key, $value = 1 ) {
365 $conn = $this->getConnection( $key );
366 if ( !$conn ) {
367 return false;
368 }
369
370 try {
371 if ( !$conn->exists( $key ) ) {
372 return false;
373 }
374 // @FIXME: on races, the key may have a 0 TTL
375 $result = $conn->incrBy( $key, $value );
376 } catch ( RedisException $e ) {
377 $result = false;
378 $this->handleException( $conn, $e );
379 }
380
381 $this->logRequest( 'incr', $key, $conn->getServer(), $result );
382
383 return $result;
384 }
385
386 protected function doChangeTTL( $key, $exptime, $flags ) {
387 $conn = $this->getConnection( $key );
388 if ( !$conn ) {
389 return false;
390 }
391
392 $relative = $this->expiryIsRelative( $exptime );
393 try {
394 if ( $exptime == 0 ) {
395 $result = $conn->persist( $key );
396 $this->logRequest( 'persist', $key, $conn->getServer(), $result );
397 } elseif ( $relative ) {
398 $result = $conn->expire( $key, $this->convertToRelative( $exptime ) );
399 $this->logRequest( 'expire', $key, $conn->getServer(), $result );
400 } else {
401 $result = $conn->expireAt( $key, $this->convertToExpiry( $exptime ) );
402 $this->logRequest( 'expireAt', $key, $conn->getServer(), $result );
403 }
404 } catch ( RedisException $e ) {
405 $result = false;
406 $this->handleException( $conn, $e );
407 }
408
409 return $result;
410 }
411
412 /**
413 * @param string $key
414 * @return RedisConnRef|Redis|null Redis handle wrapper for the key or null on failure
415 */
416 protected function getConnection( $key ) {
417 $candidates = array_keys( $this->serverTagMap );
418
419 if ( count( $this->servers ) > 1 ) {
420 ArrayUtils::consistentHashSort( $candidates, $key, '/' );
421 if ( !$this->automaticFailover ) {
422 $candidates = array_slice( $candidates, 0, 1 );
423 }
424 }
425
426 while ( ( $tag = array_shift( $candidates ) ) !== null ) {
427 $server = $this->serverTagMap[$tag];
428 $conn = $this->redisPool->getConnection( $server, $this->logger );
429 if ( !$conn ) {
430 continue;
431 }
432
433 // If automatic failover is enabled, check that the server's link
434 // to its master (if any) is up -- but only if there are other
435 // viable candidates left to consider. Also, getMasterLinkStatus()
436 // does not work with twemproxy, though $candidates will be empty
437 // by now in such cases.
438 if ( $this->automaticFailover && $candidates ) {
439 try {
440 /** @var string[] $info */
441 $info = $conn->info();
442 if ( ( $info['master_link_status'] ?? null ) === 'down' ) {
443 // If the master cannot be reached, fail-over to the next server.
444 // If masters are in data-center A, and replica DBs in data-center B,
445 // this helps avoid the case were fail-over happens in A but not
446 // to the corresponding server in B (e.g. read/write mismatch).
447 continue;
448 }
449 } catch ( RedisException $e ) {
450 // Server is not accepting commands
451 $this->redisPool->handleError( $conn, $e );
452 continue;
453 }
454 }
455
456 return $conn;
457 }
458
459 $this->setLastError( BagOStuff::ERR_UNREACHABLE );
460
461 return null;
462 }
463
464 /**
465 * Log a fatal error
466 * @param string $msg
467 */
468 protected function logError( $msg ) {
469 $this->logger->error( "Redis error: $msg" );
470 }
471
472 /**
473 * The redis extension throws an exception in response to various read, write
474 * and protocol errors. Sometimes it also closes the connection, sometimes
475 * not. The safest response for us is to explicitly destroy the connection
476 * object and let it be reopened during the next request.
477 * @param RedisConnRef $conn
478 * @param RedisException $e
479 */
480 protected function handleException( RedisConnRef $conn, RedisException $e ) {
481 $this->setLastError( BagOStuff::ERR_UNEXPECTED );
482 $this->redisPool->handleError( $conn, $e );
483 }
484
485 /**
486 * Send information about a single request to the debug log
487 * @param string $op
488 * @param string $keys
489 * @param string $server
490 * @param Exception|bool|null $e
491 */
492 public function logRequest( $op, $keys, $server, $e = null ) {
493 $this->debug( "$op($keys) on $server: " . ( $e ? "failure" : "success" ) );
494 }
495 }