RedisWebLocker.js 6.5 KB

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