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:
parent
b3252f956f
commit
7c18263227
6 changed files with 242 additions and 10 deletions
|
@ -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;
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
85
ghost/adapter-cache-redis/test/adapter-cache-redis.test.js
Normal file
85
ghost/adapter-cache-redis/test/adapter-cache-redis.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,8 +0,0 @@
|
|||
const assert = require('assert');
|
||||
|
||||
describe('Hello world', function () {
|
||||
it('Runs a test', function () {
|
||||
// TODO: Write me!
|
||||
assert.ok(require('../index'));
|
||||
});
|
||||
});
|
3
ghost/core/core/server/adapters/cache/Redis.js
vendored
Normal file
3
ghost/core/core/server/adapters/cache/Redis.js
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
const RedisCache = require('@tryghost/adapter-cache-redis');
|
||||
|
||||
module.exports = RedisCache;
|
|
@ -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",
|
||||
|
|
Loading…
Add table
Reference in a new issue