Skip to content
Merged
229 changes: 229 additions & 0 deletions lib/memoize/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
/* eslint-disable
no-unused-vars,
@typescript-eslint/explicit-function-return-type,
@typescript-eslint/no-unused-vars-experimental,
jsdoc/require-param-description
*/
/**
* Ignore eslint jsdoc rules for mocked class
* Ignore eslint unused vars rule for decorator
*/

import { memoize } from './index';

Check warning on line 12 in lib/memoize/index.test.ts

View workflow job for this annotation

GitHub Actions / ESlint

'memoize' is defined but never used
import Crypto from '../utils/crypto';

describe('memoize decorator — per-test inline classes', () => {
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
jest.clearAllMocks();
});

it('should memoize return value with concat strategy across several calls', async () => {
class Sample {
public calls = 0;

@memoize({ strategy: 'concat', ttl: 60_000, max: 50 })
public async run(a: number, b: string) {
this.calls += 1;
return `${a}-${b}`;
}
}

const sample = new Sample();

/**
* First call should memoize the method
*/
expect(await sample.run(1, 'x')).toBe('1-x');
/**
* In this case
*/
expect(await sample.run(1, 'x')).toBe('1-x');
expect(await sample.run(1, 'x')).toBe('1-x');

expect(sample.calls).toBe(1);
});

it('should memoize return value with set of arguments with concat strategy across several calls', async () => {
class Sample {
public calls = 0;

@memoize({ strategy: 'concat' })
public async run(a: unknown, b: unknown) {
this.calls += 1;
return `${String(a)}|${String(b)}`;
}
}

const sample = new Sample();

/**
* Fill the memoization cache with values
*/
await sample.run(1, 'a');
await sample.run(2, 'a');
await sample.run(1, 'b');
await sample.run(true, false);
await sample.run(undefined, null);

expect(sample.calls).toBe(5);

/**
* Those calls should not call the original method, they should return from memoize
*/
await sample.run(1, 'a');
await sample.run(2, 'a');
await sample.run(1, 'b');
await sample.run(true, false);
await sample.run(undefined, null);

expect(sample.calls).toBe(5);
});

it('should memoize return value for stringified objects across several calls', async () => {
class Sample {
public calls = 0;
@memoize({ strategy: 'concat' })
public async run(x: unknown, y: unknown) {
this.calls += 1;
return 'ok';
}
}
const sample = new Sample();
const o1 = { a: 1 };
const o2 = { b: 2 };

await sample.run(o1, o2);
await sample.run(o1, o2);

expect(sample.calls).toBe(1);
});

it('should memoize return value for method with non-default arguments (NaN, Infinity, -0, Symbol, Date, RegExp) still cache same-args', async () => {
class Sample {
public calls = 0;
@memoize({ strategy: 'concat' })
public async run(...args: unknown[]) {
this.calls += 1;
return args.map(String).join(',');
}
}
const sample = new Sample();

const sym = Symbol('t');
const d = new Date('2020-01-01T00:00:00Z');
const re = /a/i;

const first = await sample.run(NaN, Infinity, -0, sym, d, re);
const second = await sample.run(NaN, Infinity, -0, sym, d, re);

expect(second).toBe(first);
expect(sample.calls).toBe(1);
});

it('should call crypto hash with blake2b512 algo and base64url digest, should memoize return value with hash strategy', async () => {
const hashSpy = jest.spyOn(Crypto, 'hash');

class Sample {
public calls = 0;
@memoize({ strategy: 'hash' })
public async run(...args: unknown[]) {
this.calls += 1;
return 'ok';
}
}
const sample = new Sample();

await sample.run({a: 1}, undefined, 0);
await sample.run({a: 1}, undefined, 0);

expect(hashSpy).toHaveBeenCalledWith([{a: 1}, undefined, 0], 'blake2b512', 'base64url');
expect(sample.calls).toBe(1);
});

it('should not memoize return value with hash strategy and different arguments', async () => {
class Sample {
public calls = 0;
@memoize({ strategy: 'hash' })
public async run(...args: unknown[]) {
this.calls += 1;
return 'ok';
}
}
const sample = new Sample();

await sample.run({ v: 1 });
await sample.run({ v: 2 });
await sample.run({ v: 3 });

expect(sample.calls).toBe(3);
});

it('should memoize return value with hash strategy across several calls with same args', async () => {
class Sample {
public calls = 0;
@memoize({ strategy: 'hash' })
public async run(arg: unknown) {
this.calls += 1;
return 'ok';
}
}
const sample = new Sample();

await sample.run({ a: 1 });
await sample.run({ a: 1 });

expect(sample.calls).toBe(1);
});

