RedisLocker.js 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. const { promisify } = require('node:util')
  2. const metrics = require('@overleaf/metrics')
  3. const logger = require('@overleaf/logger')
  4. const os = require('node:os')
  5. const crypto = require('node: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. /**
  89. * @param {Callback} callback
  90. */
  91. tryLock(id, callback) {
  92. if (callback == null) {
  93. callback = function () {}
  94. }
  95. const lockValue = this.randomLock()
  96. const key = this.getKey(id)
  97. const startTime = Date.now()
  98. return this.rclient.set(
  99. key,
  100. lockValue,
  101. 'EX',
  102. this.LOCK_TTL,
  103. 'NX',
  104. (err, gotLock) => {
  105. if (err != null) {
  106. return callback(err)
  107. }
  108. if (gotLock === 'OK') {
  109. metrics.inc(this.metricsPrefix + '-not-blocking')
  110. const timeTaken = Date.now() - startTime
  111. if (timeTaken > MAX_REDIS_REQUEST_LENGTH) {
  112. // took too long, so try to free the lock
  113. return this.releaseLock(id, lockValue, function (err, result) {
  114. if (err != null) {
  115. return callback(err)
  116. } // error freeing lock
  117. return callback(null, false)
  118. }) // tell caller they didn't get the lock
  119. } else {
  120. return callback(null, true, lockValue)
  121. }
  122. } else {
  123. metrics.inc(this.metricsPrefix + '-blocking')
  124. return callback(null, false)
  125. }
  126. }
  127. )
  128. }
  129. /**
  130. * @param {Callback} callback
  131. */
  132. getLock(id, callback) {
  133. if (callback == null) {
  134. callback = function () {}
  135. }
  136. const startTime = Date.now()
  137. let testInterval = this.LOCK_TEST_INTERVAL
  138. const attempt = () => {
  139. if (Date.now() - startTime > this.MAX_LOCK_WAIT_TIME) {
  140. const e = this.wrapTimeoutError(new Error('Timeout'), id)
  141. return callback(e)
  142. }
  143. return this.tryLock(id, (error, gotLock, lockValue) => {
  144. if (error != null) {
  145. return callback(error)
  146. }
  147. if (gotLock) {
  148. return callback(null, lockValue)
  149. } else {
  150. setTimeout(attempt, testInterval)
  151. // back off when the lock is taken to avoid overloading
  152. return (testInterval = Math.min(
  153. testInterval * 2,
  154. this.MAX_TEST_INTERVAL
  155. ))
  156. }
  157. })
  158. }
  159. attempt()
  160. }
  161. /**
  162. * @param {Callback} callback
  163. */
  164. checkLock(id, callback) {
  165. if (callback == null) {
  166. callback = function () {}
  167. }
  168. const key = this.getKey(id)
  169. return this.rclient.exists(key, (err, exists) => {
  170. if (err != null) {
  171. return callback(err)
  172. }
  173. exists = parseInt(exists)
  174. if (exists === 1) {
  175. metrics.inc(this.metricsPrefix + '-blocking')
  176. return callback(null, false)
  177. } else {
  178. metrics.inc(this.metricsPrefix + '-not-blocking')
  179. return callback(null, true)
  180. }
  181. })
  182. }
  183. /**
  184. * @param {Callback} callback
  185. */
  186. releaseLock(id, lockValue, callback) {
  187. const key = this.getKey(id)
  188. return this.rclient.eval(
  189. UNLOCK_SCRIPT,
  190. 1,
  191. key,
  192. lockValue,
  193. (err, result) => {
  194. if (err != null) {
  195. return callback(err)
  196. } else if (result != null && result !== 1) {
  197. // successful unlock should release exactly one key
  198. logger.error(
  199. { id, key, lockValue, redis_err: err, redis_result: result },
  200. 'unlocking error'
  201. )
  202. metrics.inc(this.metricsPrefix + '-unlock-error')
  203. return callback(new Error('tried to release timed out lock'))
  204. } else {
  205. return callback(null, result)
  206. }
  207. }
  208. )
  209. }
  210. }