feat(providence-analytics): enhance and test memoization

This commit is contained in:
Thijs Louisse 2022-09-25 14:04:02 +02:00 committed by Thomas Allmer
parent d92860a565
commit dd3458af70
5 changed files with 353 additions and 37 deletions

View file

@ -0,0 +1,5 @@
---
'providence-analytics': patch
---
improve memoization

View file

@ -7,7 +7,7 @@ const {
const { resolveImportPath } = require('../../utils/resolve-import-path.js'); const { resolveImportPath } = require('../../utils/resolve-import-path.js');
const { AstService } = require('../../services/AstService.js'); const { AstService } = require('../../services/AstService.js');
const { LogService } = require('../../services/LogService.js'); const { LogService } = require('../../services/LogService.js');
const { memoizeAsync } = require('../../utils/memoize.js'); const { memoize } = require('../../utils/memoize.js');
/** /**
* @typedef {import('../../types/core').RootFile} RootFile * @typedef {import('../../types/core').RootFile} RootFile
@ -233,7 +233,7 @@ async function trackDownIdentifierFn(source, identifierName, currentFilePath, ro
return /** @type { RootFile } */ { file: rootFilePath, specifier: rootSpecifier }; return /** @type { RootFile } */ { file: rootFilePath, specifier: rootSpecifier };
} }
trackDownIdentifier = memoizeAsync(trackDownIdentifierFn); trackDownIdentifier = memoize(trackDownIdentifierFn);
/** /**
* @param {BabelPath} astPath * @param {BabelPath} astPath
@ -265,7 +265,7 @@ async function trackDownIdentifierFromScopeFn(
return rootFile; return rootFile;
} }
const trackDownIdentifierFromScope = memoizeAsync(trackDownIdentifierFromScopeFn); const trackDownIdentifierFromScope = memoize(trackDownIdentifierFromScopeFn);
module.exports = { module.exports = {
trackDownIdentifier, trackDownIdentifier,

View file

@ -1,54 +1,45 @@
const { InputDataService } = require('../services/InputDataService.js'); const { InputDataService } = require('../services/InputDataService.js');
function isObject(arg) {
return !Array.isArray(arg) && typeof arg === 'object';
}
function createCachableArg(arg) {
if (isObject(arg)) {
try {
return JSON.stringify(arg);
} catch {
return arg;
}
}
return arg;
}
/** /**
* @param {function} func * @param {function} functionToMemoize
* @param {object} [storage] * @param {{ storage:object; serializeObjects: boolean }} [opts]
*/ */
function memoize(func, storage = {}) { function memoize(functionToMemoize, { storage = {}, serializeObjects = false } = {}) {
// eslint-disable-next-line func-names // eslint-disable-next-line func-names
return function () { return function () {
// eslint-disable-next-line prefer-rest-params // eslint-disable-next-line prefer-rest-params
const args = [...arguments]; const args = [...arguments];
const cachableArgs = !serializeObjects ? args : args.map(createCachableArg);
// Allow disabling of cache for testing purposes // Allow disabling of cache for testing purposes
// @ts-ignore // @ts-ignore
if (!InputDataService.cacheDisabled && args in storage) { if (!InputDataService.cacheDisabled && cachableArgs in storage) {
// @ts-ignore // @ts-ignore
return storage[args]; return storage[cachableArgs];
} }
// @ts-ignore // @ts-ignore
const outcome = func.apply(this, args); const outcome = functionToMemoize.apply(this, args);
// @ts-ignore // @ts-ignore
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
storage[args] = outcome; storage[cachableArgs] = outcome;
return outcome;
};
}
/**
* @param {function} func
* @param {object} [storage]
*/
function memoizeAsync(func, storage = {}) {
// eslint-disable-next-line func-names
return async function () {
// eslint-disable-next-line prefer-rest-params
const args = [...arguments];
// Allow disabling of cache for testing purposes
// @ts-ignore
if (!InputDataService.cacheDisabled && args in storage) {
// @ts-ignore
return storage[args];
}
// @ts-ignore
const outcome = await func.apply(this, args);
// @ts-ignore
// eslint-disable-next-line no-param-reassign
storage[args] = outcome;
return outcome; return outcome;
}; };
} }
module.exports = { module.exports = {
memoize, memoize,
memoizeAsync,
}; };