it('should memoize return value exactly for passed ttl millis', async () => {
jest.resetModules();
jest.useFakeTimers({ legacyFakeTimers: false });
jest.setSystemTime(new Date('2025-01-01T00:00:00Z'));

const { memoize: memoizeWithMockedTimers } = await import('../memoize/index');

class Sample {
public calls = 0;
@memoizeWithMockedTimers({ strategy: 'concat', ttl: 1_000 })
public async run(x: string) {
this.calls += 1;
return x;
}
}
const sample = new Sample();

await sample.run('k1');
expect(sample.calls).toBe(1);

/**
* Skip time beyond the ttl
*/
jest.advanceTimersByTime(1_001);

await sample.run('k1');
expect(sample.calls).toBe(2);

});

it('error calls should never be momized', async () => {
class Sample {
public calls = 0;
@memoize()
public async run(x: number) {
this.calls += 1;
if (x === 1) throw new Error('boom');
return x * 2;
}
}
const sample = new Sample();

/**
* Compute with throw
*/
await expect(sample.run(1)).rejects.toThrow('boom');
await expect(sample.run(1)).rejects.toThrow('boom');
expect(sample.calls).toBe(2);
});
});
98 changes: 98 additions & 0 deletions lib/memoize/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import LRUCache from 'lru-cache';
import Crypto from '../utils/crypto';

/**
* Pick the strategy of cache key form
* It could be concatenated list of arguments like 'projectId:eventId'
* Or it could be hashed json object — blake2b512 algorithm
*/
export type MemoizeKeyStrategy = 'concat' | 'hash';

/**
* Options of the memoize decorator
*/
export interface MemoizeOptions {
/**
* Max number of values stored in LRU cache at the same time
*/
max?: number;

/**
* TTL in milliseconds
*/
ttl?: number;

/**
* Strategy for key generation
*/
strategy?: MemoizeKeyStrategy;
}

/**
* Async-only, per-method LRU-backed memoization decorator.
* Cache persists for the lifetime of the class instance (e.g. worker).
*
* @param options
*/
export function memoize(options: MemoizeOptions = {}): MethodDecorator {
/* eslint-disable @typescript-eslint/no-magic-numbers */
const {
max = 50,
ttl = 1000 * 60 * 30,
strategy = 'concat',
} = options;
/* eslint-enable */

return function (
_target,
propertyKey,
descriptor: PropertyDescriptor
): PropertyDescriptor {
const originalMethod = descriptor.value;

if (typeof originalMethod !== 'function') {
throw new Error('@Memoize can only decorate methods');
}

descriptor.value = async function (...args: unknown[]): Promise<unknown> {
/**
* Create a cache key for each decorated method
*/
const cacheKey = `memoizeCache:${String(propertyKey)}`;

/**
* Create a new cache if it does not exists yet (for certain function)
*/
const cache: LRUCache<string, any> = this[cacheKey] ??= new LRUCache<string, any>({
Comment thread
neSpecc marked this conversation as resolved.
max,
maxAge: ttl,
});

const key = strategy === 'hash'
? Crypto.hash(args, 'blake2b512', 'base64url')
: args.map((arg) => JSON.stringify(arg)).join('__ARG_JOIN__');

/**
* Check if we have a cached result
*/
const cachedResult = cache.get(key);

if (cachedResult !== undefined) {
return cachedResult;
}

try {
const result = await originalMethod.apply(this, args);

cache.set(key, result);

return result;
} catch (err) {
cache.del(key);
throw err;
}
};

return descriptor;
};
}
9 changes: 5 additions & 4 deletions lib/utils/crypto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import crypto from 'crypto';
import crypto, { BinaryToTextEncoding } from 'crypto';

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

return crypto.createHash(algo)
.update(stringifiedValue)
.digest('hex');
.digest(digest);
}
}
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */

/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
"experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */

/* Advanced Options */
Expand Down
5 changes: 3 additions & 2 deletions workers/javascript/package.json
Comment thread
neSpecc marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk-worker-javascript",
"version": "0.0.1",
"version": "0.1.0",
"description": "Handles messages from JavaScript Catcher",
"main": "src/index.ts",
"license": "UNLICENSED",
Expand All @@ -10,7 +10,8 @@
"@types/useragent": "^2.1.1",
"source-map-js": "^1.2.0",
"ts-node": "^8.3.0",
"typescript": "^3.5.3"
"typescript": "^3.5.3",
"lodash.clonedeep": "^4.5.0"
},
"dependencies": {
"useragent": "^2.3.0"
Expand Down
Loading
Loading