From d5c8091e089ed9b56574ef6488c99aac01a75298 Mon Sep 17 00:00:00 2001
From: Gusted
Date: Mon, 17 Mar 2025 09:00:34 +0000
Subject: [PATCH] perf: optimize converting releases to feed items (#7221)
- `releasesToFeedItems` is called to convert release structs to feed items, which is then used to render RSS or Atom feeds.
- Optimize the loading of attributes for the releases, introduce `ReleaseList` type which uses caching to load repository and publishers. It also no longer loads release attachments and downloads counts as that is not used in feed items.
- Optimize the composing of meta by introducing caching, this operation is especially slow when the owner is an organization.
- Add unit test (ensures new `LoadAttributes` works correctly).
- Add integration test (ensures that feed output is still as expected).
Loading https://codeberg.org/forgejo/forgejo/releases.rss reduced from ~15s to ~1s. (It is currently is deployed on codeberg.org)
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7221
Reviewed-by: Otto
Co-authored-by: Gusted
Co-committed-by: Gusted
---
models/repo/release_list.go | 45 +++++++++++++
models/repo/release_list_test.go | 42 ++++++++++++
routers/web/feed/convert.go | 22 ++++---
tests/integration/release_feed_test.go | 91 ++++++++++++++++++++++++++
4 files changed, 192 insertions(+), 8 deletions(-)
create mode 100644 models/repo/release_list.go
create mode 100644 models/repo/release_list_test.go
create mode 100644 tests/integration/release_feed_test.go
diff --git a/models/repo/release_list.go b/models/repo/release_list.go
new file mode 100644
index 0000000000..6c33262125
--- /dev/null
+++ b/models/repo/release_list.go
@@ -0,0 +1,45 @@
+// Copyright 2025 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package repo
+
+import (
+ "context"
+
+ user_model "code.gitea.io/gitea/models/user"
+)
+
+type ReleaseList []*Release
+
+// LoadAttributes loads the repository and publisher for the releases.
+func (r ReleaseList) LoadAttributes(ctx context.Context) error {
+ repoCache := make(map[int64]*Repository)
+ userCache := make(map[int64]*user_model.User)
+
+ for _, release := range r {
+ var err error
+ repo, ok := repoCache[release.RepoID]
+ if !ok {
+ repo, err = GetRepositoryByID(ctx, release.RepoID)
+ if err != nil {
+ return err
+ }
+ repoCache[release.RepoID] = repo
+ }
+ release.Repo = repo
+
+ publisher, ok := userCache[release.PublisherID]
+ if !ok {
+ publisher, err = user_model.GetUserByID(ctx, release.PublisherID)
+ if err != nil {
+ if !user_model.IsErrUserNotExist(err) {
+ return err
+ }
+ publisher = user_model.NewGhostUser()
+ }
+ userCache[release.PublisherID] = publisher
+ }
+ release.Publisher = publisher
+ }
+ return nil
+}
diff --git a/models/repo/release_list_test.go b/models/repo/release_list_test.go
new file mode 100644
index 0000000000..cbd77683d0
--- /dev/null
+++ b/models/repo/release_list_test.go
@@ -0,0 +1,42 @@
+// Copyright 2025 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package repo
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestReleaseListLoadAttributes(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ releases := ReleaseList{&Release{
+ RepoID: 1,
+ PublisherID: 1,
+ }, &Release{
+ RepoID: 2,
+ PublisherID: 2,
+ }, &Release{
+ RepoID: 1,
+ PublisherID: 2,
+ }, &Release{
+ RepoID: 2,
+ PublisherID: 1,
+ }}
+
+ require.NoError(t, releases.LoadAttributes(t.Context()))
+
+ assert.EqualValues(t, 1, releases[0].Repo.ID)
+ assert.EqualValues(t, 1, releases[0].Publisher.ID)
+ assert.EqualValues(t, 2, releases[1].Repo.ID)
+ assert.EqualValues(t, 2, releases[1].Publisher.ID)
+ assert.EqualValues(t, 1, releases[2].Repo.ID)
+ assert.EqualValues(t, 2, releases[2].Publisher.ID)
+ assert.EqualValues(t, 2, releases[3].Repo.ID)
+ assert.EqualValues(t, 1, releases[3].Publisher.ID)
+}
diff --git a/routers/web/feed/convert.go b/routers/web/feed/convert.go
index d3d01a87e0..5f7687d803 100644
--- a/routers/web/feed/convert.go
+++ b/routers/web/feed/convert.go
@@ -298,14 +298,14 @@ func GetFeedType(name string, req *http.Request) (bool, string, string) {
return false, name, ""
}
-// feedActionsToFeedItems convert gitea's Repo's Releases to feeds Item
-func releasesToFeedItems(ctx *context.Context, releases []*repo_model.Release) (items []*feeds.Item, err error) {
- for _, rel := range releases {
- err := rel.LoadAttributes(ctx)
- if err != nil {
- return nil, err
- }
+// feedActionsToFeedItems convert repository releases into feed items.
+func releasesToFeedItems(ctx *context.Context, releases repo_model.ReleaseList) (items []*feeds.Item, err error) {
+ if err := releases.LoadAttributes(ctx); err != nil {
+ return nil, err
+ }
+ composeCache := make(map[int64]map[string]string)
+ for _, rel := range releases {
var title string
var content template.HTML
@@ -315,13 +315,19 @@ func releasesToFeedItems(ctx *context.Context, releases []*repo_model.Release) (
title = rel.Title
}
+ metas, ok := composeCache[rel.RepoID]
+ if !ok {
+ metas = rel.Repo.ComposeMetas(ctx)
+ composeCache[rel.RepoID] = metas
+ }
+
link := &feeds.Link{Href: rel.HTMLURL()}
content, err = markdown.RenderString(&markup.RenderContext{
Ctx: ctx,
Links: markup.Links{
Base: rel.Repo.Link(),
},
- Metas: rel.Repo.ComposeMetas(ctx),
+ Metas: metas,
}, rel.Note)
if err != nil {
return nil, err
diff --git a/tests/integration/release_feed_test.go b/tests/integration/release_feed_test.go
new file mode 100644
index 0000000000..fd1f42c3cd
--- /dev/null
+++ b/tests/integration/release_feed_test.go
@@ -0,0 +1,91 @@
+// Copyright 2025 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package integration
+
+import (
+ "net/http"
+ "regexp"
+ "testing"
+
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestReleaseFeed(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ normalize := func(body string) string {
+ // Remove port.
+ body = regexp.MustCompile(`localhost:\d+`).ReplaceAllString(body, "localhost")
+ // date is timezone dependent.
+ body = regexp.MustCompile(`.*`).ReplaceAllString(body, "")
+ body = regexp.MustCompile(`.*`).ReplaceAllString(body, "")
+ return body
+ }
+ t.Run("RSS feed", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ resp := MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/releases.rss"), http.StatusOK)
+ assert.EqualValues(t, `
+
+ Releases for user2/repo1
+ http://localhost/user2/repo1/release
+
+
+ -
+ pre-release
+ http://localhost/user2/repo1/releases/tag/v1.0
+
+ some text for a pre release
+]]>
+ user2
+ 5: http://localhost/user2/repo1/releases/tag/v1.0
+
+
+ -
+ testing-release
+ http://localhost/user2/repo1/releases/tag/v1.1
+
+ user2
+ 1: http://localhost/user2/repo1/releases/tag/v1.1
+
+
+
+`, normalize(resp.Body.String()))
+ })
+
+ t.Run("Atom feed", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ resp := MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/releases.atom"), http.StatusOK)
+ assert.EqualValues(t, `
+ Releases for user2/repo1
+ http://localhost/user2/repo1/release
+
+
+
+ pre-release
+
+ 5: http://localhost/user2/repo1/releases/tag/v1.0
+ <p dir="auto">some text for a pre release</p>
+
+
+ user2
+ user2@noreply.example.org
+
+
+
+ testing-release
+
+ 1: http://localhost/user2/repo1/releases/tag/v1.1
+
+
+ user2
+ user2@noreply.example.org
+
+
+`, normalize(resp.Body.String()))
+ })
+}