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