Skip to content

Commit ddf880f

Browse files
authored
repo sync
2 parents 607067c + 068c472 commit ddf880f

6 files changed

Lines changed: 650 additions & 22 deletions

File tree

lib/redis-accessor.js

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
const Redis = require('ioredis')
2+
const InMemoryRedis = require('ioredis-mock')
3+
4+
const { CI, NODE_ENV, REDIS_URL, REDIS_MAX_DB } = process.env
5+
6+
// Do not use real a Redis client for CI, tests, or if the REDIS_URL is not provided
7+
const useRealRedis = !CI && NODE_ENV !== 'test' && !!REDIS_URL
8+
9+
// By default, every Redis instance supports database numbers 0 - 15
10+
const redisMaxDb = REDIS_MAX_DB || 15
11+
12+
// Enable better stack traces in non-production environments
13+
const redisOptions = {
14+
showFriendlyErrorStack: NODE_ENV !== 'production'
15+
}
16+
17+
class RedisAccessor {
18+
constructor ({ databaseNumber = 0, prefix = null, allowSetFailures = false } = {}) {
19+
if (!Number.isInteger(databaseNumber) || databaseNumber < 0 || databaseNumber > redisMaxDb) {
20+
throw new TypeError(
21+
`Redis database number must be an integer between 0 and ${redisMaxDb} but was: ${JSON.stringify(databaseNumber)}`
22+
)
23+
}
24+
25+
const redisUrl = `${REDIS_URL}/${databaseNumber}`
26+
const redisClient = useRealRedis ? new Redis(redisUrl, redisOptions) : new InMemoryRedis()
27+
this._client = redisClient
28+
29+
this._prefix = prefix ? prefix.replace(/:+$/, '') + ':' : ''
30+
31+
// Allow for graceful failures if a Redis SET operation fails?
32+
this._allowSetFailures = allowSetFailures === true
33+
}
34+
35+
/** @private */
36+
prefix (key) {
37+
if (typeof key !== 'string' || !key) {
38+
throw new TypeError(`Key must be a non-empty string but was: ${JSON.stringify(key)}`)
39+
}
40+
41+
return this._prefix + key
42+
}
43+
44+
static translateSetArguments (options = {}) {
45+
const setArgs = []
46+
47+
const defaults = {
48+
newOnly: false,
49+
existingOnly: false,
50+
expireIn: null, // No expiration
51+
rollingExpiration: true
52+
}
53+
const opts = { ...defaults, ...options }
54+
55+
if (opts.newOnly === true) {
56+
if (opts.existingOnly === true) {
57+
throw new TypeError('Misconfiguration: entry cannot be both new and existing')
58+
}
59+
setArgs.push('NX')
60+
} else if (opts.existingOnly === true) {
61+
setArgs.push('XX')
62+
}
63+
64+
if (Number.isFinite(opts.expireIn)) {
65+
const ttl = Math.round(opts.expireIn)
66+
if (ttl < 1) {
67+
throw new TypeError('Misconfiguration: cannot set a TTL of less than 1 millisecond')
68+
}
69+
setArgs.push('PX')
70+
setArgs.push(ttl)
71+
}
72+
// otherwise there is no expiration
73+
74+
if (opts.rollingExpiration === false) {
75+
if (opts.newOnly === true) {
76+
throw new TypeError('Misconfiguration: cannot keep an existing TTL on a new entry')
77+
}
78+
setArgs.push('KEEPTTL')
79+
}
80+
81+
return setArgs
82+
}
83+
84+
async set (key, value, options = {}) {
85+
const fullKey = this.prefix(key)
86+
87+
if (typeof value !== 'string' || !value) {
88+
throw new TypeError(`Value must be a non-empty string but was: ${JSON.stringify(value)}`)
89+
}
90+
91+
// Handle optional arguments
92+
const setArgs = this.constructor.translateSetArguments(options)
93+
94+
try {
95+
const result = await this._client.set(fullKey, value, ...setArgs)
96+
return result === 'OK'
97+
} catch (err) {
98+
const errorText = `Failed to set value in Redis.
99+
Key: ${fullKey}
100+
Error: ${err.message}`
101+
102+
if (this._allowSetFailures === true) {
103+
// Allow for graceful failure
104+
console.error(errorText)
105+
return false
106+
}
107+
108+
throw new Error(errorText)
109+
}
110+
}
111+
112+
async get (key) {
113+
const value = await this._client.get(this.prefix(key))
114+
return value
115+
}
116+
117+
async exists (key) {
118+
const result = await this._client.exists(this.prefix(key))
119+
return result === 1
120+
}
121+
}
122+
123+
module.exports = RedisAccessor

