RedisLocker.js 5.7 KB

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