Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 131 |
|
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 | * 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 | |
21 | use Wikimedia\ObjectCache\MemcachedBagOStuff; |
22 | use Wikimedia\ObjectCache\MemcachedPhpBagOStuff; |
23 | use 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 | */ |
39 | class 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 | } |