/* eslint-disable guard-for-in */
import timestring from 'timestring';
import LRU from './LRU';
let globalCache;
const FIFTEEN_MINUTES = 15 * 60 * 1000;
function ttlMs(options) {
let ttl = (typeof options === 'object') ? options.ttl : options;
if (typeof ttl === 'string') {
ttl = timestring(ttl, 'ms');
}
else {
ttl = ttl || 0;
}
return ttl;
}
function getCacheKey(args, key, options) {
if (typeof key === 'function') {
return key(args);
}
if (options.keyFn) {
return key + ':' + options.keyFn(args);
}
if (!args.length) {
return key + ':null';
}
return key + ':' + JSON.stringify(args);
}
function tick() {
return new Promise((resolve) => {
setImmediate(resolve);
});
}
/**
* Local cache with dogpile prevention, lru, ttl and other goodies
*/
class Cache {
static logger = console;
constructor(options = {}) {
if (options.maxItems) {
// LRU
this.data = new LRU({maxItems: options.maxItems});
}
else {
this.data = new Map();
}
this.fetching = new Map();
this.logger = options.logger || this.constructor.logger;
}
_get(key, defaultValue = undefined) {
const existing = this.data.get(key);
if (existing === undefined) return defaultValue;
if (existing.t) {
const time = Date.now();
if ((time - existing.c) > existing.t) {
// value has expired
this.data.delete(key);
// call gc if required
if (time - this.gcTime > FIFTEEN_MINUTES) {
this.gc().then(
() => {},
(err) => {
this.logger.error('[Cache] Error while gc', err);
},
);
}
return defaultValue;
}
}
return existing.v;
}
_set(key, value, ttl = 0) {
const item = {
v: value,
c: Date.now(),
};
if (ttl <= 0) {
this.data.set(key, item);
return;
}
item.t = ttl;
this.data.set(key, item);
}
_del(key) {
// delete data
this.data.delete(key);
}
_clear() {
// clear data
this.data.clear();
this.fetching.clear();
}
/**
* gets a value from the cache
* this is sync version, so it'll not help with dogpiling issues
* @param {string} key
* @param {any} defaultValue
*/
getSync(key, defaultValue = undefined) {
return this._get(key, defaultValue);
}
/**
* gets a value from the cache
* @param {string} key
* @param {any} defaultValue
*/
async get(key, defaultValue = undefined) {
const fetching = this.fetching.get(key);
if (fetching) {
// Some other process is still fetching the value
// Don't dogpile shit, wait for the other process
// to finish it
const value = await fetching;
if (value === undefined) return defaultValue;
return value;
}
return this._get(key, defaultValue);
}
/**
* gets a value from the cache immediately without waiting
* @param {string} key
* @param {any} defaultValue
* @returns {any}
*/
getStaleSync(key, defaultValue = undefined) {
return this._get(key, defaultValue);
}
/**
* gets a value from the cache immediately without waiting
* @param {string} key
* @param {any} defaultValue
* @returns {any}
*/
async getStale(key, defaultValue = undefined) {
return this._get(key, defaultValue);
}
/**
* checks if a key exists in the cache
* @param {string} key
* @returns {boolean}
*/
hasSync(key) {
return this.data.has(key);
}
/**
* checks if a key exists in the cache
* @param {string} key
* @returns {boolean}
*/
async has(key) {
return this.data.has(key);
}
/**
* @typedef {object} setOptsObject
* @property {number|string} ttl in ms / timestring ('1d 3h') default: 0
*/
/**
* @typedef {setOptsObject | string | number} setOpts
*/
/**
* sets a value in the cache
* this is sync version, so value should not be a promise or async function
* @param {string} key
* @param {any} value
* @param {setOpts} [options={}] ttl in ms/timestring('1d 3h') or opts (default: 0)
* @return {Promise<boolean>}
*/
setSync(key, value, options = {}) {
const ttl = ttlMs(options);
try {
if (typeof value === 'function') {
// value is a function
// call it and set the result
return this.setSync(key, value(key), ttl);
}
// value is normal
// just set it in the store
this._set(key, value, ttl);
return true;
}
catch (error) {
this._del(key);
return false;
}
}
/**
* sets a value in the cache
* avoids dogpiling if the value is a promise or a function returning a promise
* @param {string} key
* @param {any} value
* @param {number|string|setOpts} [options={}] ttl in ms/timestring('1d 3h') or opts (default: 0)
* @return {boolean}
*/
async set(key, value, options = {}) {
const ttl = ttlMs(options);
try {
if (value && value.then) {
// value is a Promise
// resolve it and then cache it
this.fetching.set(key, value);
const resolvedValue = await value;
this._set(key, resolvedValue, ttl);
this.fetching.delete(key);
return true;
}
if (typeof value === 'function') {
// value is a function
// call it and set the result
return this.set(key, value(key), ttl);
}
if (value === undefined) {
// don't set undefined value
this.logger.error(`[Cache] attempt to set ${key}=undefined`);
return false;
}
// value is normal
// just set it in the store
this._set(key, value, ttl);
return true;
}
catch (error) {
this.logger.error(`[Cache] error while setting key ${key}`, error);
this._del(key);
this.fetching.delete(key);
return false;
}
}
/**
* gets a value from the cache, or sets it if it doesn't exist
* this is sync version, so value should not be a promise or async function
* @param {string} key key to get
* @param {any} value value to set if the key does not exist
* @param {number|string|setOpts} [options={}] ttl in ms/timestring('1d 3h') or opts (default: 0)
* @return {any}
*/
getOrSetSync(key, value, options = {}) {
// key already exists, return it
const existing = this._get(key);
if (existing !== undefined) return existing;
// no value given, return undefined
if (value === undefined) return undefined;
this.setSync(key, value, options);
const result = this.data.get(key);
return result && result.v;
}
/**
* gets a value from the cache, or sets it if it doesn't exist
* this takes care of dogpiling (make sure value is a function to avoid dogpiling)
* @param {string} key key to get
* @param {any} value value to set if the key does not exist
* @param {setOpts} [options={}] ttl in ms/timestring('1d 3h') or opts (default: 0)
* @return {any}
*/
async getOrSet(key, value, options = {}) {
const fetching = this.fetching.get(key);
if (fetching) {
// Some other process is still fetching the value
// Don't dogpile shit, wait for the other process
// to finish it
return fetching;
}
// key already exists, return it
const existing = this._get(key);
if (existing !== undefined) return existing;
// no value given, return undefined
if (value === undefined) return undefined;
await this.set(key, value, options);
const result = this.data.get(key);
return result && result.v;
}
/**
* alias for getOrSet
* @param {string} key key to get
* @param {any} value value to set if the key does not exist
* @param {setOpts} [options={}] ttl in ms/timestring('1d 3h') or opts (default: 0)
* @return {any}
*/
async $(key, value, options = {}) {
return this.getOrSet(key, value, options);
}
/**
* deletes a value from the cache
* @param {string} key
* @return {void}
*/
delSync(key) {
return this._del(key);
}
/**
* deletes a value from the cache
* @param {string} key
* @return {void}
*/
async del(key) {
return this._del(key);
}
/**
* returns the size of the cache (no. of keys)
* NOTE: expired items are returned as part of this count
* @return {number}
*/
async size() {
return this.data.size;
}
/**
* returns the size of the cache (no. of keys)
* NOTE: expired items are returned as part of this count
* @return {number}
*/
sizeSync() {
return this.data.size;
}
/**
* clears the cache (deletes all keys)
* @return {void}
*/
async clear() {
return this._clear();
}
/**
* clears the cache (deletes all keys)
* @return {void}
*/
clearSync() {
return this._clear();
}
/**
* delete expired items
* NOTE: this method needs to loop over all the items (expensive)
*/
async gc() {
const time = Date.now();
this.gcTime = time;
if (this.data.size < 50000) {
this.gcSync();
return;
}
let i = 0;
for (const [key, value] of this.data) {
if (value.t && (time - value.c > value.t)) {
// value is expired
this.data.delete(key);
}
i++;
if ((i & 32767) === 0) {
// allow other tasks to execute after every 50000 elements
// eslint-disable-next-line no-await-in-loop
await tick();
}
}
}
/**
* delete expired items synchronously
* NOTE: this method needs to loop over all the items (expensive)
*/
gcSync() {
const time = Date.now();
this.gcTime = time;
for (const [key, value] of this.data) {
if (value.t && (time - value.c > value.t)) {
// value is expired
this.data.delete(key);
}
}
}
/**
* memoizes a function (caches the return value of the function)
* ```js
* const cachedFn = cache.memoize('expensiveFn', expensiveFn);
* const result = cachedFn('a', 'b');
* ```
* This is sync version, so fn should not be async
* @param {string} key cache key with which to memoize the results
* @param {function} fn function to memoize
* @param {setOpts} [options={}] ttl in ms/timestring('1d 3h') or opts (default: 0)
* @return {function} memoized function
*/
memoizeSync(key, fn, options = {}) {
return (...args) => (
this.getOrSetSync(
getCacheKey(args, key, options),
() => fn(...args),
options,
)
);
}
/**
* memoizes a function (caches the return value of the function)
* ```js
* const cachedFn = cache.memoize('expensiveFn', expensiveFn);
* const result = cachedFn('a', 'b');
* ```
* @param {string} key cache key with which to memoize the results
* @param {function} fn function to memoize
* @param {number|string|setOpts} [options={}] ttl in ms/timestring('1d 3h') or opts (default: 0)
* @return {function} memoized function
*/
memoize(key, fn, options = {}) {
return async (...args) => (
this.getOrSet(
getCacheKey(args, key, options),
() => fn(...args),
options,
)
);
}
/**
* returns a global cache instance
* @return {Cache}
*/
static globalCache() {
if (!globalCache) globalCache = new this();
return globalCache;
}
/**
* get value from global cache
* this is sync version, so it'll not help with dogpiling issues
* @param {string} key
* @param {any} defaultValue
* @return {any}
*/
static getSync(key, defaultValue) {
return this.globalCache().getSync(key, defaultValue);
}
/**
* get value from global cache
* @param {string} key
* @param {any} defaultValue
* @return {any}
*/
static async get(key, defaultValue) {
return this.globalCache().get(key, defaultValue);
}
/**
* gets a value from the cache immediately without waiting
* @param {string} key
* @param {any} defaultValue
* @return {any}
*/
static getStaleSync(key, defaultValue) {
return this.globalCache().getStaleSync(key, defaultValue);
}
/**
* gets a value from the cache immediately without waiting
* @param {string} key
* @param {any} defaultValue
* @return {any}
*/
static async getStale(key, defaultValue) {
return this.globalCache().getStale(key, defaultValue);
}
/**
* checks if value exists in global cache
* @param {string} key
* @return {boolean}
*/
static hasSync(key) {
return this.globalCache().hasSync(key);
}
/**
* checks if value exists in global cache
* @param {string} key
* @return {boolean}
*/
static async has(key) {
return this.globalCache().has(key);
}
/**
* sets a value in the global cache
* this is sync version, so value should not be a promise or async function
* @param {string} key
* @param {any} value
* @param {number|string|setOpts} [options={}] ttl in ms/timestring('1d 3h') or opts (default: 0)
* @return {boolean}
*/
static setSync(key, value, options = {}) {
return this.globalCache().set(key, value, options);
}
/**
* sets a value in the global cache
* @param {string} key
* @param {any} value
* @param {setOpts} [options={}] ttl in ms/timestring('1d 3h') or opts (default: 0)
* @return {Promise<boolean>}
*/
static async set(key, value, options = {}) {
return this.globalCache().set(key, value, options);
}
/**
* get or set a value in the global cache
* this is sync version, so value should not be a promise or async function
* @param {string} key
* @param {any} value
* @param {number|string|setOpts} [options={}] ttl in ms/timestring('1d 3h') or opts (default: 0)
* @return {any}
*/
static getOrSetSync(key, value, options = {}) {
return this.globalCache().getOrSetSync(key, value, options);
}
/**
* get or set a value in the global cache
* @param {string} key
* @param {any} value
* @param {setOpts} [options={}] ttl in ms/timestring('1d 3h') or opts (default: 0)
* @return {any}
*/
static async getOrSet(key, value, options = {}) {
return this.globalCache().getOrSet(key, value, options);
}
/**
* alias for getOrSet
* @param {string} key
* @param {any} value
* @param {setOpts} [options={}] ttl in ms/timestring('1d 3h') or opts (default: 0)
* @return {any}
*/
static async $(key, value, options = {}) {
return this.globalCache().getOrSet(key, value, options);
}
/**
* deletes a value from the global cache
* @param {string} key
* @return {void}
*/
static delSync(key) {
return this.globalCache().delSync(key);
}
/**
* deletes a value from the global cache
* @param {string} key
* @return {void}
*/
static async del(key) {
return this.globalCache().del(key);
}
/**
* @return {number} the size of the global cache
*/
static async size() {
return this.globalCache().size();
}
/**
* @return {number} the size of the global cache
*/
static sizeSync() {
return this.globalCache().sizeSync();
}
/**
* clear the global cache
* @return {void}
*/
static async clear() {
return this.globalCache().clear();
}
/**
* clear the global cache
* @return {void}
*/
static clearSync() {
return this.globalCache().clearSync();
}
/**
* memoizes a function (caches the return value of the function)
* this is sync version, so fn should not be async
* @param {string} key
* @param {function} fn
* @param {number|string|setOpts} [options={}] ttl in ms/timestring('1d 3h') or opts (default: 0)
* @return {function} memoized function
*/
static memoizeSync(key, fn, options = {}) {
return this.globalCache().memoize(key, fn, options);
}
/**
* memoizes a function (caches the return value of the function)
* @param {string} key
* @param {function} fn
* @param {setOpts} [options={}] ttl in ms/timestring('1d 3h') or opts (default: 0)
* @return {function} memoized function
*/
static memoize(key, fn, options = {}) {
return this.globalCache().memoize(key, fn, options);
}
}
export default Cache;