Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 90
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
RedisLockManager
0.00% covered (danger)
0.00%
0 / 90
0.00% covered (danger)
0.00%
0 / 7
870
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getLocksOnServer
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
110
 freeLocksOnServer
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
110
 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 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 recordKeyForPath
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 __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\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 */
38class 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
143LUA;
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
215LUA;
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}