View file

@ -12,7 +12,7 @@
const pathLib = require('path'); const pathLib = require('path');
const { nodeResolve } = require('@rollup/plugin-node-resolve'); const { nodeResolve } = require('@rollup/plugin-node-resolve');
const { LogService } = require('../services/LogService.js'); const { LogService } = require('../services/LogService.js');
const { memoizeAsync } = require('./memoize.js'); const { memoize } = require('./memoize.js');
const { toPosixPath } = require('./to-posix-path.js'); const { toPosixPath } = require('./to-posix-path.js');
const fakePluginContext = { const fakePluginContext = {
@ -64,6 +64,6 @@ async function resolveImportPath(importee, importer, opts = {}) {
* @param {{customResolveOptions?: {preserveSymlinks:boolean}}} [opts] nodeResolve options * @param {{customResolveOptions?: {preserveSymlinks:boolean}}} [opts] nodeResolve options
* @returns {Promise<PathFromSystemRoot|null>} the resolved file system path, like '/my/project/node_modules/@lion/core/index.js' * @returns {Promise<PathFromSystemRoot|null>} the resolved file system path, like '/my/project/node_modules/@lion/core/index.js'
*/ */
const resolveImportPathMemoized = memoizeAsync(resolveImportPath); const resolveImportPathMemoized = memoize(resolveImportPath);
module.exports = { resolveImportPath: resolveImportPathMemoized }; module.exports = { resolveImportPath: resolveImportPathMemoized };

View file

@ -0,0 +1,320 @@
const { expect } = require('chai');
const { memoize } = require('../../../src/program/utils/memoize.js');
const { InputDataService } = require('../../../src/program/services/InputDataService.js');
const cacheDisabledInitialValue = InputDataService.cacheDisabled;
describe('Memoize', () => {
beforeEach(() => {
// This is important, since memoization only works
InputDataService.cacheDisabled = false;
});
afterEach(() => {
InputDataService.cacheDisabled = cacheDisabledInitialValue;
});
describe('With primitives', () => {
describe('Numbers', () => {
it(`returns cached result when called with same parameters`, async () => {
let sumCalled = 0;
function sum(/** @type {number} a */ a, /** @type {number} a */ b) {
sumCalled += 1;
return a + b;
}
const sumMemoized = memoize(sum);
// Put in cache for args combination
expect(sumMemoized(1, 2)).to.equal(3);
expect(sumCalled).to.equal(1);
// Return from cache
expect(sumMemoized(1, 2)).to.equal(3);
expect(sumCalled).to.equal(1);
// Put in cache for args combination
expect(sumMemoized(1, 3)).to.equal(4);
expect(sumCalled).to.equal(2);
// Return from cache
expect(sumMemoized(1, 3)).to.equal(4);
expect(sumCalled).to.equal(2);
});
it(`returns cached result per function for same args`, async () => {
let sumCalled = 0;
function sum(/** @type {number} a */ a, /** @type {number} a */ b) {
sumCalled += 1;
return a + b;
}
const sumMemoized = memoize(sum);
let sum2Called = 0;
function sum2(/** @type {number} a */ a, /** @type {number} a */ b) {
sum2Called += 1;
return a + b;
}
const sum2Memoized = memoize(sum2);
expect(sumMemoized(1, 2)).to.equal(3);
expect(sumCalled).to.equal(1);
expect(sum2Called).to.equal(0);
expect(sum2Memoized(1, 2)).to.equal(3);
expect(sumCalled).to.equal(1);
expect(sum2Called).to.equal(1);
// Both cached
expect(sumMemoized(1, 2)).to.equal(3);
expect(sum2Memoized(1, 2)).to.equal(3);
expect(sumCalled).to.equal(1);
expect(sum2Called).to.equal(1);
});
});
describe('Strings', () => {
it(`returns cached result when called with same parameters`, async () => {
let sumCalled = 0;
function sum(/** @type {string} a */ a, /** @type {string} a */ b) {
sumCalled += 1;
return a + b;
}
const sumMemoized = memoize(sum);
// Put in cache for args combination
expect(sumMemoized('1', '2')).to.equal('12');
expect(sumCalled).to.equal(1);
// Return from cache
expect(sumMemoized('1', '2')).to.equal('12');
expect(sumCalled).to.equal(1);
// Put in cache for args combination
expect(sumMemoized('1', '3')).to.equal('13');
expect(sumCalled).to.equal(2);
// Return from cache
expect(sumMemoized('1', '3')).to.equal('13');
expect(sumCalled).to.equal(2);
});
it(`returns cached result per function for same args`, async () => {
let sumCalled = 0;
function sum(/** @type {string} a */ a, /** @type {string} a */ b) {
sumCalled += 1;
return a + b;
}
const sumMemoized = memoize(sum);
let sum2Called = 0;
function sum2(/** @type {string} a */ a, /** @type {string} a */ b) {
sum2Called += 1;
return a + b;
}
const sum2Memoized = memoize(sum2);
expect(sumMemoized('1', '2')).to.equal('12');
expect(sumCalled).to.equal(1);
expect(sum2Called).to.equal(0);
expect(sum2Memoized('1', '2')).to.equal('12');
expect(sumCalled).to.equal(1);
expect(sum2Called).to.equal(1);
// Both cached
expect(sumMemoized('1', '2')).to.equal('12');
expect(sum2Memoized('1', '2')).to.equal('12');
expect(sumCalled).to.equal(1);
expect(sum2Called).to.equal(1);
});
});
});
describe('With non primitives', () => {
describe('Arrays', () => {
it(`returns cached result when called with same parameters`, async () => {
let sumCalled = 0;
function sum(/** @type {number[]} a */ a, /** @type {number[]} a */ b) {
sumCalled += 1;
return [...a, ...b];
}
const sumMemoized = memoize(sum);
// Put in cache for args combination
expect(sumMemoized([1], [2])).to.eql([1, 2]);
expect(sumCalled).to.equal(1);
// Return from cache
expect(sumMemoized([1], [2])).to.eql([1, 2]);
expect(sumCalled).to.equal(1);
// Put in cache for args combination
expect(sumMemoized([1], [3])).to.eql([1, 3]);
expect(sumCalled).to.equal(2);
});
it(`returns cached result per function for same args`, async () => {
let sumCalled = 0;
function sum(/** @type {number[]} a */ a, /** @type {number[]} a */ b) {
sumCalled += 1;
return [...a, ...b];
}
const sumMemoized = memoize(sum);
let sum2Called = 0;
function sum2(/** @type {number[]} a */ a, /** @type {number[]} a */ b) {
sum2Called += 1;
return [...a, ...b];
}
const sum2Memoized = memoize(sum2);
expect(sumMemoized([1], [2])).to.eql([1, 2]);
expect(sumCalled).to.equal(1);
expect(sum2Called).to.equal(0);
expect(sum2Memoized([1], [2])).to.eql([1, 2]);
expect(sumCalled).to.equal(1);
expect(sum2Called).to.equal(1);
// Both cached
expect(sumMemoized([1], [2])).to.eql([1, 2]);
expect(sum2Memoized([1], [2])).to.eql([1, 2]);
expect(sumCalled).to.equal(1);
expect(sum2Called).to.equal(1);
});
});
describe('Objects', () => {
it(`returns cached result when called with same parameters`, async () => {
let sumCalled = 0;
function sum(/** @type {object} a */ a, /** @type {object} a */ b) {
sumCalled += 1;
return { ...a, ...b };
}
const sumMemoized = memoize(sum, { serializeObjects: true });
// Put in cache for args combination
expect(sumMemoized({ x: 1 }, { y: 2 })).to.eql({ x: 1, y: 2 });
expect(sumCalled).to.equal(1);
// Return from cache
expect(sumMemoized({ x: 1 }, { y: 2 })).to.eql({ x: 1, y: 2 });
expect(sumCalled).to.equal(1);
// Put in cache for args combination
expect(sumMemoized({ x: 1 }, { y: 3 })).to.eql({ x: 1, y: 3 });
expect(sumCalled).to.equal(2);
});
it(`returns cached result per function for same args`, async () => {
let sumCalled = 0;
function sum(/** @type {object} a */ a, /** @type {object} a */ b) {
sumCalled += 1;
return { ...a, ...b };
}
const sumMemoized = memoize(sum, { serializeObjects: true });
let sum2Called = 0;
function sum2(/** @type {object} a */ a, /** @type {object} a */ b) {
sum2Called += 1;
return { ...a, ...b };
}
const sum2Memoized = memoize(sum2, { serializeObjects: true });
expect(sumMemoized({ x: 1 }, { y: 2 })).to.eql({ x: 1, y: 2 });
expect(sumCalled).to.equal(1);
expect(sum2Called).to.equal(0);
expect(sum2Memoized({ x: 1 }, { y: 2 })).to.eql({ x: 1, y: 2 });
expect(sumCalled).to.equal(1);
expect(sum2Called).to.equal(1);
// Both cached
expect(sumMemoized({ x: 1 }, { y: 2 })).to.eql({ x: 1, y: 2 });
expect(sum2Memoized({ x: 1 }, { y: 2 })).to.eql({ x: 1, y: 2 });
expect(sumCalled).to.equal(1);
expect(sum2Called).to.equal(1);
});
});
describe('When non primitives (references) are returned', () => {
// Solve this by making sure your memoized function uses Object.freeze
it(`will be affected by edited non primitive returns`, async () => {
let sumCalled = 0;
function sum(/** @type {object} a */ a, /** @type {object} a */ b) {
sumCalled += 1;
return { ...a, ...b };
}
const sumMemoized = memoize(sum, { serializeObjects: true });
// Put in cache for args combination
const result = sumMemoized({ x: 1 }, { y: 2 });
expect(result).to.eql({ x: 1, y: 2 });
expect(sumCalled).to.equal(1);
// Return from cache
const resultCached = sumMemoized({ x: 1 }, { y: 2 });
expect(resultCached).to.equal(result);
expect(resultCached).to.eql({ x: 1, y: 2 });
expect(sumCalled).to.equal(1);
// Outside world can edit returned reference
resultCached.x = 3;
// Return from cache
const lastResult = sumMemoized({ x: 1 }, { y: 2 });
expect(lastResult).to.equal(result);
expect(lastResult).to.not.eql({ x: 1, y: 2 });
expect(sumCalled).to.equal(1);
});
});
});
describe('Asynchronous', () => {
it(`returns cached result when called with same parameters`, async () => {
let sumCalled = 0;
async function sum(/** @type {number} a */ a, /** @type {number} a */ b) {
sumCalled += 1;
return a + b;
}
const sumMemoized = memoize(sum);
// Put in cache for args combination
expect(await sumMemoized(1, 2)).to.equal(3);
expect(sumCalled).to.equal(1);
// Return from cache
expect(await sumMemoized(1, 2)).to.equal(3);
expect(sumCalled).to.equal(1);
// Put in cache for args combination
expect(await sumMemoized(1, 3)).to.equal(4);
expect(sumCalled).to.equal(2);
// Return from cache
expect(await sumMemoized(1, 3)).to.equal(4);
expect(sumCalled).to.equal(2);
});
it(`returns cached result per function for same args`, async () => {
let sumCalled = 0;
async function sum(/** @type {number} a */ a, /** @type {number} a */ b) {
sumCalled += 1;
return a + b;
}
const sumMemoized = memoize(sum);
let sum2Called = 0;
async function sum2(/** @type {number} a */ a, /** @type {number} a */ b) {
sum2Called += 1;
return a + b;
}
const sum2Memoized = memoize(sum2);
expect(await sumMemoized(1, 2)).to.equal(3);
expect(sumCalled).to.equal(1);
expect(sum2Called).to.equal(0);
expect(await sum2Memoized(1, 2)).to.equal(3);
expect(sumCalled).to.equal(1);
expect(sum2Called).to.equal(1);
// Both cached
expect(await sumMemoized(1, 2)).to.equal(3);
expect(await sum2Memoized(1, 2)).to.equal(3);
expect(sumCalled).to.equal(1);
expect(sum2Called).to.equal(1);
});
});
});