perf: speed up repository issue and pull request lists (#40114) (#40132)

Backport #40114 by @silverwind

The repository issue and pull request lists filter `access` by
`repo_id`, which no index covers, so every load scans the whole table.
Issue counts on larger repositories merge three single-column indices,
and the API pull request list loads the latest reviews with `id IN
(SELECT max(id) ... GROUP BY ...)`, which MySQL runs as a scan of every
review, about 1.1s on
https://gitea.com/api/v1/repos/gitea/gitea-mcp/pulls?limit=1.

- Index `access.repo_id`
- Index `team_repo.repo_id`
- Replace the single `repo_id`, `is_closed` and `is_pull` indices with
one `issue` index on `(repo_id, is_closed, is_pull)`
- Join the latest review ids instead of `id IN (subquery)`

Median times in ms on MySQL 8.4 with tables sized like gitea.com (1M
access rows, 500k issues, 200k reviews), for a copy of
https://gitea.com/gitea/gitea-mcp and a repository with 9.7k issues:

| Page | Before | After |
|---|---|---|
| `/{owner}/{repo}/issues` | 107 | 40 |
| `/{owner}/{repo}/pulls` | 106 | 32 |
| `/{owner}/{repo}/pulls?state=closed` | 153 | 35 |
| `/{owner}/{repo}/issues/{index}` | 141 | 60 |
| `/{owner}/{repo}/issues`, 9.7k issues | 303 | 53 |
| `/api/v1/repos/{owner}/{repo}/pulls?limit=50` | 180 | 46 |
| `/api/v1/repos/{owner}/{repo}/pulls?limit=1` | 137 | 16 |
| `/api/v1/repos/{owner}/{repo}/issues`, 9.7k issues | 248 | 43 |

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-31 18:24:15 +01:00
1 parent 6f5d66f6dd
commit 67904da6a5
6 files changed
+10 -12

No files matched your search

+3 -3
View File
@@ -55,7 +55,7 @@ var ErrIssueAlreadyChanged = util.NewInvalidArgumentErrorf("the issue is already
// Issue represents an issue or pull request of repository.
type Issue struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX UNIQUE(repo_index)"`
RepoID int64 `xorm:"INDEX(repo_closed_pull) UNIQUE(repo_index)"`
Repo *repo_model.Repository `xorm:"-"`
Index int64 `xorm:"UNIQUE(repo_index)"` // Index in one repository.
PosterID int64 `xorm:"INDEX"`
@@ -77,9 +77,9 @@ type Issue struct {
AssigneeID int64 `xorm:"-"`
Assignee *user_model.User `xorm:"-"`
isAssigneeLoaded bool `xorm:"-"`
IsClosed bool `xorm:"INDEX"`
IsClosed bool `xorm:"INDEX(repo_closed_pull)"`
IsRead bool `xorm:"-"`
IsPull bool `xorm:"INDEX"` // Indicates whether is a pull request or not.
IsPull bool `xorm:"INDEX(repo_closed_pull)"` // Indicates whether is a pull request or not.
PullRequest *PullRequest `xorm:"-"`
NumComments int
+3 -3
View File
@@ -335,8 +335,8 @@ func (prs PullRequestList) LoadReviews(ctx context.Context) (ReviewList, error)
"reviewer_team_id": 0,
}).
GroupBy("issue_id, reviewer_id")
// Get latest review of each reviewer, sorted in order they were made
if err := db.GetEngine(ctx).In("id", subQuery).OrderBy("review.updated_unix ASC").Find(&reviews); err != nil {
// Get latest review of each reviewer, sorted in order they were made. Joined because MySQL scans all reviews for "id IN (subquery)", xorm aliases the subquery "review".
if err := db.GetEngine(ctx).Table("review").Alias("latest").Join("INNER", subQuery, "latest.id = review.id").OrderBy("latest.updated_unix ASC").Find(&reviews); err != nil {
return nil, err
}
@@ -350,7 +350,7 @@ func (prs PullRequestList) LoadReviews(ctx context.Context) (ReviewList, error)
"reviewer_team_id": 0,
}).
GroupBy("issue_id, reviewer_team_id")
if err := db.GetEngine(ctx).In("id", subQueryTeam).OrderBy("review.updated_unix ASC").Find(&teamReviewRequests); err != nil {
if err := db.GetEngine(ctx).Table("review").Alias("latest").Join("INNER", subQueryTeam, "latest.id = review.id").OrderBy("latest.updated_unix ASC").Find(&teamReviewRequests); err != nil {
return nil, err
}
+2 -2
View File
@@ -13,12 +13,12 @@ import (
"xorm.io/builder"
)
// TeamRepo represents an team-repository relation.
// TeamRepo represents an org-team-repository relation.
type TeamRepo struct {
ID int64 `xorm:"pk autoincr"`
OrgID int64 `xorm:"INDEX"`
TeamID int64 `xorm:"UNIQUE(s)"`
RepoID int64 `xorm:"UNIQUE(s)"`
RepoID int64 `xorm:"UNIQUE(s) INDEX"`
}
// HasTeamRepo returns true if given repository belongs to team.
+1 -1
View File
@@ -25,7 +25,7 @@ import (
type Access struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"UNIQUE(s)"`
RepoID int64 `xorm:"UNIQUE(s)"`
RepoID int64 `xorm:"UNIQUE(s) INDEX"`
Mode perm.AccessMode
}
+1 -2
View File
@@ -494,10 +494,9 @@ func (repo *Repository) composeCommonMetas(ctx context.Context) map[string]strin
repo.MustOwner(ctx)
if repo.Owner.IsOrganization() {
teams := make([]string, 0, 5)
_ = db.GetEngine(ctx).Table("team_repo").
_ = db.GetEngine(ctx).Select("team.lower_name").Table("team_repo").
Join("INNER", "team", "team.id = team_repo.team_id").
Where("team_repo.repo_id = ?", repo.ID).
Select("team.lower_name").
OrderBy("team.lower_name").
Find(&teams)
metas["teams"] = "," + strings.Join(teams, ",") + ","
-1
View File
@@ -290,7 +290,6 @@ func DeleteIssuesByRepoID(ctx context.Context, repoID int64) (attachmentPaths []
issues := make([]*issues_model.Issue, 0, db.DefaultMaxInSize)
if err := db.GetEngine(ctx).
Where("repo_id = ?", repoID).
OrderBy("id").
Limit(db.DefaultMaxInSize).
Find(&issues); err != nil {
return nil, err