RedisLocker.js 6.3 KB

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