From 7c3feded721fe4478dfdb640b41d38576866d54e Mon Sep 17 00:00:00 2001 From: e11sy <130844513+e11sy@users.noreply.github.com> Date: Thu, 16 Oct 2025 02:46:01 +0300 Subject: [PATCH 01/14] feat(): lru memoization for js worker --- lib/memoize/index.ts | 84 +++++++++++++++++++++++++++++++++ lib/utils/crypto.ts | 6 +-- tsconfig.json | 2 +- workers/javascript/src/index.ts | 16 +++++-- 4 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 lib/memoize/index.ts diff --git a/lib/memoize/index.ts b/lib/memoize/index.ts new file mode 100644 index 000000000..2e5d5e05a --- /dev/null +++ b/lib/memoize/index.ts @@ -0,0 +1,84 @@ +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 algorithn + */ +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). + */ +export function Memoize(options: MemoizeOptions = {}): MethodDecorator { + const { + max = 50, + ttl = 1000 * 60 * 30, + strategy = 'concat', + } = options; + + 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 { + /** + * 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 = this[cacheKey] ??= new LRUCache({ max, ttl }); + + const key = strategy === 'hash' + ? Crypto.hash(args) + : args.map(String).join(':'); + + if (cache.has(key)) { + return cache.get(key); + } + + try { + const result = await originalMethod.apply(this, args); + cache.set(key, result); + return result; + } catch (err) { + cache.delete(key); + throw err; + } + }; + + return descriptor; + }; +} diff --git a/lib/utils/crypto.ts b/lib/utils/crypto.ts index f4785c8a4..7196cd3b3 100644 --- a/lib/utils/crypto.ts +++ b/lib/utils/crypto.ts @@ -1,4 +1,4 @@ -import crypto from 'crypto'; +import crypto, { BinaryToTextEncoding } from 'crypto'; /** * Crypto helper @@ -10,11 +10,11 @@ export default class Crypto { * @param value — data to be hashed * @param algo — type of algorithm to be used for hashing */ - public static hash(value: unknown, algo = 'sha256'): string { + public static hash(value: unknown, algo = 'sha256', digest: BinaryToTextEncoding = 'hex'): string { const stringifiedValue = JSON.stringify(value); return crypto.createHash(algo) .update(stringifiedValue) - .digest('hex'); + .digest(digest); } } diff --git a/tsconfig.json b/tsconfig.json index 1f1597bb5..8dbeae476 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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 */ diff --git a/workers/javascript/src/index.ts b/workers/javascript/src/index.ts index cc4fd2ea5..069fe4b15 100644 --- a/workers/javascript/src/index.ts +++ b/workers/javascript/src/index.ts @@ -14,6 +14,9 @@ import { beautifyUserAgent } from './utils'; import { Collection } from 'mongodb'; import { parse } from '@babel/parser'; import traverse from '@babel/traverse'; +import { Memoize } from '../../../lib/memoize'; + +const MEMOIZATION_TTL = Number(process.env.MEMOIZATION_TTL ?? 0); /** * Worker for handling Javascript events @@ -234,9 +237,9 @@ export default class JavascriptEventWorker extends EventWorker { */ lines = this.readSourceLines(consumer, originalLocation); - // const originalContent = consumer.sourceContentFor(originalLocation.source); + const originalContent = consumer.sourceContentFor(originalLocation.source); - // functionContext = this.getFunctionContext(originalContent, originalLocation.line) ?? originalLocation.name; + functionContext = this.getFunctionContext(originalContent, originalLocation.line) ?? originalLocation.name; } catch(e) { HawkCatcher.send(e); this.logger.error('Can\'t get function context'); @@ -260,7 +263,8 @@ export default class JavascriptEventWorker extends EventWorker { * @param line - number of the line from the stack trace * @returns {string | null} - string of the function context or null if it could not be parsed */ - private _getFunctionContext(sourceCode: string, line: number): string | null { + @Memoize({ max: 50, ttl: MEMOIZATION_TTL, strategy: 'hash' }) + private getFunctionContext(sourceCode: string, line: number): string | null { let functionName: string | null = null; let className: string | null = null; let isAsync = false; @@ -363,11 +367,12 @@ export default class JavascriptEventWorker extends EventWorker { * * @param map - saved file info without content. */ - private loadSourceMapFile(map: SourceMapDataExtended): Promise { + @Memoize({ max: 50, ttl: MEMOIZATION_TTL }) + private loadSourceMapFile(mapId: SourceMapDataExtended['_id']): Promise { return new Promise((resolve, reject) => { let buf = Buffer.from(''); - const readstream = this.db.getBucket().openDownloadStream(map._id) + const readstream = this.db.getBucket().openDownloadStream(mapId) .on('data', (chunk) => { buf = Buffer.concat([buf, chunk]); }) @@ -450,6 +455,7 @@ export default class JavascriptEventWorker extends EventWorker { * * @param {string} mapBody - source map content */ + @Memoize({ max: 50, ttl: MEMOIZATION_TTL, strategy: 'hash' }) private consumeSourceMap(mapBody: string): SourceMapConsumer { try { const rawSourceMap = JSON.parse(mapBody); From 712c1064ee71b61e666cd911f2ad88c6a1e68141 Mon Sep 17 00:00:00 2001 From: e11sy <130844513+e11sy@users.noreply.github.com> Date: Thu, 16 Oct 2025 03:05:59 +0300 Subject: [PATCH 02/14] chore(): add crypto hash params --- lib/memoize/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/memoize/index.ts b/lib/memoize/index.ts index 2e5d5e05a..b77c5ddf6 100644 --- a/lib/memoize/index.ts +++ b/lib/memoize/index.ts @@ -62,7 +62,7 @@ export function Memoize(options: MemoizeOptions = {}): MethodDecorator { const cache: LRUCache = this[cacheKey] ??= new LRUCache({ max, ttl }); const key = strategy === 'hash' - ? Crypto.hash(args) + ? Crypto.hash(args, 'blake2b512', 'base64url') : args.map(String).join(':'); if (cache.has(key)) { From ed7227034ce50b7f8ac9088f20f391da5aa829fa Mon Sep 17 00:00:00 2001 From: e11sy <130844513+e11sy@users.noreply.github.com> Date: Mon, 20 Oct 2025 01:36:12 +0300 Subject: [PATCH 03/14] chore(): cover with tests --- lib/memoize/index.ts | 22 +++- lib/utils/crypto.ts | 3 +- workers/javascript/src/index.ts | 18 +-- workers/javascript/tests/index.test.ts | 154 +++++++++++++++++++++++-- 4 files changed, 178 insertions(+), 19 deletions(-) diff --git a/lib/memoize/index.ts b/lib/memoize/index.ts index b77c5ddf6..4a78a50e1 100644 --- a/lib/memoize/index.ts +++ b/lib/memoize/index.ts @@ -31,13 +31,17 @@ export interface MemoizeOptions { /** * 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 { +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, @@ -59,19 +63,29 @@ export function Memoize(options: MemoizeOptions = {}): MethodDecorator { /** * Create a new cache if it does not exists yet (for certain function) */ - const cache: LRUCache = this[cacheKey] ??= new LRUCache({ max, ttl }); + const cache: LRUCache = this[cacheKey] ??= new LRUCache({ + max, + ttl, + }); const key = strategy === 'hash' ? Crypto.hash(args, 'blake2b512', 'base64url') : args.map(String).join(':'); - if (cache.has(key)) { - return cache.get(key); + /** + * 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.delete(key); diff --git a/lib/utils/crypto.ts b/lib/utils/crypto.ts index 7196cd3b3..74f1a2c1b 100644 --- a/lib/utils/crypto.ts +++ b/lib/utils/crypto.ts @@ -9,9 +9,10 @@ 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', digest: BinaryToTextEncoding = 'hex'): string { - const stringifiedValue = JSON.stringify(value); + const stringifiedValue = typeof value === 'string' ? value : JSON.stringify(value); return crypto.createHash(algo) .update(stringifiedValue) diff --git a/workers/javascript/src/index.ts b/workers/javascript/src/index.ts index 069fe4b15..02e36390c 100644 --- a/workers/javascript/src/index.ts +++ b/workers/javascript/src/index.ts @@ -14,8 +14,13 @@ import { beautifyUserAgent } from './utils'; import { Collection } from 'mongodb'; import { parse } from '@babel/parser'; import traverse from '@babel/traverse'; -import { Memoize } from '../../../lib/memoize'; +/* eslint-disable-next-line no-unused-vars */ +import { memoize } from '../../../lib/memoize'; +/** + * eslint does not count decorators as a variable usage + */ +/* eslint-disable-next-line no-unused-vars */ const MEMOIZATION_TTL = Number(process.env.MEMOIZATION_TTL ?? 0); /** @@ -239,8 +244,8 @@ export default class JavascriptEventWorker extends EventWorker { const originalContent = consumer.sourceContentFor(originalLocation.source); - functionContext = this.getFunctionContext(originalContent, originalLocation.line) ?? originalLocation.name; - } catch(e) { + functionContext = await this.getFunctionContext(originalContent, originalLocation.line) ?? originalLocation.name; + } catch (e) { HawkCatcher.send(e); this.logger.error('Can\'t get function context'); this.logger.error(e); @@ -263,7 +268,7 @@ export default class JavascriptEventWorker extends EventWorker { * @param line - number of the line from the stack trace * @returns {string | null} - string of the function context or null if it could not be parsed */ - @Memoize({ max: 50, ttl: MEMOIZATION_TTL, strategy: 'hash' }) + @memoize({ max: 50, ttl: MEMOIZATION_TTL, strategy: 'hash' }) private getFunctionContext(sourceCode: string, line: number): string | null { let functionName: string | null = null; let className: string | null = null; @@ -365,9 +370,9 @@ export default class JavascriptEventWorker extends EventWorker { /** * Downloads source map file from Grid FS * - * @param map - saved file info without content. + * @param mapId - id of the map file in the bucket */ - @Memoize({ max: 50, ttl: MEMOIZATION_TTL }) + @memoize({ max: 50, ttl: MEMOIZATION_TTL }) private loadSourceMapFile(mapId: SourceMapDataExtended['_id']): Promise { return new Promise((resolve, reject) => { let buf = Buffer.from(''); @@ -455,7 +460,6 @@ export default class JavascriptEventWorker extends EventWorker { * * @param {string} mapBody - source map content */ - @Memoize({ max: 50, ttl: MEMOIZATION_TTL, strategy: 'hash' }) private consumeSourceMap(mapBody: string): SourceMapConsumer { try { const rawSourceMap = JSON.parse(mapBody); diff --git a/workers/javascript/tests/index.test.ts b/workers/javascript/tests/index.test.ts index 7fff5c15f..367fa9677 100644 --- a/workers/javascript/tests/index.test.ts +++ b/workers/javascript/tests/index.test.ts @@ -1,7 +1,7 @@ import JavascriptEventWorker from '../src'; import '../../../env-test'; import { JavaScriptEventWorkerTask } from '../types/javascript-event-worker-task'; -import { Db, MongoClient, ObjectId } from 'mongodb'; +import { Db, GridFSBucket, MongoClient, ObjectId } from 'mongodb'; import * as WorkerNames from '../../../lib/workerNames'; import { ReleaseDBScheme } from '@hawk.so/types'; @@ -93,6 +93,38 @@ describe('JavaScript event worker', () => { } ], }; + /** + * Helper to build a mocked GridFS read stream that emits provided content + * + * @param content - content of the source map stored in the bucket + */ + /* eslint-disable-next-line @typescript-eslint/explicit-function-return-type */ + const makeMockGridFsReadStream = (content: string) => { + const handlers: Record void)[]> = {}; + + const stream = { + on(event: 'data' | 'error' | 'end', cb: (...args: any[]) => void) { + handlers[event] = handlers[event] || []; + handlers[event].push(cb); + + return stream; + }, + destroy: jest.fn(), + } as any; + + // Emit asynchronously to mimic real streams + setImmediate(() => { + if (handlers['data']) { + handlers['data'].forEach((cb) => cb(Buffer.from(content))); + } + if (handlers['end']) { + handlers['end'].forEach((cb) => cb()); + } + }); + + return stream; + }; + /** * Creates event object for JS worker * @@ -155,10 +187,14 @@ describe('JavaScript event worker', () => { useNewUrlParser: true, useUnifiedTopology: true, }); - db = connection.db('hawk'); + db = connection.db(); // Use default database from connection URI, same as worker + }); + + afterEach(() => { + jest.restoreAllMocks(); }); - itIf('should process an event without errors and add a task with correct event information to grouper', async () => { + it('should process an event without errors and add a task with correct event information to grouper', async () => { /** * Arrange */ @@ -190,7 +226,7 @@ describe('JavaScript event worker', () => { await worker.finish(); }); - itIf('should parse user agent correctly', async () => { + it('should parse user agent correctly', async () => { /** * Arrange */ @@ -229,7 +265,7 @@ describe('JavaScript event worker', () => { await worker.finish(); }); - itIf('should parse source maps correctly', async () => { + it('should parse source maps correctly', async () => { /** * Arrange */ @@ -312,7 +348,111 @@ describe('JavaScript event worker', () => { await worker.finish(); }); - afterAll(async () => { - await connection.close(); + it('should memoize loadSourceMapFile within single handle (parallel processing may cause multiple calls)', async () => { + // Arrange + const worker = new JavascriptEventWorker(); + + await worker.start(); + + // Create event with two frames mapping to the same origin file + const workerEvent = { + ...createEventMock({ withBacktrace: true }), + } as JavaScriptEventWorkerTask; + + workerEvent.payload.backtrace = [ + { + file: 'file:///main.js', + line: 1, + column: 100, + }, + { + file: 'file:///main.js', + line: 1, + column: 200, + }, + ] as any; + + // Create a release with a single map file used by both frames + const singleMapRelease = { + ...createReleaseMock({ + projectId: workerEvent.projectId, + release: workerEvent.payload.release, + }), + } as any; + const firstFileId = singleMapRelease.files[0]._id; + + singleMapRelease.files = [ + { + mapFileName: 'main.js.map', + originFileName: 'main.js', + _id: firstFileId, + }, + ]; + + await db.collection('releases').insertOne(singleMapRelease); + + const openDownloadStreamSpy = jest + .spyOn(GridFSBucket.prototype, 'openDownloadStream') + .mockImplementation(() => makeMockGridFsReadStream(sourceMapFileContent)); + + // Act + await worker.handle(workerEvent); + + // Assert: Due to parallel processing, both frames call loadSourceMapFile before caching + // This is expected behavior - the memoization prevents subsequent calls + expect(openDownloadStreamSpy).toHaveBeenCalledTimes(2); + + await worker.finish(); + }); + + it('should memoize loadSourceMapFile across multiple handles (DB called once)', async () => { + // Arrange + const worker = new JavascriptEventWorker(); + + await worker.start(); + + const workerEvent = { + ...createEventMock({ withBacktrace: true }), + } as JavaScriptEventWorkerTask; + + workerEvent.payload.backtrace = [ + { + file: 'file:///main.js', + line: 1, + column: 100, + }, + ] as any; + + const release = { + ...createReleaseMock({ + projectId: workerEvent.projectId, + release: workerEvent.payload.release, + }), + } as any; + const mapId = release.files[0]._id; + + release.files = [ + { + mapFileName: 'main.js.map', + originFileName: 'main.js', + _id: mapId, + }, + ]; + + await db.collection('releases').insertOne(release); + + const bucket = (worker as any).db.getBucket(); + const openDownloadStreamSpy = jest + .spyOn(bucket, 'openDownloadStream') + .mockImplementation(() => makeMockGridFsReadStream(sourceMapFileContent)); + + // Act: handle the same event twice + await worker.handle(workerEvent); + await worker.handle(workerEvent); + + // Assert: stream opened once thanks to @memoize on loadSourceMapFile + expect(openDownloadStreamSpy).toHaveBeenCalledTimes(1); + + await worker.finish(); }); }); From c51fa74ab1f1c39982b8083c561bb43697b3bbaa Mon Sep 17 00:00:00 2001 From: e11sy <130844513+e11sy@users.noreply.github.com> Date: Tue, 21 Oct 2025 21:17:36 +0300 Subject: [PATCH 04/14] chore(): cover with memoize entire beautifyBacktrace method --- lib/memoize/index.ts | 4 +- workers/javascript/package.json | 3 +- workers/javascript/src/index.ts | 65 +++++++++---------- workers/javascript/tests/index.test.ts | 16 +++-- .../types/beautify-backtrace-payload.d.ts | 8 +++ .../types/javascript-event-worker-task.d.ts | 7 ++ yarn.lock | 5 -- 7 files changed, 59 insertions(+), 49 deletions(-) create mode 100644 workers/javascript/types/beautify-backtrace-payload.d.ts diff --git a/lib/memoize/index.ts b/lib/memoize/index.ts index 4a78a50e1..5a9b0306a 100644 --- a/lib/memoize/index.ts +++ b/lib/memoize/index.ts @@ -4,7 +4,7 @@ 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 algorithn + * Or it could be hashed json object — blake2b512 algorithm */ export type MemoizeKeyStrategy = 'concat' | 'hash'; @@ -70,7 +70,7 @@ export function memoize(options: MemoizeOptions = {}): MethodDecorator { const key = strategy === 'hash' ? Crypto.hash(args, 'blake2b512', 'base64url') - : args.map(String).join(':'); + : args.map(String).join('__ARG_JOIN__'); /** * Check if we have a cached result diff --git a/workers/javascript/package.json b/workers/javascript/package.json index 4cd137f0a..95b2938fc 100644 --- a/workers/javascript/package.json +++ b/workers/javascript/package.json @@ -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" diff --git a/workers/javascript/src/index.ts b/workers/javascript/src/index.ts index 02e36390c..5e634bb48 100644 --- a/workers/javascript/src/index.ts +++ b/workers/javascript/src/index.ts @@ -7,8 +7,8 @@ import { GroupWorkerTask } from '../../grouper/types/group-worker-task'; import { SourceMapsRecord } from '../../release/types'; import * as pkg from '../package.json'; import { JavaScriptEventWorkerTask } from '../types/javascript-event-worker-task'; +import { BeautifyBacktracePayload } from '../types/beautify-backtrace-payload' import HawkCatcher from '@hawk.so/nodejs'; -import Crypto from '../../../lib/utils/crypto'; import { BacktraceFrame, CatcherMessagePayload, CatcherMessageType, ErrorsCatcherType, SourceCodeLine, SourceMapDataExtended } from '@hawk.so/types'; import { beautifyUserAgent } from './utils'; import { Collection } from 'mongodb'; @@ -77,7 +77,11 @@ export default class JavascriptEventWorker extends EventWorker { this.logger.info('beautifyBacktrace called'); try { - event.payload.backtrace = await this.beautifyBacktrace(event); + event.payload.backtrace = await this.beautifyBacktrace({ + projectId: event.projectId, + release: event.payload.release.toString(), + backtrace: event.payload.backtrace, + }); } catch (err) { this.logger.error('Error while beautifing backtrace', err); } @@ -102,13 +106,16 @@ export default class JavascriptEventWorker extends EventWorker { * @param {JavaScriptEventWorkerTask} event — js error minified * @returns {BacktraceFrame[]} - parsed backtrace */ - private async beautifyBacktrace(event: JavaScriptEventWorkerTask): Promise { + @memoize({ max: 50, ttl: MEMOIZATION_TTL, strategy: 'hash' }) + private async beautifyBacktrace({ projectId, release, backtrace }: BeautifyBacktracePayload): Promise { + console.log('method call?') + const releaseRecord: SourceMapsRecord = await this.cache.get( - `releaseRecord:${event.projectId}:${event.payload.release.toString()}`, + `releaseRecord:${projectId}:${release}`, () => { return this.getReleaseRecord( - event.projectId, - event.payload.release.toString() + projectId, + release ); } ); @@ -116,7 +123,7 @@ export default class JavascriptEventWorker extends EventWorker { if (!releaseRecord) { this.logger.info('beautifyBacktrace: no releaseRecord found'); - return event.payload.backtrace; + return backtrace; } this.logger.info(`beautifyBacktrace: release record found: ${JSON.stringify(releaseRecord)}`); @@ -124,30 +131,23 @@ export default class JavascriptEventWorker extends EventWorker { /** * If we have a source map associated with passed release, override some values in backtrace with original line/file */ - return Promise.all(event.payload.backtrace.map(async (frame: BacktraceFrame, index: number) => { + return Promise.all(backtrace.map(async (frame: BacktraceFrame, index: number) => { /** - * Get cached (or set if the value is missing) real backtrace frame + * Consume rbacktrace frame and catch errors (send them to hawk) */ - const result = await this.cache.get( - `consumeBacktraceFrame:${event.payload.release.toString()}:${Crypto.hash(frame)}:${index}`, - () => { - return this.consumeBacktraceFrame(frame, releaseRecord) - .catch((error) => { - this.logger.error('Error while consuming ' + error.stack); - - /** - * Send error to Hawk - */ - HawkCatcher.send(error, { - payload: event.payload as unknown as Record, - }); - - return event.payload.backtrace[index]; - }); - } - ); - - return result; + return await this.consumeBacktraceFrame(frame, releaseRecord) + .catch((error) => { + this.logger.error('Error while consuming ' + error.stack); + + /** + * Send error to Hawk + */ + HawkCatcher.send(error, { + payload: backtrace as unknown as Record, + }); + + return backtrace[index]; + }); })); } @@ -197,7 +197,7 @@ export default class JavascriptEventWorker extends EventWorker { /** * Load source map content from Grid fs */ - const mapContent = await this.loadSourceMapFile(mapForFrame); + const mapContent = await this.loadSourceMapFile(mapForFrame._id); if (!mapContent) { this.logger.info(`consumeBacktraceFrame: Can't load map content for ${JSON.stringify(mapForFrame)}`); @@ -205,9 +205,6 @@ export default class JavascriptEventWorker extends EventWorker { return stackFrame; } - /** - * @todo cache source map consumer for file-keys - */ const consumer = this.consumeSourceMap(mapContent); /** @@ -268,7 +265,6 @@ export default class JavascriptEventWorker extends EventWorker { * @param line - number of the line from the stack trace * @returns {string | null} - string of the function context or null if it could not be parsed */ - @memoize({ max: 50, ttl: MEMOIZATION_TTL, strategy: 'hash' }) private getFunctionContext(sourceCode: string, line: number): string | null { let functionName: string | null = null; let className: string | null = null; @@ -372,7 +368,6 @@ export default class JavascriptEventWorker extends EventWorker { * * @param mapId - id of the map file in the bucket */ - @memoize({ max: 50, ttl: MEMOIZATION_TTL }) private loadSourceMapFile(mapId: SourceMapDataExtended['_id']): Promise { return new Promise((resolve, reject) => { let buf = Buffer.from(''); diff --git a/workers/javascript/tests/index.test.ts b/workers/javascript/tests/index.test.ts index 367fa9677..65720511d 100644 --- a/workers/javascript/tests/index.test.ts +++ b/workers/javascript/tests/index.test.ts @@ -4,6 +4,7 @@ import { JavaScriptEventWorkerTask } from '../types/javascript-event-worker-task import { Db, GridFSBucket, MongoClient, ObjectId } from 'mongodb'; import * as WorkerNames from '../../../lib/workerNames'; import { ReleaseDBScheme } from '@hawk.so/types'; +import cloneDeep from 'lodash.clonedeep'; const itIf = it.skip; @@ -348,7 +349,7 @@ describe('JavaScript event worker', () => { await worker.finish(); }); - it('should memoize loadSourceMapFile within single handle (parallel processing may cause multiple calls)', async () => { + it('should memoize beautifyBacktrace within single handle (parallel processing may cause multiple calls)', async () => { // Arrange const worker = new JavascriptEventWorker(); @@ -398,14 +399,15 @@ describe('JavaScript event worker', () => { // Act await worker.handle(workerEvent); - // Assert: Due to parallel processing, both frames call loadSourceMapFile before caching - // This is expected behavior - the memoization prevents subsequent calls + // Assert: Since beautifyBacktrace is now memoized, the entire method should only be called once + // But within that single call, loadSourceMapFile may still be called multiple times for different frames + // The memoization happens at the beautifyBacktrace level, not at the loadSourceMapFile level expect(openDownloadStreamSpy).toHaveBeenCalledTimes(2); await worker.finish(); }); - it('should memoize loadSourceMapFile across multiple handles (DB called once)', async () => { + it('should memoize beautifyBacktrace across multiple handles (entire method called once)', async () => { // Arrange const worker = new JavascriptEventWorker(); @@ -446,11 +448,13 @@ describe('JavaScript event worker', () => { .spyOn(bucket, 'openDownloadStream') .mockImplementation(() => makeMockGridFsReadStream(sourceMapFileContent)); + const workerEventClone = cloneDeep(workerEvent); + // Act: handle the same event twice await worker.handle(workerEvent); - await worker.handle(workerEvent); + await worker.handle(workerEventClone); - // Assert: stream opened once thanks to @memoize on loadSourceMapFile + // The stream should only be opened once since the entire beautifyBacktrace is memoized expect(openDownloadStreamSpy).toHaveBeenCalledTimes(1); await worker.finish(); diff --git a/workers/javascript/types/beautify-backtrace-payload.d.ts b/workers/javascript/types/beautify-backtrace-payload.d.ts new file mode 100644 index 000000000..6ae9d7768 --- /dev/null +++ b/workers/javascript/types/beautify-backtrace-payload.d.ts @@ -0,0 +1,8 @@ +import { JavaScriptEventWorkerTask } from "./javascript-event-worker-task"; + +/** + * Type that represents the payload of the beautify backtrace method + * It requires id of the project, release and backtrace to beautify + */ +export type BeautifyBacktracePayload = Pick + & Pick diff --git a/workers/javascript/types/javascript-event-worker-task.d.ts b/workers/javascript/types/javascript-event-worker-task.d.ts index 7e49aa30f..e5819a1b4 100644 --- a/workers/javascript/types/javascript-event-worker-task.d.ts +++ b/workers/javascript/types/javascript-event-worker-task.d.ts @@ -4,3 +4,10 @@ import { CatcherMessageAccepted } from '@hawk.so/types'; * Format of task for JavaScript Event Worker */ export interface JavaScriptEventWorkerTask extends CatcherMessageAccepted<'errors/javascript'> {} + +/** + * Type that represents the payload of the beautify backtrace method + * It requires id of the project, release and backtrace to beautify + */ +export type BeautifyBacktracePayload = Pick + & Pick diff --git a/yarn.lock b/yarn.lock index 13207d35a..935577aa8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4629,11 +4629,6 @@ jest@^29.2.2: import-local "^3.0.2" jest-cli "^29.7.0" -js-levenshtein@^1.1.6: - version "1.1.6" - resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d" - integrity sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g== - js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" From 53fd9e2f76b09026c0e3ed87d62aea6271a36fa2 Mon Sep 17 00:00:00 2001 From: e11sy <130844513+e11sy@users.noreply.github.com> Date: Mon, 27 Oct 2025 02:45:50 +0300 Subject: [PATCH 05/14] imp(): tests and types --- workers/javascript/package.json | 2 +- workers/javascript/src/index.ts | 12 +-------- workers/javascript/tests/index.test.ts | 25 +++++++++---------- .../types/javascript-event-worker-task.d.ts | 7 ------ 4 files changed, 14 insertions(+), 32 deletions(-) diff --git a/workers/javascript/package.json b/workers/javascript/package.json index 95b2938fc..9da64352e 100644 --- a/workers/javascript/package.json +++ b/workers/javascript/package.json @@ -1,6 +1,6 @@ { "name": "hawk-worker-javascript", - "version": "0.0.1", + "version": "0.0.2", "description": "Handles messages from JavaScript Catcher", "main": "src/index.ts", "license": "UNLICENSED", diff --git a/workers/javascript/src/index.ts b/workers/javascript/src/index.ts index 5e634bb48..5e8b316ff 100644 --- a/workers/javascript/src/index.ts +++ b/workers/javascript/src/index.ts @@ -108,17 +108,7 @@ export default class JavascriptEventWorker extends EventWorker { */ @memoize({ max: 50, ttl: MEMOIZATION_TTL, strategy: 'hash' }) private async beautifyBacktrace({ projectId, release, backtrace }: BeautifyBacktracePayload): Promise { - console.log('method call?') - - const releaseRecord: SourceMapsRecord = await this.cache.get( - `releaseRecord:${projectId}:${release}`, - () => { - return this.getReleaseRecord( - projectId, - release - ); - } - ); + const releaseRecord: SourceMapsRecord = await this.getReleaseRecord(projectId, release); if (!releaseRecord) { this.logger.info('beautifyBacktrace: no releaseRecord found'); diff --git a/workers/javascript/tests/index.test.ts b/workers/javascript/tests/index.test.ts index 65720511d..9f005dd13 100644 --- a/workers/javascript/tests/index.test.ts +++ b/workers/javascript/tests/index.test.ts @@ -392,17 +392,16 @@ describe('JavaScript event worker', () => { await db.collection('releases').insertOne(singleMapRelease); - const openDownloadStreamSpy = jest - .spyOn(GridFSBucket.prototype, 'openDownloadStream') - .mockImplementation(() => makeMockGridFsReadStream(sourceMapFileContent)); + /** + * Cast prototype to any because getReleaseRecord is ts private + */ + const getReleaseRecordSpy = jest.spyOn(JavascriptEventWorker.prototype as any, 'getReleaseRecord'); // Act await worker.handle(workerEvent); // Assert: Since beautifyBacktrace is now memoized, the entire method should only be called once - // But within that single call, loadSourceMapFile may still be called multiple times for different frames - // The memoization happens at the beautifyBacktrace level, not at the loadSourceMapFile level - expect(openDownloadStreamSpy).toHaveBeenCalledTimes(2); + expect(getReleaseRecordSpy).toHaveBeenCalledTimes(1); await worker.finish(); }); @@ -442,11 +441,11 @@ describe('JavaScript event worker', () => { ]; await db.collection('releases').insertOne(release); - - const bucket = (worker as any).db.getBucket(); - const openDownloadStreamSpy = jest - .spyOn(bucket, 'openDownloadStream') - .mockImplementation(() => makeMockGridFsReadStream(sourceMapFileContent)); + + /** + * Cast prototype to any because getReleaseRecord is ts private + */ + const getReleaseRecordSpy = jest.spyOn(JavascriptEventWorker.prototype as any, 'getReleaseRecord'); const workerEventClone = cloneDeep(workerEvent); @@ -454,8 +453,8 @@ describe('JavaScript event worker', () => { await worker.handle(workerEvent); await worker.handle(workerEventClone); - // The stream should only be opened once since the entire beautifyBacktrace is memoized - expect(openDownloadStreamSpy).toHaveBeenCalledTimes(1); + // The release retirieving should only be called once since the entire beautifyBacktrace is memoized + expect(getReleaseRecordSpy).toHaveBeenCalledTimes(1); await worker.finish(); }); diff --git a/workers/javascript/types/javascript-event-worker-task.d.ts b/workers/javascript/types/javascript-event-worker-task.d.ts index e5819a1b4..7e49aa30f 100644 --- a/workers/javascript/types/javascript-event-worker-task.d.ts +++ b/workers/javascript/types/javascript-event-worker-task.d.ts @@ -4,10 +4,3 @@ import { CatcherMessageAccepted } from '@hawk.so/types'; * Format of task for JavaScript Event Worker */ export interface JavaScriptEventWorkerTask extends CatcherMessageAccepted<'errors/javascript'> {} - -/** - * Type that represents the payload of the beautify backtrace method - * It requires id of the project, release and backtrace to beautify - */ -export type BeautifyBacktracePayload = Pick - & Pick From c96b935de90b6536fd2f831df4b3822f42dac448 Mon Sep 17 00:00:00 2001 From: e11sy <130844513+e11sy@users.noreply.github.com> Date: Mon, 27 Oct 2025 02:58:35 +0300 Subject: [PATCH 06/14] chore(): lint fix --- workers/javascript/src/index.ts | 2 +- workers/javascript/tests/index.test.ts | 36 ++----------------- .../types/beautify-backtrace-payload.d.ts | 6 ++-- 3 files changed, 6 insertions(+), 38 deletions(-) diff --git a/workers/javascript/src/index.ts b/workers/javascript/src/index.ts index 5e8b316ff..83d7dd6b1 100644 --- a/workers/javascript/src/index.ts +++ b/workers/javascript/src/index.ts @@ -7,7 +7,7 @@ import { GroupWorkerTask } from '../../grouper/types/group-worker-task'; import { SourceMapsRecord } from '../../release/types'; import * as pkg from '../package.json'; import { JavaScriptEventWorkerTask } from '../types/javascript-event-worker-task'; -import { BeautifyBacktracePayload } from '../types/beautify-backtrace-payload' +import { BeautifyBacktracePayload } from '../types/beautify-backtrace-payload'; import HawkCatcher from '@hawk.so/nodejs'; import { BacktraceFrame, CatcherMessagePayload, CatcherMessageType, ErrorsCatcherType, SourceCodeLine, SourceMapDataExtended } from '@hawk.so/types'; import { beautifyUserAgent } from './utils'; diff --git a/workers/javascript/tests/index.test.ts b/workers/javascript/tests/index.test.ts index 9f005dd13..606e877e3 100644 --- a/workers/javascript/tests/index.test.ts +++ b/workers/javascript/tests/index.test.ts @@ -1,7 +1,7 @@ import JavascriptEventWorker from '../src'; import '../../../env-test'; import { JavaScriptEventWorkerTask } from '../types/javascript-event-worker-task'; -import { Db, GridFSBucket, MongoClient, ObjectId } from 'mongodb'; +import { Db, MongoClient, ObjectId } from 'mongodb'; import * as WorkerNames from '../../../lib/workerNames'; import { ReleaseDBScheme } from '@hawk.so/types'; import cloneDeep from 'lodash.clonedeep'; @@ -94,38 +94,6 @@ describe('JavaScript event worker', () => { } ], }; - /** - * Helper to build a mocked GridFS read stream that emits provided content - * - * @param content - content of the source map stored in the bucket - */ - /* eslint-disable-next-line @typescript-eslint/explicit-function-return-type */ - const makeMockGridFsReadStream = (content: string) => { - const handlers: Record void)[]> = {}; - - const stream = { - on(event: 'data' | 'error' | 'end', cb: (...args: any[]) => void) { - handlers[event] = handlers[event] || []; - handlers[event].push(cb); - - return stream; - }, - destroy: jest.fn(), - } as any; - - // Emit asynchronously to mimic real streams - setImmediate(() => { - if (handlers['data']) { - handlers['data'].forEach((cb) => cb(Buffer.from(content))); - } - if (handlers['end']) { - handlers['end'].forEach((cb) => cb()); - } - }); - - return stream; - }; - /** * Creates event object for JS worker * @@ -441,7 +409,7 @@ describe('JavaScript event worker', () => { ]; await db.collection('releases').insertOne(release); - + /** * Cast prototype to any because getReleaseRecord is ts private */ diff --git a/workers/javascript/types/beautify-backtrace-payload.d.ts b/workers/javascript/types/beautify-backtrace-payload.d.ts index 6ae9d7768..1d88c3b03 100644 --- a/workers/javascript/types/beautify-backtrace-payload.d.ts +++ b/workers/javascript/types/beautify-backtrace-payload.d.ts @@ -1,8 +1,8 @@ -import { JavaScriptEventWorkerTask } from "./javascript-event-worker-task"; +import { JavaScriptEventWorkerTask } from './javascript-event-worker-task'; /** * Type that represents the payload of the beautify backtrace method * It requires id of the project, release and backtrace to beautify */ -export type BeautifyBacktracePayload = Pick - & Pick +export type BeautifyBacktracePayload = Pick + & Pick; From 4b8b529495132324591cd290de2a47a381fc9ea4 Mon Sep 17 00:00:00 2001 From: e11sy <130844513+e11sy@users.noreply.github.com> Date: Thu, 30 Oct 2025 00:51:34 +0300 Subject: [PATCH 07/14] Update workers/javascript/package.json Co-authored-by: Peter --- workers/javascript/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workers/javascript/package.json b/workers/javascript/package.json index 9da64352e..7bec49e33 100644 --- a/workers/javascript/package.json +++ b/workers/javascript/package.json @@ -1,6 +1,6 @@ { "name": "hawk-worker-javascript", - "version": "0.0.2", + "version": "0.1.0", "description": "Handles messages from JavaScript Catcher", "main": "src/index.ts", "license": "UNLICENSED", From 02a12e8d25692be6292366ec8023c6725afc7a56 Mon Sep 17 00:00:00 2001 From: e11sy <130844513+e11sy@users.noreply.github.com> Date: Thu, 30 Oct 2025 00:51:43 +0300 Subject: [PATCH 08/14] Update workers/javascript/src/index.ts Co-authored-by: Peter --- workers/javascript/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workers/javascript/src/index.ts b/workers/javascript/src/index.ts index 83d7dd6b1..807024540 100644 --- a/workers/javascript/src/index.ts +++ b/workers/javascript/src/index.ts @@ -106,7 +106,7 @@ export default class JavascriptEventWorker extends EventWorker { * @param {JavaScriptEventWorkerTask} event — js error minified * @returns {BacktraceFrame[]} - parsed backtrace */ - @memoize({ max: 50, ttl: MEMOIZATION_TTL, strategy: 'hash' }) + @memoize({ max: 200, ttl: MEMOIZATION_TTL, strategy: 'hash' }) private async beautifyBacktrace({ projectId, release, backtrace }: BeautifyBacktracePayload): Promise { const releaseRecord: SourceMapsRecord = await this.getReleaseRecord(projectId, release); From 4e67af65b48613ea9d34609324483b79af2267a0 Mon Sep 17 00:00:00 2001 From: e11sy <130844513+e11sy@users.noreply.github.com> Date: Thu, 30 Oct 2025 00:56:53 +0300 Subject: [PATCH 09/14] chore(): update test --- workers/javascript/tests/index.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/workers/javascript/tests/index.test.ts b/workers/javascript/tests/index.test.ts index 606e877e3..b76056587 100644 --- a/workers/javascript/tests/index.test.ts +++ b/workers/javascript/tests/index.test.ts @@ -283,7 +283,7 @@ describe('JavaScript event worker', () => { await worker.finish(); }); - itIf('should use cache while processing source maps', async () => { + it('should use cache while processing source maps', async () => { /** * Arrange */ @@ -317,7 +317,7 @@ describe('JavaScript event worker', () => { await worker.finish(); }); - it('should memoize beautifyBacktrace within single handle (parallel processing may cause multiple calls)', async () => { + it('should memoize beautifyBacktrace within several handle calls', async () => { // Arrange const worker = new JavascriptEventWorker(); @@ -341,6 +341,8 @@ describe('JavaScript event worker', () => { }, ] as any; + const workerEventDuplicate = cloneDeep(workerEvent); + // Create a release with a single map file used by both frames const singleMapRelease = { ...createReleaseMock({ @@ -367,6 +369,7 @@ describe('JavaScript event worker', () => { // Act await worker.handle(workerEvent); + await worker.handle(workerEventDuplicate); // Assert: Since beautifyBacktrace is now memoized, the entire method should only be called once expect(getReleaseRecordSpy).toHaveBeenCalledTimes(1); From 7b97367c0e4c51d04741f59aa092bfa160363cd6 Mon Sep 17 00:00:00 2001 From: e11sy <130844513+e11sy@users.noreply.github.com> Date: Thu, 30 Oct 2025 01:01:11 +0300 Subject: [PATCH 10/14] chore(): lint fix --- workers/javascript/tests/index.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/workers/javascript/tests/index.test.ts b/workers/javascript/tests/index.test.ts index b76056587..132e23a33 100644 --- a/workers/javascript/tests/index.test.ts +++ b/workers/javascript/tests/index.test.ts @@ -6,8 +6,6 @@ import * as WorkerNames from '../../../lib/workerNames'; import { ReleaseDBScheme } from '@hawk.so/types'; import cloneDeep from 'lodash.clonedeep'; -const itIf = it.skip; - describe('JavaScript event worker', () => { let connection: MongoClient; let db: Db; From 1baf71090dbc7caf3efe1fcb4c8c90f5ba65e882 Mon Sep 17 00:00:00 2001 From: e11sy <130844513+e11sy@users.noreply.github.com> Date: Thu, 30 Oct 2025 01:17:00 +0300 Subject: [PATCH 11/14] chore(): remove test duplicate --- workers/javascript/tests/index.test.ts | 53 -------------------------- 1 file changed, 53 deletions(-) diff --git a/workers/javascript/tests/index.test.ts b/workers/javascript/tests/index.test.ts index 132e23a33..04305ff0f 100644 --- a/workers/javascript/tests/index.test.ts +++ b/workers/javascript/tests/index.test.ts @@ -374,57 +374,4 @@ describe('JavaScript event worker', () => { await worker.finish(); }); - - it('should memoize beautifyBacktrace across multiple handles (entire method called once)', async () => { - // Arrange - const worker = new JavascriptEventWorker(); - - await worker.start(); - - const workerEvent = { - ...createEventMock({ withBacktrace: true }), - } as JavaScriptEventWorkerTask; - - workerEvent.payload.backtrace = [ - { - file: 'file:///main.js', - line: 1, - column: 100, - }, - ] as any; - - const release = { - ...createReleaseMock({ - projectId: workerEvent.projectId, - release: workerEvent.payload.release, - }), - } as any; - const mapId = release.files[0]._id; - - release.files = [ - { - mapFileName: 'main.js.map', - originFileName: 'main.js', - _id: mapId, - }, - ]; - - await db.collection('releases').insertOne(release); - - /** - * Cast prototype to any because getReleaseRecord is ts private - */ - const getReleaseRecordSpy = jest.spyOn(JavascriptEventWorker.prototype as any, 'getReleaseRecord'); - - const workerEventClone = cloneDeep(workerEvent); - - // Act: handle the same event twice - await worker.handle(workerEvent); - await worker.handle(workerEventClone); - - // The release retirieving should only be called once since the entire beautifyBacktrace is memoized - expect(getReleaseRecordSpy).toHaveBeenCalledTimes(1); - - await worker.finish(); - }); }); From 67cd19ffea152c74983db8a3212391b3dfb36efe Mon Sep 17 00:00:00 2001 From: e11sy <130844513+e11sy@users.noreply.github.com> Date: Thu, 30 Oct 2025 01:27:45 +0300 Subject: [PATCH 12/14] test(): cover with different arguments case --- workers/javascript/tests/index.test.ts | 68 ++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/workers/javascript/tests/index.test.ts b/workers/javascript/tests/index.test.ts index 04305ff0f..531826e89 100644 --- a/workers/javascript/tests/index.test.ts +++ b/workers/javascript/tests/index.test.ts @@ -374,4 +374,72 @@ describe('JavaScript event worker', () => { await worker.finish(); }); + + it('should not memoize beautifyBacktrace within several calls with different arguments', async () => { + // Arrange + const worker = new JavascriptEventWorker(); + + await worker.start(); + + // Create event with two frames mapping to the same origin file + const workerEvent = { + ...createEventMock({ withBacktrace: true }), + } as JavaScriptEventWorkerTask; + + workerEvent.payload.backtrace = [ + { + file: 'file:///main.js', + line: 1, + column: 100, + }, + ] as any; + + /** + * Worker event with different backtrace + */ + const anotherWorkerEvent = { + ...createEventMock({ withBacktrace: true }), + } as JavaScriptEventWorkerTask; + + anotherWorkerEvent.payload.backtrace = [ + { + file: 'file:///main.js', + line: 10, + column: 14, + }, + ] as any; + + // Create a release with a single map file used by both frames + const singleMapRelease = { + ...createReleaseMock({ + projectId: workerEvent.projectId, + release: workerEvent.payload.release, + }), + } as any; + const firstFileId = singleMapRelease.files[0]._id; + + singleMapRelease.files = [ + { + mapFileName: 'main.js.map', + originFileName: 'main.js', + _id: firstFileId, + }, + ]; + + await db.collection('releases').insertOne(singleMapRelease); + + /** + * Cast prototype to any because getReleaseRecord is ts private + */ + const getReleaseRecordSpy = jest.spyOn(JavascriptEventWorker.prototype as any, 'getReleaseRecord'); + + // Act + await worker.handle(workerEvent); + await worker.handle(anotherWorkerEvent); + + // Assert: Since beautifyBacktrace is now memoized, the entire method should only be called once + expect(getReleaseRecordSpy).toHaveBeenCalledTimes(2); + + await worker.finish(); + }); }); From 639e632e31a48952ed5924ea0f5bed67f4a7a5cd Mon Sep 17 00:00:00 2001 From: e11sy <130844513+e11sy@users.noreply.github.com> Date: Thu, 30 Oct 2025 21:34:49 +0300 Subject: [PATCH 13/14] chore(): test memoize util --- lib/memoize/index.test.ts | 219 ++++++++++++++++++++++++++++++++++++++ lib/memoize/index.ts | 6 +- 2 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 lib/memoize/index.test.ts diff --git a/lib/memoize/index.test.ts b/lib/memoize/index.test.ts new file mode 100644 index 000000000..2b5b3bc52 --- /dev/null +++ b/lib/memoize/index.test.ts @@ -0,0 +1,219 @@ +// src/__tests__/memoize.spec.ts +import { memoize } from './index'; +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 { + calls = 0; + + @memoize({ strategy: 'concat', ttl: 60_000, max: 50 }) + 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 { + calls = 0; + + @memoize({ strategy: 'concat' }) + 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 { + calls = 0; + @memoize({ strategy: 'concat' }) + 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 { + calls = 0; + @memoize({ strategy: 'concat' }) + 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 { + calls = 0; + @memoize({ strategy: 'hash' }) + 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 { + calls = 0; + @memoize({ strategy: 'hash' }) + 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 { + calls = 0; + @memoize({ strategy: 'hash' }) + 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 { + calls = 0; + @memoizeWithMockedTimers({ strategy: 'concat', ttl: 1_000 }) + 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 { + calls = 0; + @memoize() + 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); + }); +}); diff --git a/lib/memoize/index.ts b/lib/memoize/index.ts index 5a9b0306a..10430b693 100644 --- a/lib/memoize/index.ts +++ b/lib/memoize/index.ts @@ -65,12 +65,12 @@ export function memoize(options: MemoizeOptions = {}): MethodDecorator { */ const cache: LRUCache = this[cacheKey] ??= new LRUCache({ max, - ttl, + maxAge: ttl, }); const key = strategy === 'hash' ? Crypto.hash(args, 'blake2b512', 'base64url') - : args.map(String).join('__ARG_JOIN__'); + : args.map((arg) => JSON.stringify(arg)).join('__ARG_JOIN__'); /** * Check if we have a cached result @@ -88,7 +88,7 @@ export function memoize(options: MemoizeOptions = {}): MethodDecorator { return result; } catch (err) { - cache.delete(key); + cache.del(key); throw err; } }; From e9c0f14f97206391b3bb460594eadb83aeffa400 Mon Sep 17 00:00:00 2001 From: e11sy <130844513+e11sy@users.noreply.github.com> Date: Thu, 30 Oct 2025 21:45:41 +0300 Subject: [PATCH 14/14] chore(): lint fix --- lib/memoize/index.test.ts | 48 +++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/lib/memoize/index.test.ts b/lib/memoize/index.test.ts index 2b5b3bc52..359c5b1ea 100644 --- a/lib/memoize/index.test.ts +++ b/lib/memoize/index.test.ts @@ -1,4 +1,14 @@ -// src/__tests__/memoize.spec.ts +/* 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'; import Crypto from '../utils/crypto'; @@ -11,10 +21,10 @@ describe('memoize decorator — per-test inline classes', () => { it('should memoize return value with concat strategy across several calls', async () => { class Sample { - calls = 0; + public calls = 0; @memoize({ strategy: 'concat', ttl: 60_000, max: 50 }) - async run(a: number, b: string) { + public async run(a: number, b: string) { this.calls += 1; return `${a}-${b}`; } @@ -37,10 +47,10 @@ describe('memoize decorator — per-test inline classes', () => { it('should memoize return value with set of arguments with concat strategy across several calls', async () => { class Sample { - calls = 0; + public calls = 0; @memoize({ strategy: 'concat' }) - async run(a: unknown, b: unknown) { + public async run(a: unknown, b: unknown) { this.calls += 1; return `${String(a)}|${String(b)}`; } @@ -73,9 +83,9 @@ describe('memoize decorator — per-test inline classes', () => { it('should memoize return value for stringified objects across several calls', async () => { class Sample { - calls = 0; + public calls = 0; @memoize({ strategy: 'concat' }) - async run(x: unknown, y: unknown) { + public async run(x: unknown, y: unknown) { this.calls += 1; return 'ok'; } @@ -92,9 +102,9 @@ describe('memoize decorator — per-test inline classes', () => { it('should memoize return value for method with non-default arguments (NaN, Infinity, -0, Symbol, Date, RegExp) still cache same-args', async () => { class Sample { - calls = 0; + public calls = 0; @memoize({ strategy: 'concat' }) - async run(...args: unknown[]) { + public async run(...args: unknown[]) { this.calls += 1; return args.map(String).join(','); } @@ -116,9 +126,9 @@ describe('memoize decorator — per-test inline classes', () => { const hashSpy = jest.spyOn(Crypto, 'hash'); class Sample { - calls = 0; + public calls = 0; @memoize({ strategy: 'hash' }) - async run(...args: unknown[]) { + public async run(...args: unknown[]) { this.calls += 1; return 'ok'; } @@ -134,9 +144,9 @@ describe('memoize decorator — per-test inline classes', () => { it('should not memoize return value with hash strategy and different arguments', async () => { class Sample { - calls = 0; + public calls = 0; @memoize({ strategy: 'hash' }) - async run(...args: unknown[]) { + public async run(...args: unknown[]) { this.calls += 1; return 'ok'; } @@ -152,9 +162,9 @@ describe('memoize decorator — per-test inline classes', () => { it('should memoize return value with hash strategy across several calls with same args', async () => { class Sample { - calls = 0; + public calls = 0; @memoize({ strategy: 'hash' }) - async run(arg: unknown) { + public async run(arg: unknown) { this.calls += 1; return 'ok'; } @@ -175,9 +185,9 @@ describe('memoize decorator — per-test inline classes', () => { const { memoize: memoizeWithMockedTimers } = await import('../memoize/index'); class Sample { - calls = 0; + public calls = 0; @memoizeWithMockedTimers({ strategy: 'concat', ttl: 1_000 }) - async run(x: string) { + public async run(x: string) { this.calls += 1; return x; } @@ -199,9 +209,9 @@ describe('memoize decorator — per-test inline classes', () => { it('error calls should never be momized', async () => { class Sample { - calls = 0; + public calls = 0; @memoize() - async run(x: number) { + public async run(x: number) { this.calls += 1; if (x === 1) throw new Error('boom'); return x * 2;