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