Skip to content

Commit 193934a

Browse files
committed
feat: add @memoize decorator with expiration and handler options
Added `@memoize` decorator for caching result of calls with identical input: - Prevents redundant calls using `executeMemoize`. - Supports configurable expiration and memoization tracking. The expiration is capped at 1000ms for efficiency and to avoid unnecessary memory usage.
1 parent e3ae86b commit 193934a

File tree

7 files changed

+241
-0
lines changed

7 files changed

+241
-0
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export const memoizationKey = Symbol('execution-engine/memoize');
2+
3+
/** Default expiration that ensures multiple rapid calls can reuse the stored result */
4+
export const memoizationDefaultExpirationMs = 100;
5+
/** Maximum allowable expiration time Prevent excessive retention */
6+
export const memoizationMaxExpirationMs = 1000;
7+
8+
export type MemoizationHandler<O> = (info: { isMemoized: boolean; callId: string; functionId: string; value?: Promise<O> | O; }) => void;
9+
10+
export interface MemoizeOptions<O> {
11+
/** Unique identifier for the function being memoized */
12+
functionId: string;
13+
14+
/**
15+
* Optional expiration time in milliseconds for the cached result.
16+
* Default is 100ms, capped at 1000ms to prevent excessive retention.
17+
*/
18+
expirationMs?: number;
19+
20+
/** Custom handler for memoization logic */
21+
memoizationHandler?: MemoizationHandler<O>;
22+
}

src/common/utils/crypto.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { generateHashId } from './crypto';
2+
3+
describe('generateUniqueId', () => {
4+
it('generates a unique hash for given inputs', () => {
5+
const id1 = generateHashId('test', 123, { key: 'value' });
6+
const id2 = generateHashId('test', 123, { key: 'value' });
7+
8+
expect(id1).toBe(id2);
9+
});
10+
11+
it('generates different hashes for different inputs', () => {
12+
const id1 = generateHashId('test1');
13+
const id2 = generateHashId('test2');
14+
15+
expect(id1).not.toBe(id2);
16+
});
17+
18+
it('handles empty input', () => {
19+
const id1 = generateHashId();
20+
const id2 = generateHashId();
21+
22+
expect(id1).toBe(id2);
23+
});
24+
25+
it('creates a 64-character hash', () => {
26+
const id = generateHashId('sample');
27+
expect(id).toHaveLength(64);
28+
});
29+
});

src/common/utils/crypto.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { createHash } from 'crypto';
2+
3+
/**
4+
* Generates a SHA-256 hash ID from the given inputs.
5+
*
6+
* @param inputs - Values to hash.
7+
* @returns A 64-character hex string.
8+
*/
9+
export function generateHashId(...inputs: unknown[]): string {
10+
return createHash('sha256').update(JSON.stringify(inputs)).digest('hex');
11+
}

