Skip to content

Commit c643c6a

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. - Update `functionMetadata` model and add `methodSignature` The expiration is capped at 1000ms for efficiency and to avoid unnecessary memory usage.
1 parent e3ae86b commit c643c6a

File tree

10 files changed

+279
-6
lines changed

10 files changed

+279
-6
lines changed

src/common/models/executionFunction.model.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ export interface FunctionMetadata {
1111
/** If the function is a class method, this represents the method name. */
1212
method?: string | symbol;
1313

14+
/** The full method signature, including parameters, if available. */
15+
methodSignature?: string;
16+
1417
/** The function name, or "anonymous" if unnamed. */
1518
name: string;
1619

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { FunctionMetadata } from './executionFunction.model';
2+
3+
export const memoizationKey = Symbol('execution-engine/memoize');
4+
5+
/** Default expiration that ensures multiple rapid calls can reuse the stored result */
6+
export const memoizationDefaultExpirationMs = 100;
7+
/** Maximum allowable expiration time Prevent excessive retention */
8+
export const memoizationMaxExpirationMs = 1000;
9+
10+
/**
11+
* Represents the context of a memoized function execution.
12+
* It includes metadata, memoization status, and the result.
13+
*/
14+
export interface MemoizationContext <O> {
15+
metadata: FunctionMetadata;
16+
isMemoized: boolean;
17+
callId: string;
18+
value?: Promise<O> | O;
19+
}
20+
21+
22+
/**
23+
* A handler function that processes the memoization context.
24+
*/
25+
export type MemoizationHandler<O> = (info: MemoizationContext<O>) => void;
26+
27+
export interface MemoizeOptions<O> {
28+
/** Unique identifier for the function being memoized */
29+
functionId: string;
30+
31+
/**
32+
* Optional expiration time in milliseconds for the cached result.
33+
* Default is 100ms, capped at 1000ms to prevent excessive retention.
34+
*/
35+
expirationMs?: number;
36+
37+
/** Custom handler for memoization logic */
38+
memoizationHandler?: MemoizationHandler<O>;
39+
}

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/common/utils/functionMetadata.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,14 @@ export function extractFunctionMetadata(fn: Function): FunctionMetadata {
2323
// source,
2424
};
2525
}
26+
27+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
28+
export function extractClassMethodMetadata(className: string, methodName: string | symbol, fn: Function): FunctionMetadata {
29+
const functionMetadata = extractFunctionMetadata(fn);
30+
return {
31+
class: className,
32+
method: methodName,
33+
methodSignature: `${className}.${methodName?.toString()}(${functionMetadata.parameters.join(',')})`,
34+
...functionMetadata
35+
};
36+
}

src/execution/memoize.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
import { extractFunctionMetadata } from '../common/utils/functionMetadata';
10+
11+
export function executeMemoize<O>(
12+
blockFunction: (...params: unknown[]) => Promise<O>,
13+
inputs?: Array<unknown>,
14+
options?: MemoizeOptions<O>
15+
): Promise<Awaited<O>>;
16+
17+
export function executeMemoize<O>(
18+
blockFunction: (...params: unknown[]) => O,
19+
inputs?: Array<unknown>,
20+
options?: MemoizeOptions<O>
21+
): O;
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<
47+
string,
48+
Map<string, Promise<O> | O>
49+
>());
50+
const memoizationStore = memoizationFullStore.get(options.functionId) ?? new Map<string, Promise<O> | O>();
51+
const callId = generateHashId(...inputs);
52+
const memoizedValue = memoizationStore.get(callId);
53+
54+
if (typeof options.memoizationHandler === 'function') {
55+
const functionMetadata = extractFunctionMetadata(blockFunction);
56+
options.memoizationHandler({ metadata: functionMetadata, callId, isMemoized: !!memoizedValue, value: memoizedValue });
57+
}
58+
59+
if (memoizedValue) {
60+
return memoizedValue;
61+
} else {
62+
const callResponseOrPromise = (execute.bind(this) as typeof execute)(blockFunction.bind(this) as typeof blockFunction, inputs);
63+
memoizationStore.set(callId, callResponseOrPromise);
64+
this[memoizationKey].set(options.functionId, memoizationStore);
65+
66+
if (callResponseOrPromise instanceof Promise) {
67+
callResponseOrPromise.finally(() => setTimeout(() => memoizationStore.delete(callId), expirationMs));
68+
} else {
69+
setTimeout(() => memoizationStore.delete(callId), expirationMs);
70+
}
71+
return callResponseOrPromise;
72+
}
73+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { memoize } from './memoizeDecorator';
2+
import { MemoizationContext } from '../common/models/executionMemoization.model';
3+
4+
describe('memoize decorator', () => {
5+
it('should memoize Fibonacci results and prevent redundant function calls', async () => {
6+
let memoizationCheckCount = 0;
7+
let memoizedCalls = 0;
8+
let totalFunctionCalls = 0;
9+
10+
class Calculator {
11+
@memoize((memoContext: MemoizationContext<number>) => {
12+
memoizationCheckCount++;
13+
if (memoContext.isMemoized) {
14+
memoizedCalls++;
15+
}
16+
})
17+
fibonacci(n: number): number {
18+
totalFunctionCalls++;
19+
if (n <= 1) {
20+
return n;
21+
}
22+
return this.fibonacci(n - 1) + this.fibonacci(n - 2);
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+
memoizationCheckCount = 0;
56+
memoizedCalls = 0;
57+
totalFunctionCalls = 0;
58+
const fib51 = calculator.fibonacci(51);
59+
expect(totalFunctionCalls).toBe(1); // we need 1 extra call to get fibonacci of 51 as we did fibonacci(50)
60+
expect(memoizedCalls).toBe(2); // yes fib(51-1) and fib(51-2) are memoized
61+
expect(memoizationCheckCount).toBe(3); // 2memoized and 1 call
62+
expect(fib51).toBe(20365011074);
63+
64+
memoizationCheckCount = 0;
65+
memoizedCalls = 0;
66+
totalFunctionCalls = 0;
67+
const fib5 = calculator.fibonacci(6);
68+
expect(totalFunctionCalls).toBe(0); // no need for extra call to get fibonacci of 5 as we did fibonacci(50)
69+
expect(memoizedCalls).toBe(1); // yes fib(5) is memoized implicitly
70+
expect(memoizationCheckCount).toBe(1); // 1memoized
71+
expect(fib5).toBe(8);
72+
});
73+
});

src/execution/memoizeDecorator.ts

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

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';

src/trace/traceDecorator.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { executionTrace, TraceContext } from './trace';
2-
import { extractFunctionMetadata } from '../common/utils/functionMetadata';
2+
import { extractClassMethodMetadata } from '../common/utils/functionMetadata';
33
import { isAsync } from '../common/utils/isAsync';
44

55
/**
@@ -26,11 +26,7 @@ export function trace<O>(
2626
const originalMethod = descriptor.value;
2727
descriptor.value = function (...args: unknown[]) {
2828
const thisTraceContext = {
29-
metadata: {
30-
class: target.constructor.name,
31-
method: propertyKey,
32-
...extractFunctionMetadata(originalMethod)
33-
},
29+
metadata: extractClassMethodMetadata(target.constructor.name, propertyKey, originalMethod),
3430
...additionalContext
3531
};
3632
if (options.contextKey) {

0 commit comments

Comments
 (0)