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
56 changes: 56 additions & 0 deletions src/common/models/executionCache.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { FunctionMetadata } from './executionFunction.model';

/**
* Interface for a cache store that provides methods to interact with cached data.
*/
export interface CacheStore {
/** Stores a key/value pair in the cache. TTL is in milliseconds.*/
set<T>(key: string, value: T, ttl?: number): Promise<T>;

/** Retrieves a value from the cache by key. */
get<T>(key: string): Promise<T | undefined> | T | undefined;
}

/**
* Represents the context of a cache function execution, providing details such as metadata, inputs, cache status, and value.
*/
export interface CacheContext<O = unknown> {
/** Metadata associated with the function being executed. */
metadata: FunctionMetadata;

/** The inputs passed to the function. */
inputs: Array<unknown>;

/** Unique key identifying the cache entry. */
cacheKey: string;

/** The time-to-live (TTL) for the cache entry. */
ttl: number;

/** Flag indicating whether the value is cached. */
isCached: boolean;

/** The cached value, if any. */
value?: O;
}


/**
* Configuration options for caching behavior.
*/
export interface CacheOptions<O = unknown> {
/** Time-to-live (TTL) for cache items. Can be static (number) or dynamic (function that returns a number). */
ttl: number | ((params: { metadata: FunctionMetadata; inputs: unknown[] }) => number);

/** Function to generate a custom cache key based on method metadata and arguments. */
cacheKey?: (params: { metadata: FunctionMetadata; inputs: unknown[] }) => string;

/** The cache provider or manager used for storing the cache (e.g., in-memory or Redis). */
cacheManager?: CacheStore | ((...args: unknown[]) => CacheStore);

/**
* Callback for handling cache events, providing full access to cache details via `CacheContext`.
* allowing additional actions based on caching behavior.
*/
onCacheEvent?: (info: CacheContext<O>) => void;
}
21 changes: 20 additions & 1 deletion src/common/utils/functionMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,23 @@ export function extractClassMethodMetadata(className: string, methodName: string
methodSignature: `${className}.${methodName?.toString()}(${functionMetadata.parameters.join(',')})`,
...functionMetadata
};
}
}


/**
* Wraps a function and attaches method metadata, or returns the value as-is.
* This is useful in method decorators, where the function needs to be aware of method-specific metadata
* that would otherwise be inaccessible in a plain function.
*
* @returns The original value or a function with attached metadata.
*/
export function attachFunctionMetadata<O = unknown>(paramOrFunction: O | undefined, thisMethodMetadata: FunctionMetadata): O | undefined {
return typeof paramOrFunction === 'function'
? (
// eslint-disable-next-line unused-imports/no-unused-vars
({ metadata, ...rest }: { metadata: FunctionMetadata }): O => {
return paramOrFunction.bind(this)({ ...rest, metadata: thisMethodMetadata });
}
) as O
: paramOrFunction;
}
41 changes: 41 additions & 0 deletions src/common/utils/mapStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export class MapCacheStore<T> {
private store: Map<string, Promise<T> | T>;

constructor(public fullStorage: Map<string, Map<string, unknown>>, private readonly functionId: string) {}

/**
* Retrieves the value associated with the specified key.
*
* @param key - The key used to retrieve the value.
* @returns The value corresponding to the key.
*/
public get(key: string): T {
this.fullStorage ??= new Map<string, Map<string, unknown>>();

if (!this.fullStorage.has(this.functionId)) {
this.fullStorage.set(this.functionId, new Map<string, unknown>());
}

this.store = this.fullStorage.get(this.functionId) as Map<string, Promise<T> | T>;

return this.store.get(key) as T;
}

/**
* Sets a value for the specified key.
*
* @param key - The key for the value.
* @param value - The value to store.
* @param ttl - Time to live in milliseconds (optional).
* @returns The value that was set.
*/
public set(key: string, value: T, ttl?: number): T {
setTimeout(() => {

Check warning on line 33 in src/common/utils/mapStore.ts

View workflow job for this annotation

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

🕹️ Function is not covered

Warning! Not covered function
this.store.delete(key);

Check warning on line 34 in src/common/utils/mapStore.ts

View workflow job for this annotation

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

🧾 Statement is not covered

Warning! Not covered statement
this.fullStorage.set(this.functionId, this.store);

Check warning on line 35 in src/common/utils/mapStore.ts

View workflow job for this annotation

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

🧾 Statement is not covered

Warning! Not covered statement
}, ttl);
this.store.set(key, value);
this.fullStorage.set(this.functionId, this.store);
return value;
}
}
82 changes: 82 additions & 0 deletions src/execution/cache.decorator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { cache } from './cache.decorator';

