From 7e25f3e8a230262aded6eece679b7eef1112265c Mon Sep 17 00:00:00 2001 From: Thijs Louisse Date: Mon, 9 Dec 2024 09:52:05 +0100 Subject: [PATCH] feat(providence): lfu and lru cache strategies for memoize --- .changeset/blue-glasses-invite.md | 5 + .../src/program/utils/memoize.js | 107 +++++++++++- .../test-node/program/utils/memoize.test.js | 153 ++++++++++++++++++ 3 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 .changeset/blue-glasses-invite.md diff --git a/.changeset/blue-glasses-invite.md b/.changeset/blue-glasses-invite.md new file mode 100644 index 000000000..42e8c6f03 --- /dev/null +++ b/.changeset/blue-glasses-invite.md @@ -0,0 +1,5 @@ +--- +'providence-analytics': patch +--- + +lfu and lru cache strategies for memoize diff --git a/packages-node/providence-analytics/src/program/utils/memoize.js b/packages-node/providence-analytics/src/program/utils/memoize.js index 7dd730b11..35ef69ede 100644 --- a/packages-node/providence-analytics/src/program/utils/memoize.js +++ b/packages-node/providence-analytics/src/program/utils/memoize.js @@ -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 {(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; + }, +}); diff --git a/packages-node/providence-analytics/test-node/program/utils/memoize.test.js b/packages-node/providence-analytics/test-node/program/utils/memoize.test.js index 1ff9dc819..b039f75e9 100644 --- a/packages-node/providence-analytics/test-node/program/utils/memoize.test.js +++ b/packages-node/providence-analytics/test-node/program/utils/memoize.test.js @@ -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 }, + ]); + }); + }); + }); }); });