lib/cache/cacheBase.js
/**
* Our base Cache implementation
* Extend this class with new implementations to create different cache types (in-memory, database, file system etc.)
* @class
*/
export default class CacheBase {
/**
* @param {Object} options
* @param {boolean} [options.useMemoryCache=true] Use an in-memory layer on top of this cache
* Avoid hitting databases too often
* Not useful if using any distributed setup where memory will be out-of-sync between processes
* @param {(number|null)} [options.memoryCacheTimeout=null] Timeout for in-memory cache values
* Default is null, which will use the incoming ttl values for each key
*/
constructor(options = {
useMemoryCache: true,
memoryCacheTimeout: null,
}) {
/**
* Setting the memory options
*/
this.memoryLayerEnabled = options.useMemoryCache;
this.memCache = {};
/**
* Create a stack and wait before every caching session is finished
*/
this.pendingCacheWraps = {};
/**
* Create a stack containing all failed cache sessions
*/
this.failedCacheWraps = {};
}
/**
* Internal implementation of Get()
* @param {string} key Unique key name for this cache entry
* @return {(Object|undefined)} Returns the object in the cache, or undefined if not present
* @abstract
* @private
*/
async _get(key) {
throw new Error('Missing Implementation CacheBase::_get(key)');
}
/**
* Internal implementation of Set()
* @param {string} key Unique key name for this cache entry
* @param {Object} object Data to be set
* @abstract
* @private
*/
async _set(key, object) {
throw new Error('Missing Implementation CacheBase::_set(key, object)');
}
/**
* Internal operation to delete a key
* @param {string} key Key name to delete
* @abstract
* @private
*/
async _del(key) {
throw new Error('Missing Implementation CacheBase::_del(key)');
}
/**
* Internal implementation of getKeys()
* @param {string} prefix
* @abstract
* @private
*/
async _getKeys(prefix) {
throw new Error('Missing Implementation CacheBase::_getKeys(prefix)');
}
/**
* Get a cached object
* @param {string} key Unique key name for this cache entry
* @param {boolean} [getFullObject] Get the full cache entry, including expiry time, even if expired
* @return {(Object|undefined)} Returns the object in the cache, or undefined if not present
*/
async get(key, getFullObject = false) {
const now = +new Date();
// our optional in-memory cache goes first
if (this.memoryLayerEnabled) {
const cacheEntry = this.memCache[key];
if (cacheEntry !== undefined) {
if (getFullObject) {
return cacheEntry;
}
if (cacheEntry.expires >= now) {
return cacheEntry.value;
}
}
}
// then use our internal cache if we haven't got the value stored locally
const cacheValue = await this._get(key);
if (cacheValue !== undefined) {
if (getFullObject) {
return cacheValue;
}
if (cacheValue.expires >= now) {
return cacheValue.value;
}
}
return undefined;
}
/**
* Set a key in our cache
* @param {string} key Unique key name for this cache entry
* @param {Object} value
* @param {(Function|number)} [ttl=3600000] How long the cache entry should last in milliseconds
* Can be a number or a function that will return a number
* Default 1 hour
*/
async set(key, value, ttl = 3600000) {
// resolve our cache time
let cacheTime = ttl;
// if our cache time input is a function, resolve it and store the result (in milliseconds)
if (typeof cacheTime === 'function') {
cacheTime = await cacheTime();
}
// optionally keep an in-memory cache layer
if (this.memoryLayerEnabled) {
const memoryCacheTime = this.memoryCacheTimeout === null ?
cacheTime :
(Math.min(this.memoryCacheTimeout, cacheTime)
);
if (memoryCacheTime < 0) {
if (this.memCache[key]) {
delete this.memCache[key];
}
} else {
this.memCache[key] = {
value,
expires: (+new Date()) + memoryCacheTime,
};
}
}
if (cacheTime < 0) {
// delete key if ttl is negative
await this._del(key);
} else {
// call the private _Set implementation to actually set the key
this._set(key, {
value,
expires: (+new Date()) + cacheTime,
});
}
}
/**
* A helper "wrap" function that will return a cached value if present
* This will call the supplied function to fetch it if the value isn't present in the cache
* @param {string} key Unique key name for this cache entry
* @param {function} fn Fetch function that will be called if the cache entry is not present
* @param {(function|number)} [ttl] How long the cache entry should last in milliseconds
* Can be a number or a function that will return a number
*/
async wrap(key, fn, ttl) {
// if another system is already wrapping this key, return it's pending Promise
if (this.pendingCacheWraps[key] !== undefined) {
return this.pendingCacheWraps[key];
}
// wrap all await calls in another Promise that we store
// this allows multiple calls to Wrap to stack up, and they all get the same result
this.pendingCacheWraps[key] = new Promise(async (resolve) => {
// try and fetch the cached value
const cachedValue = await this.get(key, true);
// if not in our cache, call the supplied fetcher function
if (cachedValue !== undefined) {
// check timestamp to see if value is still valid
const now = +new Date();
if (cachedValue.expires > now) {
// it is! return it!
return resolve(cachedValue.value);
}
// it isn't! fall through and run our wrap function to get new data
}
let error = null;
try {
const newValue = await fn();
// set the new value in our cache
this.failedCacheWraps[key] = 0;
await this.set(key, newValue, ttl);
return resolve(newValue);
} catch (e) {
// store in case we want to throw this later
error = e;
console.error(`Error caching value ${key}`, e);
}
if (this.failedCacheWraps[key] === undefined) {
this.failedCacheWraps[key] = 0;
}
this.failedCacheWraps[key]++;
// failed! store old data briefly, then return old data back
await this.set(key, cachedValue?.value, 1000 * 30); // try again in 30 seconds
if (this.failedCacheWraps[key] > 5) {
// report after multiple failures
if (error !== null) {
// throw the actual error if we had one
throw error;
}
throw new Error(`Failed to resolve wrap function for cache key ${key}`);
}
return resolve(cachedValue?.value);
});
const cachedValue = await this.pendingCacheWraps[key];
this.pendingCacheWraps[key] = undefined;
// return the fetched or calculated value
return cachedValue;
}
/**
* Get an array of all the cached keys matching the supplied prefix
* @param {string} [prefix='']
* @return {array<string>}
*/
async getKeys(prefix = '') {
return this._getKeys(prefix);
}
}