Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 132 |
|
0.00% |
0 / 12 |
CRAP | |
0.00% |
0 / 1 |
| MemcLockManager | |
0.00% |
0 / 131 |
|
0.00% |
0 / 12 |
3080 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
20 | |||
| getLocksOnServer | |
0.00% |
0 / 40 |
|
0.00% |
0 / 1 |
272 | |||
| freeLocksOnServer | |
0.00% |
0 / 34 |
|
0.00% |
0 / 1 |
132 | |||
| releaseAllLocks | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| isServerUp | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getCache | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
30 | |||
| recordKeyForPath | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| newLockArray | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| sanitizeLockArray | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
20 | |||
| acquireMutexes | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
30 | |||
| releaseMutexes | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
| __destruct | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | namespace Wikimedia\LockManager; |
| 7 | |
| 8 | use Exception; |
| 9 | use InvalidArgumentException; |
| 10 | use StatusValue; |
| 11 | use Wikimedia\ObjectCache\MapCacheLRU; |
| 12 | use Wikimedia\ObjectCache\MemcachedBagOStuff; |
| 13 | use Wikimedia\ObjectCache\MemcachedPhpBagOStuff; |
| 14 | use Wikimedia\WaitConditionLoop; |
| 15 | |
| 16 | /** |
| 17 | * Manage locks using memcached servers. |
| 18 | * |
| 19 | * Version of LockManager based on using memcached servers. |
| 20 | * This is meant for multi-wiki systems that may share files. |
| 21 | * All locks are non-blocking, which avoids deadlocks. |
| 22 | * |
| 23 | * All lock requests for a resource, identified by a hash string, will map to one |
| 24 | * bucket. Each bucket maps to one or several peer servers, each running memcached. |
| 25 | * A majority of peers must agree for a lock to be acquired. |
| 26 | * |
| 27 | * @ingroup LockManager |
| 28 | * @since 1.20 |
| 29 | */ |
| 30 | class MemcLockManager extends QuorumLockManager { |
| 31 | /** @var array Mapping of lock types to the type actually used */ |
| 32 | protected $lockTypeMap = [ |
| 33 | self::LOCK_SH => self::LOCK_SH, |
| 34 | self::LOCK_UW => self::LOCK_SH, |
| 35 | self::LOCK_EX => self::LOCK_EX |
| 36 | ]; |
| 37 | |
| 38 | /** @var MemcachedBagOStuff[] Map of (server name => MemcachedBagOStuff) */ |
| 39 | protected $cacheServers = []; |
| 40 | /** @var MapCacheLRU Server status cache */ |
| 41 | protected $statusCache; |
| 42 | |
| 43 | /** |
| 44 | * Construct a new instance from configuration. |
| 45 | * |
| 46 | * @param array $config Parameters include: |
| 47 | * - lockServers : Associative array of server names to "<IP>:<port>" strings. |
| 48 | * - srvsByBucket : [optional] An array of up to 16 arrays, each containing the server names |
| 49 | * in a bucket. Each bucket should have an odd number of servers. |
| 50 | * If omitted, all servers will be in one bucket. |
| 51 | * - memcConfig : [optional] Configuration array for MemcachedBagOStuff::construct() with an |
| 52 | * additional 'class' parameter specifying which MemcachedBagOStuff |
| 53 | * subclass to use. The server names will be injected. |
| 54 | * @throws Exception |
| 55 | */ |
| 56 | public function __construct( array $config ) { |
| 57 | parent::__construct( $config ); |
| 58 | |
| 59 | if ( isset( $config['srvsByBucket'] ) ) { |
| 60 | // Sanitize srvsByBucket config to prevent PHP errors |
| 61 | $this->srvsByBucket = array_filter( $config['srvsByBucket'], 'is_array' ); |
| 62 | $this->srvsByBucket = array_values( $this->srvsByBucket ); // consecutive |
| 63 | } else { |
| 64 | $this->srvsByBucket = [ array_keys( $config['lockServers'] ) ]; |
| 65 | } |
| 66 | |
| 67 | $memcConfig = $config['memcConfig'] ?? []; |
| 68 | $memcConfig += [ 'class' => MemcachedPhpBagOStuff::class ]; // default |
| 69 | |
| 70 | $class = $memcConfig['class']; |
| 71 | if ( !is_subclass_of( $class, MemcachedBagOStuff::class ) ) { |
| 72 | throw new InvalidArgumentException( "$class is not of type MemcachedBagOStuff." ); |
| 73 | } |
| 74 | |
| 75 | foreach ( $config['lockServers'] as $name => $address ) { |
| 76 | $params = [ 'servers' => [ $address ] ] + $memcConfig; |
| 77 | $this->cacheServers[$name] = new $class( $params ); |
| 78 | } |
| 79 | |
| 80 | $this->statusCache = new MapCacheLRU( 100 ); |
| 81 | } |
| 82 | |
| 83 | /** @inheritDoc */ |
| 84 | protected function getLocksOnServer( $lockSrv, array $pathsByType ) { |
| 85 | $status = StatusValue::newGood(); |
| 86 | |
| 87 | $memc = $this->getCache( $lockSrv ); |
| 88 | // List of affected paths |
| 89 | $paths = array_merge( ...array_values( $pathsByType ) ); |
| 90 | $paths = array_unique( $paths ); |
| 91 | // List of affected lock record keys |
| 92 | $keys = array_map( $this->recordKeyForPath( ... ), $paths ); |
| 93 | |
| 94 | // Lock all of the active lock record keys... |
| 95 | if ( !$this->acquireMutexes( $memc, $keys ) ) { |
| 96 | $status->fatal( 'lockmanager-fail-conflict' ); |
| 97 | return $status; |
| 98 | } |
| 99 | |
| 100 | // Fetch all the existing lock records... |
| 101 | $lockRecords = $memc->getMulti( $keys ); |
| 102 | |
| 103 | $now = time(); |
| 104 | // Check if the requested locks conflict with existing ones... |
| 105 | foreach ( $pathsByType as $type => $paths2 ) { |
| 106 | foreach ( $paths2 as $path ) { |
| 107 | $locksKey = $this->recordKeyForPath( $path ); |
| 108 | $locksHeld = isset( $lockRecords[$locksKey] ) |
| 109 | ? self::sanitizeLockArray( $lockRecords[$locksKey] ) |
| 110 | : self::newLockArray(); // init |
| 111 | foreach ( $locksHeld[self::LOCK_EX] as $session => $expiry ) { |
| 112 | if ( $expiry < $now ) { // stale? |
| 113 | unset( $locksHeld[self::LOCK_EX][$session] ); |
| 114 | } elseif ( $session !== $this->session ) { |
| 115 | $status->fatal( 'lockmanager-fail-conflict' ); |
| 116 | } |
| 117 | } |
| 118 | if ( $type === self::LOCK_EX ) { |
| 119 | foreach ( $locksHeld[self::LOCK_SH] as $session => $expiry ) { |
| 120 | if ( $expiry < $now ) { // stale? |
| 121 | unset( $locksHeld[self::LOCK_SH][$session] ); |
| 122 | } elseif ( $session !== $this->session ) { |
| 123 | $status->fatal( 'lockmanager-fail-conflict' ); |
| 124 | } |
| 125 | } |
| 126 | } |
| 127 | if ( $status->isOK() ) { |
| 128 | // Register the session in the lock record array |
| 129 | $locksHeld[$type][$this->session] = $now + $this->lockTTL; |
| 130 | // We will update this record if none of the other locks conflict |
| 131 | $lockRecords[$locksKey] = $locksHeld; |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | // If there were no lock conflicts, update all the lock records... |
| 137 | if ( $status->isOK() ) { |
| 138 | foreach ( $paths as $path ) { |
| 139 | $locksKey = $this->recordKeyForPath( $path ); |
| 140 | $locksHeld = $lockRecords[$locksKey]; |
| 141 | $ok = $memc->set( $locksKey, $locksHeld, self::MAX_LOCK_TTL ); |
| 142 | if ( !$ok ) { |
| 143 | $status->fatal( 'lockmanager-fail-acquirelock', $path ); |
| 144 | } else { |
| 145 | $this->logger->debug( __METHOD__ . ": acquired lock on key $locksKey." ); |
| 146 | } |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | // Unlock all of the active lock record keys... |
| 151 | $this->releaseMutexes( $memc, $keys ); |
| 152 | |
| 153 | return $status; |
| 154 | } |
| 155 | |
| 156 | /** @inheritDoc */ |
| 157 | protected function freeLocksOnServer( $lockSrv, array $pathsByType ) { |
| 158 | $status = StatusValue::newGood(); |
| 159 | |
| 160 | $memc = $this->getCache( $lockSrv ); |
| 161 | // List of affected paths |
| 162 | $paths = array_merge( ...array_values( $pathsByType ) ); |
| 163 | $paths = array_unique( $paths ); |
| 164 | // List of affected lock record keys |
| 165 | $keys = array_map( $this->recordKeyForPath( ... ), $paths ); |
| 166 | |
| 167 | // Lock all of the active lock record keys... |
| 168 | if ( !$this->acquireMutexes( $memc, $keys ) ) { |
| 169 | foreach ( $paths as $path ) { |
| 170 | $status->fatal( 'lockmanager-fail-releaselock', $path ); |
| 171 | } |
| 172 | |
| 173 | return $status; |
| 174 | } |
| 175 | |
| 176 | // Fetch all the existing lock records... |
| 177 | $lockRecords = $memc->getMulti( $keys ); |
| 178 | |
| 179 | // Remove the requested locks from all records... |
| 180 | foreach ( $pathsByType as $type => $paths2 ) { |
| 181 | foreach ( $paths2 as $path ) { |
| 182 | $locksKey = $this->recordKeyForPath( $path ); // lock record |
| 183 | if ( !isset( $lockRecords[$locksKey] ) ) { |
| 184 | $status->warning( 'lockmanager-fail-releaselock', $path ); |
| 185 | continue; // nothing to do |
| 186 | } |
| 187 | $locksHeld = $this->sanitizeLockArray( $lockRecords[$locksKey] ); |
| 188 | if ( isset( $locksHeld[$type][$this->session] ) ) { |
| 189 | unset( $locksHeld[$type][$this->session] ); // unregister this session |
| 190 | $lockRecords[$locksKey] = $locksHeld; |
| 191 | } else { |
| 192 | $status->warning( 'lockmanager-fail-releaselock', $path ); |
| 193 | } |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | // Persist the new lock record values... |
| 198 | foreach ( $paths as $path ) { |
| 199 | $locksKey = $this->recordKeyForPath( $path ); |
| 200 | if ( !isset( $lockRecords[$locksKey] ) ) { |
| 201 | continue; // nothing to do |
| 202 | } |
| 203 | $locksHeld = $lockRecords[$locksKey]; |
| 204 | if ( $locksHeld === $this->newLockArray() ) { |
| 205 | $ok = $memc->delete( $locksKey ); |
| 206 | } else { |
| 207 | $ok = $memc->set( $locksKey, $locksHeld, self::MAX_LOCK_TTL ); |
| 208 | } |
| 209 | if ( $ok ) { |
| 210 | $this->logger->debug( __METHOD__ . ": released lock on key $locksKey." ); |
| 211 | } else { |
| 212 | $status->fatal( 'lockmanager-fail-releaselock', $path ); |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | // Unlock all of the active lock record keys... |
| 217 | $this->releaseMutexes( $memc, $keys ); |
| 218 | |
| 219 | return $status; |
| 220 | } |
| 221 | |
| 222 | /** |
| 223 | * @see QuorumLockManager::releaseAllLocks() |
| 224 | * @return StatusValue |
| 225 | */ |
| 226 | protected function releaseAllLocks() { |
| 227 | return StatusValue::newGood(); // not supported |
| 228 | } |
| 229 | |
| 230 | /** |
| 231 | * @see QuorumLockManager::isServerUp() |
| 232 | * @param string $lockSrv |
| 233 | * @return bool |
| 234 | */ |
| 235 | protected function isServerUp( $lockSrv ) { |
| 236 | return (bool)$this->getCache( $lockSrv ); |
| 237 | } |
| 238 | |
| 239 | /** |
| 240 | * Get the MemcachedBagOStuff object for a $lockSrv |
| 241 | * |
| 242 | * @param string $lockSrv Server name |
| 243 | * @return MemcachedBagOStuff|null |
| 244 | */ |
| 245 | protected function getCache( $lockSrv ) { |
| 246 | if ( !isset( $this->cacheServers[$lockSrv] ) ) { |
| 247 | throw new InvalidArgumentException( "Invalid cache server '$lockSrv'." ); |
| 248 | } |
| 249 | |
| 250 | $online = $this->statusCache->get( "online:$lockSrv", 30 ); |
| 251 | if ( $online === null ) { |
| 252 | $online = $this->cacheServers[$lockSrv]->set( __CLASS__ . ':ping', 1, 1 ); |
| 253 | if ( !$online ) { // server down? |
| 254 | $this->logger->warning( __METHOD__ . ": Could not contact $lockSrv." ); |
| 255 | } |
| 256 | $this->statusCache->set( "online:$lockSrv", (int)$online ); |
| 257 | } |
| 258 | |
| 259 | return $online ? $this->cacheServers[$lockSrv] : null; |
| 260 | } |
| 261 | |
| 262 | /** |
| 263 | * @param string $path |
| 264 | * @return string |
| 265 | */ |
| 266 | protected function recordKeyForPath( $path ) { |
| 267 | return implode( ':', [ __CLASS__, 'locks', $this->sha1Base36Absolute( $path ) ] ); |
| 268 | } |
| 269 | |
| 270 | /** |
| 271 | * @return array An empty lock structure for a key |
| 272 | */ |
| 273 | protected function newLockArray() { |
| 274 | return [ self::LOCK_SH => [], self::LOCK_EX => [] ]; |
| 275 | } |
| 276 | |
| 277 | /** |
| 278 | * @param array $a |
| 279 | * @return array An empty lock structure for a key |
| 280 | */ |
| 281 | protected function sanitizeLockArray( $a ) { |
| 282 | if ( is_array( $a ) && isset( $a[self::LOCK_EX] ) && isset( $a[self::LOCK_SH] ) ) { |
| 283 | return $a; |
| 284 | } |
| 285 | |
| 286 | $this->logger->error( __METHOD__ . ": reset invalid lock array." ); |
| 287 | |
| 288 | return $this->newLockArray(); |
| 289 | } |
| 290 | |
| 291 | /** |
| 292 | * @param MemcachedBagOStuff $memc |
| 293 | * @param array $keys List of keys to acquire |
| 294 | * @return bool |
| 295 | */ |
| 296 | protected function acquireMutexes( MemcachedBagOStuff $memc, array $keys ) { |
| 297 | $lockedKeys = []; |
| 298 | |
| 299 | // Acquire the keys in lexicographical order, to avoid deadlock problems. |
| 300 | // If P1 is waiting to acquire a key P2 has, P2 can't also be waiting for a key P1 has. |
| 301 | sort( $keys ); |
| 302 | |
| 303 | // Try to quickly loop to acquire the keys, but back off after a few rounds. |
| 304 | // This reduces memcached spam, especially in the rare case where a server acquires |
| 305 | // some lock keys and dies without releasing them. Lock keys expire after a few minutes. |
| 306 | $loop = new WaitConditionLoop( |
| 307 | static function () use ( $memc, $keys, &$lockedKeys ) { |
| 308 | foreach ( array_diff( $keys, $lockedKeys ) as $key ) { |
| 309 | if ( $memc->add( "$key:mutex", 1, 180 ) ) { // lock record |
| 310 | $lockedKeys[] = $key; |
| 311 | } |
| 312 | } |
| 313 | |
| 314 | return array_diff( $keys, $lockedKeys ) |
| 315 | ? WaitConditionLoop::CONDITION_CONTINUE |
| 316 | : true; |
| 317 | }, |
| 318 | 3.0 // timeout |
| 319 | ); |
| 320 | $loop->invoke(); |
| 321 | |
| 322 | if ( count( $lockedKeys ) != count( $keys ) ) { |
| 323 | $this->releaseMutexes( $memc, $lockedKeys ); // failed; release what was locked |
| 324 | return false; |
| 325 | } |
| 326 | |
| 327 | return true; |
| 328 | } |
| 329 | |
| 330 | /** |
| 331 | * @param MemcachedBagOStuff $memc |
| 332 | * @param array $keys List of acquired keys |
| 333 | */ |
| 334 | protected function releaseMutexes( MemcachedBagOStuff $memc, array $keys ) { |
| 335 | foreach ( $keys as $key ) { |
| 336 | $memc->delete( "$key:mutex" ); |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | /** |
| 341 | * Make sure remaining locks get cleared |
| 342 | */ |
| 343 | public function __destruct() { |
| 344 | $pathsByType = []; |
| 345 | foreach ( $this->locksHeld as $path => $locks ) { |
| 346 | foreach ( $locks as $type => $count ) { |
| 347 | $pathsByType[$type][] = $path; |
| 348 | } |
| 349 | } |
| 350 | if ( $pathsByType ) { |
| 351 | $this->unlockByType( $pathsByType ); |
| 352 | } |
| 353 | } |
| 354 | } |
| 355 | /** @deprecated class alias since 1.46 */ |
| 356 | class_alias( MemcLockManager::class, 'MemcLockManager' ); |