index.js 4.6 KB

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