diff --git a/src/common/models/executionCache.model.ts b/src/common/models/executionCache.model.ts new file mode 100644 index 0000000..4c30ade --- /dev/null +++ b/src/common/models/executionCache.model.ts @@ -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(key: string, value: T, ttl?: number): Promise; + + /** Retrieves a value from the cache by key. */ + get(key: string): Promise | T | undefined; +} + +/** + * Represents the context of a cache function execution, providing details such as metadata, inputs, cache status, and value. + */ +export interface CacheContext { + /** Metadata associated with the function being executed. */ + metadata: FunctionMetadata; + + /** The inputs passed to the function. */ + inputs: Array; + + /** 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 { + /** 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) => void; +} diff --git a/src/common/utils/functionMetadata.ts b/src/common/utils/functionMetadata.ts index 5551804..199d6a5 100644 --- a/src/common/utils/functionMetadata.ts +++ b/src/common/utils/functionMetadata.ts @@ -33,4 +33,23 @@ export function extractClassMethodMetadata(className: string, methodName: string methodSignature: `${className}.${methodName?.toString()}(${functionMetadata.parameters.join(',')})`, ...functionMetadata }; -} \ No newline at end of file +} + + +/** + * 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(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; +} diff --git a/src/common/utils/mapStore.ts b/src/common/utils/mapStore.ts new file mode 100644 index 0000000..1e1629a --- /dev/null +++ b/src/common/utils/mapStore.ts @@ -0,0 +1,41 @@ +export class MapCacheStore { + private store: Map | T>; + + constructor(public fullStorage: Map>, 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>(); + + if (!this.fullStorage.has(this.functionId)) { + this.fullStorage.set(this.functionId, new Map()); + } + + this.store = this.fullStorage.get(this.functionId) as Map | 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(() => { + this.store.delete(key); + this.fullStorage.set(this.functionId, this.store); + }, ttl); + this.store.set(key, value); + this.fullStorage.set(this.functionId, this.store); + return value; + } +} diff --git a/src/execution/cache.decorator.spec.ts b/src/execution/cache.decorator.spec.ts new file mode 100644 index 0000000..f0da801 --- /dev/null +++ b/src/execution/cache.decorator.spec.ts @@ -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 { + 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 { + 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 + }); +}); diff --git a/src/execution/cache.decorator.ts b/src/execution/cache.decorator.ts new file mode 100644 index 0000000..72890cc --- /dev/null +++ b/src/execution/cache.decorator.ts @@ -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 >(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 (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) + }); + }; + }; +} diff --git a/src/execution/cache.ts b/src/execution/cache.ts new file mode 100644 index 0000000..fcafb40 --- /dev/null +++ b/src/execution/cache.ts @@ -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( + blockFunction: (...params: unknown[]) => O | Promise, + inputs: Array = [], + options: CacheOptions & { functionId: string } +): Promise | 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; + + let cacheStore: CacheStore | MapCacheStore; + if (options.cacheManager) { + cacheStore = typeof options.cacheManager === 'function' ? options.cacheManager(this) : options.cacheManager; + } else { + cacheStore = new MapCacheStore(this[cacheStoreKey], options.functionId); + } + 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).fullStorage) { + this[cacheStoreKey] = (cacheStore as MapCacheStore).fullStorage; + } + return res; + }, + (error) => { + throw error; + } + ); + } +} diff --git a/src/execution/memoizeDecorator.ts b/src/execution/memoizeDecorator.ts index c34db23..ed18458 100644 --- a/src/execution/memoizeDecorator.ts +++ b/src/execution/memoizeDecorator.ts @@ -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. @@ -22,15 +22,7 @@ export function memoize(memoizationHandler?: MemoizationHandler, expiratio 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, + memoizationHandler: attachFunctionMetadata.bind(this)(memoizationHandler, thisMethodMetadata), expirationMs }); }; diff --git a/src/index.ts b/src/index.ts index 81a4efe..e4d2af5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ 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'; @@ -13,6 +14,8 @@ 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';