index.js 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  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('read/delete errored', context, err)
  84. })
  85. if (roundTrippedHealthCheckValue !== healthCheckValue) {
  86. context.roundTrippedHealthCheckValue = roundTrippedHealthCheckValue
  87. throw new RedisHealthCheckVerifyError('read failed', context)
  88. }
  89. if (deleteAck !== 1) {
  90. context.deleteAck = deleteAck
  91. throw new RedisHealthCheckVerifyError('delete failed', context)
  92. }
  93. }
  94. function unwrapMultiResult(result, callback) {
  95. // ioredis exec returns a results like:
  96. // [ [null, 42], [null, "foo"] ]
  97. // where the first entries in each 2-tuple are
  98. // presumably errors for each individual command,
  99. // and the second entry is the result. We need to transform
  100. // this into the same result as the old redis driver:
  101. // [ 42, "foo" ]
  102. //
  103. // Basically reverse:
  104. // https://github.com/luin/ioredis/blob/v4.17.3/lib/utils/index.ts#L75-L92
  105. const filteredResult = []
  106. for (const [err, value] of result || []) {
  107. if (err) {
  108. return callback(err)
  109. } else {
  110. filteredResult.push(value)
  111. }
  112. }
  113. callback(null, filteredResult)
  114. }
  115. const unwrapMultiResultPromisified = promisify(unwrapMultiResult)
  116. function monkeyPatchIoRedisExec(client) {
  117. const _multi = client.multi
  118. client.multi = function () {
  119. const multi = _multi.apply(client, arguments)
  120. const _exec = multi.exec
  121. multi.exec = callback => {
  122. if (callback) {
  123. // callback based invocation
  124. _exec.call(multi, (error, result) => {
  125. // The command can fail all-together due to syntax errors
  126. if (error) return callback(error)
  127. unwrapMultiResult(result, callback)
  128. })
  129. } else {
  130. // Promise based invocation
  131. return _exec.call(multi).then(unwrapMultiResultPromisified)
  132. }
  133. }
  134. return multi
  135. }
  136. }
  137. async function runWithTimeout({ runner, timeout, context }) {
  138. let healthCheckDeadline
  139. await Promise.race([
  140. new Promise((resolve, reject) => {
  141. healthCheckDeadline = setTimeout(() => {
  142. // attach the timeout when hitting the timeout only
  143. context.timeout = timeout
  144. reject(new RedisHealthCheckTimedOut('timeout', context))
  145. }, timeout)
  146. }),
  147. runner.finally(() => clearTimeout(healthCheckDeadline)),
  148. ])
  149. }
  150. /**
  151. * Delete all data from the test Redis instance
  152. *
  153. * @param {Redis} rclient
  154. */
  155. async function cleanupTestRedis(rclient) {
  156. ensureTestRedis(rclient)
  157. await rclient.flushall()
  158. }
  159. /**
  160. * Checks that the Redis client points to a test database
  161. *
  162. * In tests, the Redis instance is on a host called redis_test
  163. *
  164. * @param {Redis} rclient
  165. */
  166. function ensureTestRedis(rclient) {
  167. const host = rclient.options.host
  168. const env = process.env.NODE_ENV
  169. if (host !== 'redis_test' || env !== 'test') {
  170. throw new Error(
  171. `Refusing to clear Redis instance '${host}' in environment '${env}'`
  172. )
  173. }
  174. }
  175. module.exports = {
  176. createClient,
  177. cleanupTestRedis,
  178. }