1
0
mirror of synced 2025-12-19 18:10:59 -05:00

Remove all code related to Redis page caching (#20457)

Towards simplification for https://github.com/github/docs-engineering/issues/678
This commit is contained in:
James M. Greene
2021-07-21 15:44:00 -05:00
committed by GitHub
parent 9d25c78bc5
commit df301cb5da
7 changed files with 1 additions and 815 deletions

View File

@@ -1,3 +1 @@
web: NODE_ENV=production node server.mjs web: NODE_ENV=production node server.mjs
release: NODE_ENV=production script/release-heroku

View File

@@ -1,138 +0,0 @@
import createRedisClient from './redis/create-client.js'
import InMemoryRedis from 'redis-mock'
import { promisify } from 'util'
const { CI, NODE_ENV, REDIS_URL } = process.env
// Do not use real a Redis client for CI, tests, or if the REDIS_URL is not provided
const useRealRedis = !CI && NODE_ENV !== 'test' && !!REDIS_URL
class RedisAccessor {
constructor({
databaseNumber = 0,
prefix = null,
allowSetFailures = false,
allowGetFailures = false,
name = null,
} = {}) {
const redisClient = useRealRedis
? createRedisClient({
url: REDIS_URL,
db: databaseNumber,
name: name || 'redis-accessor',
})
: InMemoryRedis.createClient()
this._client = redisClient
this._prefix = prefix ? prefix.replace(/:+$/, '') + ':' : ''
// Allow for graceful failures if a Redis SET operation fails?
this._allowSetFailures = allowSetFailures === true
// Allow for graceful failures if a Redis GET operation fails?
this._allowGetFailures = allowGetFailures === true
}
/** @private */
prefix(key) {
if (typeof key !== 'string' || !key) {
throw new TypeError(`Key must be a non-empty string but was: ${JSON.stringify(key)}`)
}
return this._prefix + key
}
static translateSetArguments(options = {}) {
const setArgs = []
const defaults = {
newOnly: false,
existingOnly: false,
expireIn: null, // No expiration
rollingExpiration: true,
}
const opts = { ...defaults, ...options }
if (opts.newOnly === true) {
if (opts.existingOnly === true) {
throw new TypeError('Misconfiguration: entry cannot be both new and existing')
}
setArgs.push('NX')
} else if (opts.existingOnly === true) {
setArgs.push('XX')
}
if (Number.isFinite(opts.expireIn)) {
const ttl = Math.round(opts.expireIn)
if (ttl < 1) {
throw new TypeError('Misconfiguration: cannot set a TTL of less than 1 millisecond')
}
setArgs.push('PX')
setArgs.push(ttl)
}
// otherwise there is no expiration
if (opts.rollingExpiration === false) {
if (opts.newOnly === true) {
throw new TypeError('Misconfiguration: cannot keep an existing TTL on a new entry')
}
setArgs.push('KEEPTTL')
}
return setArgs
}
async set(key, value, options = {}) {
const setAsync = promisify(this._client.set).bind(this._client)
const fullKey = this.prefix(key)
if (typeof value !== 'string' || !value) {
throw new TypeError(`Value must be a non-empty string but was: ${JSON.stringify(value)}`)
}
// Handle optional arguments
const setArgs = this.constructor.translateSetArguments(options)
try {
const result = await setAsync(fullKey, value, ...setArgs)
return result === 'OK'
} catch (err) {
const errorText = `Failed to set value in Redis.
Key: ${fullKey}
Error: ${err.message}`
if (this._allowSetFailures === true) {
// Allow for graceful failure
console.error(errorText)
return false
}
throw new Error(errorText)
}
}
async get(key) {
const getAsync = promisify(this._client.get).bind(this._client)
const fullKey = this.prefix(key)
try {
const value = await getAsync(fullKey)
return value
} catch (err) {
const errorText = `Failed to get value from Redis.
Key: ${fullKey}
Error: ${err.message}`
if (this._allowGetFailures === true) {
// Allow for graceful failure
console.error(errorText)
return null
}
throw new Error(errorText)
}
}
}
export default RedisAccessor

View File

@@ -2,56 +2,9 @@ import { get } from 'lodash-es'
import patterns from '../lib/patterns.js' import patterns from '../lib/patterns.js'
import getMiniTocItems from '../lib/get-mini-toc-items.js' import getMiniTocItems from '../lib/get-mini-toc-items.js'
import Page from '../lib/page.js' import Page from '../lib/page.js'
import statsd from '../lib/statsd.js'
import RedisAccessor from '../lib/redis-accessor.js'
import { isConnectionDropped } from './halt-on-dropped-connection.js' import { isConnectionDropped } from './halt-on-dropped-connection.js'
import { nextApp, nextHandleRequest } from './next.js' import { nextApp, nextHandleRequest } from './next.js'
const { HEROKU_RELEASE_VERSION } = process.env
const pageCacheDatabaseNumber = 1
const pageCache = new RedisAccessor({
databaseNumber: pageCacheDatabaseNumber,
prefix: (HEROKU_RELEASE_VERSION ? HEROKU_RELEASE_VERSION + ':' : '') + 'rp',
// Allow for graceful failures if a Redis SET operation fails
allowSetFailures: true,
// Allow for graceful failures if a Redis GET operation fails
allowGetFailures: true,
name: 'page-cache',
})
// a list of query params that *do* alter the rendered page, and therefore should be cached separately
const cacheableQueries = ['learn']
function modifyOutput(req, text) {
return addColorMode(req, addCsrf(req, text))
}
function addCsrf(req, text) {
return text.replace('$CSRFTOKEN$', req.csrfToken())
}
function addColorMode(req, text) {
let colorMode = 'auto'
let darkTheme = 'dark'
let lightTheme = 'light'
try {
const cookieValue = JSON.parse(decodeURIComponent(req.cookies.color_mode))
colorMode = encodeURIComponent(cookieValue.color_mode) || colorMode
darkTheme = encodeURIComponent(cookieValue.dark_theme.name) || darkTheme
lightTheme = encodeURIComponent(cookieValue.light_theme.name) || lightTheme
} catch (e) {
// do nothing
}
return text
.replace('$COLORMODE$', colorMode)
.replace('$DARKTHEME$', darkTheme)
.replace('$LIGHTTHEME$', lightTheme)
}
export default async function renderPage(req, res, next) { export default async function renderPage(req, res, next) {
const page = req.context.page const page = req.context.page
// render a 404 page // render a 404 page
@@ -64,59 +17,14 @@ export default async function renderPage(req, res, next) {
return nextApp.render404(req, res) return nextApp.render404(req, res)
} }
// Just finish fast without all the details like Content-Length
if (req.method === 'HEAD') { if (req.method === 'HEAD') {
return res.status(200).end() return res.status(200).end()
} }
// Remove any query string (?...) and/or fragment identifier (#...)
const { pathname, searchParams } = new URL(req.originalUrl, 'https://docs.github.com')
for (const queryKey in req.query) {
if (!cacheableQueries.includes(queryKey)) {
searchParams.delete(queryKey)
}
}
const originalUrl = pathname + ([...searchParams].length > 0 ? `?${searchParams}` : '')
// Is the request for JSON debugging info? // Is the request for JSON debugging info?
const isRequestingJsonForDebugging = 'json' in req.query && process.env.NODE_ENV !== 'production' const isRequestingJsonForDebugging = 'json' in req.query && process.env.NODE_ENV !== 'production'
// Is in an airgapped session?
const isAirgapped = Boolean(req.cookies.AIRGAP)
// Is the request for the GraphQL Explorer page?
const isGraphQLExplorer = req.context.currentPathWithoutLanguage === '/graphql/overview/explorer'
// Serve from the cache if possible
const isCacheable =
// Skip for CI
!process.env.CI &&
// Skip for tests
process.env.NODE_ENV !== 'test' &&
// Skip for HTTP methods other than GET
req.method === 'GET' &&
// Skip for JSON debugging info requests
!isRequestingJsonForDebugging &&
// Skip for airgapped sessions
!isAirgapped &&
// Skip for the GraphQL Explorer page
!isGraphQLExplorer
if (isCacheable) {
// Stop processing if the connection was already dropped
if (isConnectionDropped(req, res)) return
const cachedHtml = await pageCache.get(originalUrl)
if (cachedHtml) {
// Stop processing if the connection was already dropped
if (isConnectionDropped(req, res)) return
console.log(`Serving from cached version of ${originalUrl}`)
statsd.increment('page.sent_from_cache')
return res.send(modifyOutput(req, cachedHtml))
}
}
// add page context // add page context
const context = Object.assign({}, req.context, { page }) const context = Object.assign({}, req.context, { page })

View File

@@ -338,13 +338,6 @@ Run this script to manually purge the Fastly cache for all language variants of
--- ---
### [`purge-redis-pages.js`](purge-redis-pages.js)
Run this script to manually purge the Redis rendered page cache. This will typically only be run by Heroku during the deployment process, as triggered via our Procfile's "release" phase configuration.
---
### [`reconcile-category-dirs-with-ids.js`](reconcile-category-dirs-with-ids.js) ### [`reconcile-category-dirs-with-ids.js`](reconcile-category-dirs-with-ids.js)
An automated test checks for discrepancies between category directory names and slugified category titles as IDs. An automated test checks for discrepancies between category directory names and slugified category titles as IDs.

View File

@@ -1,185 +0,0 @@
#!/usr/bin/env node
import xDotenv from 'dotenv'
import { promisify } from 'util'
import createRedisClient from '../lib/redis/create-client.js'
// [start-readme]
//
// Run this script to manually "soft purge" the Redis rendered page cache
// by shortening the expiration window of entries.
// This will typically only be run by Heroku during the deployment process,
// as triggered via our Procfile's "release" phase configuration.
//
// [end-readme]
xDotenv.config()
const { REDIS_URL, HEROKU_RELEASE_VERSION, HEROKU_PRODUCTION_APP } = process.env
const isHerokuProd = HEROKU_PRODUCTION_APP === 'true'
const pageCacheDatabaseNumber = 1
const keyScanningPattern = HEROKU_RELEASE_VERSION ? '*:rp:*' : 'rp:*'
const scanSetSize = 250
const startTime = Date.now()
const expirationDuration = 30 * 60 * 1000 // 30 minutes
const expirationTimestamp = startTime + expirationDuration // 30 minutes from now
// print keys to be purged without actually purging
const dryRun = ['-d', '--dry-run'].includes(process.argv[2])
// verify environment variables
if (!REDIS_URL) {
if (isHerokuProd) {
console.error('Error: you must specify the REDIS_URL environment variable.\n')
process.exit(1)
} else {
console.warn('Warning: you did not specify a REDIS_URL environment variable. Exiting...\n')
process.exit(0)
}
}
console.log({
HEROKU_RELEASE_VERSION,
HEROKU_PRODUCTION_APP,
})
purgeRenderedPageCache()
function purgeRenderedPageCache() {
const redisClient = createRedisClient({
url: REDIS_URL,
db: pageCacheDatabaseNumber,
// These commands ARE important, so let's make sure they are all accounted for
enable_offline_queue: true,
})
let iteration = 0
let potentialKeyCount = 0
let totalKeyCount = 0
// Promise wrappers
const scanAsync = promisify(redisClient.scan).bind(redisClient)
const quitAsync = promisify(redisClient.quit).bind(redisClient)
// Run it!
return scan()
//
// Define other subroutines
//
async function scan(cursor = '0') {
try {
// [0]: Update the cursor position for the next scan
// [1]: Get the SCAN result for this iteration
const [nextCursor, keys] = await scanAsync(
cursor,
'MATCH',
keyScanningPattern,
'COUNT',
scanSetSize.toString()
)
console.log(`\n[Iteration ${iteration++}] Received ${keys.length} keys...`)
if (dryRun) {
console.log(
`DRY RUN! This iteration might have set TTL for up to ${
keys.length
} keys:\n - ${keys.join('\n - ')}`
)
}
// NOTE: It is possible for a SCAN cursor iteration to return 0 keys when
// using a MATCH because it is applied after the elements are retrieved
//
// Remember: more or less than COUNT or no keys may be returned
// See http://redis.io/commands/scan#the-count-option
// Also, SCAN may return the same key multiple times
// See http://redis.io/commands/scan#scan-guarantees
// Additionally, you should always have the code that uses the keys
// before the code checking the cursor.
if (keys.length > 0) {
if (dryRun) {
potentialKeyCount += keys.length
} else {
totalKeyCount += await updateTtls(keys)
}
}
// From <http://redis.io/commands/scan>:
// 'An iteration starts when the cursor is set to 0,
// and terminates when the cursor returned by the server is 0.'
if (nextCursor === '0') {
const dryRunTrailer = dryRun ? ` (potentially up to ${potentialKeyCount})` : ''
console.log(`\nDone purging keys; affected total: ${totalKeyCount}${dryRunTrailer}`)
console.log(`Time elapsed: ${Date.now() - startTime} ms`)
// Close the connection
await quitAsync()
return
}
// Tail recursion
return scan(nextCursor)
} catch (error) {
console.error('An unexpected error occurred!\n' + error.stack)
console.error('\nAborting...')
process.exit(1)
}
}
// Find existing TTLs to ensure we aren't extending the TTL if it's already set
async function getTtls(keys) {
const pttlPipeline = redisClient.batch()
keys.forEach((key) => pttlPipeline.pttl(key))
const pttlPipelineExecAsync = promisify(pttlPipeline.exec).bind(pttlPipeline)
const pttlResults = await pttlPipelineExecAsync()
if (pttlResults == null || pttlResults.length === 0) {
throw new Error('PTTL results were empty')
}
return pttlResults
}
async function updateTtls(keys) {
const pttlResults = await getTtls(keys)
// Find pertinent keys to have TTLs set
let updatingKeyCount = 0
const pexpireAtPipeline = redisClient.batch()
keys.forEach((key, i) => {
// Only operate on -1 values or those later than our desired expiration timestamp
const pttl = pttlResults[i]
// A TTL of -1 means the entry was not configured with any TTL (expiration)
// currently and will remain as a permanent entry unless a TTL is added
const needsShortenedTtl = pttl === -1 || pttl > expirationDuration
const isOldKey = !HEROKU_RELEASE_VERSION || !key.startsWith(`${HEROKU_RELEASE_VERSION}:`)
if (needsShortenedTtl && isOldKey) {
pexpireAtPipeline.pexpireat(key, expirationTimestamp)
updatingKeyCount += 1
}
})
console.log(`Purging ${updatingKeyCount} keys...`)
// Only update TTLs if there are records worth updating
if (updatingKeyCount === 0) return
// Set all the TTLs
const pexpireAtPipelineExecAsync = promisify(pexpireAtPipeline.exec).bind(pexpireAtPipeline)
const pexpireAtResults = await pexpireAtPipelineExecAsync()
if (pttlResults == null || pttlResults.length === 0) {
throw new Error('PEXPIREAT results were empty')
}
// Count only the entries whose TTLs were successfully updated
const updatedResults = pexpireAtResults.filter((result) => result === 1)
return updatedResults.length
}
}

View File

@@ -1,31 +0,0 @@
#!/usr/bin/env bash
# [start-readme]
#
# Light Bash wrapper for the Heroku release command, which sometimes fails
# unexpectedly in staging environments when a Node installation is missing
#
# [end-readme]
# Check for node but don't fail immediately if it's not present
./script/check-for-node
EXIT_STATUS=$?
# If node is missing...
if [[ "$EXIT_STATUS" -ne "0" ]]; then
# Fail hard if this is our Heroku production app or if Redis is configured
if [[ "$HEROKU_PRODUCTION_APP" == "true" || -n "$REDIS_URL" ]]; then
echo "Error: cannot execute the release script without Node.js, which is fatal."
echo "Exiting..."
exit $EXIT_STATUS
# Otherwise succeed with only a warning
else
echo "Warning: although Node.js is missing, it is non-critical."
echo "Exiting..."
exit 0
fi
else
# Execute the release script and exit with its status
node script/purge-redis-pages.js
exit $?
fi

View File

@@ -1,359 +0,0 @@
import { jest } from '@jest/globals'
import redisMock from 'redis-mock'
import RedisAccessor from '../../lib/redis-accessor.js'
const { RedisClient: InMemoryRedis } = redisMock
describe('RedisAccessor', () => {
test('is a constructor', async () => {
expect(typeof RedisAccessor).toBe('function')
const instance = new RedisAccessor()
expect(instance).toBeInstanceOf(RedisAccessor)
})
test('has expected instance properties', async () => {
const instance = new RedisAccessor()
expect(Object.keys(instance).sort()).toEqual([
'_allowGetFailures',
'_allowSetFailures',
'_client',
'_prefix',
])
})
test('has expected static methods', async () => {
expect(typeof RedisAccessor.translateSetArguments).toBe('function')
})
describe('#_allowGetFailures property', () => {
test('defaults to false', async () => {
const instance = new RedisAccessor()
expect(instance._allowGetFailures).toBe(false)
})
test('is expected value', async () => {
const instance = new RedisAccessor({ allowGetFailures: true })
expect(instance._allowGetFailures).toBe(true)
})
})
describe('#_allowSetFailures property', () => {
test('defaults to false', async () => {
const instance = new RedisAccessor()
expect(instance._allowSetFailures).toBe(false)
})
test('is expected value', async () => {
const instance = new RedisAccessor({ allowSetFailures: true })
expect(instance._allowSetFailures).toBe(true)
})
})
describe('#_client property', () => {
test('is expected Redis client', async () => {
const instance = new RedisAccessor()
expect(instance._client).toBeInstanceOf(InMemoryRedis)
})
})
describe('#_prefix property', () => {
test('defaults to empty string', async () => {
const instance = new RedisAccessor()
expect(instance._prefix).toBe('')
})
test('is expected value', async () => {
const instance = new RedisAccessor({ prefix: 'myPrefix' })
expect(instance._prefix).toBe('myPrefix:')
})
test('removes a trailing colon', async () => {
const instance = new RedisAccessor({ prefix: 'myPrefix:' })
expect(instance._prefix).toBe('myPrefix:')
})
test('removes multiple trailing colons', async () => {
const instance = new RedisAccessor({ prefix: 'myPrefix::' })
expect(instance._prefix).toBe('myPrefix:')
})
})
describe('#prefix method', () => {
test('returns prefixed key', async () => {
const prefix = 'myPrefix'
const instance = new RedisAccessor({ prefix })
expect(instance.prefix('myKey')).toBe('myPrefix:myKey')
})
test('returns original key if no prefix is configured', async () => {
const instance = new RedisAccessor()
expect(instance.prefix('myKey')).toBe('myKey')
})
test('throws if no key is provided', async () => {
const instance = new RedisAccessor()
expect(() => instance.prefix()).toThrow(
new TypeError('Key must be a non-empty string but was: undefined')
)
})
})
describe('.translateSetArguments method', () => {
test('defaults to an empty list of arguments if no options are given', async () => {
expect(RedisAccessor.translateSetArguments()).toEqual([])
})
test('adds argument "NX" if option `newOnly` is set to true', async () => {
expect(RedisAccessor.translateSetArguments({ newOnly: true })).toEqual(['NX'])
})
test('adds argument "XX" if option `existingOnly` is set to true', async () => {
expect(RedisAccessor.translateSetArguments({ existingOnly: true })).toEqual(['XX'])
})
test('adds argument "PX n" if option `expireIn` is provided with a positive finite integer', async () => {
expect(RedisAccessor.translateSetArguments({ expireIn: 20 })).toEqual(['PX', 20])
})
test('adds argument "PX n" with rounded integer if option `expireIn` is provided with a positive finite non-integer', async () => {
expect(RedisAccessor.translateSetArguments({ expireIn: 20.5 })).toEqual(['PX', 21])
expect(RedisAccessor.translateSetArguments({ expireIn: 29.1 })).toEqual(['PX', 29])
})
test('adds argument "KEEPTTL" if option `rollingExpiration` is set to false', async () => {
expect(RedisAccessor.translateSetArguments({ rollingExpiration: false })).toEqual(['KEEPTTL'])
})
test('adds expected arguments if multiple options are configured', async () => {
expect(
RedisAccessor.translateSetArguments({
newOnly: true,
expireIn: 20,
})
).toEqual(['NX', 'PX', 20])
expect(
RedisAccessor.translateSetArguments({
existingOnly: true,
expireIn: 20,
})
).toEqual(['XX', 'PX', 20])
expect(
RedisAccessor.translateSetArguments({
existingOnly: true,
expireIn: 20,
rollingExpiration: false,
})
).toEqual(['XX', 'PX', 20, 'KEEPTTL'])
expect(
RedisAccessor.translateSetArguments({
existingOnly: true,
rollingExpiration: false,
})
).toEqual(['XX', 'KEEPTTL'])
})
test('throws a misconfiguration error if options `newOnly` and `existingOnly` are both set to true', async () => {
expect(() =>
RedisAccessor.translateSetArguments({ newOnly: true, existingOnly: true })
).toThrowError(new TypeError('Misconfiguration: entry cannot be both new and existing'))
})
test('throws a misconfiguration error if option `expireIn` is set to a finite number that rounds to less than 1', async () => {
const misconfigurationError = new TypeError(
'Misconfiguration: cannot set a TTL of less than 1 millisecond'
)
expect(() => RedisAccessor.translateSetArguments({ expireIn: 0 })).toThrowError(
misconfigurationError
)
expect(() => RedisAccessor.translateSetArguments({ expireIn: -1 })).toThrowError(
misconfigurationError
)
expect(() => RedisAccessor.translateSetArguments({ expireIn: 0.4 })).toThrowError(
misconfigurationError
)
})
test('throws a misconfiguration error if option `rollingExpiration` is set to false but `newOnly` is set to true', async () => {
expect(() =>
RedisAccessor.translateSetArguments({ newOnly: true, rollingExpiration: false })
).toThrowError(new TypeError('Misconfiguration: cannot keep an existing TTL on a new entry'))
})
})
describe('#set method', () => {
test('resolves to true if value was successfully set', async () => {
const instance = new RedisAccessor()
expect(await instance.set('myKey', 'myValue')).toBe(true)
})
test('resolves to false if value was not set', async () => {
const instance = new RedisAccessor()
instance._client.set = jest.fn((...args) => args.pop()(null, 'NOT_OK'))
expect(await instance.set('myKey', 'myValue')).toBe(false)
})
test('sends expected key/value to Redis with #_client.set', async () => {
const instance = new RedisAccessor()
const setSpy = jest.spyOn(instance._client, 'set')
await instance.set('myKey', 'myValue')
expect(setSpy.mock.calls.length).toBe(1)
expect(setSpy.mock.calls[0].slice(0, 2)).toEqual(['myKey', 'myValue'])
})
test('resolves to false if Redis replies with an error and `allowSetFailures` option is set to true', async () => {
// Temporarily override `console.error`
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
const instance = new RedisAccessor({ prefix: 'myPrefix', allowSetFailures: true })
instance._client.set = jest.fn((...args) => args.pop()(new Error('Redis ReplyError')))
const result = await instance.set('myKey', 'myValue')
expect(result).toBe(false)
expect(consoleErrorSpy).toBeCalledWith(
`Failed to set value in Redis.
Key: myPrefix:myKey
Error: Redis ReplyError`
)
// Restore `console.error`
consoleErrorSpy.mockRestore()
})
test('rejects if Redis replies with an error and `allowSetFailures` option is not set to true', async () => {
// Temporarily override `console.error`
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
const instance = new RedisAccessor({ prefix: 'myPrefix' })
instance._client.set = jest.fn((...args) => args.pop()(new Error('Redis ReplyError')))
await expect(instance.set('myKey', 'myValue')).rejects.toThrowError(
new Error(`Failed to set value in Redis.
Key: myPrefix:myKey
Error: Redis ReplyError`)
)
expect(consoleErrorSpy).not.toBeCalled()
// Restore `console.error`
consoleErrorSpy.mockRestore()
})
test('rejects if value is an empty string', async () => {
const instance = new RedisAccessor()
await expect(instance.set('myKey', '')).rejects.toThrow(
new TypeError('Value must be a non-empty string but was: ""')
)
})
test('rejects if value is a non-string value', async () => {
const instance = new RedisAccessor()
await expect(instance.set('myKey', true)).rejects.toThrow(
new TypeError('Value must be a non-empty string but was: true')
)
})
test('invokes .translateSetArguments before sending values to Redis', async () => {
const argSpy = jest.spyOn(RedisAccessor, 'translateSetArguments')
const instance = new RedisAccessor()
const setSpy = jest.spyOn(instance._client, 'set')
await instance.set('myKey', 'myValue', { expireIn: 20 })
expect(argSpy).toBeCalled()
expect(setSpy.mock.calls.length).toBe(1)
expect(setSpy.mock.calls[0].slice(0, 4)).toEqual(['myKey', 'myValue', 'PX', 20])
argSpy.mockRestore()
})
})
describe('#get method', () => {
test('resolves to expected value if matching entry exists in Redis', async () => {
const instance = new RedisAccessor()
await instance.set('myKey', 'myValue')
const result = await instance.get('myKey')
expect(result).toBe('myValue')
})
test('resolves to null if no matching entry exists in Redis', async () => {
const instance = new RedisAccessor()
const result = await instance.get('fakeKey')
expect(result).toBe(null)
})
test('retrieves matching entry from Redis with #_client.get', async () => {
const instance = new RedisAccessor()
let callbackSpy
const originalGet = instance._client.get.bind(instance._client)
instance._client.get = jest.fn((...args) => {
const realCallback = args.pop()
callbackSpy = jest.fn((error, value) => {
realCallback(error, value)
})
return originalGet(...args, callbackSpy)
})
await instance.set('myKey', 'myValue')
await instance.get('myKey')
expect(instance._client.get.mock.calls.length).toBe(1)
expect(instance._client.get.mock.calls[0].slice(0, 1)).toEqual(['myKey'])
expect(callbackSpy).toHaveBeenCalledWith(null, 'myValue')
})
test('resolves to null if Redis replies with an error and `allowGetFailures` option is set to true', async () => {
// Temporarily override `console.error`
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
const instance = new RedisAccessor({ prefix: 'myPrefix', allowGetFailures: true })
instance._client.get = jest.fn((...args) => args.pop()(new Error('Redis ReplyError')))
const result = await instance.get('myKey', 'myValue')
expect(result).toBe(null)
expect(consoleErrorSpy).toBeCalledWith(
`Failed to get value from Redis.
Key: myPrefix:myKey
Error: Redis ReplyError`
)
// Restore `console.error`
consoleErrorSpy.mockRestore()
})
test('rejects if Redis replies with an error and `allowGetFailures` option is not set to true', async () => {
// Temporarily override `console.error`
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
const instance = new RedisAccessor({ prefix: 'myPrefix' })
instance._client.get = jest.fn((...args) => args.pop()(new Error('Redis ReplyError')))
await expect(instance.get('myKey')).rejects.toThrowError(
new Error(`Failed to get value from Redis.
Key: myPrefix:myKey
Error: Redis ReplyError`)
)
expect(consoleErrorSpy).not.toBeCalled()
// Restore `console.error`
consoleErrorSpy.mockRestore()
})
})
})