RedisLocker.js 6.6 KB

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