Skip to content

Commit 9e4b6af

Browse files
committed
merge: resolve conflicts with origin/master for grouper metrics
2 parents 38db313 + f4342de commit 9e4b6af

File tree

21 files changed

+1087
-15
lines changed

21 files changed

+1087
-15
lines changed

docker-compose.dev.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,18 @@ services:
158158
- ./:/usr/src/app
159159
- workers-deps:/usr/src/app/node_modules
160160

161+
hawk-worker-webhook:
162+
build:
163+
dockerfile: "dev.Dockerfile"
164+
context: .
165+
env_file:
166+
- .env
167+
restart: unless-stopped
168+
entrypoint: yarn run-webhook
169+
volumes:
170+
- ./:/usr/src/app
171+
- workers-deps:/usr/src/app/node_modules
172+
161173
volumes:
162174
workers-deps:
163175

package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "hawk.workers",
33
"private": true,
4-
"version": "0.1.2",
4+
"version": "0.1.3",
55
"description": "Hawk workers",
66
"repository": "[email protected]:codex-team/hawk.workers.git",
77
"license": "BUSL-1.1",
@@ -34,6 +34,7 @@
3434
"test:notifier": "jest workers/notifier",
3535
"test:js": "jest workers/javascript",
3636
"test:task-manager": "jest workers/task-manager",
37+
"test:webhook": "jest workers/webhook",
3738
"test:clear": "jest --clearCache",
3839
"run-default": "yarn worker hawk-worker-default",
3940
"run-sentry": "yarn worker hawk-worker-sentry",
@@ -49,13 +50,14 @@
4950
"run-email": "yarn worker hawk-worker-email",
5051
"run-telegram": "yarn worker hawk-worker-telegram",
5152
"run-limiter": "yarn worker hawk-worker-limiter",
52-
"run-task-manager": "yarn worker hawk-worker-task-manager"
53+
"run-task-manager": "yarn worker hawk-worker-task-manager",
54+
"run-webhook": "yarn worker hawk-worker-webhook"
5355
},
5456
"dependencies": {
5557
"@babel/parser": "^7.26.9",
5658
"@babel/traverse": "7.26.9",
5759
"@hawk.so/nodejs": "^3.1.1",
58-
"@hawk.so/types": "^0.5.7",
60+
"@hawk.so/types": "^0.5.9",
5961
"@types/amqplib": "^0.8.2",
6062
"@types/jest": "^29.5.14",
6163
"@types/mongodb": "^3.5.15",

workers/email/tests/provider.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ nodemailerMock.createTransport = jest.fn(() => ({
1010

1111
jest.mock('nodemailer', () => nodemailerMock);
1212

13-
import { DecodedGroupedEvent, ProjectDBScheme } from '@hawk.so/types';
13+
import { DecodedGroupedEvent, ProjectDBScheme, UserDBScheme } from '@hawk.so/types';
1414
import '../src/env';
1515
import EmailProvider from '../src/provider';
1616
import Templates from '../src/templates/names';
@@ -187,6 +187,11 @@ describe('EmailProvider', () => {
187187
password: '$argon2i$v=19$m=4096,t=3,p=1$QOo3u8uEor0t+nqCLpEW3g$aTCDEaHht9ro9VZDD1yZGpaTi+g1OWsfHbYd5TQBRPs',
188188
name: 'Hahahawk',
189189
},
190+
assignee: {
191+
_id: new ObjectId('5ec3ffe769c0030022f88f25'),
192+
193+
name: 'Assignee User',
194+
} as UserDBScheme,
190195
project: {
191196
_id: new ObjectId('5d206f7f9aaf7c0071d64596'),
192197
token: 'project-token',

workers/grouper/src/data-filter.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import type { EventAddons, EventData } from '@hawk.so/types';
22
import { unsafeFields } from '../../../lib/utils/unsafeFields';
33

4+
/**
5+
* Maximum depth for object traversal to prevent excessive memory allocations
6+
*/
7+
const MAX_TRAVERSAL_DEPTH = 20;
8+
49
/**
510
* Recursively iterate through object and call function on each key
611
*
@@ -18,7 +23,12 @@ function forAll(obj: Record<string, unknown>, callback: (path: string[], key: st
1823
if (!(typeof value === 'object' && !Array.isArray(value))) {
1924
callback(path, key, current);
2025
} else {
21-
visit(value, [...path, key]);
26+
/**
27+
* Limit path depth to prevent excessive memory allocations from deep nesting
28+
* This reduces GC pressure and memory usage for deeply nested objects
29+
*/
30+
const newPath = path.length < MAX_TRAVERSAL_DEPTH ? path.concat(key) : path;
31+
visit(value, newPath);
2232
}
2333
}
2434
};

workers/grouper/src/index.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,16 @@ import type { RepetitionDBScheme } from '../types/repetition';
1919
import { DatabaseReadWriteError, DiffCalculationError, ValidationError } from '../../../lib/workerErrors';
2020
import { decodeUnsafeFields, encodeUnsafeFields } from '../../../lib/utils/unsafeFields';
2121
import { MS_IN_SEC } from '../../../lib/utils/consts';
22+
import TimeMs from '../../../lib/utils/time';
2223
import DataFilter from './data-filter';
2324
import RedisHelper from './redisHelper';
2425
import { computeDelta } from './utils/repetitionDiff';
2526
import { rightTrim } from '../../../lib/utils/string';
2627
import { hasValue } from '../../../lib/utils/hasValue';
28+
29+
/**
30+
* eslint does not count decorators as a variable usage
31+
*/
2732
/* eslint-disable-next-line no-unused-vars */
2833
import { memoize } from '../../../lib/memoize';
2934
import { register, client } from '../../../lib/metrics';
@@ -32,7 +37,12 @@ import { register, client } from '../../../lib/metrics';
3237
* eslint does not count decorators as a variable usage
3338
*/
3439
/* eslint-disable-next-line no-unused-vars */
35-
const MEMOIZATION_TTL = Number(process.env.MEMOIZATION_TTL ?? 0);
40+
const MEMOIZATION_TTL = 600_000;
41+
42+
/**
43+
* Cache cleanup interval in minutes
44+
*/
45+
const CACHE_CLEANUP_INTERVAL_MINUTES = 5;
3646

3747
/**
3848
* Error code of MongoDB key duplication error
@@ -122,6 +132,11 @@ export default class GrouperWorker extends Worker {
122132
registers: [register],
123133
});
124134

135+
/**
136+
* Interval for periodic cache cleanup to prevent memory leaks from unbounded cache growth
137+
*/
138+
private cacheCleanupInterval: NodeJS.Timeout | null = null;
139+
125140
/**
126141
* Start consuming messages
127142
*/
@@ -135,13 +150,30 @@ export default class GrouperWorker extends Worker {
135150

136151
await this.redis.initialize();
137152
console.log('redis initialized');
153+
154+
/**
155+
* Start periodic cache cleanup to prevent memory leaks from unbounded cache growth
156+
* Runs every 5 minutes to clear old cache entries
157+
*/
158+
this.cacheCleanupInterval = setInterval(() => {
159+
this.clearCache();
160+
}, CACHE_CLEANUP_INTERVAL_MINUTES * TimeMs.MINUTE);
161+
138162
await super.start();
139163
}
140164

141165
/**
142166
* Finish everything
143167
*/
144168
public async finish(): Promise<void> {
169+
/**
170+
* Clear cache cleanup interval to prevent resource leaks
171+
*/
172+
if (this.cacheCleanupInterval) {
173+
clearInterval(this.cacheCleanupInterval);
174+
this.cacheCleanupInterval = null;
175+
}
176+
145177
await super.finish();
146178
this.prepareCache();
147179
await this.eventsDb.close();
@@ -198,7 +230,7 @@ export default class GrouperWorker extends Worker {
198230
const similarEvent = await this.findSimilarEvent(task.projectId, task.payload.title);
199231

200232
if (similarEvent) {
201-
this.logger.info(`similar event: ${JSON.stringify(similarEvent)}`);
233+
this.logger.info(`[handle] similar event found, groupHash=${similarEvent.groupHash} totalCount=${similarEvent.totalCount}`);
202234

203235
/**
204236
* Override group hash with found event's group hash
@@ -331,6 +363,12 @@ export default class GrouperWorker extends Worker {
331363
} as RepetitionDBScheme;
332364

333365
repetitionId = await this.saveRepetition(task.projectId, newRepetition);
366+
367+
/**
368+
* Clear the large event payload references to allow garbage collection
369+
* This prevents memory leaks from retaining full event objects after delta is computed
370+
*/
371+
delta = undefined;
334372
}
335373

336374
/**
@@ -432,7 +470,7 @@ export default class GrouperWorker extends Worker {
432470
* @param projectId - where to find
433471
* @param title - title of the event to find similar one
434472
*/
435-
@memoize({ max: 200, ttl: MEMOIZATION_TTL, strategy: 'hash', skipCache: [undefined] })
473+
@memoize({ max: 50, ttl: MEMOIZATION_TTL, strategy: 'hash', skipCache: [undefined] })
436474
private async findSimilarEvent(projectId: string, title: string): Promise<GroupedEventDBScheme | undefined> {
437475
/**
438476
* If no match by Levenshtein, try matching by patterns
@@ -446,9 +484,7 @@ export default class GrouperWorker extends Worker {
446484
try {
447485
const originalEvent = await this.findFirstEventByPattern(matchingPattern.pattern, projectId);
448486

449-
const originalEventSize = Buffer.byteLength(JSON.stringify(originalEvent));
450-
451-
this.logger.info(`[findSimilarEvent] found by pattern, originalEventSize=${originalEventSize}b`);
487+
this.logger.info(`[findSimilarEvent] found by pattern, groupHash=${originalEvent?.groupHash} title="${originalEvent?.payload?.title}"`);
452488

453489
if (originalEvent) {
454490
return originalEvent;

workers/grouper/tests/data-filter.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,5 +327,39 @@ describe('GrouperWorker', () => {
327327
expect(event.context['secret']).toBe('[filtered]');
328328
expect(event.context['auth']).toBe('[filtered]');
329329
});
330+
331+
test('should handle deeply nested objects (>20 levels) without excessive memory allocations', () => {
332+
// Create an object nested deeper than the cap (>20 levels)
333+
let deeplyNested: any = { value: 'leaf', secret: 'should-be-filtered' };
334+
335+
for (let i = 0; i < 25; i++) {
336+
deeplyNested = { [`level${i}`]: deeplyNested, password: `sensitive${i}` };
337+
}
338+
339+
const event = generateEvent({
340+
context: deeplyNested,
341+
});
342+
343+
// This should not throw or cause memory issues
344+
dataFilter.processEvent(event);
345+
346+
// Verify that filtering still works at various depths
347+
expect(event.context['password']).toBe('[filtered]');
348+
349+
// Navigate to a mid-level and check filtering
350+
let current = event.context['level24'] as any;
351+
for (let i = 24; i > 15; i--) {
352+
expect(current['password']).toBe('[filtered]');
353+
current = current[`level${i - 1}`];
354+
}
355+
356+
// At the leaf level, the secret should still be filtered
357+
// (though path tracking may be capped, filtering should still work)
358+
let leaf = event.context;
359+
for (let i = 24; i >= 0; i--) {
360+
leaf = leaf[`level${i}`] as any;
361+
}
362+
expect(leaf['secret']).toBe('[filtered]');
363+
});
330364
});
331365
});

workers/notifier/types/channel.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export enum ChannelType {
66
Telegram = 'telegram',
77
Slack = 'slack',
88
Loop = 'loop',
9+
Webhook = 'webhook',
910
}
1011

1112
/**

workers/sender/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ export default abstract class SenderWorker extends Worker {
253253
project,
254254
event,
255255
whoAssigned,
256+
assignee,
256257
daysRepeated,
257258
},
258259
} as AssigneeNotification);

workers/sender/types/template-variables/assignee.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ export interface AssigneeTemplateVariables extends CommonTemplateVariables {
2121
*/
2222
whoAssigned: UserDBScheme;
2323

24+
/**
25+
* User who was assigned to resolve the event
26+
*/
27+
assignee: UserDBScheme;
28+
2429
/**
2530
* Number of event repetitions
2631
*/

workers/webhook/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "hawk-worker-webhook",
3+
"version": "1.0.0",
4+
"description": "Webhook sender worker — delivers event notifications as JSON POST requests",
5+
"main": "src/index.ts",
6+
"license": "MIT",
7+
"workerType": "sender/webhook",
8+
"scripts": {
9+
"test": "jest"
10+
}
11+
}

0 commit comments

Comments
 (0)