Skip to content

Commit 21f6ce0

Browse files
committed
feat: add cache decorator
1 parent 2cd9739 commit 21f6ce0

8 files changed

Lines changed: 308 additions & 9 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { FunctionMetadata } from './executionFunction.model';
2+
3+
/**
4+
* A handler function that processes the cache context.
5+
*/
6+
export type CacheHandler<O> = (info: CacheContext<O>) => void;
7+
8+
export interface CacheStore {
9+
/** Create a key/value pair in the cache. */
10+
set<T>(key: string, value: T, ttl?: number): Promise<T>;
11+
12+
/** Retrieve a key/value pair from the cache. */
13+
get<T>(key: string): Promise<T | undefined> | T | undefined;
14+
15+
// /** Destroy a key/value pair from the cache. */
16+
// del?(key: string): void | Promise<void>;
17+
}
18+
19+
export type TtlFunction = ((params: { metadata: FunctionMetadata, inputs: unknown[] }) => number);
20+
21+
export interface CacheOptions<O = unknown> {
22+
/** Unique identifier for the function being memoized */
23+
functionId?: string;
24+
/**
25+
* Time-to-live (TTL) for cache items. Can be static (number) or dynamic (function that returns a number).
26+
* @example 3600 (TTL in seconds) or (args: unknown[]) => number
27+
*/
28+
ttl: number | TtlFunction,
29+
/**
30+
* Function to generate the cache key based on method arguments.
31+
* @param args - Arguments passed to the method.
32+
* @returns A string cache key.
33+
*/
34+
cacheKeyResolver?: (params: { metadata: FunctionMetadata; inputs: unknown[] }) => string;
35+
/**
36+
* The cache provider or manager used for storing the cache (e.g., in-memory or Redis).
37+
*/
38+
cacheManager?: CacheStore | ((...args: unknown[]) => CacheStore),
39+
/**
40+
* Optionally, log cache hits, misses, and operations.
41+
* @default false
42+
*/
43+
cacheHandler?: CacheHandler<O>
44+
}
45+
46+
export interface CacheContext<O = unknown> {
47+
metadata: FunctionMetadata;
48+
ttl: number;
49+
inputs: Array<unknown>;
50+
inputsHash: string;
51+
isCached: boolean;
52+
value?: O;
53+
}

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+
}

src/common/utils/wrapCallback.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { FunctionMetadata } from '../models/executionFunction.model';
2+
3+
/**
4+
* Wraps a function with additional metadata or returns the value as-is.
5+
* If `paramOrFunction` is a function, it binds `thisMethodMetadata` to it.
6+
*
7+
* @returns The original value or a function with bound metadata.
8+
*/
9+
export function wrapCallBackWithMetadata<O = unknown>(paramOrFunction: O | undefined, thisMethodMetadata: FunctionMetadata): O | undefined {
10+
return typeof paramOrFunction === 'function'
11+
? (
12+
// eslint-disable-next-line unused-imports/no-unused-vars
13+
({ metadata, ...rest }: { metadata: FunctionMetadata }): O => {
14+
return paramOrFunction.bind(this)({ ...rest, metadata: thisMethodMetadata });
15+
}
16+
) as O
17+
: paramOrFunction;
18+
}

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 { extractClassMethodMetadata } from '../common/utils/functionMetadata';
5+
import { wrapCallBackWithMetadata } from '../common/utils/wrapCallback';
6+
7+
/**
8+
* Caches method results to avoid redundant executions.
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+
* Uses `executeCache` to store results based on a unique key.
15+
* Cache behavior can be customized via `cacheKeyResolver`, `ttl`, and `cacheHandler`.
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+
cacheKeyResolver: wrapCallBackWithMetadata.bind(this)(options.cacheKeyResolver, thisMethodMetadata),
26+
ttl: wrapCallBackWithMetadata.bind(this)(options.ttl, thisMethodMetadata),
27+
cacheHandler: wrapCallBackWithMetadata.bind(this)(options.cacheHandler, thisMethodMetadata)
28+
});
29+
};
30+
};
31+
}

src/execution/cache.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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+
* Executes a function with cache to prevent redundant executions.
11+
* The result is stored temporarily and cleared after a short delay.
12+
*/
13+
export async function executeCache<O>(
14+
blockFunction: (...params: unknown[]) => O | Promise<O>,
15+
inputs: Array<unknown> = [],
16+
options: CacheOptions & { functionId: string }
17+
): Promise<Promise<O> | O> {
18+
const functionMetadata = extractFunctionMetadata(blockFunction);
19+
const inputsHash =
20+
options.cacheKeyResolver?.({
21+
metadata: functionMetadata,
22+
inputs
23+
}) ?? options.functionId + generateHashId(...inputs);
24+
const expirationMs = typeof options.ttl === 'function' ? options.ttl({ metadata: functionMetadata, inputs }) : options.ttl;
25+
26+
if (!expirationMs) {
27+
if (typeof options.cacheHandler === 'function') {
28+
options.cacheHandler({ ttl: expirationMs, metadata: functionMetadata, inputs, inputsHash, isCached: false });
29+
}
30+
return (execute.bind(this) as typeof execute)(
31+
blockFunction.bind(this) as typeof blockFunction,
32+
inputs,
33+
[],
34+
(res) => res,
35+
(error) => {
36+
throw error;
37+
}
38+
);
39+
}
40+
41+
42+
let cacheStore: CacheStore | MapCacheStore<O> = new MapCacheStore<O>(this[cacheStoreKey], options.functionId);
43+
if (options.cacheManager) {
44+
cacheStore = typeof options.cacheManager === 'function' ? options.cacheManager(this) : options.cacheManager;
45+
}
46+
const cachedValue: O = (await cacheStore.get(inputsHash)) as O;
47+
48+
if (typeof options.cacheHandler === 'function') {
49+
options.cacheHandler({
50+
ttl: expirationMs,
51+
metadata: functionMetadata,
52+
inputs,
53+
inputsHash,
54+
isCached: !!cachedValue,
55+
value: cachedValue
56+
});
57+
}
58+
59+
if (cachedValue) {
60+
return cachedValue;
61+
} else {
62+
return (execute.bind(this) as typeof execute)(
63+
blockFunction.bind(this) as typeof blockFunction,
64+
inputs,
65+
[],
66+
(res) => {
67+
cacheStore.set(inputsHash, res as O, expirationMs);
68+
if((cacheStore as MapCacheStore<O>).fullStorage) {
69+
this[cacheStoreKey] = (cacheStore as MapCacheStore<O>).fullStorage;
70+
}
71+
return res;
72+
},
73+
(error) => {
74+
throw error;
75+
}
76+
);
77+
}
78+
}
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+
cacheHandler: (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+
cacheHandler: (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/memoizeDecorator.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { executeMemoize } from './memoize';
22
import { FunctionMetadata } from '../common/models/executionFunction.model';
33
import { MemoizationHandler } from '../common/models/executionMemoization.model';
44
import { extractClassMethodMetadata } from '../common/utils/functionMetadata';
5+
import { wrapCallBackWithMetadata } from '../common/utils/wrapCallback';
56

67
/**
78
* Decorator to memoize method executions and prevent redundant calls.
@@ -22,15 +23,7 @@ export function memoize<O>(memoizationHandler?: MemoizationHandler<O>, expiratio
2223
const thisMethodMetadata: FunctionMetadata = extractClassMethodMetadata(target.constructor.name, propertyKey, originalMethod);
2324
return (executeMemoize.bind(this) as typeof executeMemoize<O>)(originalMethod.bind(this), args, {
2425
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,
26+
memoizationHandler: wrapCallBackWithMetadata.bind(this)(memoizationHandler, thisMethodMetadata),
3427
expirationMs
3528
});
3629
};

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)