Skip to content

Commit e766f2f

Browse files
committed
feat: add @cache decorator with configurable options (cacheManager, cacheKey, etc.)
- Added a caching mechanism to optimize function executions. - Implemented customizable TTL and cache key resolution. - Introduced metadata extraction for better cache context awareness. - Ensured errors are not cached to prevent stale failures. - Provided a callback for handling cache events with detailed context.
1 parent 2cd9739 commit e766f2f

File tree

8 files changed

+292
-11
lines changed

8 files changed

+292
-11
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { FunctionMetadata } from './executionFunction.model';
2+
3+
/**
4+
* Interface for a cache store that provides methods to interact with cached data.
5+
*/
6+
export interface CacheStore {
7+
/** Stores a key/value pair in the cache. TTL is in milliseconds.*/
8+
set<T>(key: string, value: T, ttl?: number): Promise<T>;
9+
10+
/** Retrieves a value from the cache by key. */
11+
get<T>(key: string): Promise<T | undefined> | T | undefined;
12+
}
13+
14+
/**
15+
* Represents the context of a cache function execution, providing details such as metadata, inputs, cache status, and value.
16+
*/
17+
export interface CacheContext<O = unknown> {
18+
/** Metadata associated with the function being executed. */
19+
metadata: FunctionMetadata;
20+
21+
/** The inputs passed to the function. */
22+
inputs: Array<unknown>;
23+
24+
/** Unique key identifying the cache entry. */
25+
cacheKey: string;
26+
27+
/** The time-to-live (TTL) for the cache entry. */
28+
ttl: number;
29+
30+
/** Flag indicating whether the value is cached. */
31+
isCached: boolean;
32+
33+
/** The cached value, if any. */
34+
value?: O;
35+
}
36+
37+
38+
/**
39+
* Configuration options for caching behavior.
40+
*/
41+
export interface CacheOptions<O = unknown> {
42+
/** Time-to-live (TTL) for cache items. Can be static (number) or dynamic (function that returns a number). */
43+
ttl: number | ((params: { metadata: FunctionMetadata; inputs: unknown[] }) => number);
44+
45+
/** Function to generate a custom cache key based on method metadata and arguments. */
46+
cacheKey?: (params: { metadata: FunctionMetadata; inputs: unknown[] }) => string;
47+
48+
/** The cache provider or manager used for storing the cache (e.g., in-memory or Redis). */
49+
cacheManager?: CacheStore | ((...args: unknown[]) => CacheStore);
50+
51+
/**
52+
* Callback for handling cache events, providing full access to cache details via `CacheContext`.
53+
* allowing additional actions based on caching behavior.
54+
*/
55+
onCacheEvent?: (info: CacheContext<O>) => void;
56+
}

src/common/utils/functionMetadata.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,23 @@ export function extractClassMethodMetadata(className: string, methodName: string
3333
methodSignature: `${className}.${methodName?.toString()}(${functionMetadata.parameters.join(',')})`,
3434
...functionMetadata
3535
};
36-
}
36+
}
37+
38+
39+
/**
40+
* Wraps a function and attaches method metadata, or returns the value as-is.
41+
* This is useful in method decorators, where the function needs to be aware of method-specific metadata
42+
* that would otherwise be inaccessible in a plain function.
43+
*
44+
* @returns The original value or a function with attached metadata.
45+
*/
46+
export function attachFunctionMetadata<O = unknown>(paramOrFunction: O | undefined, thisMethodMetadata: FunctionMetadata): O | undefined {
47+
return typeof paramOrFunction === 'function'
48+
? (
49+
// eslint-disable-next-line unused-imports/no-unused-vars
50+
({ metadata, ...rest }: { metadata: FunctionMetadata }): O => {
51+
return paramOrFunction.bind(this)({ ...rest, metadata: thisMethodMetadata });
52+
}
53+
) as O
54+
: paramOrFunction;
55+
}

