Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(api): reduce activity stats queries and improve performance #3029

Merged
merged 2 commits into from
Mar 17, 2023

Conversation

p-fernandez
Copy link
Contributor

@p-fernandez p-fernandez commented Mar 15, 2023

What change does this PR introduce?

Reduce the number of queries against the DB for the Activity Stats endpoint (it calculates 3 numbers, yearly total notifications, monthly and weekly).
Also introduces an index for Notification schema for the _organizationId and a test.

Why was this change needed?

We were being notified by performance degradation. Slow responses in the Activity feed. Thanks to one of our users we could spot one of the slow endpoints was /notifications/stats. Investigating the code we can find that 3 count queries were done. It is acknowledged that MongoDB count queries for large collections can be quite slow. This tries to mitigate that for large collections reducing the queries and calculating the numbers in an alternative way.

Other information (Screenshots)

We are calling this endpoint every time we load the Activity Feed page. We should think if it is useful for the users to have the yearly stat getting calculated every time. At least for large volume users.

@@ -173,7 +173,7 @@ export class ActivityNotificationResponseDto {
transactionId: string;

@ApiPropertyOptional()
createdAt?: Date;
createdAt?: string;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think this doesn't break anything as I think we were truly returning strings (this DTO is used in API responses and I couldn't find any transformation to Date in the code). Worth review this in the Web app. Running in my local couldn't find any problem with this change though. 😕

Copy link
Contributor

Choose a reason for hiding this comment

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

Yep, it's indeed an ISO string, so it's more accurate like this.

@@ -43,7 +53,7 @@ describe('Get activity stats - /notifications/stats (GET)', async () => {
{
_environmentId: session.environment._id,
},
null,
undefined,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

TS warning.

Comment on lines +95 to +107
* We generate notifications avoiding clashes of leap years and different month lengths
* so this test can be executed any time with same results
* Create 7 notifications during the week
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I just wanted to avoid dates edge cases based on the execution time.

_organizationId: command.organizationId,
createdAt: {
$gte: subMonths(new Date(), 1),
const yearlyNotifications = await this.notificationRepository.find(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We retrieve all the notifications in a year and then loop them to calculate the monthly and the weekly rather than hitting the DB again. Also to help for large collections we just retrieve 2 fields: _id and createdAt. We could even just retrieve createdAt.
All this replaces the 3 DB calls.

Copy link
Contributor

Choose a reason for hiding this comment

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

Unfortunately this approach is not very good :( Imagine an organization sending 10M events per month, you will return 120M record from the database to calculate this. Let alone the traffic bandwidth for this, the server will have to load it all in memory.

My suggestion would be to follow your approach but perhaps using a monthly aggregation pipeline and than from the aggregation results do a similar thing you've done.

For example here is a query that does it all in mongo:

db.getCollection('notifications').aggregate([
  // match events sent this week, this month, and this year
  {
    $match: {
      createdAt: {
        $gte: // Date of last year
      }
    }
  },
  // group events by week, month, and year
  {
    $group: {
      _id: {
        week: { $week: "$createdAt" },
        month: { $month: "$createdAt" },
        year: { $year: "$createdAt" }
      },
      count: { $sum: 1 }
    }
  }])

But to be honest, I'm not sure how it will be more performant than the count counterparts.

Another thing we can do here, is to also change the read preference here to query the replica and not the main db to reduce the overhead on it. wdyt?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@scopsy this was meant more like a short term fix. I do think we need to review the UI and what's the benefit of showing one year long usage all the time.
But you are right for a long term your proposal should perform better. Let me try to experiment a bit with it in MongoDB.

Comment on lines +91 to +103
async insertMany(
data: FilterQuery<T_DBModel> & T_Enforcement[]
): Promise<{ acknowledged: boolean; insertedCount: number; insertedIds: Types.ObjectId[] }> {
const result = await this.MongooseModel.insertMany(data, { ordered: false });

const insertedIds = result.map((inserted) => inserted._id);

return {
acknowledged: true,
insertedCount: result.length,
insertedIds,
};
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Introduced to help with the testing.

@@ -19,6 +19,7 @@ const notificationSchema = new Schema<NotificationDBModel>(
_organizationId: {
type: Schema.Types.ObjectId,
ref: 'Organization',
index: true,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The query for the stats is done with _environmentId and _organizationId. I would expect this field not being indexed, to slow down the query. Another option would be to just query based on the indexed field _environmentId as it should be enough.

Copy link
Contributor

Choose a reason for hiding this comment

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

I have introduced the compound index of:

{ _environmentId: 1, createdAt: -1} 

yesterday, because of the cardinality of the environment field it will be better the the organizationId, and mongoDB is smart enough to use the new compound index for the new query even if it contains the organizationId.

One thing we can actually do is to remove the organizationId from the query.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@scopsy The compound index was a great move. 👍🏻
I think we should also introduce the index for organization for future multitenancy purposes. We might forget.

Copy link
Contributor

@scopsy scopsy left a comment

Choose a reason for hiding this comment

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

Added a few comments regarding the selected query, I'm afraid it might introduce more performance issues once implemented :(

@@ -173,7 +173,7 @@ export class ActivityNotificationResponseDto {
transactionId: string;

@ApiPropertyOptional()
createdAt?: Date;
createdAt?: string;
Copy link
Contributor

Choose a reason for hiding this comment

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

Yep, it's indeed an ISO string, so it's more accurate like this.

_organizationId: command.organizationId,
createdAt: {
$gte: subMonths(new Date(), 1),
const yearlyNotifications = await this.notificationRepository.find(
Copy link
Contributor

Choose a reason for hiding this comment

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

Unfortunately this approach is not very good :( Imagine an organization sending 10M events per month, you will return 120M record from the database to calculate this. Let alone the traffic bandwidth for this, the server will have to load it all in memory.

My suggestion would be to follow your approach but perhaps using a monthly aggregation pipeline and than from the aggregation results do a similar thing you've done.

For example here is a query that does it all in mongo:

db.getCollection('notifications').aggregate([
  // match events sent this week, this month, and this year
  {
    $match: {
      createdAt: {
        $gte: // Date of last year
      }
    }
  },
  // group events by week, month, and year
  {
    $group: {
      _id: {
        week: { $week: "$createdAt" },
        month: { $month: "$createdAt" },
        year: { $year: "$createdAt" }
      },
      count: { $sum: 1 }
    }
  }])

But to be honest, I'm not sure how it will be more performant than the count counterparts.

Another thing we can do here, is to also change the read preference here to query the replica and not the main db to reduce the overhead on it. wdyt?

@@ -19,6 +19,7 @@ const notificationSchema = new Schema<NotificationDBModel>(
_organizationId: {
type: Schema.Types.ObjectId,
ref: 'Organization',
index: true,
Copy link
Contributor

Choose a reason for hiding this comment

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

I have introduced the compound index of:

{ _environmentId: 1, createdAt: -1} 

yesterday, because of the cardinality of the environment field it will be better the the organizationId, and mongoDB is smart enough to use the new compound index for the new query even if it contains the organizationId.

One thing we can actually do is to remove the organizationId from the query.

Copy link
Contributor

@scopsy scopsy left a comment

Choose a reason for hiding this comment

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

Looking good!

@p-fernandez p-fernandez added this pull request to the merge queue Mar 17, 2023
Merged via the queue into next with commit 6854964 Mar 17, 2023
@p-fernandez p-fernandez deleted the fix-activity-stats-queries branch March 17, 2023 08:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants