d8b629c713
ref https://linear.app/tryghost/issue/ENG-902/add-an-optional-timeout-in-the-redis-cache-adapter-in-case-redis - Added an optional timeout parameter to AdapterCacheRedis, so that the `get(key)` method will return `null` after the timeout if it hasn't received a response from Redis - When load testing the `LinkRedirectRepository` with the Redis cache enabled on staging, we noticed that for some reason Redis stopped responding to commands for ~30 seconds. - The `LinkRedirectRepository` was waiting for the Redis cache to respond and resulted in a drastic increase in response times for link redirects - This change will allow us to set a timeout on the `get(key)` method, so that if Redis doesn't respond within the timeout, the method will return `null` as if it were a cache miss. - Then the `LinkRedirectRepository` will fall back to the database and return the link redirect from the database instead of waiting indefinitely for Redis to respond
288 lines
11 KiB
JavaScript
288 lines
11 KiB
JavaScript
const BaseCacheAdapter = require('@tryghost/adapter-base-cache');
|
|
const logging = require('@tryghost/logging');
|
|
const metrics = require('@tryghost/metrics');
|
|
const debug = require('@tryghost/debug')('redis-cache');
|
|
const cacheManager = require('cache-manager');
|
|
const redisStoreFactory = require('./redis-store-factory');
|
|
const calculateSlot = require('cluster-key-slot');
|
|
|
|
class AdapterCacheRedis extends BaseCacheAdapter {
|
|
/**
|
|
*
|
|
* @param {Object} config
|
|
* @param {Object} [config.cache] - caching instance compatible with cache-manager's redis store
|
|
* @param {String} [config.host] - redis host used in case no cache instance provided
|
|
* @param {Number} [config.port] - redis port used in case no cache instance provided
|
|
* @param {String} [config.password] - redis password used in case no cache instance provided
|
|
* @param {Object} [config.clusterConfig] - redis cluster config used in case no cache instance provided
|
|
* @param {Object} [config.storeConfig] - extra redis client config used in case no cache instance provided
|
|
* @param {Number} [config.ttl] - default cached value Time To Live (expiration) in *seconds*
|
|
* @param {Number} [config.getTimeoutMilliseconds] - default timeout for cache get operations in *milliseconds*
|
|
* @param {Number} [config.refreshAheadFactor] - 0-1 number to use to determine how old (as a percentage of ttl) an entry should be before refreshing it
|
|
* @param {String} [config.keyPrefix] - prefix to use when building a unique cache key, e.g.: 'some_id:image-sizes:'
|
|
* @param {Boolean} [config.reuseConnection] - specifies if the redis store/connection should be reused within the process
|
|
*/
|
|
constructor(config) {
|
|
super();
|
|
|
|
this.cache = config.cache;
|
|
|
|
if (!this.cache) {
|
|
// @NOTE: this condition can be avoided if we add merging of nested options
|
|
// to adapter configuration. Than adding adapter-specific {clusterConfig: {options: {ttl: XXX}}}
|
|
// will be enough to set ttl for redis cluster.
|
|
if (config.ttl && config.clusterConfig) {
|
|
if (!config.clusterConfig.options) {
|
|
config.clusterConfig.options = {};
|
|
}
|
|
|
|
config.clusterConfig.options.ttl = config.ttl;
|
|
}
|
|
|
|
const storeOptions = {
|
|
ttl: config.ttl,
|
|
host: config.host,
|
|
port: config.port,
|
|
username: config.username,
|
|
password: config.password,
|
|
retryStrategy: () => {
|
|
return (config.storeConfig.retryConnectSeconds || 10) * 1000;
|
|
},
|
|
...config.storeConfig,
|
|
clusterConfig: config.clusterConfig
|
|
};
|
|
const store = redisStoreFactory.getRedisStore(storeOptions, config.reuseConnection);
|
|
|
|
this.cache = cacheManager.caching({
|
|
store: store,
|
|
...storeOptions
|
|
});
|
|
}
|
|
|
|
this.ttl = config.ttl;
|
|
this.refreshAheadFactor = config.refreshAheadFactor || 0;
|
|
this.getTimeoutMilliseconds = config.getTimeoutMilliseconds || null;
|
|
this.currentlyExecutingBackgroundRefreshes = new Set();
|
|
this.keyPrefix = config.keyPrefix;
|
|
this._keysPattern = config.keyPrefix ? `${config.keyPrefix}*` : '';
|
|
this.redisClient = this.cache.store.getClient();
|
|
this.redisClient.on('error', this.handleRedisError);
|
|
}
|
|
|
|
handleRedisError(error) {
|
|
logging.error(error);
|
|
}
|
|
|
|
#getPrimaryRedisNode() {
|
|
debug('getPrimaryRedisNode');
|
|
if (this.redisClient.constructor.name !== 'Cluster') {
|
|
return this.redisClient;
|
|
}
|
|
const slot = calculateSlot(this.keyPrefix);
|
|
const [ip, port] = this.redisClient.slots[slot][0].split(':');
|
|
for (const node of this.redisClient.nodes()) {
|
|
if (node.options.host === ip && node.options.port === parseInt(port)) {
|
|
return node;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
#scanNodeForKeys(node) {
|
|
debug(`scanNodeForKeys matching ${this._keysPattern}`);
|
|
return new Promise((resolve, reject) => {
|
|
const stream = node.scanStream({match: this._keysPattern, count: 100});
|
|
let keys = [];
|
|
stream.on('data', (resultKeys) => {
|
|
keys = keys.concat(resultKeys);
|
|
});
|
|
stream.on('error', (e) => {
|
|
reject(e);
|
|
});
|
|
stream.on('end', () => {
|
|
resolve(keys);
|
|
});
|
|
});
|
|
}
|
|
|
|
async #getKeys() {
|
|
debug('#getKeys');
|
|
const primaryNode = this.#getPrimaryRedisNode();
|
|
if (primaryNode === null) {
|
|
return [];
|
|
}
|
|
return await this.#scanNodeForKeys(primaryNode);
|
|
}
|
|
|
|
/**
|
|
* This is a recommended way to build cache key prefixes from
|
|
* the cache-manager package. Might be a good contribution to make
|
|
* in the package itself (https://github.com/node-cache-manager/node-cache-manager/issues/158)
|
|
* @param {string} key
|
|
* @returns {string}
|
|
*/
|
|
_buildKey(key) {
|
|
if (this.keyPrefix) {
|
|
return `${this.keyPrefix}${key}`;
|
|
}
|
|
|
|
return key;
|
|
}
|
|
|
|
/**
|
|
* This is a method to remove the key prefix from any raw key returned from redis.
|
|
* @param {string} key
|
|
* @returns {string}
|
|
*/
|
|
_removeKeyPrefix(key) {
|
|
return key.slice(this.keyPrefix.length);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {String} internalKey
|
|
*/
|
|
async shouldRefresh(internalKey) {
|
|
if (this.refreshAheadFactor === 0) {
|
|
debug(`shouldRefresh ${internalKey}: false - refreshAheadFactor = 0`);
|
|
return false;
|
|
}
|
|
if (this.refreshAheadFactor === 1) {
|
|
debug(`shouldRefresh ${internalKey}: true - refreshAheadFactor = 1`);
|
|
return true;
|
|
}
|
|
try {
|
|
const ttlRemainingForEntry = await this.cache.ttl(internalKey);
|
|
const shouldRefresh = ttlRemainingForEntry < this.refreshAheadFactor * this.ttl;
|
|
debug(`shouldRefresh ${internalKey}: ${shouldRefresh} - TTL remaining = ${ttlRemainingForEntry}`);
|
|
return shouldRefresh;
|
|
} catch (err) {
|
|
logging.error(err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the specified key from the cache if it exists, otherwise returns null
|
|
* - If getTimeoutMilliseconds is set, the method will return a promise that resolves with the value or null if the timeout is exceeded
|
|
*
|
|
* @param {string} key
|
|
*/
|
|
async _get(key) {
|
|
if (typeof this.getTimeoutMilliseconds !== 'number') {
|
|
return this.cache.get(key);
|
|
} else {
|
|
return new Promise((resolve) => {
|
|
const timer = setTimeout(() => {
|
|
debug('get', key, 'timeout');
|
|
resolve(null);
|
|
}, this.getTimeoutMilliseconds);
|
|
|
|
this.cache.get(key).then((result) => {
|
|
clearTimeout(timer);
|
|
resolve(result);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} key
|
|
* @param {() => Promise<any>} [fetchData] An optional function to fetch the data, which will be used in the case of a cache MISS or a background refresh
|
|
*/
|
|
async get(key, fetchData) {
|
|
const internalKey = this._buildKey(key);
|
|
try {
|
|
const result = await this._get(internalKey);
|
|
debug(`get ${internalKey}: Cache ${result ? 'HIT' : 'MISS'}`);
|
|
if (!fetchData) {
|
|
return result;
|
|
}
|
|
if (result) {
|
|
const shouldRefresh = await this.shouldRefresh(internalKey);
|
|
const isRefreshing = this.currentlyExecutingBackgroundRefreshes.has(internalKey);
|
|
if (isRefreshing) {
|
|
debug(`Background refresh already happening for ${internalKey}`);
|
|
}
|
|
if (!isRefreshing && shouldRefresh) {
|
|
debug(`Doing background refresh for ${internalKey}`);
|
|
this.currentlyExecutingBackgroundRefreshes.add(internalKey);
|
|
fetchData().then(async (data) => {
|
|
await this.set(key, data); // We don't use `internalKey` here because `set` handles it
|
|
this.currentlyExecutingBackgroundRefreshes.delete(internalKey);
|
|
}).catch((error) => {
|
|
this.currentlyExecutingBackgroundRefreshes.delete(internalKey);
|
|
logging.error({
|
|
message: 'There was an error refreshing cache data in the background',
|
|
error: error
|
|
});
|
|
});
|
|
}
|
|
return result;
|
|
} else {
|
|
const data = await fetchData();
|
|
await this.set(key, data); // We don't use `internalKey` here because `set` handles it
|
|
return data;
|
|
}
|
|
} catch (err) {
|
|
logging.error(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {String} key
|
|
* @param {*} value
|
|
*/
|
|
async set(key, value) {
|
|
const internalKey = this._buildKey(key);
|
|
debug('set', internalKey);
|
|
try {
|
|
return await this.cache.set(internalKey, value);
|
|
} catch (err) {
|
|
logging.error(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset the cache by deleting everything from redis
|
|
*/
|
|
async reset() {
|
|
debug('reset');
|
|
try {
|
|
const t0 = performance.now();
|
|
logging.debug(`[RedisAdapter] Clearing cache: scanning for keys matching ${this._keysPattern}`);
|
|
const keys = await this.#getKeys();
|
|
logging.debug(`[RedisAdapter] Clearing cache: found ${keys.length} keys matching ${this._keysPattern} in ${(performance.now() - t0).toFixed(1)}ms`);
|
|
metrics.metric('cache-reset-scan', (performance.now() - t0).toFixed(1));
|
|
const t1 = performance.now();
|
|
for (const key of keys) {
|
|
await this.cache.del(key);
|
|
}
|
|
logging.debug(`[RedisAdapter] Clearing cache: deleted ${keys.length} keys matching ${this._keysPattern} in ${(performance.now() - t1).toFixed(1)}ms`);
|
|
metrics.metric('cache-reset-delete', (performance.now() - t1).toFixed(1));
|
|
metrics.metric('cache-reset', (performance.now() - t0).toFixed(1));
|
|
metrics.metric('cache-reset-key-count', keys.length);
|
|
} catch (err) {
|
|
logging.error(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper method to assist "getAll" type of operations
|
|
* @returns {Promise<Array<String>>} all keys present in the cache
|
|
*/
|
|
async keys() {
|
|
try {
|
|
return (await this.#getKeys()).map((key) => {
|
|
return this._removeKeyPrefix(key);
|
|
});
|
|
} catch (err) {
|
|
logging.error(err);
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = AdapterCacheRedis;
|