@akashbabu/lfu-cache
Version:
LFU cache implementation with a complexity of `O(1)` for all transactions
538 lines (531 loc) • 16.1 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = global || self, global.LFUCache = factory());
}(this, (function () { 'use strict';
class DLLItem {
constructor(data, prev = null, next = null) {
this.data = data;
this.prev = prev;
this.next = next;
}
}
class DLLItemAccessRestrictor {
/**
* Grants all the access on the given dllItem
*
* @param accessRestrictedDllItem dll item whose access is restricted
*
* @returns all access granted dll item
*/
grantAccess(accessRestrictedDllItem) {
return accessRestrictedDllItem.__dllItem__;
}
/**
* Revokes write access to `prev` & `next` properties
*
* @param dllItem a node in the dll chain
*
* @returns Access restricted dll item
*/
revokeAccess(dllItem) {
return (dllItem
? new AccessRestrictedDLLItem(dllItem)
: null);
}
}
class AccessRestrictedDLLItem {
constructor(dllItem) {
this.dllItem = dllItem;
this.dllItemAccessRestrictor = new DLLItemAccessRestrictor();
this.__dllItem__ = dllItem;
}
get data() {
return this.dllItem.data;
}
set data(dt) {
this.dllItem.data = dt;
}
get prev() {
return this.dllItemAccessRestrictor.revokeAccess(this.dllItem.prev);
}
get next() {
return this.dllItemAccessRestrictor.revokeAccess(this.dllItem.next);
}
}
class DLL {
constructor() {
this.state = this.getFreshState();
this.dllItemAccessRestrictor = new DLLItemAccessRestrictor();
}
get head() {
return this.dllItemAccessRestrictor.revokeAccess(this.state.head);
}
get tail() {
return this.dllItemAccessRestrictor.revokeAccess(this.state.tail);
}
get length() {
return this.state.length;
}
/**
* Removes and returns the first
* item in the list
*
* @returns Same data that was
* used to append to this list
*/
shift() {
let dllItem = this.state.head;
if (!(dllItem instanceof DLLItem))
return undefined;
this.remove(dllItem);
const value = dllItem.data;
// for gc
dllItem = null;
return value;
}
/**
* Add the given item to the head of
* DLL chain
*
* In other words the new item would
* be the new head of the chain
*
* @returns Same data that was
* used to append to this list
*/
unshift(data) {
const currHead = this.state.head;
const dllItem = new DLLItem(data, null, currHead);
this.state.head = dllItem;
if (currHead instanceof DLLItem) {
currHead.prev = dllItem;
// if HEAD is not set
// then its an empty list
}
else {
this.state.tail = dllItem;
}
this.state.length++;
}
/**
* Iterate through the entire DLL chain
* just like Array.forEach()
*
* @param cb iterator callback
*/
forEach(cb) {
this.iterate((dllItem, i) => {
cb(dllItem.data, i);
});
}
/**
* Iterates through the entire DLL chain
* and returns the result array, just
* like Array.map()
*
* @param cb iterator callback
*
* @returns the result array just like Array.map()
*/
map(cb) {
const mapped = [];
this.forEach((value, i) => {
mapped.push(cb(value, i));
});
return mapped;
}
/**
* Adds the given item the tail of DLL
*
* @param data Data to be appended to the list
*
* @returns {DLLItem} dllItem, the same
* can be used to remove this item from
* DLL
*/
push(data) {
return this.appendAfter(this.state.tail, data);
}
/**
* Appends the given value after the
* specified node
*
* @param node Node to append the new item
* @param data Value for the new node
*
* @returns the newly appended node
*/
appendAfter(accessRestrictedNode, data) {
let node;
if (accessRestrictedNode === null && this.state.length > 0) {
throw new Error('Invalid Node `null`: DLL is not empty, hence can\'t append to the given node');
}
else if (accessRestrictedNode instanceof AccessRestrictedDLLItem) {
node = this.dllItemAccessRestrictor.grantAccess(accessRestrictedNode);
}
else {
node = accessRestrictedNode;
}
const dllItem = new DLLItem(data);
// if node is null, then it means
// that the list is empty
if (node === null) {
this.state.head = this.state.tail = dllItem;
}
else {
dllItem.prev = node;
dllItem.next = node.next;
node.next = dllItem;
// if the node was a tail node
// then reset the tail node
if (node === this.state.tail) {
this.state.tail = dllItem;
}
}
this.state.length++;
return this.dllItemAccessRestrictor.revokeAccess(dllItem);
}
/**
* Removes the given item from DLL
*
* @param dllItem
*/
remove(accessRestrictedDllItem) {
let dllItem;
if (accessRestrictedDllItem instanceof AccessRestrictedDLLItem) {
dllItem = this.dllItemAccessRestrictor.grantAccess(accessRestrictedDllItem);
}
else if (accessRestrictedDllItem instanceof DLLItem) {
dllItem = accessRestrictedDllItem;
}
else {
return false;
}
// Isolate the node from the chain
if (dllItem.prev) {
dllItem.prev.next = dllItem.next;
}
else {
// If it's HEAD node
this.state.head = dllItem.next;
}
if (dllItem.next) {
dllItem.next.prev = dllItem.prev;
}
else {
// if it's also a TAIL node
this.state.tail = dllItem.prev;
}
this.state.length--;
return true;
}
/**
* Clears the DLL chain
*/
clear() {
// unlink all the items in the DLL chain
// such it will be garbage collected
// as no reference between them is found
this.iterate((dllItem) => {
dllItem.prev = dllItem.next = null;
});
this.state = this.getFreshState();
}
getFreshState() {
return {
length: 0,
head: null,
tail: null,
};
}
iterate(cb) {
let dllItem = this.state.head;
let i = 0;
while (dllItem) {
cb(dllItem, i++);
dllItem = dllItem.next;
}
}
}
class LFUCache {
// private dummy: any[] = [];
constructor(options = {}) {
this.state = this.getFreshState();
if (options.max && !options.evictCount) {
// choose between 10% of max count and 1 as evictCount
options.evictCount = Math.max(1, options.max * 0.1);
}
this.options = {
max: options.max || 100,
evictCount: options.evictCount || 10,
maxAge: options.maxAge,
};
}
/**
* Returns the current size of the cache
*/
get size() {
return this.state.size;
}
/**
* Caches the given key-value pair
*
* **Example**
* ```TS
* const lfu = new LFUCache<string>();
*
* lfu.set('key', 'value');
* lfu.set('key', 'value1', true);
* ```
*
* @param key Key
* @param value Value to be caches against the provided key
* @param force Replaces the existing value if any, else will add the value to cache
*/
set(key, value, force) {
const existingValue = this.state.byKey.get(key);
// for duplicate entry / updating
// remove the current entry
// and create a new entry in the cache
if (existingValue) {
if (force) {
// if set by force, then replace only the existing value
// with the new one without touching size, frequency or nodeList
existingValue.data.value = value;
return value;
}
else {
return existingValue.data.value;
}
}
this.state.size++;
const freqListItem = this.addToFreqList(key);
// Add the new node to nodeList
const nodeItem = this.state.nodeList.push({
key,
value,
utime: Date.now(),
parent: freqListItem,
});
// create a mapping btw new member
// and the created nodeItem
this.state.byKey.set(key, nodeItem);
if (this.state.size > this.options.max) {
this.evict(this.options.evictCount);
}
return value;
}
/**
* Returns cached value for the provided key
* if one exists, else returns undefined
*
* **Example**
* ```TS
* const lfu = new LFUCache<string>();
*
* lfu.set('key', 'value');
* lfu.get('key') // => 'value'
* ```
*
* @param key Key whose value is needed
*
* @returns Value for the given key if one exists, else
* return undefined
*
*/
get(key) {
const nodeListItem = this.state.byKey.get(key);
if (!nodeListItem)
return undefined;
if (this.options.maxAge) {
if (nodeListItem.data.utime + this.options.maxAge < Date.now()) {
this.delete(key);
return;
}
}
// Get the current parent frequency item
const freqListItem = nodeListItem.data.parent;
// get next freq item
let nextFreqListItem = freqListItem.next;
const nextFreqValue = freqListItem.data.value + 1;
// if the next freq item value is not as expected,
// then create a new one with the expected freq value
if (!nextFreqListItem || nextFreqListItem.data.value !== nextFreqValue) {
// create a new freq item list and append it
// after the curr freq item and add the
// requested key to freq items list
nextFreqListItem = this.state.freqList.appendAfter(freqListItem, {
value: freqListItem.data.value + 1,
items: new Set([key]),
});
}
else {
// add requested key to the next freq list item
nextFreqListItem.data.items.add(key);
}
this.removeKeyFromFreqItem(freqListItem, key);
// Set the new parent
nodeListItem.data.parent = nextFreqListItem;
// Update utime/accessed time of the node
nodeListItem.data.utime = Date.now();
return nodeListItem.data.value;
}
/**
* Removes the given key from cache
*
* **Example**
* ```TS
* const lfu = new LFUCache<string>();
*
* lfu.set('key', 'value')
* lfu.get('key') // => value
*
* lfu.delete('key')
* lfu.get('key') // => undefined
* ```
*
* @param key Key to be removed from cache
*/
delete(key) {
// get the current node item
const nodeListItem = this.state.byKey.get(key);
if (nodeListItem) {
// Remove the key from frequency node item list
const freqListItem = nodeListItem.data.parent;
this.removeKeyFromFreqItem(freqListItem, key);
// remove the corresponding node from nodelist
this.state.nodeList.remove(nodeListItem);
// remove the key from byKey Map
this.state.byKey.delete(key);
// Since a node is removed,
// decrease the size of the cache
this.state.size--;
return true;
}
return false;
}
/**
* Returns the value for the provided key
* without increasing the accessed frequency
* of the requested key
*
* **Example**
* ```TS
* const lfu = new LFUCache<string>();
*
* lfu.set('key', 'value');
* lfu.peek('key') // => value
*
* // but the access frequency of 'key' remain untouched
* ```
*
* @param key Key whose value has to be peeked
*
* @returns Value for the provided key
*/
peek(key) {
return this.state.byKey.get(key)
? this.state.byKey.get(key).data.value
: undefined;
}
/**
* Iterates over the entire cache
* in the form of cb([key, val], i)
*
* @param cb Iterator callback
*/
forEach(cb) {
this.state.nodeList.forEach((nodeListItem, i) => {
cb([nodeListItem.key, nodeListItem.value], i);
});
}
/**
* Iterates over the entire cache
* in the form of cb([key, val], i)
* and returns the resultant array
*
* @param cb Iterator callback
*/
map(cb) {
const result = [];
this.forEach((data, i) => {
result.push(cb(data, i));
});
return result;
}
/**
* Clears all the data in the cache
*/
clear() {
this.state = this.getFreshState();
}
/**
* Returns the internal state of the cache.
* MUST BE USED ONLY FOR TESTING PURPOSE.
* This must NOT BE tampered for any reasons,
* if not, the integrity of the functionality
* CANNOT BE PROMISED.
*/
dangerously_getState() {
return this.state;
}
/*********************
* PRIVATE METHODS
********************/
getFreshState() {
return {
freqList: new DLL(),
nodeList: new DLL(),
byKey: new Map(),
size: 0,
};
}
removeKeyFromFreqItem(freqListItem, key) {
// remove the requested key from the current parent
freqListItem.data.items.delete(key);
// if the curr freq item does not have any items
// after removing the requested key, then
// remove the item from freqListItem DLL
if (freqListItem.data.items.size === 0) {
this.state.freqList.remove(freqListItem);
}
}
evict(count) {
while (count--) {
const freqListHead = this.state.freqList.head;
if (freqListHead) {
// remove the first key from frequency List head item
const key = freqListHead.data.items.keys().next().value;
this.delete(key);
}
}
}
addToFreqList(key) {
const freqListHead = this.state.freqList.head;
if (!freqListHead || freqListHead.data.value !== 1) {
this.state.freqList.unshift({
value: 1,
items: new Set([key]),
});
}
else {
freqListHead.data.items.add(key);
}
return this.state.freqList.head;
}
}
if (require.main === module) {
const SIZE = 3;
const lfu = new LFUCache({ max: SIZE });
new Array(SIZE).fill(0).forEach((_, i) => {
lfu.set(`foo_${i + 1}`, `bar_${i + 1}`);
});
// Increment the access frequency
lfu.get('foo_1');
lfu.set('foo_1', 'bar_1_2', true);
lfu.set('foo_4', 'bar_4');
lfu.set('foo_5', 'bar_5');
}
return LFUCache;
})));