Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/common/models/executionFunction.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
38 changes: 38 additions & 0 deletions src/common/models/executionMemoization.model.ts
Original file line number Diff line number Diff line change
@@ -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 <O> {
metadata: FunctionMetadata;
inputsHash: string;
isMemoized: boolean;
value?: Promise<O> | O;
}


/**
* A handler function that processes the memoization context.
*/
export type MemoizationHandler<O> = (info: MemoizationContext<O>) => void;

export interface MemoizeOptions<O> {
/** 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<O>;
}
29 changes: 29 additions & 0 deletions src/common/utils/crypto.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
11 changes: 11 additions & 0 deletions src/common/utils/crypto.ts
Original file line number Diff line number Diff line change
@@ -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');
}
11 changes: 11 additions & 0 deletions src/common/utils/functionMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}
73 changes: 73 additions & 0 deletions src/execution/memoize.ts
Original file line number Diff line number Diff line change
@@ -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<O>(
blockFunction: (...params: unknown[]) => Promise<O>,
inputs?: Array<unknown>,
options?: MemoizeOptions<O>
): Promise<Awaited<O>>;

export function executeMemoize<O>(
blockFunction: (...params: unknown[]) => O,
inputs?: Array<unknown>,
options?: MemoizeOptions<O>
): 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<O>(
blockFunction: (...params: unknown[]) => O | Promise<O>,
inputs: Array<unknown> = [],

Check warning on line 42 in src/execution/memoize.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
options: MemoizeOptions<O>
): Promise<O> | O {
const expirationMs = Math.min(options.expirationMs ?? memoizationDefaultExpirationMs, memoizationMaxExpirationMs); // Default short delay and Prevent excessive retention
const memoizationFullStore: Map<string, Map<string, Promise<O> | O>> = (this[memoizationKey] ??= new Map<
string,
Map<string, Promise<O> | O>
>());
const memoizationStore = memoizationFullStore.get(options.functionId) ?? new Map<string, Promise<O> | 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));

Check warning on line 67 in src/execution/memoize.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 67 in src/execution/memoize.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 67 in src/execution/memoize.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 67 in src/execution/memoize.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🕹️ Function is not covered

Warning! Not covered function

Check warning on line 67 in src/execution/memoize.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🕹️ Function is not covered

Warning! Not covered function
} else {
setTimeout(() => memoizationStore.delete(inputsHash), expirationMs);

Check warning on line 69 in src/execution/memoize.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 69 in src/execution/memoize.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🕹️ Function is not covered

Warning! Not covered function
}

Check warning on line 70 in src/execution/memoize.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
return callResponseOrPromise;
}
}
73 changes: 73 additions & 0 deletions src/execution/memoizeDecorator.spec.ts
Original file line number Diff line number Diff line change
@@ -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<number>) => {
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);
});
});
38 changes: 38 additions & 0 deletions src/execution/memoizeDecorator.ts
Original file line number Diff line number Diff line change
@@ -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<O>(memoizationHandler?: MemoizationHandler<O>, expirationMs?: number): MethodDecorator {
return function <T extends Record<string, unknown>>(target: T, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: unknown[]): ReturnType<typeof originalMethod> {
const thisMethodMetadata: FunctionMetadata = extractClassMethodMetadata(target.constructor.name, propertyKey, originalMethod);
return (executeMemoize.bind(this) as typeof executeMemoize<O>)(originalMethod.bind(this), args, {
functionId: thisMethodMetadata.methodSignature,
memoizationHandler:
typeof memoizationHandler === 'function'
? (memoContext): ReturnType<typeof memoizationHandler> => {
return (memoizationHandler.bind(this) as typeof memoizationHandler)({
...memoContext,
metadata: thisMethodMetadata
});
}
: undefined,

Check warning on line 33 in src/execution/memoizeDecorator.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
expirationMs
});
};
};
}
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
8 changes: 2 additions & 6 deletions src/trace/traceDecorator.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -26,11 +26,7 @@ export function trace<O>(
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) {
Expand Down
Loading