Skip to content

Commit

Permalink
Add context cache as a request level cache (#22294)
Browse files Browse the repository at this point in the history
To avoid duplicated load of the same data in an HTTP request, we can set
a context cache to do that. i.e. Some pages may load a user from a
database with the same id in different areas on the same page. But the
code is hidden in two different deep logic. How should we share the
user? As a result of this PR, now if both entry functions accept
`context.Context` as the first parameter and we just need to refactor
`GetUserByID` to reuse the user from the context cache. Then it will not
be loaded twice on an HTTP request.

But of course, sometimes we would like to reload an object from the
database, that's why `RemoveContextData` is also exposed.

The core context cache is here. It defines a new context
```go
type cacheContext struct {
	ctx  context.Context
	data map[any]map[any]any
        lock sync.RWMutex
}

var cacheContextKey = struct{}{}

func WithCacheContext(ctx context.Context) context.Context {
	return context.WithValue(ctx, cacheContextKey, &cacheContext{
		ctx:  ctx,
		data: make(map[any]map[any]any),
	})
}
```

Then you can use the below 4 methods to read/write/del the data within
the same context.

```go
func GetContextData(ctx context.Context, tp, key any) any
func SetContextData(ctx context.Context, tp, key, value any)
func RemoveContextData(ctx context.Context, tp, key any)
func GetWithContextCache[T any](ctx context.Context, cacheGroupKey string, cacheTargetID any, f func() (T, error)) (T, error)
```

Then let's take a look at how `system.GetString` implement it.

```go
func GetSetting(ctx context.Context, key string) (string, error) {
	return cache.GetWithContextCache(ctx, contextCacheKey, key, func() (string, error) {
		return cache.GetString(genSettingCacheKey(key), func() (string, error) {
			res, err := GetSettingNoCache(ctx, key)
			if err != nil {
				return "", err
			}
			return res.SettingValue, nil
		})
	})
}
```

First, it will check if context data include the setting object with the
key. If not, it will query from the global cache which may be memory or
a Redis cache. If not, it will get the object from the database. In the
end, if the object gets from the global cache or database, it will be
set into the context cache.

An object stored in the context cache will only be destroyed after the
context disappeared.
  • Loading branch information
lunny authored Feb 15, 2023
1 parent 03638f9 commit bd820aa
Show file tree
Hide file tree
Showing 150 changed files with 663 additions and 516 deletions.
2 changes: 1 addition & 1 deletion cmd/admin_user_delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func runDeleteUser(c *cli.Context) error {
var err error
var user *user_model.User
if c.IsSet("email") {
user, err = user_model.GetUserByEmail(c.String("email"))
user, err = user_model.GetUserByEmail(ctx, c.String("email"))
} else if c.IsSet("username") {
user, err = user_model.GetUserByName(ctx, c.String("username"))
} else {
Expand Down
6 changes: 3 additions & 3 deletions models/activities/repo_activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,12 @@ func GetActivityStatsTopAuthors(ctx context.Context, repo *repo_model.Repository
}
users := make(map[int64]*ActivityAuthorData)
var unknownUserID int64
unknownUserAvatarLink := user_model.NewGhostUser().AvatarLink()
unknownUserAvatarLink := user_model.NewGhostUser().AvatarLink(ctx)
for _, v := range code.Authors {
if len(v.Email) == 0 {
continue
}
u, err := user_model.GetUserByEmail(v.Email)
u, err := user_model.GetUserByEmail(ctx, v.Email)
if u == nil || user_model.IsErrUserNotExist(err) {
unknownUserID--
users[unknownUserID] = &ActivityAuthorData{
Expand All @@ -119,7 +119,7 @@ func GetActivityStatsTopAuthors(ctx context.Context, repo *repo_model.Repository
users[u.ID] = &ActivityAuthorData{
Name: u.DisplayName(),
Login: u.LowerName,
AvatarLink: u.AvatarLink(),
AvatarLink: u.AvatarLink(ctx),
HomeLink: u.HomeLink(),
Commits: v.Commits,
}
Expand Down
11 changes: 6 additions & 5 deletions models/asymkey/gpg_key_commit_verification.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package asymkey

import (
"context"
"fmt"
"hash"
"strings"
Expand Down Expand Up @@ -70,14 +71,14 @@ const (
)

// ParseCommitsWithSignature checks if signaute of commits are corresponding to users gpg keys.
func ParseCommitsWithSignature(oldCommits []*user_model.UserCommit, repoTrustModel repo_model.TrustModelType, isOwnerMemberCollaborator func(*user_model.User) (bool, error)) []*SignCommit {
func ParseCommitsWithSignature(ctx context.Context, oldCommits []*user_model.UserCommit, repoTrustModel repo_model.TrustModelType, isOwnerMemberCollaborator func(*user_model.User) (bool, error)) []*SignCommit {
newCommits := make([]*SignCommit, 0, len(oldCommits))
keyMap := map[string]bool{}

for _, c := range oldCommits {
signCommit := &SignCommit{
UserCommit: c,
Verification: ParseCommitWithSignature(c.Commit),
Verification: ParseCommitWithSignature(ctx, c.Commit),
}

_ = CalculateTrustStatus(signCommit.Verification, repoTrustModel, isOwnerMemberCollaborator, &keyMap)
Expand All @@ -88,13 +89,13 @@ func ParseCommitsWithSignature(oldCommits []*user_model.UserCommit, repoTrustMod
}

// ParseCommitWithSignature check if signature is good against keystore.
func ParseCommitWithSignature(c *git.Commit) *CommitVerification {
func ParseCommitWithSignature(ctx context.Context, c *git.Commit) *CommitVerification {
var committer *user_model.User
if c.Committer != nil {
var err error
// Find Committer account
committer, err = user_model.GetUserByEmail(c.Committer.Email) // This finds the user by primary email or activated email so commit will not be valid if email is not
if err != nil { // Skipping not user for committer
committer, err = user_model.GetUserByEmail(ctx, c.Committer.Email) // This finds the user by primary email or activated email so commit will not be valid if email is not
if err != nil { // Skipping not user for committer
committer = &user_model.User{
Name: c.Committer.Name,
Email: c.Committer.Email,
Expand Down
14 changes: 7 additions & 7 deletions models/avatars/avatar.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,13 @@ func generateRecognizedAvatarURL(u url.URL, size int) string {
// generateEmailAvatarLink returns a email avatar link.
// if final is true, it may use a slow path (eg: query DNS).
// if final is false, it always uses a fast path.
func generateEmailAvatarLink(email string, size int, final bool) string {
func generateEmailAvatarLink(ctx context.Context, email string, size int, final bool) string {
email = strings.TrimSpace(email)
if email == "" {
return DefaultAvatarLink()
}

enableFederatedAvatar := system_model.GetSettingBool(system_model.KeyPictureEnableFederatedAvatar)
enableFederatedAvatar := system_model.GetSettingBool(ctx, system_model.KeyPictureEnableFederatedAvatar)

var err error
if enableFederatedAvatar && system_model.LibravatarService != nil {
Expand All @@ -174,7 +174,7 @@ func generateEmailAvatarLink(email string, size int, final bool) string {
return urlStr
}

disableGravatar := system_model.GetSettingBool(system_model.KeyPictureDisableGravatar)
disableGravatar := system_model.GetSettingBool(ctx, system_model.KeyPictureDisableGravatar)
if !disableGravatar {
// copy GravatarSourceURL, because we will modify its Path.
avatarURLCopy := *system_model.GravatarSourceURL
Expand All @@ -186,11 +186,11 @@ func generateEmailAvatarLink(email string, size int, final bool) string {
}

// GenerateEmailAvatarFastLink returns a avatar link (fast, the link may be a delegated one: "/avatar/${hash}")
func GenerateEmailAvatarFastLink(email string, size int) string {
return generateEmailAvatarLink(email, size, false)
func GenerateEmailAvatarFastLink(ctx context.Context, email string, size int) string {
return generateEmailAvatarLink(ctx, email, size, false)
}

// GenerateEmailAvatarFinalLink returns a avatar final link (maybe slow)
func GenerateEmailAvatarFinalLink(email string, size int) string {
return generateEmailAvatarLink(email, size, true)
func GenerateEmailAvatarFinalLink(ctx context.Context, email string, size int) string {
return generateEmailAvatarLink(ctx, email, size, true)
}
11 changes: 6 additions & 5 deletions models/avatars/avatar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"testing"

avatars_model "code.gitea.io/gitea/models/avatars"
"code.gitea.io/gitea/models/db"
system_model "code.gitea.io/gitea/models/system"
"code.gitea.io/gitea/modules/setting"

Expand All @@ -16,15 +17,15 @@ import (
const gravatarSource = "https://secure.gravatar.com/avatar/"

func disableGravatar(t *testing.T) {
err := system_model.SetSettingNoVersion(system_model.KeyPictureEnableFederatedAvatar, "false")
err := system_model.SetSettingNoVersion(db.DefaultContext, system_model.KeyPictureEnableFederatedAvatar, "false")
assert.NoError(t, err)
err = system_model.SetSettingNoVersion(system_model.KeyPictureDisableGravatar, "true")
err = system_model.SetSettingNoVersion(db.DefaultContext, system_model.KeyPictureDisableGravatar, "true")
assert.NoError(t, err)
system_model.LibravatarService = nil
}

func enableGravatar(t *testing.T) {
err := system_model.SetSettingNoVersion(system_model.KeyPictureDisableGravatar, "false")
err := system_model.SetSettingNoVersion(db.DefaultContext, system_model.KeyPictureDisableGravatar, "false")
assert.NoError(t, err)
setting.GravatarSource = gravatarSource
err = system_model.Init()
Expand All @@ -47,11 +48,11 @@ func TestSizedAvatarLink(t *testing.T) {

disableGravatar(t)
assert.Equal(t, "/testsuburl/assets/img/avatar_default.png",
avatars_model.GenerateEmailAvatarFastLink("gitea@example.com", 100))
avatars_model.GenerateEmailAvatarFastLink(db.DefaultContext, "gitea@example.com", 100))

enableGravatar(t)
assert.Equal(t,
"https://secure.gravatar.com/avatar/353cbad9b58e69c96154ad99f92bedc7?d=identicon&s=100",
avatars_model.GenerateEmailAvatarFastLink("gitea@example.com", 100),
avatars_model.GenerateEmailAvatarFastLink(db.DefaultContext, "gitea@example.com", 100),
)
}
3 changes: 2 additions & 1 deletion models/git/commit_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,8 @@ func hashCommitStatusContext(context string) string {
func ConvertFromGitCommit(ctx context.Context, commits []*git.Commit, repo *repo_model.Repository) []*SignCommitWithStatuses {
return ParseCommitsWithStatus(ctx,
asymkey_model.ParseCommitsWithSignature(
user_model.ValidateCommitsWithEmails(commits),
ctx,
user_model.ValidateCommitsWithEmails(ctx, commits),
repo.GetTrustModel(),
func(user *user_model.User) (bool, error) {
return repo_model.IsOwnerMemberCollaborator(repo, user.ID)
Expand Down
4 changes: 2 additions & 2 deletions models/organization/org.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,8 @@ func (org *Organization) hasMemberWithUserID(ctx context.Context, userID int64)
}

// AvatarLink returns the full avatar link with http host
func (org *Organization) AvatarLink() string {
return org.AsUser().AvatarLink()
func (org *Organization) AvatarLink(ctx context.Context) string {
return org.AsUser().AvatarLink(ctx)
}

// HTMLURL returns the organization's full link.
Expand Down
7 changes: 1 addition & 6 deletions models/repo/avatar.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,7 @@ func (repo *Repository) relAvatarLink(ctx context.Context) string {
}

// AvatarLink returns a link to the repository's avatar.
func (repo *Repository) AvatarLink() string {
return repo.avatarLink(db.DefaultContext)
}

// avatarLink returns user avatar absolute link.
func (repo *Repository) avatarLink(ctx context.Context) string {
func (repo *Repository) AvatarLink(ctx context.Context) string {
link := repo.relAvatarLink(ctx)
// we only prepend our AppURL to our known (relative, internal) avatar link to get an absolute URL
if strings.HasPrefix(link, "/") && !strings.HasPrefix(link, "//") {
Expand Down
59 changes: 33 additions & 26 deletions models/system/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ func IsErrDataExpired(err error) bool {
}

// GetSettingNoCache returns specific setting without using the cache
func GetSettingNoCache(key string) (*Setting, error) {
v, err := GetSettings([]string{key})
func GetSettingNoCache(ctx context.Context, key string) (*Setting, error) {
v, err := GetSettings(ctx, []string{key})
if err != nil {
return nil, err
}
Expand All @@ -91,27 +91,31 @@ func GetSettingNoCache(key string) (*Setting, error) {
return v[strings.ToLower(key)], nil
}

const contextCacheKey = "system_setting"

// GetSetting returns the setting value via the key
func GetSetting(key string) (string, error) {
return cache.GetString(genSettingCacheKey(key), func() (string, error) {
res, err := GetSettingNoCache(key)
if err != nil {
return "", err
}
return res.SettingValue, nil
func GetSetting(ctx context.Context, key string) (string, error) {
return cache.GetWithContextCache(ctx, contextCacheKey, key, func() (string, error) {
return cache.GetString(genSettingCacheKey(key), func() (string, error) {
res, err := GetSettingNoCache(ctx, key)
if err != nil {
return "", err
}
return res.SettingValue, nil
})
})
}

// GetSettingBool return bool value of setting,
// none existing keys and errors are ignored and result in false
func GetSettingBool(key string) bool {
s, _ := GetSetting(key)
func GetSettingBool(ctx context.Context, key string) bool {
s, _ := GetSetting(ctx, key)
v, _ := strconv.ParseBool(s)
return v
}

// GetSettings returns specific settings
func GetSettings(keys []string) (map[string]*Setting, error) {
func GetSettings(ctx context.Context, keys []string) (map[string]*Setting, error) {
for i := 0; i < len(keys); i++ {
keys[i] = strings.ToLower(keys[i])
}
Expand Down Expand Up @@ -161,16 +165,17 @@ func GetAllSettings() (AllSettings, error) {
}

// DeleteSetting deletes a specific setting for a user
func DeleteSetting(setting *Setting) error {
func DeleteSetting(ctx context.Context, setting *Setting) error {
cache.RemoveContextData(ctx, contextCacheKey, setting.SettingKey)
cache.Remove(genSettingCacheKey(setting.SettingKey))
_, err := db.GetEngine(db.DefaultContext).Delete(setting)
return err
}

func SetSettingNoVersion(key, value string) error {
s, err := GetSettingNoCache(key)
func SetSettingNoVersion(ctx context.Context, key, value string) error {
s, err := GetSettingNoCache(ctx, key)
if IsErrSettingIsNotExist(err) {
return SetSetting(&Setting{
return SetSetting(ctx, &Setting{
SettingKey: key,
SettingValue: value,
})
Expand All @@ -179,11 +184,11 @@ func SetSettingNoVersion(key, value string) error {
return err
}
s.SettingValue = value
return SetSetting(s)
return SetSetting(ctx, s)
}

// SetSetting updates a users' setting for a specific key
func SetSetting(setting *Setting) error {
func SetSetting(ctx context.Context, setting *Setting) error {
if err := upsertSettingValue(strings.ToLower(setting.SettingKey), setting.SettingValue, setting.Version); err != nil {
return err
}
Expand All @@ -192,9 +197,11 @@ func SetSetting(setting *Setting) error {

cc := cache.GetCache()
if cc != nil {
return cc.Put(genSettingCacheKey(setting.SettingKey), setting.SettingValue, setting_module.CacheService.TTLSeconds())
if err := cc.Put(genSettingCacheKey(setting.SettingKey), setting.SettingValue, setting_module.CacheService.TTLSeconds()); err != nil {
return err
}
}

cache.SetContextData(ctx, contextCacheKey, setting.SettingKey, setting.SettingValue)
return nil
}

Expand Down Expand Up @@ -244,7 +251,7 @@ var (

func Init() error {
var disableGravatar bool
disableGravatarSetting, err := GetSettingNoCache(KeyPictureDisableGravatar)
disableGravatarSetting, err := GetSettingNoCache(db.DefaultContext, KeyPictureDisableGravatar)
if IsErrSettingIsNotExist(err) {
disableGravatar = setting_module.GetDefaultDisableGravatar()
disableGravatarSetting = &Setting{SettingValue: strconv.FormatBool(disableGravatar)}
Expand All @@ -255,7 +262,7 @@ func Init() error {
}

var enableFederatedAvatar bool
enableFederatedAvatarSetting, err := GetSettingNoCache(KeyPictureEnableFederatedAvatar)
enableFederatedAvatarSetting, err := GetSettingNoCache(db.DefaultContext, KeyPictureEnableFederatedAvatar)
if IsErrSettingIsNotExist(err) {
enableFederatedAvatar = setting_module.GetDefaultEnableFederatedAvatar(disableGravatar)
enableFederatedAvatarSetting = &Setting{SettingValue: strconv.FormatBool(enableFederatedAvatar)}
Expand All @@ -268,13 +275,13 @@ func Init() error {
if setting_module.OfflineMode {
disableGravatar = true
enableFederatedAvatar = false
if !GetSettingBool(KeyPictureDisableGravatar) {
if err := SetSettingNoVersion(KeyPictureDisableGravatar, "true"); err != nil {
if !GetSettingBool(db.DefaultContext, KeyPictureDisableGravatar) {
if err := SetSettingNoVersion(db.DefaultContext, KeyPictureDisableGravatar, "true"); err != nil {
return fmt.Errorf("Failed to set setting %q: %w", KeyPictureDisableGravatar, err)
}
}
if GetSettingBool(KeyPictureEnableFederatedAvatar) {
if err := SetSettingNoVersion(KeyPictureEnableFederatedAvatar, "false"); err != nil {
if GetSettingBool(db.DefaultContext, KeyPictureEnableFederatedAvatar) {
if err := SetSettingNoVersion(db.DefaultContext, KeyPictureEnableFederatedAvatar, "false"); err != nil {
return fmt.Errorf("Failed to set setting %q: %w", KeyPictureEnableFederatedAvatar, err)
}
}
Expand Down
13 changes: 7 additions & 6 deletions models/system/setting_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strings"
"testing"

"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/system"
"code.gitea.io/gitea/models/unittest"

Expand All @@ -20,24 +21,24 @@ func TestSettings(t *testing.T) {
newSetting := &system.Setting{SettingKey: keyName, SettingValue: "50"}

// create setting
err := system.SetSetting(newSetting)
err := system.SetSetting(db.DefaultContext, newSetting)
assert.NoError(t, err)
// test about saving unchanged values
err = system.SetSetting(newSetting)
err = system.SetSetting(db.DefaultContext, newSetting)
assert.NoError(t, err)

// get specific setting
settings, err := system.GetSettings([]string{keyName})
settings, err := system.GetSettings(db.DefaultContext, []string{keyName})
assert.NoError(t, err)
assert.Len(t, settings, 1)
assert.EqualValues(t, newSetting.SettingValue, settings[strings.ToLower(keyName)].SettingValue)

// updated setting
updatedSetting := &system.Setting{SettingKey: keyName, SettingValue: "100", Version: settings[strings.ToLower(keyName)].Version}
err = system.SetSetting(updatedSetting)
err = system.SetSetting(db.DefaultContext, updatedSetting)
assert.NoError(t, err)

value, err := system.GetSetting(keyName)
value, err := system.GetSetting(db.DefaultContext, keyName)
assert.NoError(t, err)
assert.EqualValues(t, updatedSetting.SettingValue, value)

Expand All @@ -48,7 +49,7 @@ func TestSettings(t *testing.T) {
assert.EqualValues(t, updatedSetting.SettingValue, settings[strings.ToLower(updatedSetting.SettingKey)].SettingValue)

// delete setting
err = system.DeleteSetting(&system.Setting{SettingKey: strings.ToLower(keyName)})
err = system.DeleteSetting(db.DefaultContext, &system.Setting{SettingKey: strings.ToLower(keyName)})
assert.NoError(t, err)
settings, err = system.GetAllSettings()
assert.NoError(t, err)
Expand Down
Loading

0 comments on commit bd820aa

Please sign in to comment.