Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ ACCOUNTING_API_ENDPOINT=http://localhost:3999/graphql
# Notify URL for sending Telegram notifications
REPORT_NOTIFY_URL=http://mock.com/

# Url for connecting to Redis
REDIS_URL=redis://localhost:6379
# REDIS_URL is set automatically by jest.setup.redis-mock.js (redis-memory-server).
# Set REDIS_URL in the environment to use an external Redis instance instead.

# Disable memoization in tests
MEMOIZATION_TTL=-1
6 changes: 6 additions & 0 deletions workers/grouper/.env.sample
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
# Url for connecting to Redis
REDIS_URL=redis://redis:6379

# How often to refresh project rate limits from MongoDB (seconds)
PROJECTS_LIMITS_UPDATE_PERIOD=3600

# Redis hash key for per-project rate limit counters
REDIS_RATE_LIMITS_KEY=rate_limits
26 changes: 23 additions & 3 deletions workers/grouper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,31 @@
Language-workers adds tasks for Group Worker in this format.
Group Worker gets these tasks (events from language-workers) and saves it to the DB

## Rate limiting

Per-project rate limits are enforced before events are saved to MongoDB. Limits are loaded from the accounts database (`rateLimitSettings` on plans, workspaces, and projects) and tracked in Redis hash `rate_limits` (`timestamp:count` per project).

Environment variables:

| Variable | Description | Default |
|----------|-------------|---------|
| `PROJECTS_LIMITS_UPDATE_PERIOD` | Cache refresh interval (seconds) | `3600` |
| `REDIS_RATE_LIMITS_KEY` | Redis hash key for counters | `rate_limits` |

When the limit is exceeded, the event is dropped (message acked, no DB write) and `events-rate-limited` TimeSeries metrics are recorded.

## How to run

1. Make sure you are in Workers root directory
3. `yarn install`
4. `yarn run-grouper`

2. `nvm use` (requires Node 24, see `.nvmrc`)
3. Start dependencies: Redis and MongoDB (e.g. `docker compose up -d redis` from repo root)
4. `yarn install`
5. `yarn run-grouper`

## Tests

```bash
nvm use
yarn build
yarn test:grouper
```
81 changes: 80 additions & 1 deletion workers/grouper/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ import { MS_IN_SEC } from '../../../lib/utils/consts';
import TimeMs from '../../../lib/utils/time';
import DataFilter from './data-filter';
import RedisHelper from './redisHelper';
import ProjectLimitsCache from './projectLimitsCache';
import { computeDelta } from './utils/repetitionDiff';
import { bucketTimestampMs } from './utils/bucketTimestamp';
import { rightTrim } from '../../../lib/utils/string';
import { hasValue } from '../../../lib/utils/hasValue';
import { positiveIntEnv } from '../../../lib/utils/positiveIntEnv';
import GrouperMetrics from './metrics/grouperMetrics';
import GrouperMemoryMonitor from './metrics/memoryMonitor';
import SlowHandleDiagnostics, { SlowHandleSession } from './metrics/slowHandleDiagnostics';
Expand Down Expand Up @@ -63,6 +65,11 @@ const DAILY_METRICS_RETENTION_DAYS = 90;
*/
const MAX_CODE_LINE_LENGTH = 140;

/**
* Default interval for refreshing project limits cache (in seconds)
*/
const DEFAULT_PROJECTS_LIMITS_UPDATE_PERIOD_SECONDS = 3600;