describe('cache decorator', () => {
it('should cache async function results and prevent redundant calls', async () => {
let memoizationCheckCount = 0;
let memoizedCalls = 0;
let totalFunctionCalls = 0;

class DataService {
@cache({
ttl: 3000,
onCacheEvent: (cacheContext) => {
memoizationCheckCount++;
if (cacheContext.isCached) {
memoizedCalls++;
}
}
})
async fetchData(id: number): Promise<string> {
totalFunctionCalls++;
return new Promise((resolve) => setTimeout(() => resolve(`Data for ID: ${id}`), 100));
}

@cache({
ttl: 3000,
onCacheEvent: (cacheContext) => {
memoizationCheckCount++;
if (cacheContext.isCached) {
memoizedCalls++;
}
}
})
async throwData(name: string): Promise<string> {
totalFunctionCalls++;
throw new Error(`hello ${name} but I throw!`);
}
}

const service = new DataService();

memoizationCheckCount = 0;
memoizedCalls = 0;
totalFunctionCalls = 0;

const result1 = await service.fetchData(1);
expect(result1).toBe('Data for ID: 1');
expect(memoizedCalls).toBe(0);
expect(totalFunctionCalls).toBe(1);
expect(memoizationCheckCount).toBe(1); // Called once

const result2 = await service.fetchData(1);
expect(result2).toBe('Data for ID: 1');
expect(memoizedCalls).toBe(1); // Now it should be memoized
expect(totalFunctionCalls).toBe(1); // No new calls
expect(memoizationCheckCount).toBe(2); // Checked twice

const result3 = await service.fetchData(2);
expect(result3).toBe('Data for ID: 2');
expect(memoizedCalls).toBe(1); // No extra memoized calls yet
expect(totalFunctionCalls).toBe(2); // New call for different ID
expect(memoizationCheckCount).toBe(3); // Three checks (1st, 2nd for ID 1, and 3rd for ID 2)

const result4 = await service.fetchData(2);
expect(result4).toBe('Data for ID: 2');
expect(memoizedCalls).toBe(2); // ID 2 result is now memoized
expect(totalFunctionCalls).toBe(2); // No extra new calls
expect(memoizationCheckCount).toBe(4); // 4 checks in total

// test NO cache for a throwing async method
memoizationCheckCount = 0;
memoizedCalls = 0;
totalFunctionCalls = 0;
await Promise.all([
expect(service.throwData('akram')).rejects.toThrow('hello akram but I throw!'),
expect(service.throwData('akram')).rejects.toThrow('hello akram but I throw!'),
expect(service.throwData('akram')).rejects.toThrow('hello akram but I throw!')
]);
expect(memoizationCheckCount).toEqual(totalFunctionCalls + memoizedCalls);
expect(memoizedCalls).toEqual(0); // No cache
expect(totalFunctionCalls).toBe(3); // we call everytime we get a throw
});
});
31 changes: 31 additions & 0 deletions src/execution/cache.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { executeCache } from './cache';
import { CacheOptions } from '../common/models/executionCache.model';
import { FunctionMetadata } from '../common/models/executionFunction.model';
import { attachFunctionMetadata, extractClassMethodMetadata } from '../common/utils/functionMetadata';

/**
* Caches function results to avoid redundant expensive computations
* If the result is already cached, it returns the cached value; otherwise, it executes the function and stores the result.
*
* @param options - Caching configuration specifying TTL, cache key generation, cache management, and optional logging.
* @returns A method decorator that applies caching logic.
*
* @remarks
* - Cache behavior can be customized via `cacheKey`, `ttl`, and `cacheHandler`.
* - Errors are thrown immediately and **not cached** to allow retries.
*/
export function cache(options: CacheOptions): 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 (executeCache.bind(this) as typeof executeCache)(originalMethod.bind(this), args, {
functionId: thisMethodMetadata.methodSignature as string,
...options,
cacheKey: attachFunctionMetadata.bind(this)(options.cacheKey, thisMethodMetadata),
ttl: attachFunctionMetadata.bind(this)(options.ttl, thisMethodMetadata),
onCacheEvent: attachFunctionMetadata.bind(this)(options.onCacheEvent, thisMethodMetadata)
});
};
};
}
57 changes: 57 additions & 0 deletions src/execution/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { execute } from './execute';
import { CacheOptions, CacheStore } from '../common/models/executionCache.model';
import { generateHashId } from '../common/utils/crypto';
import { extractFunctionMetadata } from '../common/utils/functionMetadata';
import { MapCacheStore } from '../common/utils/mapStore';

