RedisLockManager.php 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. <?php
  2. /**
  3. * Version of LockManager based on using redis servers.
  4. *
  5. * This program is free software; you can redistribute it and/or modify
  6. * it under the terms of the GNU General Public License as published by
  7. * the Free Software Foundation; either version 2 of the License, or
  8. * (at your option) any later version.
  9. *
  10. * This program is distributed in the hope that it will be useful,
  11. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. * GNU General Public License for more details.
  14. *
  15. * You should have received a copy of the GNU General Public License along
  16. * with this program; if not, write to the Free Software Foundation, Inc.,
  17. * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  18. * http://www.gnu.org/copyleft/gpl.html
  19. *
  20. * @file
  21. * @ingroup LockManager
  22. */
  23. /**
  24. * Manage locks using redis servers.
  25. *
  26. * Version of LockManager based on using redis servers.
  27. * This is meant for multi-wiki systems that may share files.
  28. * All locks are non-blocking, which avoids deadlocks.
  29. *
  30. * All lock requests for a resource, identified by a hash string, will map to one
  31. * bucket. Each bucket maps to one or several peer servers, each running redis.
  32. * A majority of peers must agree for a lock to be acquired.
  33. *
  34. * This class requires Redis 2.6 as it makes use Lua scripts for fast atomic operations.
  35. *
  36. * @ingroup LockManager
  37. * @since 1.22
  38. */
  39. class RedisLockManager 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. /** @var RedisConnectionPool */
  47. protected $redisPool;
  48. /** @var array Map server names to hostname/IP and port numbers */
  49. protected $lockServers = [];
  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 : Array of 1-16 consecutive integer keys, starting from 0,
  56. * each having an odd-numbered list of server names (peers) as values.
  57. * - redisConfig : Configuration for RedisConnectionPool::__construct().
  58. * @throws Exception
  59. */
  60. public function __construct( array $config ) {
  61. parent::__construct( $config );
  62. $this->lockServers = $config['lockServers'];
  63. // Sanitize srvsByBucket config to prevent PHP errors
  64. $this->srvsByBucket = array_filter( $config['srvsByBucket'], 'is_array' );
  65. $this->srvsByBucket = array_values( $this->srvsByBucket ); // consecutive
  66. $config['redisConfig']['serializer'] = 'none';
  67. $this->redisPool = RedisConnectionPool::singleton( $config['redisConfig'] );
  68. }
  69. protected function getLocksOnServer( $lockSrv, array $pathsByType ) {
  70. $status = StatusValue::newGood();
  71. $pathList = array_merge( ...array_values( $pathsByType ) );
  72. $server = $this->lockServers[$lockSrv];
  73. $conn = $this->redisPool->getConnection( $server, $this->logger );
  74. if ( !$conn ) {
  75. foreach ( $pathList as $path ) {
  76. $status->fatal( 'lockmanager-fail-acquirelock', $path );
  77. }
  78. return $status;
  79. }
  80. $pathsByKey = []; // (type:hash => path) map
  81. foreach ( $pathsByType as $type => $paths ) {
  82. $typeString = ( $type == LockManager::LOCK_SH ) ? 'SH' : 'EX';
  83. foreach ( $paths as $path ) {
  84. $pathsByKey[$this->recordKeyForPath( $path, $typeString )] = $path;
  85. }
  86. }
  87. try {
  88. static $script =
  89. /** @lang Lua */
  90. <<<LUA
  91. local failed = {}
  92. -- Load input params (e.g. session, ttl, time of request)
  93. local rSession, rTTL, rMaxTTL, rTime = unpack(ARGV)
  94. -- Check that all the locks can be acquired
  95. for i,requestKey in ipairs(KEYS) do
  96. local _, _, rType, resourceKey = string.find(requestKey,"(%w+):(%w+)$")
  97. local keyIsFree = true
  98. local currentLocks = redis.call('hKeys',resourceKey)
  99. for i,lockKey in ipairs(currentLocks) do
  100. -- Get the type and session of this lock
  101. local _, _, type, session = string.find(lockKey,"(%w+):(%w+)")
  102. -- Check any locks that are not owned by this session
  103. if session ~= rSession then
  104. local lockExpiry = redis.call('hGet',resourceKey,lockKey)
  105. if 1*lockExpiry < 1*rTime then
  106. -- Lock is stale, so just prune it out
  107. redis.call('hDel',resourceKey,lockKey)
  108. elseif rType == 'EX' or type == 'EX' then
  109. keyIsFree = false
  110. break
  111. end
  112. end
  113. end
  114. if not keyIsFree then
  115. failed[#failed+1] = requestKey
  116. end
  117. end
  118. -- If all locks could be acquired, then do so
  119. if #failed == 0 then
  120. for i,requestKey in ipairs(KEYS) do
  121. local _, _, rType, resourceKey = string.find(requestKey,"(%w+):(%w+)$")
  122. redis.call('hSet',resourceKey,rType .. ':' .. rSession,rTime + rTTL)
  123. -- In addition to invalidation logic, be sure to garbage collect
  124. redis.call('expire',resourceKey,rMaxTTL)
  125. end
  126. end
  127. return failed
  128. LUA;
  129. $res = $conn->luaEval( $script,
  130. array_merge(
  131. array_keys( $pathsByKey ), // KEYS[0], KEYS[1],...,KEYS[N]
  132. [
  133. $this->session, // ARGV[1]
  134. $this->lockTTL, // ARGV[2]
  135. self::MAX_LOCK_TTL, // ARGV[3]
  136. time() // ARGV[4]
  137. ]
  138. ),
  139. count( $pathsByKey ) # number of first argument(s) that are keys
  140. );
  141. } catch ( RedisException $e ) {
  142. $res = false;
  143. $this->redisPool->handleError( $conn, $e );
  144. }
  145. if ( $res === false ) {
  146. foreach ( $pathList as $path ) {
  147. $status->fatal( 'lockmanager-fail-acquirelock', $path );
  148. }
  149. } else {
  150. foreach ( $res as $key ) {
  151. $status->fatal( 'lockmanager-fail-acquirelock', $pathsByKey[$key] );
  152. }
  153. }
  154. return $status;
  155. }
  156. protected function freeLocksOnServer( $lockSrv, array $pathsByType ) {
  157. $status = StatusValue::newGood();
  158. $pathList = array_merge( ...array_values( $pathsByType ) );
  159. $server = $this->lockServers[$lockSrv];
  160. $conn = $this->redisPool->getConnection( $server, $this->logger );
  161. if ( !$conn ) {
  162. foreach ( $pathList as $path ) {
  163. $status->fatal( 'lockmanager-fail-releaselock', $path );
  164. }
  165. return $status;
  166. }
  167. $pathsByKey = []; // (type:hash => path) map
  168. foreach ( $pathsByType as $type => $paths ) {
  169. $typeString = ( $type == LockManager::LOCK_SH ) ? 'SH' : 'EX';
  170. foreach ( $paths as $path ) {
  171. $pathsByKey[$this->recordKeyForPath( $path, $typeString )] = $path;
  172. }
  173. }
  174. try {
  175. static $script =
  176. /** @lang Lua */
  177. <<<LUA
  178. local failed = {}
  179. -- Load input params (e.g. session)
  180. local rSession = unpack(ARGV)
  181. for i,requestKey in ipairs(KEYS) do
  182. local _, _, rType, resourceKey = string.find(requestKey,"(%w+):(%w+)$")
  183. local released = redis.call('hDel',resourceKey,rType .. ':' .. rSession)
  184. if released > 0 then
  185. -- Remove the whole structure if it is now empty
  186. if redis.call('hLen',resourceKey) == 0 then
  187. redis.call('del',resourceKey)
  188. end
  189. else
  190. failed[#failed+1] = requestKey
  191. end
  192. end
  193. return failed
  194. LUA;
  195. $res = $conn->luaEval( $script,
  196. array_merge(
  197. array_keys( $pathsByKey ), // KEYS[0], KEYS[1],...,KEYS[N]
  198. [
  199. $this->session, // ARGV[1]
  200. ]
  201. ),
  202. count( $pathsByKey ) # number of first argument(s) that are keys
  203. );
  204. } catch ( RedisException $e ) {
  205. $res = false;
  206. $this->redisPool->handleError( $conn, $e );
  207. }
  208. if ( $res === false ) {
  209. foreach ( $pathList as $path ) {
  210. $status->fatal( 'lockmanager-fail-releaselock', $path );
  211. }
  212. } else {
  213. foreach ( $res as $key ) {
  214. $status->fatal( 'lockmanager-fail-releaselock', $pathsByKey[$key] );
  215. }
  216. }
  217. return $status;
  218. }
  219. protected function releaseAllLocks() {
  220. return StatusValue::newGood(); // not supported
  221. }
  222. protected function isServerUp( $lockSrv ) {
  223. $conn = $this->redisPool->getConnection( $this->lockServers[$lockSrv], $this->logger );
  224. return (bool)$conn;
  225. }
  226. /**
  227. * @param string $path
  228. * @param string $type One of (EX,SH)
  229. * @return string
  230. */
  231. protected function recordKeyForPath( $path, $type ) {
  232. return implode( ':',
  233. [ __CLASS__, 'locks', "$type:" . $this->sha1Base36Absolute( $path ) ] );
  234. }
  235. /**
  236. * Make sure remaining locks get cleared for sanity
  237. */
  238. function __destruct() {
  239. while ( count( $this->locksHeld ) ) {
  240. $pathsByType = [];
  241. foreach ( $this->locksHeld as $path => $locks ) {
  242. foreach ( $locks as $type => $count ) {
  243. $pathsByType[$type][] = $path;
  244. }
  245. }
  246. $this->unlockByType( $pathsByType );
  247. }
  248. }
  249. }