RedisLocker.js 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. const metrics = require('@overleaf/metrics')
  2. const logger = require('@overleaf/logger')
  3. const os = require('os')
  4. const crypto = require('crypto')
  5. const HOST = os.hostname()
  6. const PID = process.pid
  7. const RND = crypto.randomBytes(4).toString('hex')
  8. let COUNT = 0
  9. const MAX_REDIS_REQUEST_LENGTH = 5000 // 5 seconds
  10. const UNLOCK_SCRIPT =
  11. 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end'
  12. module.exports = class RedisLocker {
  13. /**
  14. * @param rclient initialized ioredis client
  15. * @param getKey compose the redis key based on the passed id
  16. * @param wrapTimeoutError assign the id to a designated field on the error
  17. * @param metricsPrefix prefix all the metrics with the given prefix
  18. *
  19. * @example ```
  20. * const lock = new RedisLocker({
  21. * rclient,
  22. * getKey(userId) { return `blocking:{userId}` },
  23. * wrapTimeoutError(err, userId) { err.userId = userId; return err },
  24. * metricsPrefix: 'user',
  25. * })
  26. *
  27. * lock.getLock(user._id, (err, value) => {
  28. * if (err) return callback(err)
  29. * // do work
  30. * lock.releaseLock(user._id, callback)
  31. * }
  32. * ```
  33. */
  34. constructor({ rclient, getKey, wrapTimeoutError, metricsPrefix }) {
  35. this.rclient = rclient
  36. this.getKey = getKey
  37. this.wrapTimeoutError = wrapTimeoutError
  38. this.metricsPrefix = metricsPrefix
  39. this.LOCK_TEST_INTERVAL = 50 // 50ms between each test of the lock
  40. this.MAX_TEST_INTERVAL = 1000 // back off to 1s between each test of the lock
  41. this.MAX_LOCK_WAIT_TIME = 10000 // 10s maximum time to spend trying to get the lock
  42. this.LOCK_TTL = 30 // seconds. Time until lock auto expires in redis.
  43. // read-only copy for unit tests
  44. this.unlockScript = UNLOCK_SCRIPT
  45. }
  46. // Use a signed lock value as described in
  47. // https://redis.io/docs/reference/patterns/distributed-locks/#correct-implementation-with-a-single-instance
  48. // to prevent accidental unlocking by multiple processes
  49. randomLock() {
  50. const time = Date.now()
  51. return `locked:host=${HOST}:pid=${PID}:random=${RND}:time=${time}:count=${COUNT++}`
  52. }
  53. tryLock(id, callback) {
  54. if (callback == null) {
  55. callback = function () {}
  56. }
  57. const lockValue = this.randomLock()
  58. const key = this.getKey(id)
  59. const startTime = Date.now()
  60. return this.rclient.set(
  61. key,
  62. lockValue,
  63. 'EX',
  64. this.LOCK_TTL,
  65. 'NX',
  66. (err, gotLock) => {
  67. if (err != null) {
  68. return callback(err)
  69. }
  70. if (gotLock === 'OK') {
  71. metrics.inc(this.metricsPrefix + '-not-blocking')
  72. const timeTaken = Date.now() - startTime
  73. if (timeTaken > MAX_REDIS_REQUEST_LENGTH) {
  74. // took too long, so try to free the lock
  75. return this.releaseLock(id, lockValue, function (err, result) {
  76. if (err != null) {
  77. return callback(err)
  78. } // error freeing lock
  79. return callback(null, false)
  80. }) // tell caller they didn't get the lock
  81. } else {
  82. return callback(null, true, lockValue)
  83. }
  84. } else {
  85. metrics.inc(this.metricsPrefix + '-blocking')
  86. return callback(null, false)
  87. }
  88. }
  89. )
  90. }
  91. getLock(id, callback) {
  92. if (callback == null) {
  93. callback = function () {}
  94. }
  95. const startTime = Date.now()
  96. let testInterval = this.LOCK_TEST_INTERVAL
  97. const attempt = () => {
  98. if (Date.now() - startTime > this.MAX_LOCK_WAIT_TIME) {
  99. const e = this.wrapTimeoutError(new Error('Timeout'), id)
  100. return callback(e)
  101. }
  102. return this.tryLock(id, (error, gotLock, lockValue) => {
  103. if (error != null) {
  104. return callback(error)
  105. }
  106. if (gotLock) {
  107. return callback(null, lockValue)
  108. } else {
  109. setTimeout(attempt, testInterval)
  110. // back off when the lock is taken to avoid overloading
  111. return (testInterval = Math.min(
  112. testInterval * 2,
  113. this.MAX_TEST_INTERVAL
  114. ))
  115. }
  116. })
  117. }
  118. attempt()
  119. }
  120. checkLock(id, callback) {
  121. if (callback == null) {
  122. callback = function () {}
  123. }
  124. const key = this.getKey(id)
  125. return this.rclient.exists(key, (err, exists) => {
  126. if (err != null) {
  127. return callback(err)
  128. }
  129. exists = parseInt(exists)
  130. if (exists === 1) {
  131. metrics.inc(this.metricsPrefix + '-blocking')
  132. return callback(null, false)
  133. } else {
  134. metrics.inc(this.metricsPrefix + '-not-blocking')
  135. return callback(null, true)
  136. }
  137. })
  138. }
  139. releaseLock(id, lockValue, callback) {
  140. const key = this.getKey(id)
  141. return this.rclient.eval(
  142. UNLOCK_SCRIPT,
  143. 1,
  144. key,
  145. lockValue,
  146. (err, result) => {
  147. if (err != null) {
  148. return callback(err)
  149. } else if (result != null && result !== 1) {
  150. // successful unlock should release exactly one key
  151. logger.error(
  152. { id, key, lockValue, redis_err: err, redis_result: result },
  153. 'unlocking error'
  154. )
  155. metrics.inc(this.metricsPrefix + '-unlock-error')
  156. return callback(new Error('tried to release timed out lock'))
  157. } else {
  158. return callback(null, result)
  159. }
  160. }
  161. )
  162. }
  163. }