Skip to content

Commit 12ee807

Browse files
committed
Add opt-in dailyEvents limiter counter
1 parent a7dcbb6 commit 12ee807

5 files changed

Lines changed: 236 additions & 22 deletions

File tree

.env.sample

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,8 @@ HAWK_CATCHER_TOKEN=
5555
## If true, Grouper worker will send messages about new events to Notifier worker
5656
IS_NOTIFIER_WORKER_ENABLED=false
5757

58+
## Comma-separated workspace ids that should use dailyEvents counters in Limiter quota checks. Use * for all workspaces.
59+
LIMITER_DAILY_EVENTS_COUNTER_WORKSPACE_IDS=
60+
5861
## Url for telegram notifications about workspace blocks and unblocks
5962
TELEGRAM_LIMITER_CHAT_URL=

workers/limiter/src/dbHelper.ts

Lines changed: 91 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { WorkspaceWithTariffPlan } from '../types';
44
import HawkCatcher from '@hawk.so/nodejs';
55
import { CriticalError, NonCriticalError } from '../../../lib/workerErrors';
66
import { MS_IN_SEC } from '../../../lib/utils/consts';
7+
import TimeMs from '../../../lib/utils/time';
8+
9+
const SEC_IN_DAY = TimeMs.DAY / TimeMs.SECOND;
710

