Files
Anna Claire Fields 6113c6c291 Enable TS noImplicitAny in web (#31636)
GitOrigin-RevId: 18881694770f2476c475f8fef4c6a2678a2a12fe
2026-03-27 09:05:30 +00:00

240 lines
6.1 KiB
JavaScript

// @ts-check
const crypto = require('node:crypto')
const os = require('node:os')
const { promisify } = require('node:util')
// @ts-ignore - ioredis types may not be available in all contexts
const Redis = require('ioredis')
const {
RedisHealthCheckTimedOut,
RedisHealthCheckWriteError,
RedisHealthCheckVerifyError,
} = require('./Errors')
const HEARTBEAT_TIMEOUT = 2000
// generate unique values for health check
const HOST = os.hostname()
const PID = process.pid
const RND = crypto.randomBytes(4).toString('hex')
let COUNT = 0
/**
* @param {any} opts
*/
function createClient(opts) {
const standardOpts = Object.assign({}, opts)
delete standardOpts.key_schema
if (standardOpts.retry_max_delay == null) {
standardOpts.retry_max_delay = 5000 // ms
}
if (standardOpts.endpoints) {
throw new Error(
'@overleaf/redis-wrapper: redis-sentinel is no longer supported'
)
}
let client
if (standardOpts.cluster) {
delete standardOpts.cluster
client = new Redis.Cluster(opts.cluster, standardOpts)
} else {
client = new Redis(standardOpts)
}
monkeyPatchIoRedisExec(client)
/** @param {any} callback */
client.healthCheck = callback => {
if (callback) {
// callback based invocation
healthCheck(client).then(callback).catch(callback)
} else {
// Promise based invocation
return healthCheck(client)
}
}
return client
}
/**
* @param {any} client
*/
async function healthCheck(client) {
// check the redis connection by storing and retrieving a unique key/value pair
const uniqueToken = `host=${HOST}:pid=${PID}:random=${RND}:time=${Date.now()}:count=${COUNT++}`
// o-error context
const context = {
uniqueToken,
stage: 'add context for a timeout',
}
await runWithTimeout({
runner: runCheck(client, uniqueToken, context),
timeout: HEARTBEAT_TIMEOUT,
context,
})
}
/**
* @param {any} client
* @param {any} uniqueToken
* @param {any} context
*/
async function runCheck(client, uniqueToken, context) {
const healthCheckKey = `_redis-wrapper:healthCheckKey:{${uniqueToken}}`
const healthCheckValue = `_redis-wrapper:healthCheckValue:{${uniqueToken}}`
// set the unique key/value pair
context.stage = 'write'
const writeAck = await client
.set(healthCheckKey, healthCheckValue, 'EX', 60)
.catch(
/** @param {any} err */ err => {
throw new RedisHealthCheckWriteError('write errored', context, err)
}
)
if (writeAck !== 'OK') {
context.writeAck = writeAck
throw new RedisHealthCheckWriteError('write failed', context)
}
// check that we can retrieve the unique key/value pair
context.stage = 'verify'
const [roundTrippedHealthCheckValue, deleteAck] = await client
.multi()
.get(healthCheckKey)
.del(healthCheckKey)
.exec()
.catch(
/** @param {any} err */ err => {
throw new RedisHealthCheckVerifyError(
'read/delete errored',
context,
err
)
}
)
if (roundTrippedHealthCheckValue !== healthCheckValue) {
context.roundTrippedHealthCheckValue = roundTrippedHealthCheckValue
throw new RedisHealthCheckVerifyError('read failed', context)
}
if (deleteAck !== 1) {
context.deleteAck = deleteAck
throw new RedisHealthCheckVerifyError('delete failed', context)
}
}
/**
* @param {any} result
* @param {any} callback
*/
function unwrapMultiResult(result, callback) {
// ioredis exec returns a results like:
// [ [null, 42], [null, "foo"] ]
// where the first entries in each 2-tuple are
// presumably errors for each individual command,
// and the second entry is the result. We need to transform
// this into the same result as the old redis driver:
// [ 42, "foo" ]
//
// Basically reverse:
// https://github.com/luin/ioredis/blob/v4.17.3/lib/utils/index.ts#L75-L92
const filteredResult = []
for (const [err, value] of result || []) {
if (err) {
return callback(err)
} else {
filteredResult.push(value)
}
}
callback(null, filteredResult)
}
const unwrapMultiResultPromisified = promisify(unwrapMultiResult)
/**
* @param {any} client
*/
function monkeyPatchIoRedisExec(client) {
const _multi = client.multi
client.multi = function () {
const multi = _multi.apply(client, arguments)
const _exec = multi.exec
/** @param {any} callback */
multi.exec = callback => {
if (callback) {
// callback based invocation
_exec.call(
multi,
/**
* @param {any} error
* @param {any} result
*/
(error, result) => {
// The command can fail all-together due to syntax errors
if (error) return callback(error)
unwrapMultiResult(result, callback)
}
)
} else {
// Promise based invocation
return _exec.call(multi).then(unwrapMultiResultPromisified)
}
}
return multi
}
}
/**
* @param {{runner: any, timeout: any, context: any}} param0
*/
async function runWithTimeout({ runner, timeout, context }) {
/** @type {any} */
let healthCheckDeadline
await Promise.race([
new Promise((resolve, reject) => {
healthCheckDeadline = setTimeout(() => {
// attach the timeout when hitting the timeout only
context.timeout = timeout
reject(new RedisHealthCheckTimedOut('timeout', context))
}, timeout)
}),
runner.finally(() => clearTimeout(healthCheckDeadline)),
])
}
/**
* Delete all data from the test Redis instance
*
* @param {Redis} rclient
*/
async function cleanupTestRedis(rclient) {
ensureTestRedis(rclient)
await rclient.flushall()
}
/**
* Checks that the Redis client points to a test database
*
* In tests, the Redis instance is on a host called redis_test
*
* @param {Redis} rclient
*/
function ensureTestRedis(rclient) {
const host = rclient.options.host
const env = process.env.NODE_ENV
if (host !== 'redis_test' || env !== 'test') {
throw new Error(
`Refusing to clear Redis instance '${host}' in environment '${env}'`
)
}
}
module.exports = {
createClient,
cleanupTestRedis,
}