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