811
const WORKSPACE_PROJECTION = {
912
_id: 1,
@@ -138,6 +141,45 @@ export class DbHelper {
138141
/**
139142
* Returns total event counts for last billing period
140143
*
144+
* @param project - project to check
145+
* @param since - timestamp of the time from which we count the events
146+
*/
147+
public async getEventsCountByProject(
148+
project: ProjectDBScheme,
149+
since: number
150+
): Promise<number> {
151+
try {
152+
const query = {
153+
timestamp: {
154+
$gt: since,
155+
},
156+
};
157+
158+
return await this.getRawEventsCountByProject(project, query);
159+
} catch (e) {
160+
HawkCatcher.send(e);
161+
throw new CriticalError(e);
162+
}
163+
}
164+
165+
/**
166+
* Calculates total events count for all provided projects since the specific date
167+
*
168+
* @param projects - projects to calculate for
169+
* @param since - timestamp of the time from which we count the events
170+
*/
171+
public async getEventsCountByProjects(projects: ProjectDBScheme[], since: number): Promise<number> {
172+
const sum = (array: number[]): number => array.reduce((acc, val) => acc + val, 0);
173+
174+
return Promise.all(projects.map(
175+
project => this.getEventsCountByProject(project, since)
176+
))
177+
.then(sum);
178+
}
179+
180+
/**
181+
* Returns total event counts for last billing period using dailyEvents counters.
182+
*
141183
* Full days are summed from dailyEvents per-day counters (grouper
142184
* increments `count` for originals and repetitions alike); only the
143185
* partial day containing `since` is counted from the raw collections,
@@ -146,39 +188,42 @@ export class DbHelper {
146188
* @param project - project to check
147189
* @param since - timestamp of the time from which we count the events
148190
*/
149-
public async getEventsCountByProject(
191+
public async getEventsCountByProjectUsingDailyEvents(
150192
project: ProjectDBScheme,
151193
since: number
152194
): Promise<number> {
153195
try {
154196
const projectId = project._id.toString();
155-
const repetitionsCollection = this.eventsDbConnection.collection('repetitions:' + projectId);
156-
const eventsCollection = this.eventsDbConnection.collection('events:' + projectId);
157197
const dailyEventsCollection = this.eventsDbConnection.collection('dailyEvents:' + projectId);
158-
159-
const sinceNextMidnight = this.getNextUtcMidnight(since);
198+
const firstFullDayTimestamp = this.getFirstFullDailyEventsTimestamp(since);
160199

161200
const boundaryDayQuery = {
162201
timestamp: {
163202
$gt: since,
164-
$lt: sinceNextMidnight,
203+
$lt: firstFullDayTimestamp,
165204
},
166205
};
167206

168-
const [repetitionsCount, originalEventCount, dailyCounters] = await Promise.all([
169-
repetitionsCollection.countDocuments(boundaryDayQuery),
170-
eventsCollection.countDocuments(boundaryDayQuery),
207+
const [boundaryDayCount, dailyCounters] = await Promise.all([
208+
since < firstFullDayTimestamp
209+
? this.getRawEventsCountByProject(project, boundaryDayQuery)
210+
: 0,
171211
dailyEventsCollection
172212
.aggregate<{ count: number }>([
173-
{ $match: { groupingTimestamp: { $gte: sinceNextMidnight } } },
174-
{ $group: { _id: null, count: { $sum: '$count' } } },
213+
{ $match: { groupingTimestamp: { $gte: firstFullDayTimestamp } } },
214+
{
215+
$group: {
216+
_id: null,
217+
count: { $sum: '$count' },
218+
},
219+
},
175220
])
176221
.toArray(),
177222
]);
178223

179224
const fullDaysCount = dailyCounters.length > 0 ? dailyCounters[0].count : 0;
180225

181-
return repetitionsCount + originalEventCount + fullDaysCount;
226+
return boundaryDayCount + fullDaysCount;
182227
} catch (e) {
183228
HawkCatcher.send(e);
184229
throw new CriticalError(e);
@@ -187,15 +232,16 @@ export class DbHelper {
187232

188233
/**
189234
* Calculates total events count for all provided projects since the specific date
235+
* using dailyEvents counters for full days.
190236
*
191237
* @param projects - projects to calculate for
192238
* @param since - timestamp of the time from which we count the events
193239
*/
194-
public async getEventsCountByProjects(projects: ProjectDBScheme[], since: number): Promise<number> {
240+
public async getEventsCountByProjectsUsingDailyEvents(projects: ProjectDBScheme[], since: number): Promise<number> {
195241
const sum = (array: number[]): number => array.reduce((acc, val) => acc + val, 0);
196242

197243
return Promise.all(projects.map(
198-
project => this.getEventsCountByProject(project, since)
244+
project => this.getEventsCountByProjectUsingDailyEvents(project, since)
199245
))
200246
.then(sum);
201247
}
@@ -233,6 +279,37 @@ export class DbHelper {
233279
return date.getTime() / MS_IN_SEC;
234280
}
235281

282+
/**
283+
* Returns first dailyEvents bucket that can be safely used without counting
284+
* events before the requested timestamp.
285+
*
286+
* @param timestamp - unix timestamp in seconds
287+
*/
288+
private getFirstFullDailyEventsTimestamp(timestamp: number): number {
289+
const midnight = timestamp - (timestamp % SEC_IN_DAY);
290+
291+
return timestamp === midnight ? timestamp : this.getNextUtcMidnight(timestamp);
292+
}
293+
294+
/**
295+
* Counts raw original events and repetitions for the passed query.
296+
*
297+
* @param project - project to check
298+
* @param query - MongoDB timestamp query
299+
*/
300+
private async getRawEventsCountByProject(project: ProjectDBScheme, query: Record<string, unknown>): Promise<number> {
301+
const projectId = project._id.toString();
302+
const repetitionsCollection = this.eventsDbConnection.collection('repetitions:' + projectId);
303+
const eventsCollection = this.eventsDbConnection.collection('events:' + projectId);
304+
305+
const [repetitionsCount, originalEventCount] = await Promise.all([
306+
repetitionsCollection.countDocuments(query),
307+
eventsCollection.countDocuments(query),
308+
]);
309+
310+
return repetitionsCount + originalEventCount;
311+
}
312+
236313
/**
237314
* Returns plan from cache, refetches once on miss
238315
*

workers/limiter/src/index.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ const NOTIFY_ABOUT_LIMIT = [
3030
0.95,
3131
];
3232

33+
/**
34+
* Enables dailyEvents-based quota counting for listed workspaces.
35+
* Use comma-separated workspace ids or `*` to enable it for every workspace.
36+
*/
37+
const DAILY_EVENTS_COUNTER_WORKSPACE_IDS = process.env.LIMITER_DAILY_EVENTS_COUNTER_WORKSPACE_IDS || '';
38+
3339
/**
3440
* Worker for checking current total events count in workspaces and limits events receiving if workspace exceed the limit
3541
*/
@@ -266,7 +272,7 @@ export default class LimiterWorker extends Worker {
266272

267273
const since = Math.floor(new Date(workspace.lastChargeDate).getTime() / MS_IN_SEC);
268274

269-
const workspaceEventsCount = await this.dbHelper.getEventsCountByProjects(projects, since);
275+
const workspaceEventsCount = await this.getWorkspaceEventsCount(workspace, projects, since);
270276

271277
this.logger.info(`workspace ${workspace._id} events count since last charge date: ${workspaceEventsCount}`);
272278

@@ -328,6 +334,48 @@ export default class LimiterWorker extends Worker {
328334
};
329335
}
330336

337+
/**
338+
* Returns workspace events count using the default raw counter or the
339+
* dailyEvents-based counter when it is explicitly enabled for the workspace.
340+
*
341+
* @param workspace - workspace to count events for
342+
* @param projects - workspace projects
343+
* @param since - timestamp of the time from which we count the events
344+
*/
345+
private async getWorkspaceEventsCount(
346+
workspace: WorkspaceWithTariffPlan,
347+
projects: ProjectDBScheme[],
348+
since: number
349+
): Promise<number> {
350+
if (!this.shouldUseDailyEventsCounter(workspace._id.toString())) {
351+
return this.dbHelper.getEventsCountByProjects(projects, since);
352+
}
353+
354+
try {
355+
return await this.dbHelper.getEventsCountByProjectsUsingDailyEvents(projects, since);
356+
} catch (error) {
357+
HawkCatcher.send(error, {
358+
workspaceId: workspace._id.toString(),
359+
});
360+
361+
return this.dbHelper.getEventsCountByProjects(projects, since);
362+
}
363+
}
364+
365+
/**
366+
* Checks whether dailyEvents-based quota counting is enabled for the workspace.
367+
*
368+
* @param workspaceId - workspace id
369+
*/
370+
private shouldUseDailyEventsCounter(workspaceId: string): boolean {
371+
const enabledWorkspaceIds = DAILY_EVENTS_COUNTER_WORKSPACE_IDS
372+
.split(',')
373+
.map(id => id.trim())
374+
.filter(Boolean);
375+
376+
return enabledWorkspaceIds.includes('*') || enabledWorkspaceIds.includes(workspaceId);
377+
}
378+
331379
/**
332380
* Method that formats project list to html used in report messages
333381
*

workers/limiter/tests/dbHelper.test.ts

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ describe('DbHelper', () => {
102102
const fillDatabaseWithMockedData = async (parameters: {
103103
workspace?: WorkspaceDBScheme,
104104
project: ProjectDBScheme,
105-
eventsToMock: number
105+
eventsToMock: number,
106106
repetitionsToMock?: number,
107107
dailyEventsToMock?: Array<{ groupingTimestamp: number; count: number }>,
108108
}): Promise<void> => {
@@ -119,7 +119,9 @@ describe('DbHelper', () => {
119119
for (let i = 0; i < parameters.eventsToMock; i++) {
120120
mockedEvents.push(createEventMock());
121121
}
122-
await eventsCollection.insertMany(mockedEvents);
122+
if (mockedEvents.length > 0) {
123+
await eventsCollection.insertMany(mockedEvents);
124+
}
123125

124126
mockedEvents.length = 0;
125127

@@ -608,6 +610,47 @@ describe('DbHelper', () => {
608610
expect(count).toBe(10); // 5 events + 5 repetitions
609611
});
610612

613+
test('Should keep raw counting as default and include raw docs after the boundary day', async () => {
614+
/**
615+
* Arrange
616+
*/
617+
const workspace = createWorkspaceMock({
618+
plan: mockedPlans.eventsLimit10,
619+
billingPeriodEventsCount: 0,
620+
lastChargeDate: new Date(),
621+
});
622+
const project = createProjectMock({ workspaceId: workspace._id });
623+
const since = Math.floor(LAST_CHARGE_DATE.getTime() / MS_IN_SEC);
624+
625+
await fillDatabaseWithMockedData({
626+
workspace,
627+
project,
628+
eventsToMock: 1,
629+
dailyEventsToMock: [
630+
{
631+
groupingTimestamp: NEXT_MIDNIGHT_AFTER_LAST_CHARGE,
632+
count: 100,
633+
},
634+
],
635+
});
636+
637+
await db.collection(`events:${project._id.toString()}`).insertOne(
638+
createEventMock(NEXT_MIDNIGHT_AFTER_LAST_CHARGE + 100)
639+
);
640+
641+
/**
642+
* Act
643+
*/
644+
const count = await dbHelper.getEventsCountByProject(project, since);
645+
646+
/**
647+
* Assert
648+
*/
649+
expect(count).toBe(2);
650+
});
651+
});
652+
653+
describe('getEventsCountByProjectUsingDailyEvents', () => {
611654
test('Should add per-day counters from dailyEvents for days after the boundary day', async () => {
612655
/**
613656
* Arrange
@@ -641,7 +684,7 @@ describe('DbHelper', () => {
641684
/**
642685
* Act
643686
*/
644-
const count = await dbHelper.getEventsCountByProject(project, since);
687+
const count = await dbHelper.getEventsCountByProjectUsingDailyEvents(project, since);
645688

646689
/**
647690
* Assert
@@ -686,16 +729,59 @@ describe('DbHelper', () => {
686729
/**
687730
* Act
688731
*/
689-
const count = await dbHelper.getEventsCountByProject(project, since);
732+
const count = await dbHelper.getEventsCountByProjectUsingDailyEvents(project, since);
690733

691734
/**
692735
* Assert
693736
*/
694737
expect(count).toBe(1); // only the single boundary-day event
695738
});
739+
740+
test('Should count dailyEvents bucket at since when since is already UTC midnight', async () => {
741+
/**
742+
* Arrange
743+
*/
744+
const workspace = createWorkspaceMock({
745+
plan: mockedPlans.eventsLimit10,
746+
billingPeriodEventsCount: 0,
747+
lastChargeDate: new Date(),
748+
});
749+
const project = createProjectMock({ workspaceId: workspace._id });
750+
const since = NEXT_MIDNIGHT_AFTER_LAST_CHARGE;
751+
752+
await fillDatabaseWithMockedData({
753+
workspace,
754+
project,
755+
eventsToMock: 0,
756+
dailyEventsToMock: [
757+
{
758+
groupingTimestamp: since,
759+
count: 4,
760+
},
761+
{
762+
groupingTimestamp: since + 86400,
763+
count: 3,
764+
},
765+
],
766+
});
767+
768+
await db.collection(`events:${project._id.toString()}`).insertOne(
769+
createEventMock(since + 100)
770+
);
771+
772+
/**
773+
* Act
774+
*/
775+
const count = await dbHelper.getEventsCountByProjectUsingDailyEvents(project, since);
776+
777+
/**
778+
* Assert
779+
*/
780+
expect(count).toBe(7);
781+
});
696782
});
697783

698-
describe('getEventsCountByProjects', () => {
784+
describe('getEventsCountByProjectsUsingDailyEvents', () => {
699785
test('Should count events, repetitions and dailyEvents for multiple projects', async () => {
700786
/**
701787
* Arrange
@@ -731,7 +817,7 @@ describe('DbHelper', () => {
731817
/**
732818
* Act
733819
*/
734-
const count = await dbHelper.getEventsCountByProjects([project1, project2], since);
820+
const count = await dbHelper.getEventsCountByProjectsUsingDailyEvents([project1, project2], since);
735821

736822
/**
737823
* Assert

0 commit comments

Comments
 (0)