index.js 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. // @ts-check
  2. const crypto = require('node:crypto')
  3. const os = require('node:os')
  4. const { promisify } = require('node:util')
  5. const Redis = require('ioredis')
  6. const {
  7. RedisHealthCheckTimedOut,
  8. RedisHealthCheckWriteError,
  9. RedisHealthCheckVerifyError,
  10. } = require('./Errors')
  11. const HEARTBEAT_TIMEOUT = 2000
  12. // generate unique values for health check
  13. const HOST = os.hostname()
  14. const PID = process.pid
  15. const RND = crypto.randomBytes(4).toString('hex')
  16. let COUNT = 0
  17. function createClient(opts) {
  18. const standardOpts = Object.assign({}, opts)
  19. delete standardOpts.key_schema
  20. if (standardOpts.retry_max_delay == null) {
  21. standardOpts.retry_max_delay = 5000 // ms
  22. }
  23. if (standardOpts.endpoints) {
  24. throw new Error(
  25. '@overleaf/redis-wrapper: redis-sentinel is no longer supported'
  26. )
  27. }
  28. let client
  29. if (standardOpts.cluster) {
  30. delete standardOpts.cluster
  31. client = new Redis.Cluster(opts.cluster, standardOpts)
  32. } else {
  33. client = new Redis(standardOpts)
  34. }
  35. monkeyPatchIoRedisExec(client)
  36. client.healthCheck = callback => {
  37. if (callback) {
  38. // callback based invocation
  39. healthCheck(client).then(callback).catch(callback)
  40. } else {
  41. // Promise based invocation
  42. return healthCheck(client)
  43. }
  44. }
  45. return client
  46. }
  47. async function healthCheck(client) {
  48. // check the redis connection by storing and retrieving a unique key/value pair
  49. const uniqueToken = `host=${HOST}:pid=${PID}:random=${RND}:time=${Date.now()}:count=${COUNT++}`
  50. // o-error context
  51. const context = {
  52. uniqueToken,
  53. stage: 'add context for a timeout',
  54. }
  55. await runWithTimeout({
  56. runner: runCheck(client, uniqueToken, context),
  57. timeout: HEARTBEAT_TIMEOUT,
  58. context,
  59. })
  60. }
  61. async function runCheck(client, uniqueToken, context) {
  62. const healthCheckKey = `_redis-wrapper:healthCheckKey:{${uniqueToken}}`
  63. const healthCheckValue = `_redis-wrapper:healthCheckValue:{${uniqueToken}}`
  64. // set the unique key/value pair
  65. context.stage = 'write'
  66. const writeAck = await client
  67. .set(healthCheckKey, healthCheckValue, 'EX', 60)
  68. .catch(err => {
  69. throw new RedisHealthCheckWriteError('write errored', context, err)
  70. })
  71. if (writeAck !== 'OK') {
  72. context.writeAck = writeAck
  73. throw new RedisHealthCheckWriteError('write failed', context)
  74. }
  75. // check that we can retrieve the unique key/value pair
  76. context.stage = 'verify'
  77. const [roundTrippedHealthCheckValue, deleteAck] = await client
  78. .multi()
  79. .get(healthCheckKey)
  80. .del(healthCheckKey)
  81. .exec()
  82. .catch(err => {
  83. throw new RedisHealthCheckVerifyError(
  84. 'read/delete errored',
  85. context,
  86. err
  87. )
  88. })
  89. if (roundTrippedHealthCheckValue !== healthCheckValue) {
  90. context.roundTrippedHealthCheckValue = roundTrippedHealthCheckValue
  91. throw new RedisHealthCheckVerifyError('read failed', context)
  92. }
  93. if (deleteAck !== 1) {
  94. context.deleteAck = deleteAck
  95. throw new RedisHealthCheckVerifyError('delete failed', context)
  96. }
  97. }
  98. function unwrapMultiResult(result, callback) {
  99. // ioredis exec returns a results like:
  100. // [ [null, 42], [null, "foo"] ]
  101. // where the first entries in each 2-tuple are
  102. // presumably errors for each individual command,
  103. // and the second entry is the result. We need to transform
  104. // this into the same result as the old redis driver:
  105. // [ 42, "foo" ]
  106. //
  107. // Basically reverse:
  108. // https://github.com/luin/ioredis/blob/v4.17.3/lib/utils/index.ts#L75-L92
  109. const filteredResult = []
  110. for (const [err, value] of result || []) {
  111. if (err) {
  112. return callback(err)
  113. } else {
  114. filteredResult.push(value)
  115. }
  116. }
  117. callback(null, filteredResult)
  118. }
  119. const unwrapMultiResultPromisified = promisify(unwrapMultiResult)
  120. function monkeyPatchIoRedisExec(client) {
  121. const _multi = client.multi
  122. client.multi = function () {
  123. const multi = _multi.apply(client, arguments)
  124. const _exec = multi.exec
  125. multi.exec = callback => {
  126. if (callback) {
  127. // callback based invocation
  128. _exec.call(multi, (error, result) => {
  129. // The command can fail all-together due to syntax errors
  130. if (error) return callback(error)
  131. unwrapMultiResult(result, callback)
  132. })
  133. } else {
  134. // Promise based invocation
  135. return _exec.call(multi).then(unwrapMultiResultPromisified)
  136. }
  137. }
  138. return multi
  139. }
  140. }
  141. async function runWithTimeout({ runner, timeout, context }) {
  142. let healthCheckDeadline
  143. await Promise.race([
  144. new Promise((resolve, reject) => {
  145. healthCheckDeadline = setTimeout(() => {
  146. // attach the timeout when hitting the timeout only
  147. context.timeout = timeout
  148. reject(new RedisHealthCheckTimedOut('timeout', context))
  149. }, timeout)
  150. }),
  151. runner.finally(() => clearTimeout(healthCheckDeadline)),
  152. ])
  153. }
  154. /**
  155. * Delete all data from the test Redis instance
  156. *
  157. * @param {Redis} rclient
  158. */
  159. async function cleanupTestRedis(rclient) {
  160. ensureTestRedis(rclient)
  161. await rclient.flushall()
  162. }
  163. /**
  164. * Checks that the Redis client points to a test database
  165. *
  166. * In tests, the Redis instance is on a host called redis_test
  167. *
  168. * @param {Redis} rclient
  169. */
  170. function ensureTestRedis(rclient) {
  171. const host = rclient.options.host
  172. const env = process.env.NODE_ENV
  173. if (host !== 'redis_test' || env !== 'test') {
  174. throw new Error(
  175. `Refusing to clear Redis instance '${host}' in environment '${env}'`
  176. )
  177. }
  178. }
  179. module.exports = {
  180. createClient,
  181. cleanupTestRedis,
  182. }