Used a key prefix to enable immediate cache resets

Rather than trying to clear all of the data in Redis, we can use a key prefix
to simulate a cache clear, after changing the prefix, any reads from the cache
will now MISS and we can rely on the TTL and eviction policy of Redis to clear
stale data.
This commit is contained in:
Fabien "egg" O'Carroll 2024-01-18 17:31:46 +07:00
parent f34999a51f
commit dd3cc9d6e0
2 changed files with 87 additions and 52 deletions

View File

@ -1,6 +1,6 @@
const crypto = require('crypto');
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');
@ -61,22 +61,41 @@ class AdapterCacheRedis extends BaseCacheAdapter {
this.ttl = config.ttl;
this.refreshAheadFactor = config.refreshAheadFactor || 0;
this.currentlyExecutingBackgroundRefreshes = new Set();
this.keyPrefix = config.keyPrefix;
this._keysPattern = config.keyPrefix ? `${config.keyPrefix}*` : '';
this._keyPrefix = config.keyPrefix || '';
this.redisClient = this.cache.store.getClient();
this.redisClient.on('error', this.handleRedisError);
}
async prefixHash() {
const currentPrefixHash = await this.cache.get(this._keyPrefix + 'prefix_hash');
if (currentPrefixHash) {
return currentPrefixHash;
}
const newPrefixHash = await this.cyclePrefixHash();
return newPrefixHash;
}
async keyPrefix() {
const prefixHash = await this.prefixHash();
return this._keyPrefix + prefixHash;
}
async keysPattern() {
const keyPrefix = await this.keyPrefix();
return keyPrefix + '*';
}
handleRedisError(error) {
logging.error(error);
}
#getPrimaryRedisNode() {
async #getPrimaryRedisNode() {
debug('getPrimaryRedisNode');
if (this.redisClient.constructor.name !== 'Cluster') {
return this.redisClient;
}
const slot = calculateSlot(this.keyPrefix);
const keyPrefix = await this.keyPrefix();
const slot = calculateSlot(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)) {
@ -86,10 +105,11 @@ class AdapterCacheRedis extends BaseCacheAdapter {
return null;
}
#scanNodeForKeys(node) {
debug(`scanNodeForKeys matching ${this._keysPattern}`);
async #scanNodeForKeys(node) {
const keysPattern = await this.keysPattern();
debug(`scanNodeForKeys matching ${keysPattern}`);
return new Promise((resolve, reject) => {
const stream = node.scanStream({match: this._keysPattern, count: 100});
const stream = node.scanStream({match: keysPattern, count: 100});
let keys = [];
stream.on('data', (resultKeys) => {
keys = keys.concat(resultKeys);
@ -105,7 +125,7 @@ class AdapterCacheRedis extends BaseCacheAdapter {
async #getKeys() {
debug('#getKeys');
const primaryNode = this.#getPrimaryRedisNode();
const primaryNode = await this.#getPrimaryRedisNode();
if (primaryNode === null) {
return [];
}
@ -117,11 +137,12 @@ class AdapterCacheRedis extends BaseCacheAdapter {
* 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}
* @returns {Promise<string>}
*/
_buildKey(key) {
if (this.keyPrefix) {
return `${this.keyPrefix}${key}`;
async _buildKey(key) {
const keyPrefix = await this.keyPrefix();
if (keyPrefix) {
return `${keyPrefix}${key}`;
}
return key;
@ -130,10 +151,11 @@ class AdapterCacheRedis extends BaseCacheAdapter {
/**
* This is a method to remove the key prefix from any raw key returned from redis.
* @param {string} key
* @returns {string}
* @returns {Promise<string>}
*/
_removeKeyPrefix(key) {
return key.slice(this.keyPrefix.length);
async _removeKeyPrefix(key) {
const keyPrefix = await this.keyPrefix();
return key.slice(keyPrefix.length);
}
/**
@ -166,7 +188,7 @@ class AdapterCacheRedis extends BaseCacheAdapter {
* @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);
const internalKey = await this._buildKey(key);
try {
const result = await this.cache.get(internalKey);
debug(`get ${internalKey}: Cache ${result ? 'HIT' : 'MISS'}`);
@ -210,7 +232,7 @@ class AdapterCacheRedis extends BaseCacheAdapter {
* @param {*} value
*/
async set(key, value) {
const internalKey = this._buildKey(key);
const internalKey = await this._buildKey(key);
debug('set', internalKey);
try {
return await this.cache.set(internalKey, value);
@ -219,28 +241,18 @@ class AdapterCacheRedis extends BaseCacheAdapter {
}
}
async cyclePrefixHash() {
const value = crypto.randomBytes(12).toString('hex');
await this.cache.set(this._keyPrefix + 'prefix_hash', value, {ttl: 0});
return value;
}
/**
* Reset the cache by deleting everything from redis
* Reset the cache by refreshing the current prefix hash
*/
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);
}
await this.cyclePrefixHash();
}
/**
@ -249,9 +261,9 @@ class AdapterCacheRedis extends BaseCacheAdapter {
*/
async keys() {
try {
return (await this.#getKeys()).map((key) => {
return Promise.all((await this.#getKeys()).map((key) => {
return this._removeKeyPrefix(key);
});
}));
} catch (err) {
logging.error(err);
}

View File

@ -64,12 +64,17 @@ describe('Adapter Cache Redis', function () {
let cachedValue = null;
const redisCacheInstanceStub = {
get: function (key) {
assert(key === KEY);
return cachedValue;
if (key === 'prefix_hash') {
return 'prefix_hash';
}
if (key === 'prefix_hash' + KEY) {
return cachedValue;
}
},
set: function (key, value) {
assert(key === KEY);
cachedValue = value;
if (key === 'prefix_hash' + KEY) {
cachedValue = value;
}
},
store: {
getClient: sinon.stub().returns({
@ -105,16 +110,22 @@ describe('Adapter Cache Redis', function () {
const redisCacheInstanceStub = {
get: function (key) {
assert(key === KEY);
return cachedValue;
if (key === 'prefix_hash') {
return 'prefix_hash';
}
if (key === 'prefix_hash' + KEY) {
return cachedValue;
}
},
set: function (key, value) {
assert(key === KEY);
cachedValue = value;
if (key === 'prefix_hash' + KEY) {
cachedValue = value;
}
},
ttl: function (key) {
assert(key === KEY);
return remainingTTL;
if (key === 'prefix_hash' + KEY) {
return remainingTTL;
}
},
store: {
getClient: sinon.stub().returns({
@ -185,6 +196,11 @@ describe('Adapter Cache Redis', function () {
describe('set', function () {
it('can set a value in the cache', async function () {
const redisCacheInstanceStub = {
get: function (key) {
if (key === 'prefix_hash') {
return 'prefix_hash';
}
},
set: sinon.stub().resolvesArg(1),
store: {
getClient: sinon.stub().returns({
@ -199,11 +215,16 @@ describe('Adapter Cache Redis', function () {
const value = await cache.set('key-here', 'new value');
assert.equal(value, 'new value');
assert.equal(redisCacheInstanceStub.set.args[0][0], 'key-here');
assert.equal(redisCacheInstanceStub.set.args[0][0], 'prefix_hashkey-here');
});
it('sets a key based on keyPrefix', async function () {
const redisCacheInstanceStub = {
get: function (key) {
if (key === 'testing-prefix:prefix_hash') {
return 'prefix_hash';
}
},
set: sinon.stub().resolvesArg(1),
store: {
getClient: sinon.stub().returns({
@ -219,14 +240,15 @@ describe('Adapter Cache Redis', function () {
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');
assert.equal(redisCacheInstanceStub.set.args[0][0], 'testing-prefix:prefix_hashkey-here');
});
});
describe('reset', function () {
it('catches an error when thrown during the reset', async function () {
it('Updates the prefix_hash cache input with 0 ttl', async function () {
const redisCacheInstanceStub = {
get: sinon.stub().resolves('value from cache'),
set: sinon.stub().resolvesArg(1),
store: {
getClient: sinon.stub().returns({
on: sinon.stub()
@ -239,7 +261,8 @@ describe('Adapter Cache Redis', function () {
await cache.reset();
assert.ok(logging.error.calledOnce, 'error was logged');
assert.equal(redisCacheInstanceStub.set.args[0][0], 'prefix_hash');
assert.deepEqual(redisCacheInstanceStub.set.args[0][2], {ttl: 0});
});
});
});