src/common/utils/mapStore.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
export class MapCacheStore<T> {
2+
private store: Map<string, Promise<T> | T>;
3+
4+
constructor(public fullStorage: Map<string, Map<string, unknown>>, private readonly functionId: string) {}
5+
6+
/**
7+
* Retrieves the value associated with the specified key.
8+
*
9+
* @param key - The key used to retrieve the value.
10+
* @returns The value corresponding to the key.
11+
*/
12+
public get(key: string): T {
13+
this.fullStorage ??= new Map<string, Map<string, unknown>>();
14+
15+
if (!this.fullStorage.has(this.functionId)) {
16+
this.fullStorage.set(this.functionId, new Map<string, unknown>());
17+
}
18+
19+
this.store = this.fullStorage.get(this.functionId) as Map<string, Promise<T> | T>;
20+
21+
return this.store.get(key) as T;
22+
}
23+
24+
/**
25+
* Sets a value for the specified key.
26+
*
27+
* @param key - The key for the value.
28+
* @param value - The value to store.
29+
* @param ttl - Time to live in milliseconds (optional).
30+
* @returns The value that was set.
31+
*/
32+
public set(key: string, value: T, ttl?: number): T {
33+
setTimeout(() => {
34+
this.store.delete(key);
35+
this.fullStorage.set(this.functionId, this.store);
36+
}, ttl);
37+
this.store.set(key, value);
38+
this.fullStorage.set(this.functionId, this.store);
39+
return value;
40+
}
41+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { cache } from './cache.decorator';
2+
3+
describe('cache decorator', () => {
4+
it('should cache async function results and prevent redundant calls', async () => {
5+
let memoizationCheckCount = 0;
6+
let memoizedCalls = 0;
7+
let totalFunctionCalls = 0;
8+
9+
class DataService {
10+
@cache({
11+
ttl: 3000,
12+
onCacheEvent: (cacheContext) => {
13+
memoizationCheckCount++;
14+
if (cacheContext.isCached) {
15+
memoizedCalls++;
16+
}
17+
}
18+
})
19+
async fetchData(id: number): Promise<string> {
20+
totalFunctionCalls++;
21+
return new Promise((resolve) => setTimeout(() => resolve(`Data for ID: ${id}`), 100));
22+
}
23+
24+
@cache({
25+
ttl: 3000,
26+
onCacheEvent: (cacheContext) => {
27+
memoizationCheckCount++;
28+
if (cacheContext.isCached) {
29+
memoizedCalls++;
30+
}
31+
}
32+
})
33+
async throwData(name: string): Promise<string> {
34+
totalFunctionCalls++;
35+
throw new Error(`hello ${name} but I throw!`);
36+
}
37+
}
38+
39+
const service = new DataService();
40+
41+
memoizationCheckCount = 0;
42+
memoizedCalls = 0;
43+
totalFunctionCalls = 0;
44+
45+
const result1 = await service.fetchData(1);
46+
expect(result1).toBe('Data for ID: 1');
47+
expect(memoizedCalls).toBe(0);
48+
expect(totalFunctionCalls).toBe(1);
49+
expect(memoizationCheckCount).toBe(1); // Called once
50+
51+
const result2 = await service.fetchData(1);
52+
expect(result2).toBe('Data for ID: 1');
53+
expect(memoizedCalls).toBe(1); // Now it should be memoized
54+
expect(totalFunctionCalls).toBe(1); // No new calls
55+
expect(memoizationCheckCount).toBe(2); // Checked twice
56+
57+
const result3 = await service.fetchData(2);
58+
expect(result3).toBe('Data for ID: 2');
59+
expect(memoizedCalls).toBe(1); // No extra memoized calls yet
60+
expect(totalFunctionCalls).toBe(2); // New call for different ID
61+
expect(memoizationCheckCount).toBe(3); // Three checks (1st, 2nd for ID 1, and 3rd for ID 2)
62+
63+
const result4 = await service.fetchData(2);
64+
expect(result4).toBe('Data for ID: 2');
65+
expect(memoizedCalls).toBe(2); // ID 2 result is now memoized
66+
expect(totalFunctionCalls).toBe(2); // No extra new calls
67+
expect(memoizationCheckCount).toBe(4); // 4 checks in total
68+
69+
// test NO cache for a throwing async method
70+
memoizationCheckCount = 0;
71+
memoizedCalls = 0;
72+
totalFunctionCalls = 0;
73+
await Promise.all([
74+
expect(service.throwData('akram')).rejects.toThrow('hello akram but I throw!'),
75+
expect(service.throwData('akram')).rejects.toThrow('hello akram but I throw!'),
76+
expect(service.throwData('akram')).rejects.toThrow('hello akram but I throw!')
77+
]);
78+
expect(memoizationCheckCount).toEqual(totalFunctionCalls + memoizedCalls);
79+
expect(memoizedCalls).toEqual(0); // No cache
80+
expect(totalFunctionCalls).toBe(3); // we call everytime we get a throw
81+
});
82+
});

src/execution/cache.decorator.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { executeCache } from './cache';
2+
import { CacheOptions } from '../common/models/executionCache.model';
3+
import { FunctionMetadata } from '../common/models/executionFunction.model';
4+
import { attachFunctionMetadata, extractClassMethodMetadata } from '../common/utils/functionMetadata';
5+
6+
/**
7+
* Caches function results to avoid redundant expensive computations
8+
* If the result is already cached, it returns the cached value; otherwise, it executes the function and stores the result.
9+
*
10+
* @param options - Caching configuration specifying TTL, cache key generation, cache management, and optional logging.
11+
* @returns A method decorator that applies caching logic.
12+
*
13+
* @remarks
14+
* - Cache behavior can be customized via `cacheKey`, `ttl`, and `cacheHandler`.
15+
* - Errors are thrown immediately and **not cached** to allow retries.
16+
*/
17+
export function cache(options: CacheOptions): MethodDecorator {
18+
return function <T extends Record<string, unknown>>(target: T, propertyKey: string, descriptor: PropertyDescriptor) {
19+
const originalMethod = descriptor.value;
20+
descriptor.value = function (...args: unknown[]): ReturnType<typeof originalMethod> {
21+
const thisMethodMetadata: FunctionMetadata = extractClassMethodMetadata(target.constructor.name, propertyKey, originalMethod);
22+
return (executeCache.bind(this) as typeof executeCache)(originalMethod.bind(this), args, {
23+
functionId: thisMethodMetadata.methodSignature as string,
24+
...options,
25+
cacheKey: attachFunctionMetadata.bind(this)(options.cacheKey, thisMethodMetadata),
26+
ttl: attachFunctionMetadata.bind(this)(options.ttl, thisMethodMetadata),
27+
onCacheEvent: attachFunctionMetadata.bind(this)(options.onCacheEvent, thisMethodMetadata)
28+
});
29+
};
30+
};
31+
}

src/execution/cache.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { execute } from './execute';
2+
import { CacheOptions, CacheStore } from '../common/models/executionCache.model';
3+
import { generateHashId } from '../common/utils/crypto';
4+
import { extractFunctionMetadata } from '../common/utils/functionMetadata';
5+
import { MapCacheStore } from '../common/utils/mapStore';
6+
7+
export const cacheStoreKey = Symbol('execution-engine/cache');
8+
9+
/**
10+
* Caches function results to avoid redundant expensive computations
11+
* If the result is already cached, it returns the cached value; otherwise, it executes the function and stores the result.
12+
*
13+
* This is useful for optimizing expensive computations or API calls by reducing duplicate executions.
14+
* @remarks
15+
* - Errors are thrown immediately and **not cached** to allow retries.
16+
*/
17+
export async function executeCache<O>(
18+
blockFunction: (...params: unknown[]) => O | Promise<O>,
19+
inputs: Array<unknown> = [],
20+
options: CacheOptions & { functionId: string }
21+
): Promise<Promise<O> | O> {
22+
const functionMetadata = extractFunctionMetadata(blockFunction);
23+
const cacheKey = options.cacheKey?.({ metadata: functionMetadata, inputs }) ?? generateHashId(...inputs);
24+
const ttl = typeof options.ttl === 'function' ? options.ttl({ metadata: functionMetadata, inputs }) : options.ttl;
25+
26+
let cacheStore: CacheStore | MapCacheStore<O>;
27+
if (options.cacheManager) {
28+
cacheStore = typeof options.cacheManager === 'function' ? options.cacheManager(this) : options.cacheManager;
29+
} else {
30+
cacheStore = new MapCacheStore<O>(this[cacheStoreKey], options.functionId);
31+
}
32+
const cachedValue: O = (await cacheStore.get(cacheKey)) as O;
33+
34+
if (typeof options.onCacheEvent === 'function') {
35+
options.onCacheEvent({ ttl, metadata: functionMetadata, inputs, cacheKey, isCached: !!cachedValue, value: cachedValue });
36+
}
37+
38+
if (cachedValue) {
39+
return cachedValue;
40+
} else {
41+
return (execute.bind(this) as typeof execute)(
42+
blockFunction.bind(this) as typeof blockFunction,
43+
inputs,
44+
[],
45+
(res) => {
46+
cacheStore.set(cacheKey, res as O, ttl);
47+
if((cacheStore as MapCacheStore<O>).fullStorage) {
48+
this[cacheStoreKey] = (cacheStore as MapCacheStore<O>).fullStorage;
49+
}
50+
return res;
51+
},
52+
(error) => {
53+
throw error;
54+
}
55+
);
56+
}
57+
}

src/execution/memoizeDecorator.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { executeMemoize } from './memoize';
22
import { FunctionMetadata } from '../common/models/executionFunction.model';
33
import { MemoizationHandler } from '../common/models/executionMemoization.model';
4-
import { extractClassMethodMetadata } from '../common/utils/functionMetadata';
4+
import { attachFunctionMetadata, extractClassMethodMetadata } from '../common/utils/functionMetadata';
55

66
/**
77
* Decorator to memoize method executions and prevent redundant calls.
@@ -22,15 +22,7 @@ export function memoize<O>(memoizationHandler?: MemoizationHandler<O>, expiratio
2222
const thisMethodMetadata: FunctionMetadata = extractClassMethodMetadata(target.constructor.name, propertyKey, originalMethod);
2323
return (executeMemoize.bind(this) as typeof executeMemoize<O>)(originalMethod.bind(this), args, {
2424
functionId: thisMethodMetadata.methodSignature,
25-
memoizationHandler:
26-
typeof memoizationHandler === 'function'
27-
? (memoContext): ReturnType<typeof memoizationHandler> => {
28-
return (memoizationHandler.bind(this) as typeof memoizationHandler)({
29-
...memoContext,
30-
metadata: thisMethodMetadata
31-
});
32-
}
33-
: undefined,
25+
memoizationHandler: attachFunctionMetadata.bind(this)(memoizationHandler, thisMethodMetadata),
3426
expirationMs
3527
});
3628
};

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ export * from './common/models/engineEdgeData.model';
66
export * from './common/models/engineNodeData.model';
77
export * from './common/models/engineTrace.model';
88
export * from './common/models/engineTraceOptions.model';
9+
export * from './common/models/executionCache.model';
910
export * from './common/models/executionFunction.model';
1011
export * from './common/models/executionMemoization.model';
1112
export * from './common/models/executionTrace.model';
1213
export * from './common/models/timer.model';
1314
export * from './engine/executionEngine';
1415
export * from './engine/executionEngineDecorators';
1516
export * from './engine/traceableEngine';
17+
export * from './execution/cache.decorator';
18+
export * from './execution/cache';
1619
export * from './execution/execute';
1720
export * from './execution/memoize';
1821
export * from './execution/memoizeDecorator';

0 commit comments

Comments
 (0)