diff --git a/src/common/models/executionFunction.model.ts b/src/common/models/executionFunction.model.ts index fe6143a..d7d9360 100644 --- a/src/common/models/executionFunction.model.ts +++ b/src/common/models/executionFunction.model.ts @@ -11,6 +11,9 @@ export interface FunctionMetadata { /** If the function is a class method, this represents the method name. */ method?: string | symbol; + /** The full method signature, including parameters, if available. */ + methodSignature?: string; + /** The function name, or "anonymous" if unnamed. */ name: string; diff --git a/src/common/models/executionMemoization.model.ts b/src/common/models/executionMemoization.model.ts new file mode 100644 index 0000000..77bc5c3 --- /dev/null +++ b/src/common/models/executionMemoization.model.ts @@ -0,0 +1,38 @@ +import { FunctionMetadata } from './executionFunction.model'; + +export const memoizationKey = Symbol('execution-engine/memoize'); + +/** Default expiration that ensures multiple rapid calls can reuse the stored result */ +export const memoizationDefaultExpirationMs = 100; +/** Maximum allowable expiration time Prevent excessive retention */ +export const memoizationMaxExpirationMs = 1000; + +/** + * Represents the context of a memoized function execution. + */ +export interface MemoizationContext { + metadata: FunctionMetadata; + inputsHash: string; + isMemoized: boolean; + value?: Promise | O; +} + + +/** + * A handler function that processes the memoization context. + */ +export type MemoizationHandler = (info: MemoizationContext) => void; + +export interface MemoizeOptions { + /** Unique identifier for the function being memoized */ + functionId: string; + + /** + * Optional expiration time in milliseconds for the cached result. + * Default is 100ms, capped at 1000ms to prevent excessive retention. + */ + expirationMs?: number; + + /** Custom handler for memoization logic */ + memoizationHandler?: MemoizationHandler; +} diff --git a/src/common/utils/crypto.spec.ts b/src/common/utils/crypto.spec.ts new file mode 100644 index 0000000..c901b97 --- /dev/null +++ b/src/common/utils/crypto.spec.ts @@ -0,0 +1,29 @@ +import { generateHashId } from './crypto'; + +describe('generateUniqueId', () => { + it('generates a unique hash for given inputs', () => { + const id1 = generateHashId('test', 123, { key: 'value' }); + const id2 = generateHashId('test', 123, { key: 'value' }); + + expect(id1).toBe(id2); + }); + + it('generates different hashes for different inputs', () => { + const id1 = generateHashId('test1'); + const id2 = generateHashId('test2'); + + expect(id1).not.toBe(id2); + }); + + it('handles empty input', () => { + const id1 = generateHashId(); + const id2 = generateHashId(); + + expect(id1).toBe(id2); + }); + + it('creates a 64-character hash', () => { + const id = generateHashId('sample'); + expect(id).toHaveLength(64); + }); +}); diff --git a/src/common/utils/crypto.ts b/src/common/utils/crypto.ts new file mode 100644 index 0000000..c44c377 --- /dev/null +++ b/src/common/utils/crypto.ts @@ -0,0 +1,11 @@ +import { createHash } from 'crypto'; + +/** + * Generates a SHA-256 hash ID from the given inputs. + * + * @param inputs - Values to hash. + * @returns A 64-character hex string. + */ +export function generateHashId(...inputs: unknown[]): string { + return createHash('sha256').update(JSON.stringify(inputs)).digest('hex'); +} diff --git a/src/common/utils/functionMetadata.ts b/src/common/utils/functionMetadata.ts index 2c5b8ba..5551804 100644 --- a/src/common/utils/functionMetadata.ts +++ b/src/common/utils/functionMetadata.ts @@ -23,3 +23,14 @@ export function extractFunctionMetadata(fn: Function): FunctionMetadata { // source, }; } + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export function extractClassMethodMetadata(className: string, methodName: string | symbol, fn: Function): FunctionMetadata { + const functionMetadata = extractFunctionMetadata(fn); + return { + class: className, + method: methodName, + methodSignature: `${className}.${methodName?.toString()}(${functionMetadata.parameters.join(',')})`, + ...functionMetadata + }; +} \ No newline at end of file diff --git a/src/execution/memoize.ts b/src/execution/memoize.ts new file mode 100644 index 0000000..41f85a8 --- /dev/null +++ b/src/execution/memoize.ts @@ -0,0 +1,73 @@ +import { execute } from './execute'; +import { + memoizationDefaultExpirationMs, + memoizationKey, + memoizationMaxExpirationMs, + MemoizeOptions +} from '../common/models/executionMemoization.model'; +import { generateHashId } from '../common/utils/crypto'; +import { extractFunctionMetadata } from '../common/utils/functionMetadata'; + +export function executeMemoize( + blockFunction: (...params: unknown[]) => Promise, + inputs?: Array, + options?: MemoizeOptions +): Promise>; + +export function executeMemoize( + blockFunction: (...params: unknown[]) => O, + inputs?: Array, + options?: MemoizeOptions +): O; + +/** + * Executes a function with memoization to prevent redundant executions. + * The result is stored temporarily and cleared after a short delay. + * + * @param blockFunction - The function to execute and memoize. + * @param inputs - Arguments used to generate a unique memoization key. + * @param options - Additional options including a unique function identifier. + * @param options.expirationMs - Duration (in milliseconds) before clearing the stored result, + * capped at 1000ms to prevent excessive retention. + * @param options.memoizationHandler - Optional callback triggered after checking memoization memory. + * @returns The memoized result or a newly computed value. + * + * @remarks + * The JavaScript engine may clear the memoized value before another call retrieves it. + * A short delay (e.g., 100ms) ensures that multiple rapid calls can reuse the stored result. + * The expiration is capped at 1000ms for efficiency and to avoid unnecessary memory usage. + */ +export function executeMemoize( + blockFunction: (...params: unknown[]) => O | Promise, + inputs: Array = [], + options: MemoizeOptions +): Promise | O { + const expirationMs = Math.min(options.expirationMs ?? memoizationDefaultExpirationMs, memoizationMaxExpirationMs); // Default short delay and Prevent excessive retention + const memoizationFullStore: Map | O>> = (this[memoizationKey] ??= new Map< + string, + Map | O> + >()); + const memoizationStore = memoizationFullStore.get(options.functionId) ?? new Map | O>(); + const inputsHash = generateHashId(...inputs); + const memoizedValue = memoizationStore.get(inputsHash); + + if (typeof options.memoizationHandler === 'function') { + const functionMetadata = extractFunctionMetadata(blockFunction); + options.memoizationHandler({ metadata: functionMetadata, inputsHash, isMemoized: !!memoizedValue, value: memoizedValue }); + } + + if (memoizedValue) { + return memoizedValue; + } else { + const callResponseOrPromise = (execute.bind(this) as typeof execute)(blockFunction.bind(this) as typeof blockFunction, inputs); + memoizationStore.set(inputsHash, callResponseOrPromise); + this[memoizationKey].set(options.functionId, memoizationStore); + + if (callResponseOrPromise instanceof Promise) { + callResponseOrPromise.finally(() => setTimeout(() => memoizationStore.delete(inputsHash), expirationMs)); + } else { + setTimeout(() => memoizationStore.delete(inputsHash), expirationMs); + } + return callResponseOrPromise; + } +} diff --git a/src/execution/memoizeDecorator.spec.ts b/src/execution/memoizeDecorator.spec.ts new file mode 100644 index 0000000..66847bd --- /dev/null +++ b/src/execution/memoizeDecorator.spec.ts @@ -0,0 +1,73 @@ +import { memoize } from './memoizeDecorator'; +import { MemoizationContext } from '../common/models/executionMemoization.model'; + +describe('memoize decorator', () => { + it('should memoize Fibonacci results and prevent redundant function calls', async () => { + let memoizationCheckCount = 0; + let memoizedCalls = 0; + let totalFunctionCalls = 0; + + class Calculator { + @memoize((memoContext: MemoizationContext) => { + memoizationCheckCount++; + if (memoContext.isMemoized) { + memoizedCalls++; + } + }) + fibonacci(n: number): number { + totalFunctionCalls++; + if (n <= 1) { + return n; + } + return this.fibonacci(n - 1) + this.fibonacci(n - 2); + } + } + + const calculator = new Calculator(); + memoizationCheckCount = 0; + memoizedCalls = 0; + totalFunctionCalls = 0; + const fib3 = calculator.fibonacci(3); + expect(memoizedCalls).toBe(0); + expect(totalFunctionCalls).toBe(5); // fib(3) = (fib(2) = fib(1) + fib(0)) + fib(1) + expect(memoizationCheckCount).toEqual(totalFunctionCalls + memoizedCalls); + expect(fib3).toBe(2); + + memoizationCheckCount = 0; + memoizedCalls = 0; + totalFunctionCalls = 0; + // first call: + const fib50_1 = calculator.fibonacci(50); + expect(memoizedCalls).toBeGreaterThan(0); + expect(totalFunctionCalls).toBeLessThan(1274); // 1274 calls for fibonacci(50) if all exist + expect(memoizationCheckCount).toEqual(totalFunctionCalls + memoizedCalls); + expect(fib50_1).toBe(12586269025); + const memoizedCallsAfterFirstCall = memoizedCalls; + const totalFunctionCallsAfterFirstCall = totalFunctionCalls; + + // second call: + const fib50_2 = calculator.fibonacci(50); + expect(memoizedCalls).toBe(memoizedCallsAfterFirstCall + 1); // a new get of memoized fib50 + expect(totalFunctionCalls).toBe(totalFunctionCallsAfterFirstCall); // no new call, fib50 is memoized + expect(memoizationCheckCount).toEqual(totalFunctionCalls + memoizedCalls); + expect(fib50_2).toBe(12586269025); + + memoizationCheckCount = 0; + memoizedCalls = 0; + totalFunctionCalls = 0; + const fib51 = calculator.fibonacci(51); + expect(totalFunctionCalls).toBe(1); // we need 1 extra call to get fibonacci of 51 as we did fibonacci(50) + expect(memoizedCalls).toBe(2); // yes fib(51-1) and fib(51-2) are memoized + expect(memoizationCheckCount).toBe(3); // 2memoized and 1 call + expect(fib51).toBe(20365011074); + + memoizationCheckCount = 0; + memoizedCalls = 0; + totalFunctionCalls = 0; + const fib5 = calculator.fibonacci(6); + expect(totalFunctionCalls).toBe(0); // no need for extra call to get fibonacci of 5 as we did fibonacci(50) + expect(memoizedCalls).toBe(1); // yes fib(5) is memoized implicitly + expect(memoizationCheckCount).toBe(1); // 1memoized + expect(fib5).toBe(8); + }); +}); diff --git a/src/execution/memoizeDecorator.ts b/src/execution/memoizeDecorator.ts new file mode 100644 index 0000000..c34db23 --- /dev/null +++ b/src/execution/memoizeDecorator.ts @@ -0,0 +1,38 @@ +import { executeMemoize } from './memoize'; +import { FunctionMetadata } from '../common/models/executionFunction.model'; +import { MemoizationHandler } from '../common/models/executionMemoization.model'; +import { extractClassMethodMetadata } from '../common/utils/functionMetadata'; + +/** + * Decorator to memoize method executions and prevent redundant calls. + * + * @param memoizationHandler - Optional callback triggered after checking memory + * @param expirationMs - Duration (in milliseconds) before clearing the stored result, + * capped at 1000ms to prevent excessive retention. + * @returns A method decorator for applying memoization. + * + * @remarks + * Uses `executeMemoize` internally to store and reuse results. + * A short delay (e.g., 100ms) ensures that multiple rapid calls can reuse the stored result. + */ +export function memoize(memoizationHandler?: MemoizationHandler, expirationMs?: number): MethodDecorator { + return function >(target: T, propertyKey: string, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value; + descriptor.value = function (...args: unknown[]): ReturnType { + const thisMethodMetadata: FunctionMetadata = extractClassMethodMetadata(target.constructor.name, propertyKey, originalMethod); + return (executeMemoize.bind(this) as typeof executeMemoize)(originalMethod.bind(this), args, { + functionId: thisMethodMetadata.methodSignature, + memoizationHandler: + typeof memoizationHandler === 'function' + ? (memoContext): ReturnType => { + return (memoizationHandler.bind(this) as typeof memoizationHandler)({ + ...memoContext, + metadata: thisMethodMetadata + }); + } + : undefined, + expirationMs + }); + }; + }; +} diff --git a/src/index.ts b/src/index.ts index 8047251..81a4efe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,12 +7,15 @@ export * from './common/models/engineNodeData.model'; export * from './common/models/engineTrace.model'; export * from './common/models/engineTraceOptions.model'; export * from './common/models/executionFunction.model'; +export * from './common/models/executionMemoization.model'; export * from './common/models/executionTrace.model'; export * from './common/models/timer.model'; export * from './engine/executionEngine'; export * from './engine/executionEngineDecorators'; export * from './engine/traceableEngine'; export * from './execution/execute'; +export * from './execution/memoize'; +export * from './execution/memoizeDecorator'; export * from './timer/executionTimer'; export * from './trace/trace'; export * from './trace/traceDecorator'; diff --git a/src/trace/traceDecorator.ts b/src/trace/traceDecorator.ts index b12a08c..c878764 100644 --- a/src/trace/traceDecorator.ts +++ b/src/trace/traceDecorator.ts @@ -1,5 +1,5 @@ import { executionTrace, TraceContext } from './trace'; -import { extractFunctionMetadata } from '../common/utils/functionMetadata'; +import { extractClassMethodMetadata } from '../common/utils/functionMetadata'; import { isAsync } from '../common/utils/isAsync'; /** @@ -26,11 +26,7 @@ export function trace( const originalMethod = descriptor.value; descriptor.value = function (...args: unknown[]) { const thisTraceContext = { - metadata: { - class: target.constructor.name, - method: propertyKey, - ...extractFunctionMetadata(originalMethod) - }, + metadata: extractClassMethodMetadata(target.constructor.name, propertyKey, originalMethod), ...additionalContext }; if (options.contextKey) {