middleware/rate-limit.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
const rateLimit = require('express-rate-limit')
22
const RedisStore = require('rate-limit-redis')
3+
const Redis = require('ioredis')
34

45
const isProduction = process.env.NODE_ENV === 'production'
5-
const REDIS_URL = process.env.REDIS_URL
6+
const { REDIS_URL } = process.env
7+
const rateLimitDatabaseNumber = 0
8+
const redisUrl = `${REDIS_URL}/${rateLimitDatabaseNumber}`
69

710
module.exports = rateLimit({
811
// 1 minute (or practically unlimited outside of production)
@@ -13,5 +16,5 @@ module.exports = rateLimit({
1316
// Or anything with a status code less than 400
1417
skipSuccessfulRequests: true,
1518
// When available, use Redis
16-
store: REDIS_URL && new RedisStore({ redisURL: REDIS_URL })
19+
store: REDIS_URL && new RedisStore({ client: new Redis(redisUrl) })
1720
})

middleware/render-page.js

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,32 @@ const layouts = require('../lib/layouts')
55
const getMiniTocItems = require('../lib/get-mini-toc-items')
66
const Page = require('../lib/page')
77
const statsd = require('../lib/statsd')
8+
const RedisAccessor = require('../lib/redis-accessor')
89

9-
// We've got lots of memory, let's use it
10-
// We can eventually throw this into redis
11-
const pageCache = {}
10+
const { HEROKU_RELEASE_VERSION } = process.env
11+
const pageCacheDatabaseNumber = 1
12+
const pageCacheExpiration = 24 * 60 * 60 * 1000 // 24 hours
13+
14+
const pageCache = new RedisAccessor({
15+
databaseNumber: pageCacheDatabaseNumber,
16+
prefix: (HEROKU_RELEASE_VERSION ? HEROKU_RELEASE_VERSION + ':' : '') + 'rp',
17+
// Allow for graceful failures if a Redis SET operation fails
18+
allowSetFailures: true
19+
})
1220

1321
module.exports = async function renderPage (req, res, next) {
1422
const page = req.context.page
1523
const originalUrl = req.originalUrl
1624

1725
// Serve from the cache if possible (skip during tests)
18-
if (!process.env.CI && process.env.NODE_ENV !== 'test') {
19-
if (req.method === 'GET' && pageCache[originalUrl]) {
26+
const isCacheable = !process.env.CI && process.env.NODE_ENV !== 'test' && req.method === 'GET'
27+
28+
if (isCacheable) {
29+
const cachedHtml = await pageCache.get(originalUrl)
30+
if (cachedHtml) {
2031
console.log(`Serving from cached version of ${originalUrl}`)
2132
statsd.increment('page.sent_from_cache')
22-
return res.send(pageCache[originalUrl])
33+
return res.send(cachedHtml)
2334
}
2435
}
2536

@@ -88,13 +99,11 @@ module.exports = async function renderPage (req, res, next) {
8899

89100
const output = await liquid.parseAndRender(layout, context)
90101

91-
// Save output to cache for the next time around
92-
if (!process.env.CI) {
93-
if (req.method === 'GET') {
94-
pageCache[originalUrl] = output
95-
}
96-
}
102+
// First, send the response so the user isn't waiting
103+
res.send(output)
97104

98-
// send response
99-
return res.send(output)
105+
// Finally, save output to cache for the next time around
106+
if (isCacheable) {
107+
await pageCache.set(originalUrl, output, { expireIn: pageCacheExpiration })
108+
}
100109
}

0 commit comments

Comments
 (0)