From ac6ece75c94947d8fb3a980d0130097216ae5f29 Mon Sep 17 00:00:00 2001 From: Gusted Date: Wed, 4 Dec 2024 14:38:41 +0100 Subject: [PATCH] feat: improve performance of notifications page for MySQL - For the notifications page the unread and pinned notifications are gathered for doer those that and are ordered by the updated unix. MariaDB makes a bad decision (sometimes, for most users it does not make this decision) with this query, it uses the index for the `updated_unix` column to speed up this query, however this is not the correct index to be taking, if the doer does not have more than 20 (the page size) unread and pinned notifications combined MariaDB will traverse the whole notifications table before it realizes that there are no more notifications to be gathered. It instead should use the index for the `user_id` column (this is what MariaDB already does for most users), so the list that has to be traversed is limited to the doer's notifications which is significantly less than the whole notifications table. - This is a different approach than what Gitea has taken to solve this problem, which is to add a index to the (status, userid, updated_unix) tuple (Ref: https://github.com/go-gitea/gitea/pull/32395). Adding more and more indexes is not a good way if we can use existing indexes to get a query to a acceptable performance. - The code cannot use `db.Find` as it's hard to add a index hint option specifically for this query and not for the other instances that uses `activities_model.FindNotificationOptions`. - Only add a index hint for MySQL as I have not been able to test if SQLite or PostgreSQL are smart enough to use the better index (as you need a large enough dataset to test this meaningfully). - Integration test added to ensure the SQL is run by all databases. --- Performance numbers (from Codeberg's database - MariaDB 10.11.6-MariaDB-0+deb12u1): Currently: ```sql SELECT * FROM `notification` WHERE notification.user_id=26734 AND (notification.status=3 OR notification.status=1) ORDER BY notification.updated_unix DESC LIMIT 20; (5.731 sec) +------+-------------+--------------+-------+--------------------------------------------------+-------------------------------+---------+-------+---------+------------+----------+------------+-------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | r_rows | filtered | r_filtered | Extra | +------+-------------+--------------+-------+--------------------------------------------------+-------------------------------+---------+-------+---------+------------+----------+------------+-------------+ | 1 | SIMPLE | notification | index | IDX_notification_status,IDX_notification_user_id | IDX_notification_updated_unix | 8 | const | 1376836 | 1474066.00 | 50.03 | 0.00 | Using where | +------+-------------+--------------+-------+--------------------------------------------------+-------------------------------+---------+-------+---------+------------+----------+------------+-------------+ ``` Using the better index: ```sql SELECT * FROM `notification` USE INDEX (IDX_notification_user_id) WHERE notification.user_id=26734 AND (notification.status=3 OR notification.status=1) ORDER BY notification.updated_unix DESC LIMIT 20; (0.834 sec) +------+-------------+--------------+--------+----------------------------------------------------------+--------------------------+---------+----------------------------------+-------+----------+----------+------------+----------------------------------------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | r_rows | filtered | r_filtered | Extra | +------+-------------+--------------+--------+----------------------------------------------------------+--------------------------+---------+----------------------------------+-------+----------+----------+------------+----------------------------------------------+ | 1 | PRIMARY | notification | ref | PRIMARY,IDX_notification_status,IDX_notification_user_id | IDX_notification_user_id | 8 | const | 22042 | 10756.00 | 50.03 | 0.02 | Using where; Using temporary; Using filesort | | 1 | PRIMARY | notification | eq_ref | PRIMARY | PRIMARY | 8 | gitea_production.notification.id | 1 | 1.00 | 100.00 | 100.00 | | +------+-------------+--------------+--------+----------------------------------------------------------+--------------------------+---------+----------------------------------+-------+----------+----------+------------+----------------------------------------------+ ``` --- routers/web/user/notification.go | 30 ++++++++++++++---------- tests/integration/notification_test.go | 32 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 12 deletions(-) create mode 100644 tests/integration/notification_test.go diff --git a/routers/web/user/notification.go b/routers/web/user/notification.go index dfcaf58e08..d3c9365dfe 100644 --- a/routers/web/user/notification.go +++ b/routers/web/user/notification.go @@ -111,20 +111,26 @@ func getNotifications(ctx *context.Context) { return } - statuses := []activities_model.NotificationStatus{status, activities_model.NotificationStatusPinned} - nls, err := db.Find[activities_model.Notification](ctx, activities_model.FindNotificationOptions{ - ListOptions: db.ListOptions{ - PageSize: perPage, - Page: page, - }, - UserID: ctx.Doer.ID, - Status: statuses, - }) - if err != nil { - ctx.ServerError("db.Find[activities_model.Notification]", err) - return + sess := db.GetEngine(ctx).Table("notification") + if setting.Database.Type.IsMySQL() { + sess = sess.IndexHint("USE", "JOIN", "IDX_notification_user_id") + } + sess.Where("user_id = ?", ctx.Doer.ID). + And("status = ? OR status = ?", status, activities_model.NotificationStatusPinned). + OrderBy("notification.updated_unix DESC") + + if perPage > 0 { + if page == 0 { + page = 1 + } + sess.Limit(perPage, (page-1)*perPage) } + nls := make([]*activities_model.Notification, 0, perPage) + if err := sess.Find(&nls); err != nil { + ctx.ServerError("FindNotifications", err) + return + } notifications := activities_model.NotificationList(nls) failCount := 0 diff --git a/tests/integration/notification_test.go b/tests/integration/notification_test.go new file mode 100644 index 0000000000..6195ec2282 --- /dev/null +++ b/tests/integration/notification_test.go @@ -0,0 +1,32 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/tests" +) + +func TestNotification(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + + req := NewRequest(t, "GET", "/notifications") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + // Unread and pinned notification. + htmlDoc.AssertElement(t, ".notifications-link[href='/user2/repo1/pulls/3']", true) + htmlDoc.AssertElement(t, ".notifications-link[href='/user2/repo1/issues/4']", true) + htmlDoc.AssertElement(t, ".notifications-link[href='/user2/repo2/issues/1']", true) + + // Read notification. + htmlDoc.AssertElement(t, ".notifications-link[href='/user2/repo2/pulls/2']", false) +}