src/execution/memoize.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { execute } from './execute';
2+
import {
3+
memoizationDefaultExpirationMs,
4+
memoizationKey,
5+
memoizationMaxExpirationMs,
6+
MemoizeOptions
7+
} from '../common/models/executionMemoization.model';
8+
import { generateHashId } from '../common/utils/crypto';
9+
10+
export function executeMemoize<O>(
11+
blockFunction: (...params: unknown[]) => Promise<O>,
12+
inputs?: Array<unknown>,
13+
options?: MemoizeOptions<O>
14+
): Promise<Awaited<O>>;
15+
16+
export function executeMemoize<O>(
17+
blockFunction: (...params: unknown[]) => O,
18+
inputs?: Array<unknown>,
19+
options?: MemoizeOptions<O>
20+
): O;
21+
22+
23+
/**
24+
* Executes a function with memoization to prevent redundant executions.
25+
* The result is stored temporarily and cleared after a short delay.
26+
*
27+
* @param blockFunction - The function to execute and memoize.
28+
* @param inputs - Arguments used to generate a unique memoization key.
29+
* @param options - Additional options including a unique function identifier.
30+
* @param options.expirationMs - Duration (in milliseconds) before clearing the stored result,
31+
* capped at 1000ms to prevent excessive retention.
32+
* @param options.memoizationHandler - Optional callback triggered after checking memoization memory.
33+
* @returns The memoized result or a newly computed value.
34+
*
35+
* @remarks
36+
* The JavaScript engine may clear the memoized value before another call retrieves it.
37+
* A short delay (e.g., 100ms) ensures that multiple rapid calls can reuse the stored result.
38+
* The expiration is capped at 1000ms for efficiency and to avoid unnecessary memory usage.
39+
*/
40+
export function executeMemoize<O>(
41+
blockFunction: (...params: unknown[]) => O | Promise<O>,
42+
inputs: Array<unknown> = [],
43+
options: MemoizeOptions<O>
44+
): Promise<O> | O {
45+
const expirationMs = Math.min(options.expirationMs ?? memoizationDefaultExpirationMs, memoizationMaxExpirationMs); // Default short delay and Prevent excessive retention
46+
const memoizationFullStore: Map<string, Map<string, Promise<O> | O>> = this[memoizationKey] ??= new Map<string, Map<string, Promise<O> | O>>();
47+
const memoizationStore = memoizationFullStore.get(options.functionId) ?? new Map<string, Promise<O> | O>();
48+
const callId = generateHashId(...inputs);
49+
const memoizedValue = memoizationStore.get(callId);
50+
51+
if (typeof options.memoizationHandler === 'function') {
52+
options.memoizationHandler({ functionId: options.functionId, callId, isMemoized: !!memoizedValue, value: memoizedValue });
53+
}
54+
55+
if (memoizedValue) {
56+
return memoizedValue;
57+
} else {
58+
const callResponseOrPromise = (execute.bind(this) as typeof execute)(
59+
blockFunction.bind(this) as typeof blockFunction,
60+
inputs
61+
);
62+
memoizationStore.set(callId, callResponseOrPromise);
63+
this[memoizationKey].set(options.functionId, memoizationStore);
64+
65+
if (callResponseOrPromise instanceof Promise) {
66+
callResponseOrPromise.finally(() =>
67+
setTimeout(() => memoizationStore.delete(callId), expirationMs)
68+
);
69+
} else {
70+
setTimeout(() => memoizationStore.delete(callId), expirationMs);
71+
}
72+
return callResponseOrPromise;
73+
}
74+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { memoize } from './memoizeDecorator';
2+
3+
describe('memoize decorator', () => {
4+
it('should memoize Fibonacci results and prevent redundant function calls', async () => {
5+
let memoizationCheckCount = 0;
6+
let memoizedCalls = 0;
7+
let totalFunctionCalls = 0;
8+
9+
class Calculator {
10+
@memoize(({ isMemoized }) => {
11+
memoizationCheckCount++;
12+
if (isMemoized) {
13+
memoizedCalls++;
14+
}
15+
})
16+
fibonacci(n: number): number {
17+
totalFunctionCalls++;
18+
if (n <= 1) {
19+
return n;
20+
}
21+
return this.fibonacci(n - 1) + this.fibonacci(n - 2);
22+
}
23+
}
24+
25+
26+
const calculator = new Calculator();
27+
memoizationCheckCount = 0;
28+
memoizedCalls = 0;
29+
totalFunctionCalls = 0;
30+
const fib3 = calculator.fibonacci(3);
31+
expect(memoizedCalls).toBe(0);
32+
expect(totalFunctionCalls).toBe(5); // fib(3) = (fib(2) = fib(1) + fib(0)) + fib(1)
33+
expect(memoizationCheckCount).toEqual(totalFunctionCalls + memoizedCalls);
34+
expect(fib3).toBe(2);
35+
36+
memoizationCheckCount = 0;
37+
memoizedCalls = 0;
38+
totalFunctionCalls = 0;
39+
// first call:
40+
const fib50_1 = calculator.fibonacci(50);
41+
expect(memoizedCalls).toBeGreaterThan(0);
42+
expect(totalFunctionCalls).toBeLessThan(1274); // 1274 calls for fibonacci(50) if all exist
43+
expect(memoizationCheckCount).toEqual(totalFunctionCalls + memoizedCalls);
44+
expect(fib50_1).toBe(12586269025);
45+
const memoizedCallsAfterFirstCall = memoizedCalls;
46+
const totalFunctionCallsAfterFirstCall = totalFunctionCalls;
47+
48+
// second call:
49+
const fib50_2 = calculator.fibonacci(50);
50+
expect(memoizedCalls).toBe(memoizedCallsAfterFirstCall + 1); // a new get of memoized fib50
51+
expect(totalFunctionCalls).toBe(totalFunctionCallsAfterFirstCall); // no new call, fib50 is memoized
52+
expect(memoizationCheckCount).toEqual(totalFunctionCalls + memoizedCalls);
53+
expect(fib50_2).toBe(12586269025);
54+
55+
56+
memoizationCheckCount = 0;
57+
memoizedCalls = 0;
58+
totalFunctionCalls = 0;
59+
const fib51 = calculator.fibonacci(51);
60+
expect(totalFunctionCalls).toBe(1); // we need 1 extra call to get fibonacci of 51 as we did fibonacci(50)
61+
expect(memoizedCalls).toBe(2); // yes fib(51-1) and fib(51-2) are memoized
62+
expect(memoizationCheckCount).toBe(3); // 2memoized and 1 call
63+
expect(fib51).toBe(20365011074);
64+
65+
memoizationCheckCount = 0;
66+
memoizedCalls = 0;
67+
totalFunctionCalls = 0;
68+
const fib5 = calculator.fibonacci(6);
69+
expect(totalFunctionCalls).toBe(0); // no need for extra call to get fibonacci of 5 as we did fibonacci(50)
70+
expect(memoizedCalls).toBe(1); // yes fib(5) is memoized implicitly
71+
expect(memoizationCheckCount).toBe(1); // 1memoized
72+
expect(fib5).toBe(8);
73+
});
74+
});

src/execution/memoizeDecorator.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { executeMemoize } from './memoize';
2+
import { MemoizationHandler } from '../common/models/executionMemoization.model';
3+
4+
/**
5+
* Decorator to memoize method executions and prevent redundant calls.
6+
*
7+
* @param memoizationHandler - Optional callback triggered after checking memory
8+
* @param expirationMs - Duration (in milliseconds) before clearing the stored result,
9+
* capped at 1000ms to prevent excessive retention.
10+
* @returns A method decorator for applying memoization.
11+
*
12+
* @remarks
13+
* Uses `executeMemoize` internally to store and reuse results.
14+
* A short delay (e.g., 100ms) ensures that multiple rapid calls can reuse the stored result.
15+
*/
16+
export function memoize<O>(memoizationHandler?: MemoizationHandler<O>, expirationMs?: number): MethodDecorator {
17+
return function <T extends Record<string, unknown>>(target: T, propertyKey: string, descriptor: PropertyDescriptor) {
18+
const originalMethod = descriptor.value;
19+
descriptor.value = function(...args: unknown[]): ReturnType<typeof originalMethod> {
20+
const functionSignature = `${target.constructor.name}.${propertyKey}(${descriptor.value.length})`;
21+
return (executeMemoize.bind(this) as typeof executeMemoize<Promise<Awaited<O>> | O>)(
22+
originalMethod.bind(this),
23+
args,
24+
{ functionId: functionSignature, memoizationHandler, expirationMs }
25+
);
26+
};
27+
};
28+
}

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@ export * from './common/models/engineNodeData.model';
77
export * from './common/models/engineTrace.model';
88
export * from './common/models/engineTraceOptions.model';
99
export * from './common/models/executionFunction.model';
10+
export * from './common/models/executionMemoization.model';
1011
export * from './common/models/executionTrace.model';
1112
export * from './common/models/timer.model';
1213
export * from './engine/executionEngine';
1314
export * from './engine/executionEngineDecorators';
1415
export * from './engine/traceableEngine';
1516
export * from './execution/execute';
17+
export * from './execution/memoize';
18+
export * from './execution/memoizeDecorator';
1619
export * from './timer/executionTimer';
1720
export * from './trace/trace';
1821
export * from './trace/traceDecorator';

0 commit comments

Comments
 (0)