LockManager.js 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. const { callbackify, promisify } = require('util')
  2. const metrics = require('metrics-sharelatex')
  3. const RedisWrapper = require('./RedisWrapper')
  4. const rclient = RedisWrapper.client('lock')
  5. const logger = require('logger-sharelatex')
  6. const os = require('os')
  7. const crypto = require('crypto')
  8. const async = require('async')
  9. const settings = require('settings-sharelatex')
  10. const HOST = os.hostname()
  11. const PID = process.pid
  12. const RND = crypto.randomBytes(4).toString('hex')
  13. let COUNT = 0
  14. const LOCK_QUEUES = new Map() // queue lock requests for each name/id so they get the lock on a first-come first-served basis
  15. logger.log(
  16. { lockManagerSettings: settings.lockManager },
  17. 'LockManager initialising'
  18. )
  19. const LockManager = {
  20. // ms between each test of the lock
  21. LOCK_TEST_INTERVAL: settings.lockManager.lockTestInterval || 50,
  22. // back off to ms between each test of the lock
  23. MAX_TEST_INTERVAL: settings.lockManager.maxTestInterval || 1000,
  24. // ms maximum time to spend trying to get the lock
  25. MAX_LOCK_WAIT_TIME: settings.lockManager.maxLockWaitTime || 10000,
  26. // seconds. Time until lock auto expires in redis
  27. REDIS_LOCK_EXPIRY: settings.lockManager.redisLockExpiry || 30,
  28. // ms, if execution takes longer than this then log
  29. SLOW_EXECUTION_THRESHOLD: settings.lockManager.slowExecutionThreshold || 5000,
  30. // Use a signed lock value as described in
  31. // http://redis.io/topics/distlock#correct-implementation-with-a-single-instance
  32. // to prevent accidental unlocking by multiple processes
  33. randomLock() {
  34. const time = Date.now()
  35. return `locked:host=${HOST}:pid=${PID}:random=${RND}:time=${time}:count=${COUNT++}`
  36. },
  37. unlockScript:
  38. 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end',
  39. runWithLock(namespace, id, runner, callback) {
  40. // runner must be a function accepting a callback, e.g. runner = (cb) ->
  41. // This error is defined here so we get a useful stacktrace
  42. const slowExecutionError = new Error('slow execution during lock')
  43. const timer = new metrics.Timer(`lock.${namespace}`)
  44. const key = `lock:web:${namespace}:${id}`
  45. LockManager._getLock(key, namespace, (error, lockValue) => {
  46. if (error != null) {
  47. return callback(error)
  48. }
  49. // The lock can expire in redis but the process carry on. This setTimeout call
  50. // is designed to log if this happens.
  51. function countIfExceededLockTimeout() {
  52. metrics.inc(`lock.${namespace}.exceeded_lock_timeout`)
  53. logger.log('exceeded lock timeout', {
  54. namespace,
  55. id,
  56. slowExecutionError
  57. })
  58. }
  59. const exceededLockTimeout = setTimeout(
  60. countIfExceededLockTimeout,
  61. LockManager.REDIS_LOCK_EXPIRY * 1000
  62. )
  63. runner((error1, ...values) =>
  64. LockManager._releaseLock(key, lockValue, error2 => {
  65. clearTimeout(exceededLockTimeout)
  66. const timeTaken = new Date() - timer.start
  67. if (timeTaken > LockManager.SLOW_EXECUTION_THRESHOLD) {
  68. logger.log('slow execution during lock', {
  69. namespace,
  70. id,
  71. timeTaken,
  72. slowExecutionError
  73. })
  74. }
  75. timer.done()
  76. error = error1 || error2
  77. if (error != null) {
  78. return callback(error)
  79. }
  80. callback(null, ...values)
  81. })
  82. )
  83. })
  84. },
  85. _tryLock(key, namespace, callback) {
  86. const lockValue = LockManager.randomLock()
  87. rclient.set(
  88. key,
  89. lockValue,
  90. 'EX',
  91. LockManager.REDIS_LOCK_EXPIRY,
  92. 'NX',
  93. (err, gotLock) => {
  94. if (err != null) {
  95. return callback(err)
  96. }
  97. if (gotLock === 'OK') {
  98. metrics.inc(`lock.${namespace}.try.success`)
  99. callback(err, true, lockValue)
  100. } else {
  101. metrics.inc(`lock.${namespace}.try.failed`)
  102. logger.log({ key, redis_response: gotLock }, 'lock is locked')
  103. callback(err, false)
  104. }
  105. }
  106. )
  107. },
  108. // it's sufficient to serialize within a process because that is where the parallel operations occur
  109. _getLock(key, namespace, callback) {
  110. // this is what we need to do for each lock we want to request
  111. const task = next =>
  112. LockManager._getLockByPolling(key, namespace, (error, lockValue) => {
  113. // tell the queue to start trying to get the next lock (if any)
  114. next()
  115. // we have got a lock result, so we can continue with our own execution
  116. callback(error, lockValue)
  117. })
  118. // create a queue for this key if needed
  119. const queueName = `${key}:${namespace}`
  120. let queue = LOCK_QUEUES.get(queueName)
  121. if (queue == null) {
  122. const handler = (fn, cb) => fn(cb)
  123. // set up a new queue for this key
  124. queue = async.queue(handler, 1)
  125. queue.push(task)
  126. // remove the queue object when queue is empty
  127. queue.drain = () => LOCK_QUEUES.delete(queueName)
  128. // store the queue in our global map
  129. LOCK_QUEUES.set(queueName, queue)
  130. } else {
  131. // queue the request to get the lock
  132. queue.push(task)
  133. }
  134. },
  135. _getLockByPolling(key, namespace, callback) {
  136. const startTime = Date.now()
  137. const testInterval = LockManager.LOCK_TEST_INTERVAL
  138. let attempts = 0
  139. function attempt() {
  140. if (Date.now() - startTime > LockManager.MAX_LOCK_WAIT_TIME) {
  141. metrics.inc(`lock.${namespace}.get.failed`)
  142. return callback(new Error('Timeout'))
  143. }
  144. attempts += 1
  145. LockManager._tryLock(key, namespace, (error, gotLock, lockValue) => {
  146. if (error != null) {
  147. return callback(error)
  148. }
  149. if (gotLock) {
  150. metrics.gauge(`lock.${namespace}.get.success.tries`, attempts)
  151. callback(null, lockValue)
  152. } else {
  153. setTimeout(attempt, testInterval)
  154. }
  155. })
  156. }
  157. attempt()
  158. },
  159. _releaseLock(key, lockValue, callback) {
  160. rclient.eval(LockManager.unlockScript, 1, key, lockValue, (err, result) => {
  161. if (err != null) {
  162. callback(err)
  163. } else if (result != null && result !== 1) {
  164. // successful unlock should release exactly one key
  165. logger.warn(
  166. { key, lockValue, redis_err: err, redis_result: result },
  167. 'unlocking error'
  168. )
  169. metrics.inc('unlock-error')
  170. callback(new Error('tried to release timed out lock'))
  171. } else {
  172. callback(null, result)
  173. }
  174. })
  175. }
  176. }
  177. module.exports = LockManager
  178. const promisifiedRunWithLock = promisify(LockManager.runWithLock)
  179. LockManager.promises = {
  180. runWithLock(namespace, id, runner) {
  181. const cbRunner = callbackify(runner)
  182. return promisifiedRunWithLock(namespace, id, cbRunner)
  183. }
  184. }