Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 90 |
|
0.00% |
0 / 7 |
CRAP | |
0.00% |
0 / 1 |
RedisLockManager | |
0.00% |
0 / 90 |
|
0.00% |
0 / 7 |
870 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
getLocksOnServer | |
0.00% |
0 / 37 |
|
0.00% |
0 / 1 |
110 | |||
freeLocksOnServer | |
0.00% |
0 / 34 |
|
0.00% |
0 / 1 |
110 | |||
releaseAllLocks | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isServerUp | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
recordKeyForPath | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
__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\RedisConnectionPool; |
22 | |
23 | /** |
24 | * Manage locks using redis servers. |
25 | * |
26 | * This is meant for multi-wiki systems that may share files. |
27 | * All locks are non-blocking, which avoids deadlocks. |
28 | * |
29 | * All lock requests for a resource, identified by a hash string, will map to one |
30 | * bucket. Each bucket maps to one or several peer servers, each running redis. |
31 | * A majority of peers must agree for a lock to be acquired. |
32 | * |
33 | * This class requires Redis 2.6 as it makes use of Lua scripts for fast atomic operations. |
34 | * |
35 | * @ingroup LockManager |
36 | * @since 1.22 |
37 | */ |
38 | class RedisLockManager extends QuorumLockManager { |
39 | /** @var array Mapping of lock types to the type actually used */ |
40 | protected $lockTypeMap = [ |
41 | self::LOCK_SH => self::LOCK_SH, |
42 | self::LOCK_UW => self::LOCK_SH, |
43 | self::LOCK_EX => self::LOCK_EX |
44 | ]; |
45 | |
46 | /** @var RedisConnectionPool */ |
47 | protected $redisPool; |
48 | |
49 | /** @var array Map server names to hostname/IP and port numbers */ |
50 | protected $lockServers = []; |
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 | * - redisConfig : Configuration for RedisConnectionPool::singleton() (optional). |
61 | * @throws Exception |
62 | */ |
63 | public function __construct( array $config ) { |
64 | parent::__construct( $config ); |
65 | |
66 | $this->lockServers = $config['lockServers']; |
67 | if ( isset( $config['srvsByBucket'] ) ) { |
68 | // Sanitize srvsByBucket config to prevent PHP errors |
69 | $this->srvsByBucket = array_filter( $config['srvsByBucket'], 'is_array' ); |
70 | $this->srvsByBucket = array_values( $this->srvsByBucket ); // consecutive |
71 | } else { |
72 | $this->srvsByBucket = [ array_keys( $this->lockServers ) ]; |
73 | } |
74 | |
75 | $config['redisConfig']['serializer'] = 'none'; |
76 | $this->redisPool = RedisConnectionPool::singleton( $config['redisConfig'] ); |
77 | } |
78 | |
79 | protected function getLocksOnServer( $lockSrv, array $pathsByType ) { |
80 | $status = StatusValue::newGood(); |
81 | |
82 | $pathList = array_merge( ...array_values( $pathsByType ) ); |
83 | |
84 | $server = $this->lockServers[$lockSrv]; |
85 | $conn = $this->redisPool->getConnection( $server, $this->logger ); |
86 | if ( !$conn ) { |
87 | foreach ( $pathList as $path ) { |
88 | $status->fatal( 'lockmanager-fail-acquirelock', $path ); |
89 | } |
90 | |
91 | return $status; |
92 | } |
93 | |
94 | $pathsByKey = []; // (type:hash => path) map |
95 | foreach ( $pathsByType as $type => $paths ) { |
96 | $typeString = ( $type == LockManager::LOCK_SH ) ? 'SH' : 'EX'; |
97 | foreach ( $paths as $path ) { |
98 | $pathsByKey[$this->recordKeyForPath( $path, $typeString )] = $path; |
99 | } |
100 | } |
101 | |
102 | try { |
103 | static $script = |
104 | /** @lang Lua */ |
105 | <<<LUA |
106 | local failed = {} |
107 | -- Load input params (e.g. session, ttl, time of request) |
108 | local rSession, rTTL, rMaxTTL, rTime = unpack(ARGV) |
109 | -- Check that all the locks can be acquired |
110 | for i,requestKey in ipairs(KEYS) do |
111 | local _, _, rType, resourceKey = string.find(requestKey,"(%w+):(%w+)$") |
112 | local keyIsFree = true |
113 | local currentLocks = redis.call('hKeys',resourceKey) |
114 | for i,lockKey in ipairs(currentLocks) do |
115 | -- Get the type and session of this lock |
116 | local _, _, type, session = string.find(lockKey,"(%w+):(%w+)") |
117 | -- Check any locks that are not owned by this session |
118 | if session ~= rSession then |
119 | local lockExpiry = redis.call('hGet',resourceKey,lockKey) |
120 | if 1*lockExpiry < 1*rTime then |
121 | -- Lock is stale, so just prune it out |
122 | redis.call('hDel',resourceKey,lockKey) |
123 | elseif rType == 'EX' or type == 'EX' then |
124 | keyIsFree = false |
125 | break |
126 | end |
127 | end |
128 | end |
129 | if not keyIsFree then |
130 | failed[#failed+1] = requestKey |
131 | end |
132 | end |
133 | -- If all locks could be acquired, then do so |
134 | if #failed == 0 then |
135 | for i,requestKey in ipairs(KEYS) do |
136 | local _, _, rType, resourceKey = string.find(requestKey,"(%w+):(%w+)$") |
137 | redis.call('hSet',resourceKey,rType .. ':' .. rSession,rTime + rTTL) |
138 | -- In addition to invalidation logic, be sure to garbage collect |
139 | redis.call('expire',resourceKey,rMaxTTL) |
140 | end |
141 | end |
142 | return failed |
143 | LUA; |
144 | $res = $conn->luaEval( $script, |
145 | array_merge( |
146 | array_keys( $pathsByKey ), // KEYS[0], KEYS[1],...,KEYS[N] |
147 | [ |
148 | $this->session, // ARGV[1] |
149 | $this->lockTTL, // ARGV[2] |
150 | self::MAX_LOCK_TTL, // ARGV[3] |
151 | time() // ARGV[4] |
152 | ] |
153 | ), |
154 | count( $pathsByKey ) # number of first argument(s) that are keys |
155 | ); |
156 | } catch ( RedisException $e ) { |
157 | $res = false; |
158 | $this->redisPool->handleError( $conn, $e ); |
159 | } |
160 | |
161 | if ( $res === false ) { |
162 | foreach ( $pathList as $path ) { |
163 | $status->fatal( 'lockmanager-fail-acquirelock', $path ); |
164 | } |
165 | } elseif ( count( $res ) ) { |
166 | $status->fatal( 'lockmanager-fail-conflict' ); |
167 | } |
168 | |
169 | return $status; |
170 | } |
171 | |
172 | protected function freeLocksOnServer( $lockSrv, array $pathsByType ) { |
173 | $status = StatusValue::newGood(); |
174 | |
175 | $pathList = array_merge( ...array_values( $pathsByType ) ); |
176 | |
177 | $server = $this->lockServers[$lockSrv]; |
178 | $conn = $this->redisPool->getConnection( $server, $this->logger ); |
179 | if ( !$conn ) { |
180 | foreach ( $pathList as $path ) { |
181 | $status->fatal( 'lockmanager-fail-releaselock', $path ); |
182 | } |
183 | |
184 | return $status; |
185 | } |
186 | |
187 | $pathsByKey = []; // (type:hash => path) map |
188 | foreach ( $pathsByType as $type => $paths ) { |
189 | $typeString = ( $type == LockManager::LOCK_SH ) ? 'SH' : 'EX'; |
190 | foreach ( $paths as $path ) { |
191 | $pathsByKey[$this->recordKeyForPath( $path, $typeString )] = $path; |
192 | } |
193 | } |
194 | |
195 | try { |
196 | static $script = |
197 | /** @lang Lua */ |
198 | <<<LUA |
199 | local failed = {} |
200 | -- Load input params (e.g. session) |
201 | local rSession = unpack(ARGV) |
202 | for i,requestKey in ipairs(KEYS) do |
203 | local _, _, rType, resourceKey = string.find(requestKey,"(%w+):(%w+)$") |
204 | local released = redis.call('hDel',resourceKey,rType .. ':' .. rSession) |
205 | if released > 0 then |
206 | -- Remove the whole structure if it is now empty |
207 | if redis.call('hLen',resourceKey) == 0 then |
208 | redis.call('del',resourceKey) |
209 | end |
210 | else |
211 | failed[#failed+1] = requestKey |
212 | end |
213 | end |
214 | return failed |
215 | LUA; |
216 | $res = $conn->luaEval( $script, |
217 | array_merge( |
218 | array_keys( $pathsByKey ), // KEYS[0], KEYS[1],...,KEYS[N] |
219 | [ |
220 | $this->session, // ARGV[1] |
221 | ] |
222 | ), |
223 | count( $pathsByKey ) # number of first argument(s) that are keys |
224 | ); |
225 | } catch ( RedisException $e ) { |
226 | $res = false; |
227 | $this->redisPool->handleError( $conn, $e ); |
228 | } |
229 | |
230 | if ( $res === false ) { |
231 | foreach ( $pathList as $path ) { |
232 | $status->fatal( 'lockmanager-fail-releaselock', $path ); |
233 | } |
234 | } else { |
235 | foreach ( $res as $key ) { |
236 | $status->fatal( 'lockmanager-fail-releaselock', $pathsByKey[$key] ); |
237 | } |
238 | } |
239 | |
240 | return $status; |
241 | } |
242 | |
243 | protected function releaseAllLocks() { |
244 | return StatusValue::newGood(); // not supported |
245 | } |
246 | |
247 | protected function isServerUp( $lockSrv ) { |
248 | $conn = $this->redisPool->getConnection( $this->lockServers[$lockSrv], $this->logger ); |
249 | |
250 | return (bool)$conn; |
251 | } |
252 | |
253 | /** |
254 | * @param string $path |
255 | * @param string $type One of (EX,SH) |
256 | * @return string |
257 | */ |
258 | protected function recordKeyForPath( $path, $type ) { |
259 | return implode( ':', |
260 | [ __CLASS__, 'locks', "$type:" . $this->sha1Base36Absolute( $path ) ] ); |
261 | } |
262 | |
263 | /** |
264 | * Make sure remaining locks get cleared |
265 | */ |
266 | public function __destruct() { |
267 | $pathsByType = []; |
268 | foreach ( $this->locksHeld as $path => $locks ) { |
269 | foreach ( $locks as $type => $count ) { |
270 | $pathsByType[$type][] = $path; |
271 | } |
272 | } |
273 | if ( $pathsByType ) { |
274 | $this->unlockByType( $pathsByType ); |
275 | } |
276 | } |
277 | } |