export const cacheStoreKey = Symbol('execution-engine/cache');

/**
* Caches function results to avoid redundant expensive computations
* If the result is already cached, it returns the cached value; otherwise, it executes the function and stores the result.
*
* This is useful for optimizing expensive computations or API calls by reducing duplicate executions.
* @remarks
* - Errors are thrown immediately and **not cached** to allow retries.
*/
export async function executeCache<O>(
blockFunction: (...params: unknown[]) => O | Promise<O>,
inputs: Array<unknown> = [],

Check warning on line 19 in src/execution/cache.ts

View workflow job for this annotation

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

🌿 Branch is not covered

Warning! Not covered branch
options: CacheOptions & { functionId: string }
): Promise<Promise<O> | O> {
const functionMetadata = extractFunctionMetadata(blockFunction);
const cacheKey = options.cacheKey?.({ metadata: functionMetadata, inputs }) ?? generateHashId(...inputs);
const ttl = typeof options.ttl === 'function' ? options.ttl({ metadata: functionMetadata, inputs }) : options.ttl;

Check warning on line 24 in src/execution/cache.ts

View workflow job for this annotation

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

🌿 Branch is not covered

Warning! Not covered branch

let cacheStore: CacheStore | MapCacheStore<O>;
if (options.cacheManager) {
cacheStore = typeof options.cacheManager === 'function' ? options.cacheManager(this) : options.cacheManager;

Check warning on line 28 in src/execution/cache.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 28 in src/execution/cache.ts

View workflow job for this annotation

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

🌿 Branch is not covered

Warning! Not covered branch

Check warning on line 28 in src/execution/cache.ts

View workflow job for this annotation

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

🌿 Branch is not covered

Warning! Not covered branch
} else {
cacheStore = new MapCacheStore<O>(this[cacheStoreKey], options.functionId);
}

Check warning on line 31 in src/execution/cache.ts

View workflow job for this annotation

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

🌿 Branch is not covered

Warning! Not covered branch
const cachedValue: O = (await cacheStore.get(cacheKey)) as O;

if (typeof options.onCacheEvent === 'function') {
options.onCacheEvent({ ttl, metadata: functionMetadata, inputs, cacheKey, isCached: !!cachedValue, value: cachedValue });
}

if (cachedValue) {
return cachedValue;
} else {
return (execute.bind(this) as typeof execute)(
blockFunction.bind(this) as typeof blockFunction,
inputs,
[],
(res) => {
cacheStore.set(cacheKey, res as O, ttl);
if((cacheStore as MapCacheStore<O>).fullStorage) {
this[cacheStoreKey] = (cacheStore as MapCacheStore<O>).fullStorage;
}
return res;
},
(error) => {
throw error;
}
);
}
}
12 changes: 2 additions & 10 deletions src/execution/memoizeDecorator.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { executeMemoize } from './memoize';
import { FunctionMetadata } from '../common/models/executionFunction.model';
import { MemoizationHandler } from '../common/models/executionMemoization.model';
import { extractClassMethodMetadata } from '../common/utils/functionMetadata';
import { attachFunctionMetadata, extractClassMethodMetadata } from '../common/utils/functionMetadata';

/**
* Decorator to memoize method executions and prevent redundant calls.
Expand All @@ -22,15 +22,7 @@ export function memoize<O>(memoizationHandler?: MemoizationHandler<O>, expiratio
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,
memoizationHandler: attachFunctionMetadata.bind(this)(memoizationHandler, thisMethodMetadata),
expirationMs
});
};
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ export * from './common/models/engineEdgeData.model';
export * from './common/models/engineNodeData.model';
export * from './common/models/engineTrace.model';
export * from './common/models/engineTraceOptions.model';
export * from './common/models/executionCache.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/cache.decorator';
export * from './execution/cache';
export * from './execution/execute';
export * from './execution/memoize';
export * from './execution/memoizeDecorator';
Expand Down
Loading