Skip to content

Commit

Permalink
feat(providence): lfu and lru cache strategies for memoize
Browse files Browse the repository at this point in the history
  • Loading branch information
tlouisse committed Dec 9, 2024
1 parent 344ffa9 commit 7e25f3e
Show file tree
Hide file tree
Showing 3 changed files with 264 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .changeset/blue-glasses-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'providence-analytics': patch
---

lfu and lru cache strategies for memoize
107 changes: 106 additions & 1 deletion packages-node/providence-analytics/src/program/utils/memoize.js
Original file line number Diff line number Diff line change
@@ -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;

/**
Expand All @@ -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];
Expand All @@ -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
Expand All @@ -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);
}

Expand All @@ -66,10 +132,49 @@ memoize.disableCaching = () => {
*/
memoize.restoreCaching = initialValue => {
shouldCache = initialValue || true;
limitForCacheStrategy = 100;
cacheStrategyItems = [];
cacheStrategy = 'lfu';
};

Object.defineProperty(memoize, 'isCacheEnabled', {
get() {
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;
},
});
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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 },
]);
});
});
});
});
});

0 comments on commit 7e25f3e

Please sign in to comment.