0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

Extracted adapter-cache-redis from core codebase

refs https://github.com/TryGhost/Toolbox/issues/515

- This implementation allows to use Redis cluster as a caching adapter. The cache adapter can be configured through same adapter configuration interface as others. It accepts following config values:
  - "ttl" - time in SECONDS for stored entity to live in Redis cache
  - "keyPrefix" - special cache key prefix to use with stored entities
  - "host" / "port" / "password" / "clusterConfig" - Redis instance specific configs
- Set test coverage to non-standard 75% because the code mostly consists of the glue code that would require unnecessary Redis server mocking and would be a bad ROI. This module has been used in production for a long time already, so can be considered pretty stable.
This commit is contained in:
Naz 2023-02-13 16:27:47 +08:00 committed by naz
parent b3252f956f
commit 7c18263227
6 changed files with 242 additions and 10 deletions

View file

@ -0,0 +1,147 @@
const BaseCacheAdapter = require('@tryghost/adapter-base-cache');
const logging = require('@tryghost/logging');
const cacheManager = require('cache-manager');
const redisStore = require('cache-manager-ioredis');
const calculateSlot = require('cluster-key-slot');
class Redis extends BaseCacheAdapter {
/**
*
* @param {Object} config
* @param {Object} [config.cache] - caching instance compatible with cache-manager with 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 {Number} [config.ttl] - default cached value Time To Live (expiration) in *seconds*
* @param {String} [config.keyPrefix] - prefix to use when building a unique cache key, e.g.: 'some_id:image-sizes:'
*/
constructor(config) {
super();
this.cache = config.cache;
if (!this.cache) {
this.cache = cacheManager.caching({
store: redisStore,
ttl: config.ttl,
host: config.host,
port: config.port,
password: config.password,
clusterConfig: config.clusterConfig
});
}
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() {
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) {
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);
});
});
}
/**
* 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} key
*/
async get(key) {
try {
return await this.cache.get(this._buildKey(key));
} catch (err) {
logging.error(err);
}
}
/**
*
* @param {String} key
* @param {*} value
*/
async set(key, value) {
try {
return await this.cache.set(this._buildKey(key), value);
} catch (err) {
logging.error(err);
}
}
async reset() {
// NOTE: dangerous in shared environment, and not used in production code anyway!
// return await this.cache.reset();
logging.error('Cache reset has not been implemented with shared cache environment in mind');
}
/**
* Helper method to assist "getAll" type of operations
* @returns {Promise<Array<String>>} all keys present in the cache
*/
async keys() {
try {
const primaryNode = this.#getPrimaryRedisNode();
if (primaryNode === null) {
return [];
}
const rawKeys = await this.#scanNodeForKeys(primaryNode);
return rawKeys.map((key) => {
return this._removeKeyPrefix(key);
});
} catch (err) {
logging.error(err);
}
}
}
module.exports = Redis;

View file

@ -7,7 +7,7 @@
"main": "index.js",
"scripts": {
"dev": "echo \"Implement me!\"",
"test:unit": "NODE_ENV=testing c8 --all --check-coverage --100 --reporter text --reporter cobertura mocha './test/**/*.test.js'",
"test:unit": "NODE_ENV=testing c8 --all --lines 75 --reporter text --reporter cobertura mocha './test/**/*.test.js'",
"test": "yarn test:unit",
"lint:code": "eslint *.js lib/ --ext .js --cache",
"lint": "yarn lint:code && yarn lint:test",
@ -22,5 +22,9 @@
"mocha": "10.2.0",
"sinon": "15.0.1"
},
"dependencies": {}
"dependencies": {
"cache-manager": "4.1.0",
"cache-manager-ioredis": "2.1.0",
"cluster-key-slot": "1.1.2"
}
}

View file

@ -0,0 +1,85 @@
const assert = require('assert');
const sinon = require('sinon');
const RedisCache = require('../index');
describe('Adapter Cache Redis', function () {
afterEach(function () {
sinon.restore();
});
it('can initialize Redis cache instance directly', async function () {
const redisCacheInstanceStub = {
store: {
getClient: sinon.stub().returns({
on: sinon.stub()
})
}
};
const cache = new RedisCache({
cache: redisCacheInstanceStub
});
assert.ok(cache);
});
describe('get', function () {
it('can get a value from the cache', async function () {
const redisCacheInstanceStub = {
get: sinon.stub().resolves('value from cache'),
store: {
getClient: sinon.stub().returns({
on: sinon.stub()
})
}
};
const cache = new RedisCache({
cache: redisCacheInstanceStub
});
const value = await cache.get('key');
assert.equal(value, 'value from cache');
});
});
describe('set', function () {
it('can set a value in the cache', async function () {
const redisCacheInstanceStub = {
set: sinon.stub().resolvesArg(1),
store: {
getClient: sinon.stub().returns({
on: sinon.stub()
})
}
};
const cache = new RedisCache({
cache: redisCacheInstanceStub
});
const value = await cache.set('key-here', 'new value');
assert.equal(value, 'new value');
assert.equal(redisCacheInstanceStub.set.args[0][0], 'key-here');
});
it('sets a key based on keyPrefix', async function () {
const redisCacheInstanceStub = {
set: sinon.stub().resolvesArg(1),
store: {
getClient: sinon.stub().returns({
on: sinon.stub()
})
}
};
const cache = new RedisCache({
cache: redisCacheInstanceStub,
keyPrefix: 'testing-prefix:'
});
const value = await cache.set('key-here', 'new value');
assert.equal(value, 'new value');
assert.equal(redisCacheInstanceStub.set.args[0][0], 'testing-prefix:key-here');
});
});
});

View file

@ -1,8 +0,0 @@
const assert = require('assert');
describe('Hello world', function () {
it('Runs a test', function () {
// TODO: Write me!
assert.ok(require('../index'));
});
});

View file

@ -0,0 +1,3 @@
const RedisCache = require('@tryghost/adapter-cache-redis');
module.exports = RedisCache;

View file

@ -58,6 +58,7 @@
"dependencies": {
"@sentry/node": "7.11.1",
"@tryghost/adapter-base-cache": "0.1.3",
"@tryghost/adapter-cache-redis": "0.0.0",
"@tryghost/adapter-manager": "0.0.0",
"@tryghost/admin-api-schema": "4.2.1",
"@tryghost/api-framework": "0.0.0",