feat(providence): lfu and lru cache strategies for memoize
This commit is contained in:
parent
344ffa9046
commit
7e25f3e8a2
3 changed files with 264 additions and 1 deletions
5
.changeset/blue-glasses-invite.md
Normal file
5
.changeset/blue-glasses-invite.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'providence-analytics': patch
|
||||
---
|
||||
|
||||
lfu and lru cache strategies for memoize
|
||||
|
|
@ -1,6 +1,14 @@
|
|||
/**
|
||||
* For testing purposes, it is possible to disable caching.
|
||||
* @typedef {{fn:MemoizedFn; count:number}} CacheStrategyItem
|
||||
* @typedef {function & {clearCache: () => void}} MemoizedFn
|
||||
*/
|
||||
|
||||
/** @type {CacheStrategyItem[]} */
|
||||
let cacheStrategyItems = [];
|
||||
/** @type {'lfu'|'lru'} */
|
||||
let cacheStrategy = 'lfu';
|
||||
let limitForCacheStrategy = 100;
|
||||
/** For testing purposes, it is possible to disable caching. */
|
||||
let shouldCache = true;
|
||||
|
||||
/**
|
||||
|
|
@ -22,11 +30,59 @@ function createCachableArg(arg) {
|
|||
}
|
||||
}
|
||||
|
||||
function updateCacheStrategyItemsList() {
|
||||
const hasReachedlimitForCacheStrategy = cacheStrategyItems.length >= limitForCacheStrategy;
|
||||
if (!hasReachedlimitForCacheStrategy) return;
|
||||
|
||||
if (cacheStrategy === 'lfu') {
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const lowestCount = Math.min(...cacheStrategyItems.map(({ count }) => count));
|
||||
const leastUsedIndex = cacheStrategyItems.findIndex(({ count }) => count === lowestCount);
|
||||
const [itemToClear] = cacheStrategyItems.splice(leastUsedIndex, 1);
|
||||
itemToClear?.fn.clearCache();
|
||||
return;
|
||||
}
|
||||
|
||||
// acheStrategy === 'lru'
|
||||
const itemToClear = /** @type {CacheStrategyItem} */ (cacheStrategyItems.pop());
|
||||
itemToClear?.fn.clearCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MemoizedFn} newlyMemoizedFn
|
||||
* @returns {CacheStrategyItem}
|
||||
*/
|
||||
function addCacheStrategyItem(newlyMemoizedFn) {
|
||||
if (cacheStrategy === 'lfu') {
|
||||
cacheStrategyItems.push({ fn: newlyMemoizedFn, count: 1 });
|
||||
return cacheStrategyItems[cacheStrategyItems.length - 1];
|
||||
}
|
||||
// lru
|
||||
cacheStrategyItems.unshift({ fn: newlyMemoizedFn, count: 1 });
|
||||
return cacheStrategyItems[0];
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {CacheStrategyItem} currentCacheStrategyItem
|
||||
*/
|
||||
function updateCacheStrategyItem(currentCacheStrategyItem) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
currentCacheStrategyItem.count += 1;
|
||||
|
||||
if (cacheStrategy === 'lfu') return;
|
||||
|
||||
// 'lru': move recently used to top
|
||||
cacheStrategyItems.splice(cacheStrategyItems.indexOf(currentCacheStrategyItem), 1);
|
||||
cacheStrategyItems.unshift(currentCacheStrategyItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @type {<T extends Function>(functionToMemoize:T, opts?:{ cacheStorage?:object; }) => T & {clearCache:() => void}}
|
||||
*/
|
||||
export function memoize(functionToMemoize, { cacheStorage = {} } = {}) {
|
||||
/** @type {CacheStrategyItem|undefined} */
|
||||
let currentCacheStrategyItem;
|
||||
function memoizedFn() {
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
const args = [...arguments];
|
||||
|
|
@ -36,9 +92,17 @@ export function memoize(functionToMemoize, { cacheStorage = {} } = {}) {
|
|||
// Allow disabling of cache for testing purposes
|
||||
// @ts-expect-error
|
||||
if (shouldCache && cachableArgs in cacheStorage) {
|
||||
updateCacheStrategyItem(/** @type {CacheStrategyItem} */ (currentCacheStrategyItem));
|
||||
|
||||
// @ts-expect-error
|
||||
return cacheStorage[cachableArgs];
|
||||
}
|
||||
|
||||
if (!currentCacheStrategyItem) {
|
||||
updateCacheStrategyItemsList();
|
||||
currentCacheStrategyItem = addCacheStrategyItem(memoizedFn);
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
const outcome = functionToMemoize.apply(this, args);
|
||||
// @ts-expect-error
|
||||
|
|
@ -49,7 +113,9 @@ export function memoize(functionToMemoize, { cacheStorage = {} } = {}) {
|
|||
memoizedFn.clearCache = () => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
cacheStorage = {};
|
||||
currentCacheStrategyItem = undefined;
|
||||
};
|
||||
|
||||
return /** @type {* & T & {clearCache:() => void}} */ (memoizedFn);
|
||||
}
|
||||
|
||||
|
|
@ -66,6 +132,9 @@ memoize.disableCaching = () => {
|
|||
*/
|
||||
memoize.restoreCaching = initialValue => {
|
||||
shouldCache = initialValue || true;
|
||||
limitForCacheStrategy = 100;
|
||||
cacheStrategyItems = [];
|
||||
cacheStrategy = 'lfu';
|
||||
};
|
||||
|
||||
Object.defineProperty(memoize, 'isCacheEnabled', {
|
||||
|
|
@ -73,3 +142,39 @@ Object.defineProperty(memoize, 'isCacheEnabled', {
|
|||
return shouldCache;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(memoize, 'limitForCacheStrategy', {
|
||||
get() {
|
||||
return limitForCacheStrategy;
|
||||
},
|
||||
set(/** @type {number} */ newValue) {
|
||||
if (typeof newValue !== 'number') {
|
||||
throw new Error('Please provide a number');
|
||||
}
|
||||
if (cacheStrategyItems.length) {
|
||||
throw new Error('Please configure limitForCacheStrategy before using memoize');
|
||||
}
|
||||
limitForCacheStrategy = newValue;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(memoize, 'cacheStrategy', {
|
||||
get() {
|
||||
return cacheStrategy;
|
||||
},
|
||||
set(/** @type {'lfu'|'lru'} */ newStrategy) {
|
||||
if (!['lfu', 'lru'].includes(newStrategy)) {
|
||||
throw new Error("Please provide 'lfu' or 'lru'");
|
||||
}
|
||||
if (cacheStrategyItems.length) {
|
||||
throw new Error('Please configure a strategy before using memoize');
|
||||
}
|
||||
cacheStrategy = newStrategy;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(memoize, 'cacheStrategyItems', {
|
||||
get() {
|
||||
return cacheStrategyItems;
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { expect } from 'chai';
|
||||
import { it } from 'mocha';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import { memoize } from '../../../src/program/utils/memoize.js';
|
||||
|
||||
describe('Memoize', () => {
|
||||
|
|
@ -341,5 +343,156 @@ describe('Memoize', () => {
|
|||
expect(sumMemoized('1', '2')).to.equal('12');
|
||||
expect(sumCalled).to.equal(2);
|
||||
});
|
||||
|
||||
describe('Strategies', () => {
|
||||
beforeEach(() => {
|
||||
memoize.restoreCaching();
|
||||
});
|
||||
|
||||
describe('lfu (least frequently used) strategy', () => {
|
||||
it('has lfu strategy by default', async () => {
|
||||
expect(memoize.cacheStrategy).to.equal('lfu');
|
||||
});
|
||||
|
||||
it('removes least used from cache', async () => {
|
||||
memoize.limitForCacheStrategy = 2;
|
||||
|
||||
const spy1 = sinon.spy(() => {});
|
||||
const spy2 = sinon.spy(() => {});
|
||||
const spy3 = sinon.spy(() => {});
|
||||
|
||||
const spy1Memoized = memoize(spy1);
|
||||
const spy2Memoized = memoize(spy2);
|
||||
const spy3Memoized = memoize(spy3);
|
||||
|
||||
// Call spy1 3 times
|
||||
spy1Memoized();
|
||||
expect(spy1.callCount).to.equal(1);
|
||||
expect(memoize.cacheStrategyItems).to.deep.equal([{ fn: spy1Memoized, count: 1 }]);
|
||||
|
||||
spy1Memoized();
|
||||
expect(spy1.callCount).to.equal(1);
|
||||
expect(memoize.cacheStrategyItems).to.deep.equal([{ fn: spy1Memoized, count: 2 }]);
|
||||
|
||||
spy1Memoized();
|
||||
expect(spy1.callCount).to.equal(1);
|
||||
expect(memoize.cacheStrategyItems).to.deep.equal([{ fn: spy1Memoized, count: 3 }]);
|
||||
|
||||
// Call spy2 2 times (so it's the least frequently used)
|
||||
spy2Memoized();
|
||||
expect(spy2.callCount).to.equal(1);
|
||||
expect(memoize.cacheStrategyItems).to.deep.equal([
|
||||
{ fn: spy1Memoized, count: 3 },
|
||||
{ fn: spy2Memoized, count: 1 },
|
||||
]);
|
||||
|
||||
spy2Memoized();
|
||||
expect(spy2.callCount).to.equal(1);
|
||||
expect(memoize.cacheStrategyItems).to.deep.equal([
|
||||
{ fn: spy1Memoized, count: 3 },
|
||||
{ fn: spy2Memoized, count: 2 },
|
||||
]);
|
||||
|
||||
// When we add number 3, we exceed limitForCacheStrategy
|
||||
// This means that we 'free' the least frequently used (spy2)
|
||||
spy3Memoized();
|
||||
expect(memoize.cacheStrategyItems).to.deep.equal([
|
||||
{ fn: spy1Memoized, count: 3 },
|
||||
{ fn: spy3Memoized, count: 1 },
|
||||
]);
|
||||
|
||||
spy2Memoized();
|
||||
expect(spy2.callCount).to.equal(2);
|
||||
expect(memoize.cacheStrategyItems).to.deep.equal([
|
||||
{ fn: spy1Memoized, count: 3 },
|
||||
{ fn: spy2Memoized, count: 1 }, // we start over
|
||||
]);
|
||||
|
||||
spy2Memoized(); // 2
|
||||
expect(memoize.cacheStrategyItems).to.deep.equal([
|
||||
{ fn: spy1Memoized, count: 3 }, // we start over
|
||||
{ fn: spy2Memoized, count: 2 },
|
||||
]);
|
||||
spy2Memoized(); // 3
|
||||
expect(memoize.cacheStrategyItems).to.deep.equal([
|
||||
{ fn: spy1Memoized, count: 3 }, // we start over
|
||||
{ fn: spy2Memoized, count: 3 },
|
||||
]);
|
||||
spy2Memoized(); // 4
|
||||
expect(memoize.cacheStrategyItems).to.deep.equal([
|
||||
{ fn: spy1Memoized, count: 3 }, // we start over
|
||||
{ fn: spy2Memoized, count: 4 },
|
||||
]);
|
||||
|
||||
console.debug('spy3Memoized');
|
||||
spy3Memoized();
|
||||
console.debug(memoize.cacheStrategyItems);
|
||||
expect(memoize.cacheStrategyItems).to.deep.equal([
|
||||
{ fn: spy2Memoized, count: 4 },
|
||||
{ fn: spy3Memoized, count: 1 }, // we start over
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lru (least recently used) strategy', () => {
|
||||
it(`can set lru strategy"`, async () => {
|
||||
memoize.cacheStrategy = 'lru';
|
||||
expect(memoize.cacheStrategy).to.equal('lru');
|
||||
});
|
||||
|
||||
it('removes least recently used from cache', async () => {
|
||||
memoize.limitForCacheStrategy = 2;
|
||||
memoize.cacheStrategy = 'lru';
|
||||
|
||||
const spy1 = sinon.spy(() => {});
|
||||
const spy2 = sinon.spy(() => {});
|
||||
const spy3 = sinon.spy(() => {});
|
||||
|
||||
const spy1Memoized = memoize(spy1);
|
||||
const spy2Memoized = memoize(spy2);
|
||||
const spy3Memoized = memoize(spy3);
|
||||
|
||||
// Call spy1 3 times
|
||||
spy1Memoized();
|
||||
expect(spy1.callCount).to.equal(1);
|
||||
expect(memoize.cacheStrategyItems).to.deep.equal([{ fn: spy1Memoized, count: 1 }]);
|
||||
|
||||
spy1Memoized();
|
||||
expect(spy1.callCount).to.equal(1);
|
||||
expect(memoize.cacheStrategyItems).to.deep.equal([{ fn: spy1Memoized, count: 2 }]);
|
||||
|
||||
spy1Memoized();
|
||||
expect(spy1.callCount).to.equal(1);
|
||||
expect(memoize.cacheStrategyItems).to.deep.equal([{ fn: spy1Memoized, count: 3 }]);
|
||||
|
||||
spy2Memoized();
|
||||
expect(spy2.callCount).to.equal(1);
|
||||
expect(memoize.cacheStrategyItems).to.deep.equal([
|
||||
{ fn: spy2Memoized, count: 1 },
|
||||
{ fn: spy1Memoized, count: 3 },
|
||||
]);
|
||||
|
||||
spy2Memoized();
|
||||
expect(spy2.callCount).to.equal(1);
|
||||
expect(memoize.cacheStrategyItems).to.deep.equal([
|
||||
{ fn: spy2Memoized, count: 2 },
|
||||
{ fn: spy1Memoized, count: 3 },
|
||||
]);
|
||||
|
||||
spy3Memoized();
|
||||
expect(memoize.cacheStrategyItems).to.deep.equal([
|
||||
{ fn: spy3Memoized, count: 1 },
|
||||
{ fn: spy2Memoized, count: 2 },
|
||||
]);
|
||||
|
||||
spy1Memoized();
|
||||
expect(spy1.callCount).to.equal(2);
|
||||
expect(memoize.cacheStrategyItems).to.deep.equal([
|
||||
{ fn: spy1Memoized, count: 1 }, // we start over
|
||||
{ fn: spy3Memoized, count: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue