Skip to content

Commit fe4b341

Browse files
e11syneSpecc
andauthored
feat(): lru memoization for js worker (#466)
* feat(): lru memoization for js worker * chore(): add crypto hash params * chore(): cover with tests * chore(): cover with memoize entire beautifyBacktrace method * imp(): tests and types * chore(): lint fix * Update workers/javascript/package.json Co-authored-by: Peter <specc.dev@gmail.com> * Update workers/javascript/src/index.ts Co-authored-by: Peter <specc.dev@gmail.com> * chore(): update test * chore(): lint fix * chore(): remove test duplicate * test(): cover with different arguments case * chore(): test memoize util * chore(): lint fix --------- Co-authored-by: Peter <specc.dev@gmail.com>
1 parent 1de0465 commit fe4b341

File tree

9 files changed

+521
-67
lines changed

9 files changed

+521
-67
lines changed

lib/memoize/index.test.ts

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
/* eslint-disable
2+
no-unused-vars,
3+
@typescript-eslint/explicit-function-return-type,
4+
@typescript-eslint/no-unused-vars-experimental,
5+
jsdoc/require-param-description
6+
*/
7+
/**
8+
* Ignore eslint jsdoc rules for mocked class
9+
* Ignore eslint unused vars rule for decorator
10+
*/
11+
12+
import { memoize } from './index';
13+
import Crypto from '../utils/crypto';
14+
15+
describe('memoize decorator — per-test inline classes', () => {
16+
afterEach(() => {
17+
jest.useRealTimers();
18+
jest.restoreAllMocks();
19+
jest.clearAllMocks();
20+
});
21+
22+
it('should memoize return value with concat strategy across several calls', async () => {
23+
class Sample {
24+
public calls = 0;
25+
26+
@memoize({ strategy: 'concat', ttl: 60_000, max: 50 })
27+
public async run(a: number, b: string) {
28+
this.calls += 1;
29+
return `${a}-${b}`;
30+
}
31+
}
32+
33+
const sample = new Sample();
34+
35+
/**
36+
* First call should memoize the method
37+
*/
38+
expect(await sample.run(1, 'x')).toBe('1-x');
39+
/**
40+
* In this case
41+
*/
42+
expect(await sample.run(1, 'x')).toBe('1-x');
43+
expect(await sample.run(1, 'x')).toBe('1-x');
44+
45+
expect(sample.calls).toBe(1);
46+
});
47+
48+
it('should memoize return value with set of arguments with concat strategy across several calls', async () => {
49+
class Sample {
50+
public calls = 0;
51+
52+
@memoize({ strategy: 'concat' })
53+
public async run(a: unknown, b: unknown) {
54+
this.calls += 1;
55+
return `${String(a)}|${String(b)}`;
56+
}
57+
}
58+
59+
const sample = new Sample();
60+
61+
/**
62+
* Fill the memoization cache with values
63+
*/
64+
await sample.run(1, 'a');
65+
await sample.run(2, 'a');
66+
await sample.run(1, 'b');
67+
await sample.run(true, false);
68+
await sample.run(undefined, null);
69+
70+
expect(sample.calls).toBe(5);
71+
72+
/**
73+
* Those calls should not call the original method, they should return from memoize
74+
*/
75+
await sample.run(1, 'a');
76+
await sample.run(2, 'a');
77+
await sample.run(1, 'b');
78+
await sample.run(true, false);
79+
await sample.run(undefined, null);
80+
81+
expect(sample.calls).toBe(5);
82+
});
83+
84+
it('should memoize return value for stringified objects across several calls', async () => {
85+
class Sample {
86+
public calls = 0;
87+
@memoize({ strategy: 'concat' })
88+
public async run(x: unknown, y: unknown) {
89+
this.calls += 1;
90+
return 'ok';
91+
}
92+
}
93+
const sample = new Sample();
94+
const o1 = { a: 1 };
95+
const o2 = { b: 2 };
96+
97+
await sample.run(o1, o2);
98+
await sample.run(o1, o2);
99+
100+
expect(sample.calls).toBe(1);
101+
});
102+
103+
it('should memoize return value for method with non-default arguments (NaN, Infinity, -0, Symbol, Date, RegExp) still cache same-args', async () => {
104+
class Sample {
105+
public calls = 0;
106+
@memoize({ strategy: 'concat' })
107+
public async run(...args: unknown[]) {
108+
this.calls += 1;
109+
return args.map(String).join(',');
110+
}
111+
}
112+
const sample = new Sample();
113+
114+
const sym = Symbol('t');
115+
const d = new Date('2020-01-01T00:00:00Z');
116+
const re = /a/i;
117+
118+
const first = await sample.run(NaN, Infinity, -0, sym, d, re);
119+
const second = await sample.run(NaN, Infinity, -0, sym, d, re);
120+
121+
expect(second).toBe(first);
122+
expect(sample.calls).toBe(1);
123+
});
124+
125+
it('should call crypto hash with blake2b512 algo and base64url digest, should memoize return value with hash strategy', async () => {
126+
const hashSpy = jest.spyOn(Crypto, 'hash');
127+
128+
class Sample {
129+
public calls = 0;
130+
@memoize({ strategy: 'hash' })
131+
public async run(...args: unknown[]) {
132+
this.calls += 1;
133+
return 'ok';
134+
}
135+
}
136+
const sample = new Sample();
137+
138+
await sample.run({a: 1}, undefined, 0);
139+
await sample.run({a: 1}, undefined, 0);
140+
141+
expect(hashSpy).toHaveBeenCalledWith([{a: 1}, undefined, 0], 'blake2b512', 'base64url');
142+
expect(sample.calls).toBe(1);
143+
});
144+
145+
it('should not memoize return value with hash strategy and different arguments', async () => {
146+
class Sample {
147+
public calls = 0;
148+
@memoize({ strategy: 'hash' })
149+
public async run(...args: unknown[]) {
150+
this.calls += 1;
151+
return 'ok';
152+
}
153+
}
154+
const sample = new Sample();
155+
156+
await sample.run({ v: 1 });
157+
await sample.run({ v: 2 });
158+
await sample.run({ v: 3 });
159+
160+
expect(sample.calls).toBe(3);
161+
});
162+
163+
it('should memoize return value with hash strategy across several calls with same args', async () => {
164+
class Sample {
165+
public calls = 0;
166+
@memoize({ strategy: 'hash' })
167+
public async run(arg: unknown) {
168+
this.calls += 1;
169+
return 'ok';
170+
}
171+
}
172+
const sample = new Sample();
173+
174+
await sample.run({ a: 1 });
175+
await sample.run({ a: 1 });
176+
177+
expect(sample.calls).toBe(1);
178+
});
179+
180+
it('should memoize return value exactly for passed ttl millis', async () => {
181+
jest.resetModules();
182+
jest.useFakeTimers({ legacyFakeTimers: false });
183+
jest.setSystemTime(new Date('2025-01-01T00:00:00Z'));
184+
185+
const { memoize: memoizeWithMockedTimers } = await import('../memoize/index');
186+
187+
class Sample {
188+
public calls = 0;
189+
@memoizeWithMockedTimers({ strategy: 'concat', ttl: 1_000 })
190+
public async run(x: string) {
191+
this.calls += 1;
192+
return x;
193+
}
194+
}
195+
const sample = new Sample();
196+
197+
await sample.run('k1');
198+
expect(sample.calls).toBe(1);
199+
200+
/**
201+
* Skip time beyond the ttl
202+
*/
203+
jest.advanceTimersByTime(1_001);
204+
205+
await sample.run('k1');
206+
expect(sample.calls).toBe(2);
207+
208+
});
209+
210+
it('error calls should never be momized', async () => {
211+
class Sample {
212+
public calls = 0;
213+
@memoize()
214+
public async run(x: number) {
215+
this.calls += 1;
216+
if (x === 1) throw new Error('boom');
217+
return x * 2;
218+
}
219+
}
220+
const sample = new Sample();
221+
222+
/**
223+
* Compute with throw
224+
*/
225+
await expect(sample.run(1)).rejects.toThrow('boom');
226+
await expect(sample.run(1)).rejects.toThrow('boom');
227+
expect(sample.calls).toBe(2);
228+
});
229+
});

lib/memoize/index.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import LRUCache from 'lru-cache';
2+
import Crypto from '../utils/crypto';
3+
4+
/**
5+
* Pick the strategy of cache key form
6+
* It could be concatenated list of arguments like 'projectId:eventId'
7+
* Or it could be hashed json object — blake2b512 algorithm
8+
*/
9+
export type MemoizeKeyStrategy = 'concat' | 'hash';
10+
11+
/**
12+
* Options of the memoize decorator
13+
*/
14+
export interface MemoizeOptions {
15+
/**
16+
* Max number of values stored in LRU cache at the same time
17+
*/
18+
max?: number;
19+
20+
/**
21+
* TTL in milliseconds
22+
*/
23+
ttl?: number;
24+
25+
/**
26+
* Strategy for key generation
27+
*/
28+
strategy?: MemoizeKeyStrategy;
29+
}
30+
31+
/**
32+
* Async-only, per-method LRU-backed memoization decorator.
33+
* Cache persists for the lifetime of the class instance (e.g. worker).
34+
*
35+
* @param options
36+
*/
37+
export function memoize(options: MemoizeOptions = {}): MethodDecorator {
38+
/* eslint-disable @typescript-eslint/no-magic-numbers */
39+
const {
40+
max = 50,
41+
ttl = 1000 * 60 * 30,
42+
strategy = 'concat',
43+
} = options;
44+
/* eslint-enable */
45+
46+
return function (
47+
_target,
48+
propertyKey,
49+
descriptor: PropertyDescriptor
50+
): PropertyDescriptor {
51+
const originalMethod = descriptor.value;
52+
53+
if (typeof originalMethod !== 'function') {
54+
throw new Error('@Memoize can only decorate methods');
55+
}
56+
57+
descriptor.value = async function (...args: unknown[]): Promise<unknown> {
58+
/**
59+
* Create a cache key for each decorated method
60+
*/
61+
const cacheKey = `memoizeCache:${String(propertyKey)}`;
62+
63+
/**
64+
* Create a new cache if it does not exists yet (for certain function)
65+
*/
66+
const cache: LRUCache<string, any> = this[cacheKey] ??= new LRUCache<string, any>({
67+
max,
68+
maxAge: ttl,
69+
});
70+
71+
const key = strategy === 'hash'
72+
? Crypto.hash(args, 'blake2b512', 'base64url')
73+
: args.map((arg) => JSON.stringify(arg)).join('__ARG_JOIN__');
74+
75+
/**
76+
* Check if we have a cached result
77+
*/
78+
const cachedResult = cache.get(key);
79+
80+
if (cachedResult !== undefined) {
81+
return cachedResult;
82+
}
83+
84+
try {
85+
const result = await originalMethod.apply(this, args);
86+
87+
cache.set(key, result);
88+
89+
return result;
90+
} catch (err) {
91+
cache.del(key);
92+
throw err;
93+
}
94+
};
95+
96+
return descriptor;
97+
};
98+
}

lib/utils/crypto.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import crypto from 'crypto';
1+
import crypto, { BinaryToTextEncoding } from 'crypto';
22

33
/**
44
* Crypto helper
@@ -9,12 +9,13 @@ export default class Crypto {
99
*
1010
* @param value — data to be hashed
1111
* @param algo — type of algorithm to be used for hashing
12+
* @param digest - type of the representation of the hashed value
1213
*/
13-
public static hash(value: unknown, algo = 'sha256'): string {
14-
const stringifiedValue = JSON.stringify(value);
14+
public static hash(value: unknown, algo = 'sha256', digest: BinaryToTextEncoding = 'hex'): string {
15+
const stringifiedValue = typeof value === 'string' ? value : JSON.stringify(value);
1516

1617
return crypto.createHash(algo)
1718
.update(stringifiedValue)
18-
.digest('hex');
19+
.digest(digest);
1920
}
2021
}

tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
5959

6060
/* Experimental Options */
61-
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
61+
"experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
6262
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
6363

6464
/* Advanced Options */

workers/javascript/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hawk-worker-javascript",
3-
"version": "0.0.1",
3+
"version": "0.1.0",
44
"description": "Handles messages from JavaScript Catcher",
55
"main": "src/index.ts",
66
"license": "UNLICENSED",
@@ -10,7 +10,8 @@
1010
"@types/useragent": "^2.1.1",
1111
"source-map-js": "^1.2.0",
1212
"ts-node": "^8.3.0",
13-
"typescript": "^3.5.3"
13+
"typescript": "^3.5.3",
14+
"lodash.clonedeep": "^4.5.0"
1415
},
1516
"dependencies": {
1617
"useragent": "^2.3.0"

0 commit comments

Comments
 (0)