/**
* Worker for handling Javascript events
*/
Expand Down Expand Up @@ -92,6 +99,16 @@ export default class GrouperWorker extends Worker {
*/
private redis = new RedisHelper();

/**
* Cached project rate limits loaded from accounts MongoDB
*/
private projectLimitsCache: ProjectLimitsCache;

/**
* Interval for periodic project limits cache refresh
*/
private projectLimitsRefreshInterval: NodeJS.Timeout | null = null;

/**
* Prometheus metrics facade.
*/
Expand All @@ -117,6 +134,14 @@ export default class GrouperWorker extends Worker {
*/
private handledTasksCount = 0;

/**
* Create grouper worker instance
*/
constructor() {
super();
this.projectLimitsCache = new ProjectLimitsCache(this.accountsDb);
}

/**
* Start consuming messages
*/
Expand All @@ -131,6 +156,21 @@ export default class GrouperWorker extends Worker {
await this.redis.initialize();
console.log('redis initialized');

await this.projectLimitsCache.refresh();

const limitsUpdatePeriodSeconds = positiveIntEnv(
process.env.PROJECTS_LIMITS_UPDATE_PERIOD,
DEFAULT_PROJECTS_LIMITS_UPDATE_PERIOD_SECONDS,
);

this.projectLimitsRefreshInterval = setInterval(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we already have 2 cache mechanics:

  • lib/cache/controller.ts
  • lim/memoize/index.ts

Let's use one of them and do not implement another one.

/* eslint-disable-next-line no-void */
void this.projectLimitsCache.refresh()
.catch((error) => {
this.logger.error('Failed to refresh project limits cache', error);
});
}, limitsUpdatePeriodSeconds * MS_IN_SEC);
Comment on lines +166 to +172

/**
* Start periodic cache cleanup to prevent memory leaks from unbounded cache growth
* Runs every 30 seconds to clear old cache entries
Expand All @@ -155,6 +195,11 @@ export default class GrouperWorker extends Worker {
this.cacheCleanupInterval = null;
}

if (this.projectLimitsRefreshInterval) {
clearInterval(this.projectLimitsRefreshInterval);
this.projectLimitsRefreshInterval = null;
}

this.memoryMonitor.logShutdown(this.handledTasksCount);
await super.finish();
this.prepareCache();
Expand All @@ -170,6 +215,16 @@ export default class GrouperWorker extends Worker {
*/
public async handle(task: GroupWorkerTask<ErrorsCatcherType>): Promise<void> {
try {
const withinLimit = await this.checkRateLimit(task.projectId);

if (!withinLimit) {
this.grouperMetrics.incrementRateLimitedTotal();
await this.recordProjectMetrics(task.projectId, 'events-rate-limited');
this.logger.info(`[rate-limit] project=${task.projectId} title="${task.payload?.title}" dropped`);

return;
}

await this.grouperMetrics.observeHandleDuration(async () => {
await this.handleInternal(task);
});
Expand All @@ -180,6 +235,30 @@ export default class GrouperWorker extends Worker {
}
}

/**
* Check and update per-project rate limit in Redis.
*
* @param projectId - project id
*/
private async checkRateLimit(projectId: string): Promise<boolean> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m not fond of the constant addition of new methods to the grouper index file. Let’s adhere to the principle of separation of concerns and create a separate ReteLimitter file that will encapsulate all the necessary logic.

const limits = this.projectLimitsCache.getProjectLimits(projectId);

if (!limits) {
this.logger.warn(`Project ${projectId} is not in the projects limits cache`);
}

const eventsLimit = limits?.eventsLimit ?? 0;
const eventsPeriod = limits?.eventsPeriod ?? 0;

try {
return await this.redis.updateRateLimit(projectId, eventsLimit, eventsPeriod);
} catch (error) {
this.logger.error(`Failed to update rate limit for project ${projectId}`, error);

return false;
}
Comment on lines +255 to +259
}

/**
* Internal task handling function
*
Expand Down Expand Up @@ -301,7 +380,7 @@ export default class GrouperWorker extends Worker {
this.grouperMetrics.incrementDuplicateRetriesTotal();
this.logger.info(`[saveEvent] project=${task.projectId} title="${task.payload.title}" duplicate key, retrying as repetition`);

await this.handle(task);
await this.handleInternal(task);

return;
}
Expand Down
16 changes: 16 additions & 0 deletions workers/grouper/src/metrics/grouperMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,15 @@ export default class GrouperMetrics {
})
);

private readonly rateLimitedTotal = getOrCreateMetric(
'hawk_grouper_events_rate_limited_total',
() => new client.Counter({
name: 'hawk_grouper_events_rate_limited_total',
help: 'Number of events dropped due to rate limiting',
registers: [ register ],
})
);

/**
* Measure top-level handle() duration.
*
Expand Down Expand Up @@ -207,6 +216,13 @@ export default class GrouperMetrics {
this.duplicateRetriesTotal.inc();
}

/**
* Increment events dropped by rate limiting.
*/
public incrementRateLimitedTotal(): void {
this.rateLimitedTotal.inc();
}

/**
* Measure Mongo operation duration.
*
Expand Down
Loading
Loading