Skip to content

Commit

Permalink
add max entries options
Browse files Browse the repository at this point in the history
  • Loading branch information
salazarm committed Dec 19, 2024
1 parent 24ef26c commit 5d97f0b
Show file tree
Hide file tree
Showing 2 changed files with 142 additions and 46 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ describe('weakMapMemoize', () => {
// Test 1: Function with primitive arguments
it('should memoize correctly with primitive arguments and avoid redundant calls', () => {
const spy = jest.fn((a: number, b: number) => a + b);
const memoizedAdd = weakMapMemoize(spy);
const memoizedAdd = weakMapMemoize(spy, {maxEntries: 3});

const result1 = memoizedAdd(1, 2);
const result2 = memoizedAdd(1, 2);
Expand All @@ -21,7 +21,7 @@ describe('weakMapMemoize', () => {
// Test 2: Function with object arguments
it('should memoize correctly based on object references', () => {
const spy = jest.fn((obj: {x: number}, y: number) => obj.x + y);
const memoizedFn = weakMapMemoize(spy);
const memoizedFn = weakMapMemoize(spy, {maxEntries: 2});

const obj1 = {x: 10};
const obj2 = {x: 20};
Expand All @@ -41,7 +41,7 @@ describe('weakMapMemoize', () => {
// Test 3: Function with mixed arguments
it('should memoize correctly with mixed primitive and object arguments', () => {
const spy = jest.fn((a: number, obj: {y: number}) => a + obj.y);
const memoizedFn = weakMapMemoize(spy);
const memoizedFn = weakMapMemoize(spy, {maxEntries: 3});

const obj1 = {y: 100};
const obj2 = {y: 200};
Expand All @@ -61,7 +61,7 @@ describe('weakMapMemoize', () => {
// Test 4: Function with no arguments
it('should memoize the result when function has no arguments', () => {
const spy = jest.fn(() => Math.random());
const memoizedFn = weakMapMemoize(spy);
const memoizedFn = weakMapMemoize(spy, {maxEntries: 1});

const result1 = memoizedFn();
const result2 = memoizedFn();
Expand All @@ -80,7 +80,7 @@ describe('weakMapMemoize', () => {
}
return 'other';
});
const memoizedFn = weakMapMemoize(spy);
const memoizedFn = weakMapMemoize(spy, {maxEntries: 2});

const result1 = memoizedFn(null, undefined);
const result2 = memoizedFn(null, undefined);
Expand All @@ -97,7 +97,7 @@ describe('weakMapMemoize', () => {
// Test 6: Function with function arguments
it('should memoize based on function references', () => {
const spy = jest.fn((fn: AnyFunction, value: number) => fn(value));
const memoizedFn = weakMapMemoize(spy);
const memoizedFn = weakMapMemoize(spy, {maxEntries: 3});

const func1 = (x: number) => x * 2;
const func2 = (x: number) => x * 3;
Expand All @@ -117,7 +117,7 @@ describe('weakMapMemoize', () => {
// Test 7: Function with multiple mixed arguments
it('should memoize correctly with multiple mixed argument types', () => {
const spy = jest.fn((a: number, b: string, c: {key: string}) => `${a}-${b}-${c.key}`);
const memoizedFn = weakMapMemoize(spy);
const memoizedFn = weakMapMemoize(spy, {maxEntries: 4});

const obj1 = {key: 'value1'};
const obj2 = {key: 'value2'};
Expand All @@ -137,7 +137,7 @@ describe('weakMapMemoize', () => {
// Test 8: Function with array arguments
it('should memoize based on array references', () => {
const spy = jest.fn((arr: number[]) => arr.reduce((sum, val) => sum + val, 0));
const memoizedFn = weakMapMemoize(spy);
const memoizedFn = weakMapMemoize(spy, {maxEntries: 3});

const array1 = [1, 2, 3];
const array2 = [4, 5, 6];
Expand All @@ -160,7 +160,7 @@ describe('weakMapMemoize', () => {
const sym2 = Symbol('sym2');

const spy = jest.fn((a: symbol, b: number) => a.toString() + b);
const memoizedFn = weakMapMemoize(spy);
const memoizedFn = weakMapMemoize(spy, {maxEntries: 4});

const result1 = memoizedFn(sym1, 10);
const result2 = memoizedFn(sym1, 10);
Expand All @@ -177,23 +177,29 @@ describe('weakMapMemoize', () => {
// Test 10: Function with a large number of arguments
it('should memoize correctly with a large number of arguments', () => {
const spy = jest.fn((...args: number[]) => args.reduce((sum, val) => sum + val, 0));
const memoizedFn = weakMapMemoize(spy);
const memoizedFn = weakMapMemoize(spy, {maxEntries: 5});

const args1 = [1, 2, 3, 4, 5];
const args2 = [1, 2, 3, 4, 5];
const args3 = [5, 4, 3, 2, 1];
const args4 = [1, 2, 3, 4, 6];
const args5 = [6, 5, 4, 3, 2];
const args6 = [1, 2, 3, 4, 7];

const result1 = memoizedFn(...args1);
const result2 = memoizedFn(...args2);
const result3 = memoizedFn(...args3);
const result4 = memoizedFn(...args4);
const result5 = memoizedFn(...args5);
const result6 = memoizedFn(...args6);

expect(result1).toBe(15);
expect(result2).toBe(15);
expect(result3).toBe(15);
expect(result4).toBe(16);
expect(spy).toHaveBeenCalledTimes(3); // Three unique calls
expect(result5).toBe(20);
expect(result6).toBe(17);
expect(spy).toHaveBeenCalledTimes(5); // Five unique calls (args1, args3, args4, args5, args6)
});

// Test 11: Function with alternating object and primitive arguments
Expand All @@ -208,54 +214,77 @@ describe('weakMapMemoize', () => {
prim3: number,
) => obj1.a + prim1.length + obj2.b + (prim2 ? 1 : 0) + obj3.c + prim3,
);
const memoizedFn = weakMapMemoize(spy);
const memoizedFn = weakMapMemoize(spy, {maxEntries: 7});

const object1 = {a: 5};
const object2 = {b: 10};
const object3 = {c: 15};
const object4 = {b: 20}; // Corrected to have 'b' property
const object5 = {c: 25}; // Corrected to have 'c' property

// First unique call
const result1 = memoizedFn(object1, 'test', object2, true, object3, 20);
const result1 = memoizedFn(object1, 'test', object2, true, object3, 20); // 5 +4 +10 +1 +15 +20 =55

// Duplicate of the first call
const result2 = memoizedFn(object1, 'test', object2, true, object3, 20);
const result2 = memoizedFn(object1, 'test', object2, true, object3, 20); // 55

// Different primitive in second argument
const result3 = memoizedFn(object1, 'testing', object2, true, object3, 20);
const result3 = memoizedFn(object1, 'testing', object2, true, object3, 20); //5 +7 +10 +1 +15 +20=58

// Different object in third argument
const object4 = {b: 20};
const result4 = memoizedFn(object1, 'test', object4, true, object3, 20);
const result4 = memoizedFn(object1, 'test', object4, true, object3, 20); //5 +4 +20 +1 +15 +20=65

// Different primitive in fourth argument
const result5 = memoizedFn(object1, 'test', object2, false, object3, 20);
const result5 = memoizedFn(object1, 'test', object2, false, object3, 20); //5 +4 +10 +0 +15 +20=54

// Different object in fifth argument
const object5 = {c: 25};
const result6 = memoizedFn(object1, 'test', object2, true, object5, 20);
const result6 = memoizedFn(object1, 'test', object2, true, object5, 20); //5 +4 +10 +1 +25 +20=65

// Different primitive in sixth argument
const result7 = memoizedFn(object1, 'test', object2, true, object3, 30);
const result7 = memoizedFn(object1, 'test', object2, true, object3, 30); //5 +4 +10 +1 +15 +30=65

// Different objects and primitives
const result8 = memoizedFn(object1, 'testing', object2, false, object5, 30);
const result8 = memoizedFn(object1, 'testing', object2, false, object3, 30); //5 +7 +10 +0 +15 +30=67

// Duplicate of the first call again
const result9 = memoizedFn(object1, 'test', object2, true, object3, 20);

expect(result1).toBe(5 + 4 + 10 + 1 + 15 + 20); // 5 + 4 + 10 + 1 + 15 + 20 = 55
expect(result1).toBe(5 + 4 + 10 + 1 + 15 + 20); // 55
expect(result2).toBe(55); // Cached
expect(result3).toBe(5 + 7 + 10 + 1 + 15 + 20); // 5 + 7 + 10 + 1 + 15 + 20 = 58
expect(result4).toBe(5 + 4 + 20 + 1 + 15 + 20); // 5 + 4 + 20 + 1 + 15 + 20 = 65
expect(result5).toBe(5 + 4 + 10 + 0 + 15 + 20); // 5 + 4 + 10 + 0 + 15 + 20 = 54
expect(result6).toBe(5 + 4 + 10 + 1 + 25 + 20); // 5 + 4 + 10 + 1 + 25 + 20 = 65
expect(result7).toBe(5 + 4 + 10 + 1 + 15 + 30); // 5 + 4 + 10 + 1 + 15 + 30 = 65
expect(result8).toBe(5 + 7 + 10 + 0 + 25 + 30); // 5 + 7 + 25 + 0 + 15 + 30 = 97
expect(result3).toBe(5 + 7 + 10 + 1 + 15 + 20); // 58
expect(result4).toBe(5 + 4 + 20 + 1 + 15 + 20); // 65
expect(result5).toBe(5 + 4 + 10 + 0 + 15 + 20); // 54
expect(result6).toBe(5 + 4 + 10 + 1 + 25 + 20); // 65
expect(result7).toBe(5 + 4 + 10 + 1 + 15 + 30); // 65
expect(result8).toBe(5 + 7 + 10 + 0 + 15 + 30); // 67
expect(result9).toBe(55); // Cached

// spy should be called for each unique combination
// Unique calls: result1, result3, result4, result5, result6, result7, result8
// Total unique calls: 7
expect(spy).toHaveBeenCalledTimes(7);
});

// Test 12: Exercising the maxEntries option
it('should evict least recently used entries when maxEntries is exceeded', () => {
const spy = jest.fn((a: number) => a * 2);
const memoizedFn = weakMapMemoize(spy, {maxEntries: 2});

const result1 = memoizedFn(1); // Cached
const result2 = memoizedFn(2); // Cached
const result3 = memoizedFn(3); // Evicts least recently used (1)
const result4 = memoizedFn(2); // Cached, updates recentness
const result5 = memoizedFn(4); // Evicts least recently used (3)

expect(result1).toBe(2);
expect(result2).toBe(4);
expect(result3).toBe(6);
expect(result4).toBe(4);
expect(result5).toBe(8);
expect(spy).toHaveBeenCalledTimes(4); // Calls for 1,2,3,4

// Accessing 1 again should trigger a new call since it was evicted
const result6 = memoizedFn(1);
expect(result6).toBe(2);
expect(spy).toHaveBeenCalledTimes(5); // Call for 1 again
});
});
99 changes: 83 additions & 16 deletions js_modules/dagster-ui/packages/ui-core/src/util/weakMapMemoize.ts
Original file line number Diff line number Diff line change
@@ -1,66 +1,133 @@
import LRU from 'lru-cache';

type AnyFunction = (...args: any[]) => any;

function isObject(value: any): value is object {
return value !== null && (typeof value === 'object' || typeof value === 'function');
interface WeakMapMemoizeOptions {
maxEntries?: number; // Optional limit for cached entries
}

/**
* Interface representing a cache node that holds both a Map for primitive keys
* and a WeakMap for object keys.
*/
interface CacheNode {
map: Map<any, CacheNode>;
weakMap: WeakMap<object, CacheNode>;
result?: any;
lruKey?: any; // Reference to the key in the LRU cache
}

/**
* Determines if a value is a non-null object or function.
* @param value - The value to check.
* @returns True if the value is a non-null object or function, false otherwise.
*/
function isObject(value: any): value is object {
return value !== null && (typeof value === 'object' || typeof value === 'function');
}

/**
* Memoizes a function using nested Maps and WeakMaps based on the arguments.
* Handles both primitive and object arguments.
* Optionally limits the number of cached entries using an LRU cache.
* Handles both primitive and object arguments efficiently.
* @param fn - The function to memoize.
* @param options - Optional settings for memoization.
* @returns A memoized version of the function.
*/
export function weakMapMemoize<T extends AnyFunction>(fn: T): T {
export function weakMapMemoize<T extends AnyFunction>(fn: T, options?: WeakMapMemoizeOptions): T {
const {maxEntries} = options || {};

// Initialize the root cache node
const cacheRoot: CacheNode = {
map: new Map(),
weakMap: new WeakMap(),
};

// Initialize LRU Cache if maxEntries is specified
let lruCache: LRU.Cache<any, any> | null = null;

if (maxEntries) {
lruCache = new LRU<any, any>({
max: maxEntries,
dispose: (key, value) => {
// When an entry is evicted from the LRU cache,
// traverse the cache tree and remove the cached result
const keyPath = key as any[];
let currentCache = cacheRoot;

for (let i = 0; i < keyPath.length; i++) {
const arg = keyPath[i];
const isArgObject = isObject(arg);

if (isArgObject) {
currentCache = currentCache.weakMap.get(arg);
} else {
currentCache = currentCache.map.get(arg);
}

if (!currentCache) {
// The cache node has already been removed
return;
}
}

// Remove the cached result
delete currentCache.result;
delete currentCache.lruKey;
},
});
}

return function memoizedFunction(this: any, ...args: any[]) {
let currentCache = cacheRoot;
const path: any[] = [];

for (let i = 0; i < args.length; i++) {
const arg = args[i];
path.push(arg);
const isArgObject = isObject(arg);

if (isObject(arg)) {
// Use WeakMap for object keys
// Determine the appropriate cache level
if (isArgObject) {
if (!currentCache.weakMap.has(arg)) {
currentCache.weakMap.set(arg, {
const newCacheNode: CacheNode = {
map: new Map(),
weakMap: new WeakMap(),
});
};
currentCache.weakMap.set(arg, newCacheNode);
}
currentCache = currentCache.weakMap.get(arg)!;
} else {
// Use Map for primitive keys
if (!currentCache.map.has(arg)) {
currentCache.map.set(arg, {
const newCacheNode: CacheNode = {
map: new Map(),
weakMap: new WeakMap(),
});
};
currentCache.map.set(arg, newCacheNode);
}
currentCache = currentCache.map.get(arg)!;
}
}

// After traversing all arguments, check if the result is cached
if ('result' in currentCache) {
// If using LRU Cache, update its usage
if (lruCache && currentCache.lruKey) {
lruCache.get(currentCache.lruKey); // This updates the recentness
}
return currentCache.result;
}
// Compute the result and cache it

// Compute the result
const result = fn.apply(this, args);

// Cache the result
currentCache.result = result;

// If LRU cache is enabled, manage the cache entries
if (lruCache) {
const cacheEntryKey: any[] = path.slice(); // Clone the path to ensure uniqueness
currentCache.lruKey = cacheEntryKey; // Associate the cache node with the LRU key

lruCache.set(cacheEntryKey, currentCache);
}

return result;
} as T;
}

0 comments on commit 5d97f0b

Please sign in to comment.