gallery: Fix phan annotation for ImageGalleryBase::getImages
[mediawiki.git] / includes / libs / objectcache / RedisBagOStuff.php
blob44853a78e71fdb172f183f99eb6e400cbaa5edcb
1 <?php
2 /**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
18 * @file
20 namespace Wikimedia\ObjectCache;
22 use ArrayUtils;
23 use Exception;
24 use Redis;
25 use RedisException;
27 /**
28 * Store data in Redis.
30 * This requires the php-redis PECL extension (2.2.4 or later) and
31 * a Redis server (2.6.12 or later).
33 * @see http://redis.io/
34 * @see https://github.com/phpredis/phpredis/blob/d310ed7c8/Changelog.md
36 * @note Avoid use of Redis::MULTI transactions for twemproxy support
38 * @ingroup Cache
39 * @ingroup Redis
40 * @phan-file-suppress PhanTypeComparisonFromArray It's unclear whether exec() can return false
42 class RedisBagOStuff extends MediumSpecificBagOStuff {
43 /** @var RedisConnectionPool */
44 protected $redisPool;
45 /** @var array List of server names */
46 protected $servers;
47 /** @var array Map of (tag => server name) */
48 protected $serverTagMap;
49 /** @var bool */
50 protected $automaticFailover;
52 /**
53 * Construct a RedisBagOStuff object. Parameters are:
55 * - servers: An array of server names. A server name may be a hostname,
56 * a hostname/port combination or the absolute path of a UNIX socket.
57 * If a hostname is specified but no port, the standard port number
58 * 6379 will be used. Arrays keys can be used to specify the tag to
59 * hash on in place of the host/port. Required.
61 * - connectTimeout: The timeout for new connections, in seconds. Optional,
62 * default is 1 second.
64 * - persistent: Set this to true to allow connections to persist across
65 * multiple web requests. False by default.
67 * - password: The authentication password, will be sent to Redis in
68 * clear text. Optional, if it is unspecified, no AUTH command will be
69 * sent.
71 * - automaticFailover: If this is false, then each key will be mapped to
72 * a single server, and if that server is down, any requests for that key
73 * will fail. If this is true, a connection failure will cause the client
74 * to immediately try the next server in the list (as determined by a
75 * consistent hashing algorithm). True by default. This has the
76 * potential to create consistency issues if a server is slow enough to
77 * flap, for example if it is in swap death.
79 * @param array $params
81 public function __construct( $params ) {
82 parent::__construct( $params );
83 $redisConf = [ 'serializer' => 'none' ]; // manage that in this class
84 foreach ( [ 'connectTimeout', 'persistent', 'password' ] as $opt ) {
85 if ( isset( $params[$opt] ) ) {
86 $redisConf[$opt] = $params[$opt];
89 $this->redisPool = RedisConnectionPool::singleton( $redisConf );
91 $this->servers = $params['servers'];
92 foreach ( $this->servers as $key => $server ) {
93 $this->serverTagMap[is_int( $key ) ? $server : $key] = $server;
96 $this->automaticFailover = $params['automaticFailover'] ?? true;
98 // ...and uses rdb snapshots (redis.conf default)
99 $this->attrMap[self::ATTR_DURABILITY] = self::QOS_DURABILITY_DISK;
102 protected function doGet( $key, $flags = 0, &$casToken = null ) {
103 $getToken = ( $casToken === self::PASS_BY_REF );
104 $casToken = null;
106 $conn = $this->getConnection( $key );
107 if ( !$conn ) {
108 return false;
111 $e = null;
112 try {
113 $blob = $conn->get( $key );
114 if ( $blob !== false ) {
115 $value = $this->unserialize( $blob );
116 $valueSize = strlen( $blob );
117 } else {
118 $value = false;
119 $valueSize = false;
121 if ( $getToken && $value !== false ) {
122 $casToken = $blob;
124 } catch ( RedisException $e ) {
125 $value = false;
126 $valueSize = false;
127 $this->handleException( $conn, $e );
130 $this->logRequest( 'get', $key, $conn->getServer(), $e );
132 $this->updateOpStats( self::METRIC_OP_GET, [ $key => [ 0, $valueSize ] ] );
134 return $value;
137 protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
138 $conn = $this->getConnection( $key );
139 if ( !$conn ) {
140 return false;
143 $ttl = $this->getExpirationAsTTL( $exptime );
144 $serialized = $this->getSerialized( $value, $key );
145 $valueSize = strlen( $serialized );
147 $e = null;
148 try {
149 if ( $ttl ) {
150 $result = $conn->setex( $key, $ttl, $serialized );
151 } else {
152 $result = $conn->set( $key, $serialized );
154 } catch ( RedisException $e ) {
155 $result = false;
156 $this->handleException( $conn, $e );
159 $this->logRequest( 'set', $key, $conn->getServer(), $e );
161 $this->updateOpStats( self::METRIC_OP_SET, [ $key => [ $valueSize, 0 ] ] );
163 return $result;
166 protected function doDelete( $key, $flags = 0 ) {
167 $conn = $this->getConnection( $key );
168 if ( !$conn ) {
169 return false;
172 $e = null;
173 try {
174 // Note that redis does not return false if the key was not there
175 $result = ( $conn->del( $key ) !== false );
176 } catch ( RedisException $e ) {
177 $result = false;
178 $this->handleException( $conn, $e );
181 $this->logRequest( 'delete', $key, $conn->getServer(), $e );
183 $this->updateOpStats( self::METRIC_OP_DELETE, [ $key ] );
185 return $result;
188 protected function doGetMulti( array $keys, $flags = 0 ) {
189 $blobsFound = [];
191 [ $keysByServer, $connByServer ] = $this->getConnectionsForKeys( $keys );
192 foreach ( $keysByServer as $server => $batchKeys ) {
193 $conn = $connByServer[$server];
195 $e = null;
196 try {
197 // Avoid mget() to reduce CPU hogging from a single request
198 $conn->multi( Redis::PIPELINE );
199 foreach ( $batchKeys as $key ) {
200 $conn->get( $key );
202 $batchResult = $conn->exec();
203 if ( $batchResult === false ) {
204 $this->logRequest( 'get', implode( ',', $batchKeys ), $server, true );
205 continue;
208 foreach ( $batchResult as $i => $blob ) {
209 if ( $blob !== false ) {
210 $blobsFound[$batchKeys[$i]] = $blob;
213 } catch ( RedisException $e ) {
214 $this->handleException( $conn, $e );
217 $this->logRequest( 'get', implode( ',', $batchKeys ), $server, $e );
220 // Preserve the order of $keys
221 $result = [];
222 $valueSizesByKey = [];
223 foreach ( $keys as $key ) {
224 if ( array_key_exists( $key, $blobsFound ) ) {
225 $blob = $blobsFound[$key];
226 $value = $this->unserialize( $blob );
227 if ( $value !== false ) {
228 $result[$key] = $value;
230 $valueSize = strlen( $blob );
231 } else {
232 $valueSize = false;
234 $valueSizesByKey[$key] = [ 0, $valueSize ];
237 $this->updateOpStats( self::METRIC_OP_GET, $valueSizesByKey );
239 return $result;
242 protected function doSetMulti( array $data, $exptime = 0, $flags = 0 ) {
243 $ttl = $this->getExpirationAsTTL( $exptime );
244 $op = $ttl ? 'setex' : 'set';
246 $keys = array_keys( $data );
247 $valueSizesByKey = [];
249 [ $keysByServer, $connByServer, $result ] = $this->getConnectionsForKeys( $keys );
250 foreach ( $keysByServer as $server => $batchKeys ) {
251 $conn = $connByServer[$server];
253 $e = null;
254 try {
255 // Avoid mset() to reduce CPU hogging from a single request
256 $conn->multi( Redis::PIPELINE );
257 foreach ( $batchKeys as $key ) {
258 $serialized = $this->getSerialized( $data[$key], $key );
259 if ( $ttl ) {
260 $conn->setex( $key, $ttl, $serialized );
261 } else {
262 $conn->set( $key, $serialized );
264 $valueSizesByKey[$key] = [ strlen( $serialized ), 0 ];
266 $batchResult = $conn->exec();
267 if ( $batchResult === false ) {
268 $result = false;
269 $this->logRequest( $op, implode( ',', $batchKeys ), $server, true );
270 continue;
273 $result = $result && !in_array( false, $batchResult, true );
274 } catch ( RedisException $e ) {
275 $this->handleException( $conn, $e );
276 $result = false;
279 $this->logRequest( $op, implode( ',', $batchKeys ), $server, $e );
282 $this->updateOpStats( self::METRIC_OP_SET, $valueSizesByKey );
284 return $result;
287 protected function doDeleteMulti( array $keys, $flags = 0 ) {
288 [ $keysByServer, $connByServer, $result ] = $this->getConnectionsForKeys( $keys );
289 foreach ( $keysByServer as $server => $batchKeys ) {
290 $conn = $connByServer[$server];
292 $e = null;
293 try {
294 // Avoid delete() with array to reduce CPU hogging from a single request
295 $conn->multi( Redis::PIPELINE );
296 foreach ( $batchKeys as $key ) {
297 $conn->del( $key );
299 $batchResult = $conn->exec();
300 if ( $batchResult === false ) {
301 $result = false;
302 $this->logRequest( 'delete', implode( ',', $batchKeys ), $server, true );
303 continue;
305 // Note that redis does not return false if the key was not there
306 $result = $result && !in_array( false, $batchResult, true );
307 } catch ( RedisException $e ) {
308 $this->handleException( $conn, $e );
309 $result = false;
312 $this->logRequest( 'delete', implode( ',', $batchKeys ), $server, $e );
315 $this->updateOpStats( self::METRIC_OP_DELETE, array_values( $keys ) );
317 return $result;
320 public function doChangeTTLMulti( array $keys, $exptime, $flags = 0 ) {
321 $relative = $this->isRelativeExpiration( $exptime );
322 $op = ( $exptime == self::TTL_INDEFINITE )
323 ? 'persist'
324 : ( $relative ? 'expire' : 'expireAt' );
326 [ $keysByServer, $connByServer, $result ] = $this->getConnectionsForKeys( $keys );
327 foreach ( $keysByServer as $server => $batchKeys ) {
328 $conn = $connByServer[$server];
330 $e = null;
331 try {
332 $conn->multi( Redis::PIPELINE );
333 foreach ( $batchKeys as $key ) {
334 if ( $exptime == self::TTL_INDEFINITE ) {
335 $conn->persist( $key );
336 } elseif ( $relative ) {
337 $conn->expire( $key, $this->getExpirationAsTTL( $exptime ) );
338 } else {
339 $conn->expireAt( $key, $this->getExpirationAsTimestamp( $exptime ) );
342 $batchResult = $conn->exec();
343 if ( $batchResult === false ) {
344 $result = false;
345 $this->logRequest( $op, implode( ',', $batchKeys ), $server, true );
346 continue;
348 $result = in_array( false, $batchResult, true ) ? false : $result;
349 } catch ( RedisException $e ) {
350 $this->handleException( $conn, $e );
351 $result = false;
354 $this->logRequest( $op, implode( ',', $batchKeys ), $server, $e );
357 $this->updateOpStats( self::METRIC_OP_CHANGE_TTL, array_values( $keys ) );
359 return $result;
362 protected function doAdd( $key, $value, $exptime = 0, $flags = 0 ) {
363 $conn = $this->getConnection( $key );
364 if ( !$conn ) {
365 return false;
368 $ttl = $this->getExpirationAsTTL( $exptime );
369 $serialized = $this->getSerialized( $value, $key );
370 $valueSize = strlen( $serialized );
372 try {
373 $result = $conn->set(
374 $key,
375 $serialized,
376 $ttl ? [ 'nx', 'ex' => $ttl ] : [ 'nx' ]
378 } catch ( RedisException $e ) {
379 $result = false;
380 $this->handleException( $conn, $e );
383 $this->logRequest( 'add', $key, $conn->getServer(), $result );
385 $this->updateOpStats( self::METRIC_OP_ADD, [ $key => [ $valueSize, 0 ] ] );
387 return $result;
390 protected function doIncrWithInit( $key, $exptime, $step, $init, $flags ) {
391 $conn = $this->getConnection( $key );
392 if ( !$conn ) {
393 return false;
396 $ttl = $this->getExpirationAsTTL( $exptime );
397 try {
398 static $script =
399 /** @lang Lua */
400 <<<LUA
401 local key = KEYS[1]
402 local ttl, step, init = unpack( ARGV )
403 if redis.call( 'exists', key ) == 1 then
404 return redis.call( 'incrBy', key, step )
406 if 1 * ttl ~= 0 then
407 redis.call( 'setex', key, ttl, init )
408 else
409 redis.call( 'set', key, init )
411 return 1 * init
412 LUA;
413 $result = $conn->luaEval( $script, [ $key, $ttl, $step, $init ], 1 );
414 } catch ( RedisException $e ) {
415 $result = false;
416 $this->handleException( $conn, $e );
418 $this->logRequest( 'incrWithInit', $key, $conn->getServer(), $result );
420 return $result;
423 protected function doChangeTTL( $key, $exptime, $flags ) {
424 $conn = $this->getConnection( $key );
425 if ( !$conn ) {
426 return false;
429 $relative = $this->isRelativeExpiration( $exptime );
430 try {
431 if ( $exptime == self::TTL_INDEFINITE ) {
432 $result = $conn->persist( $key );
433 $this->logRequest( 'persist', $key, $conn->getServer(), $result );
434 } elseif ( $relative ) {
435 $result = $conn->expire( $key, $this->getExpirationAsTTL( $exptime ) );
436 $this->logRequest( 'expire', $key, $conn->getServer(), $result );
437 } else {
438 $result = $conn->expireAt( $key, $this->getExpirationAsTimestamp( $exptime ) );
439 $this->logRequest( 'expireAt', $key, $conn->getServer(), $result );
441 } catch ( RedisException $e ) {
442 $result = false;
443 $this->handleException( $conn, $e );
446 $this->updateOpStats( self::METRIC_OP_CHANGE_TTL, [ $key ] );
448 return $result;
452 * @param string[] $keys
454 * @return array ((server => redis handle wrapper), (server => key batch), success)
455 * @phan-return array{0:array<string,string[]>,1:array<string,RedisConnRef|Redis>,2:bool}
457 protected function getConnectionsForKeys( array $keys ) {
458 $keysByServer = [];
459 $connByServer = [];
460 $success = true;
461 foreach ( $keys as $key ) {
462 $candidateTags = $this->getCandidateServerTagsForKey( $key );
464 $conn = null;
465 // Find a suitable server for this key...
466 // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
467 while ( ( $tag = array_shift( $candidateTags ) ) !== null ) {
468 $server = $this->serverTagMap[$tag];
469 // Reuse connection handles for keys mapping to the same server
470 if ( isset( $connByServer[$server] ) ) {
471 $conn = $connByServer[$server];
472 } else {
473 $conn = $this->redisPool->getConnection( $server, $this->logger );
474 if ( !$conn ) {
475 continue;
477 // If automatic failover is enabled, check that the server's link
478 // to its master (if any) is up -- but only if there are other
479 // viable candidates left to consider. Also, getMasterLinkStatus()
480 // does not work with twemproxy, though $candidates will be empty
481 // by now in such cases.
482 if ( $this->automaticFailover && $candidateTags ) {
483 try {
484 /** @var string[] $info */
485 $info = $conn->info();
486 if ( ( $info['master_link_status'] ?? null ) === 'down' ) {
487 // If the master cannot be reached, fail-over to the next server.
488 // If masters are in data-center A, and replica DBs in data-center B,
489 // this helps avoid the case were fail-over happens in A but not
490 // to the corresponding server in B (e.g. read/write mismatch).
491 continue;
493 } catch ( RedisException $e ) {
494 // Server is not accepting commands
495 $this->redisPool->handleError( $conn, $e );
496 continue;
499 // Use this connection handle
500 $connByServer[$server] = $conn;
502 // Use this server for this key
503 $keysByServer[$server][] = $key;
504 break;
507 if ( !$conn ) {
508 // No suitable server found for this key
509 $success = false;
510 $this->setLastError( BagOStuff::ERR_UNREACHABLE );
514 return [ $keysByServer, $connByServer, $success ];
518 * @param string $key
520 * @return RedisConnRef|Redis|null Redis handle wrapper for the key or null on failure
522 protected function getConnection( $key ) {
523 [ , $connByServer ] = $this->getConnectionsForKeys( [ $key ] );
525 return reset( $connByServer ) ?: null;
528 private function getCandidateServerTagsForKey( string $key ): array {
529 $candidates = array_keys( $this->serverTagMap );
531 if ( count( $this->servers ) > 1 ) {
532 ArrayUtils::consistentHashSort( $candidates, $key, '/' );
533 if ( !$this->automaticFailover ) {
534 $candidates = array_slice( $candidates, 0, 1 );
538 return $candidates;
542 * Log a fatal error
544 * @param string $msg
546 protected function logError( $msg ) {
547 $this->logger->error( "Redis error: $msg" );
551 * The redis extension throws an exception in response to various read, write
552 * and protocol errors. Sometimes it also closes the connection, sometimes
553 * not. The safest response for us is to explicitly destroy the connection
554 * object and let it be reopened during the next request.
556 * @param RedisConnRef $conn
557 * @param RedisException $e
559 protected function handleException( RedisConnRef $conn, RedisException $e ) {
560 $this->setLastError( BagOStuff::ERR_UNEXPECTED );
561 $this->redisPool->handleError( $conn, $e );
565 * Send information about a single request to the debug log
567 * @param string $op
568 * @param string $keys
569 * @param string $server
570 * @param Exception|bool|null $e
572 public function logRequest( $op, $keys, $server, $e = null ) {
573 $this->debug( "$op($keys) on $server: " . ( $e ? "failure" : "success" ) );
577 /** @deprecated class alias since 1.43 */
578 class_alias( RedisBagOStuff::class, 'RedisBagOStuff' );