Files
pathfinder/js/app/lib/cache.js

275 lines
8.2 KiB
JavaScript

define([], () => {
'use strict';
/**
* Abstract Cache Strategy class
* @type {AbstractStrategy}
*/
let AbstractStrategy = class AbstractStrategy {
constructor(){
if(new.target === AbstractStrategy){
throw new TypeError('Cannot construct AbstractStrategy instances directly');
}
}
/**
* factory for Strategy* instances
* @returns {AbstractStrategy}
*/
static create(){
return new this();
}
};
/**
* LIFO Cache Strategy - First In First Out
* -> The cache evicts the entries in the order they were added,
* without any regard to how often or how many times they were accessed before.
* @type {StrategyFIFO}
*/
let StrategyFIFO = class StrategyFIFO extends AbstractStrategy {
valueToCompare(metaData){
return metaData.age();
}
compare(a, b){
return b - a;
}
};
/**
* LFU Cache Strategy - Least Frequently Used
* -> The cache evicts the entries in order how often have been accessed.
* Those that are used least often are discarded first
* @type {StrategyLFU}
*/
let StrategyLFU = class StrategyLFU extends AbstractStrategy {
valueToCompare(metaData){
return metaData.hitCount;
}
compare(a, b){
return a - b;
}
};
/**
* LRU Cache Strategy - Least Recently Used
* -> The cache evicts entries that have not been used for the longest amount of time.
* No matter how often they have been accessed.
* @type {StrategyLRU}
*/
let StrategyLRU = class StrategyLRU extends AbstractStrategy {
valueToCompare(metaData){
return metaData.hits[metaData.hits.length - 1] || metaData.set;
}
compare(a, b){
return a - b;
}
};
/**
* Each entry in cache also has its own instance of CacheEntryMeta
* -> The configured Cache Strategy use this meta data for eviction policy
* @type {CacheEntryMeta}
*/
let CacheEntryMeta = class CacheEntryMeta {
constructor(ttl, tSet){
this.ttl = ttl;
this.tSet = tSet || this.constructor.now();
this.tHits = [];
}
get set(){
return this.tSet;
}
get hits(){
return this.tHits;
}
get hitCount(){
return this.hits.length;
}
newHit(current){
this.tHits.push(current || this.constructor.now());
}
age(current){
return (current || this.constructor.now()) - this.tSet;
}
expired(current){
return this.ttl < this.age(current);
}
static now(){
return new Date().getTime() / 1000;
}
static create(ttl, tSet){
return new this(ttl, tSet);
}
};
/**
* Each instance of Cache represents a key value in memory data store
* -> Name should be set to identify current Cache instance
* -> Default ttl can be overwritten by individual entries
* -> Cache Strategy handles eviction policy
* -> Buffer Size (in percent) can be used to remove e.g. 10% of all entries
* if cache reaches maxSize limit, to increase performance.
* @type {Cache}
*/
let Cache = class Cache {
constructor(config){
this.config = Object.assign({},{
name: 'Default', // custom name for identification
ttl: 3600, // default ttl for cache entries
maxSize: 600, // max cache entries
bufferSize: 10, // cache entry count in percent to be removed if maxSize reached
strategy: 'FIFO', // cache strategy policy
debug: false // debug output in console
}, config);
this.store = new Map();
this.metaStore = new WeakMap();
this.strategy = this.constructor.setStrategy(this.config.strategy);
this.debug = (msg,...data) => {
if(this.config.debug){
data = (data || []);
data.unshift(this.config.name);
console.info('debug: CACHE %o | ' + msg, ...data);
}
};
this.debug('New Cache instance');
}
get size(){
return this.store.size;
}
isFull(){
return this.size>= this.config.maxSize;
}
set(key, value, ttl){
if(this.store.has(key)){
this.debug('SET key %o, UPDATE value %o', key, value);
this.store.set(key, value);
}else{
this.debug('SET key %o, NEW value %o', key, value);
if(this.isFull()){
this.debug(' ↪ FULL trim cache…');
this.trim(this.trimCount(1));
}
this.store.set(key, value);
}
this.metaStore.set(value, CacheEntryMeta.create(ttl || this.config.ttl));
}
get(key){
if(this.store.has(key)){
let value = this.store.get(key);
if(value){
let metaData = this.metaStore.get(value);
if(metaData.expired()){
this.debug('EXPIRED key %o delete', key);
this.delete(key);
}else{
this.debug('HIT key %o', key);
metaData.newHit();
return value;
}
}
}
this.debug('MISS key %o', key);
}
getOrDefault(key, def){
return this.get(key) || def;
}
keysForTrim(count){
let trimKeys = [];
let compare = [];
for(let [key, value] of this.store){
let metaData = this.metaStore.get(value);
if(metaData.expired()){
trimKeys.push(key);
if(count === trimKeys.length){
break;
}
}else{
compare.push({
key: key,
value: this.strategy.valueToCompare(metaData)
});
}
}
let countLeft = count - trimKeys.length;
if(countLeft > 0){
compare = compare.sort((a, b) => this.strategy.compare(a.value, b.value));
trimKeys = trimKeys.concat(compare.splice(0, countLeft).map(a => a.key));
}
return trimKeys;
}
keys(){
return this.store.keys();
}
delete(key){
return this.store.delete(key);
}
clear(){
this.store.clear();
}
trimCount(spaceLeft){
let bufferSize = Math.max(Math.round(this.config.maxSize / 100 * this.config.bufferSize), spaceLeft);
return Math.min(Math.max(this.size - this.config.maxSize + bufferSize, 0), this.size);
}
trim(count){
if(count > 0){
let trimKeys = this.keysForTrim(count);
if(count > trimKeys.length){
console.warn(' ↪ Failed to trim(%i) entries. Only %i in store', count, this.size);
}
this.debug(' ↪ DELETE min %i keys: %o', count, trimKeys);
trimKeys.forEach(key => this.delete(key));
}
}
status(){
return {
config: this.config,
store: this.store,
metaStore: this.metaStore
};
}
static setStrategy(name){
switch(name){
case 'FIFO': return StrategyFIFO.create();
case 'LFU': return StrategyLFU.create();
case 'LRU': return StrategyLRU.create();
default:
throw new ReferenceError('Unknown cache strategy name: ' + name);
}
}
};
return Cache;
});