Skip to content

Add cache for common package queries #22491

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

Merged
merged 7 commits into from
Apr 13, 2025
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions models/packages/descriptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/cache"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/packages/alpine"
"code.gitea.io/gitea/modules/packages/arch"
Expand Down Expand Up @@ -102,22 +103,26 @@ func (pd *PackageDescriptor) CalculateBlobSize() int64 {

// GetPackageDescriptor gets the package description for a version
func GetPackageDescriptor(ctx context.Context, pv *PackageVersion) (*PackageDescriptor, error) {
p, err := GetPackageByID(ctx, pv.PackageID)
return getPackageDescriptor(ctx, pv, cache.NewEphemeralCache())
}

func getPackageDescriptor(ctx context.Context, pv *PackageVersion, c *cache.EphemeralCache) (*PackageDescriptor, error) {
p, err := cache.GetWithEphemeralCache(ctx, c, "package", pv.PackageID, GetPackageByID)
if err != nil {
return nil, err
}
o, err := user_model.GetUserByID(ctx, p.OwnerID)
o, err := cache.GetWithEphemeralCache(ctx, c, "user", p.OwnerID, user_model.GetUserByID)
if err != nil {
return nil, err
}
var repository *repo_model.Repository
if p.RepoID > 0 {
repository, err = repo_model.GetRepositoryByID(ctx, p.RepoID)
repository, err = cache.GetWithEphemeralCache(ctx, c, "repo", p.RepoID, repo_model.GetRepositoryByID)
if err != nil && !repo_model.IsErrRepoNotExist(err) {
return nil, err
}
}
creator, err := user_model.GetUserByID(ctx, pv.CreatorID)
creator, err := cache.GetWithEphemeralCache(ctx, c, "user", pv.CreatorID, user_model.GetUserByID)
if err != nil {
if errors.Is(err, util.ErrNotExist) {
creator = user_model.NewGhostUser()
Expand Down Expand Up @@ -145,9 +150,13 @@ func GetPackageDescriptor(ctx context.Context, pv *PackageVersion) (*PackageDesc
return nil, err
}

pfds, err := GetPackageFileDescriptors(ctx, pfs)
if err != nil {
return nil, err
pfds := make([]*PackageFileDescriptor, 0, len(pfs))
for _, pf := range pfs {
pfd, err := getPackageFileDescriptor(ctx, pf, c)
if err != nil {
return nil, err
}
pfds = append(pfds, pfd)
}

var metadata any
Expand Down Expand Up @@ -221,7 +230,11 @@ func GetPackageDescriptor(ctx context.Context, pv *PackageVersion) (*PackageDesc

// GetPackageFileDescriptor gets a package file descriptor for a package file
func GetPackageFileDescriptor(ctx context.Context, pf *PackageFile) (*PackageFileDescriptor, error) {
pb, err := GetBlobByID(ctx, pf.BlobID)
return getPackageFileDescriptor(ctx, pf, cache.NewEphemeralCache())
}

func getPackageFileDescriptor(ctx context.Context, pf *PackageFile, c *cache.EphemeralCache) (*PackageFileDescriptor, error) {
pb, err := cache.GetWithEphemeralCache(ctx, c, "package_file_blob", pf.BlobID, GetBlobByID)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -251,9 +264,13 @@ func GetPackageFileDescriptors(ctx context.Context, pfs []*PackageFile) ([]*Pack

// GetPackageDescriptors gets the package descriptions for the versions
func GetPackageDescriptors(ctx context.Context, pvs []*PackageVersion) ([]*PackageDescriptor, error) {
return getPackageDescriptors(ctx, pvs, cache.NewEphemeralCache())
}

func getPackageDescriptors(ctx context.Context, pvs []*PackageVersion, c *cache.EphemeralCache) ([]*PackageDescriptor, error) {
pds := make([]*PackageDescriptor, 0, len(pvs))
for _, pv := range pvs {
pd, err := GetPackageDescriptor(ctx, pv)
pd, err := getPackageDescriptor(ctx, pv, c)
if err != nil {
return nil, err
}
Expand Down
138 changes: 27 additions & 111 deletions modules/cache/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,75 +5,17 @@

import (
"context"
"sync"
"time"

"code.gitea.io/gitea/modules/log"
)

// cacheContext is a context that can be used to cache data in a request level context
// This is useful for caching data that is expensive to calculate and is likely to be
// used multiple times in a request.
type cacheContext struct {
data map[any]map[any]any
lock sync.RWMutex
created time.Time
discard bool
}

func (cc *cacheContext) Get(tp, key any) any {
cc.lock.RLock()
defer cc.lock.RUnlock()
return cc.data[tp][key]
}

func (cc *cacheContext) Put(tp, key, value any) {
cc.lock.Lock()
defer cc.lock.Unlock()

if cc.discard {
return
}

d := cc.data[tp]
if d == nil {
d = make(map[any]any)
cc.data[tp] = d
}
d[key] = value
}

func (cc *cacheContext) Delete(tp, key any) {
cc.lock.Lock()
defer cc.lock.Unlock()
delete(cc.data[tp], key)
}

func (cc *cacheContext) Discard() {
cc.lock.Lock()
defer cc.lock.Unlock()
cc.data = nil
cc.discard = true
}

func (cc *cacheContext) isDiscard() bool {
cc.lock.RLock()
defer cc.lock.RUnlock()
return cc.discard
}

// cacheContextLifetime is the max lifetime of cacheContext.
// Since cacheContext is used to cache data in a request level context, 5 minutes is enough.
// If a cacheContext is used more than 5 minutes, it's probably misuse.
const cacheContextLifetime = 5 * time.Minute

var timeNow = time.Now
type cacheContextKeyType struct{}

func (cc *cacheContext) Expired() bool {
return timeNow().Sub(cc.created) > cacheContextLifetime
}
var cacheContextKey = cacheContextKeyType{}

var cacheContextKey = struct{}{}
// contextCacheLifetime is the max lifetime of context cache.
// Since context cache is used to cache data in a request level context, 5 minutes is enough.
// If a context cache is used more than 5 minutes, it's probably abused.
const contextCacheLifetime = 5 * time.Minute

/*
Since there are both WithCacheContext and WithNoCacheContext,
Expand Down Expand Up @@ -103,78 +45,52 @@
*/

func WithCacheContext(ctx context.Context) context.Context {
if c, ok := ctx.Value(cacheContextKey).(*cacheContext); ok {
if c, ok := ctx.Value(cacheContextKey).(*EphemeralCache); ok {
if !c.isDiscard() {
// reuse parent context
return ctx
return ctx // reuse parent context
}
}
// FIXME: review the use of this nolint directive
return context.WithValue(ctx, cacheContextKey, &cacheContext{ //nolint:staticcheck
data: make(map[any]map[any]any),
created: timeNow(),
})
return context.WithValue(ctx, cacheContextKey, NewEphemeralCache(contextCacheLifetime))
}

func WithNoCacheContext(ctx context.Context) context.Context {
if c, ok := ctx.Value(cacheContextKey).(*cacheContext); ok {
func withNoCacheContext(ctx context.Context) context.Context {
if c, ok := ctx.Value(cacheContextKey).(*EphemeralCache); ok {
// The caller want to run long-life tasks, but the parent context is a cache context.
// So we should disable and clean the cache data, or it will be kept in memory for a long time.
c.Discard()
c.discard()
return ctx
}

return ctx
}

func GetContextData(ctx context.Context, tp, key any) any {
if c, ok := ctx.Value(cacheContextKey).(*cacheContext); ok {
if c.Expired() {
// The warning means that the cache context is misused for long-life task,
// it can be resolved with WithNoCacheContext(ctx).
log.Warn("cache context is expired, is highly likely to be misused for long-life tasks: %v", c)
return nil
}
func getContextData(ctx context.Context, tp, key any) (any, bool) {

Check failure on line 66 in modules/cache/context.go

View workflow job for this annotation

GitHub Actions / lint-backend

getContextData - key always receives "my_config1" (unparam)

Check failure on line 66 in modules/cache/context.go

View workflow job for this annotation

GitHub Actions / lint-go-gogit

getContextData - key always receives "my_config1" (unparam)

Check failure on line 66 in modules/cache/context.go

View workflow job for this annotation

GitHub Actions / lint-go-windows

getContextData - key always receives "my_config1" (unparam)
if c, ok := ctx.Value(cacheContextKey).(*EphemeralCache); ok {
return c.Get(tp, key)
}
return nil
return nil, false
}

func SetContextData(ctx context.Context, tp, key, value any) {
if c, ok := ctx.Value(cacheContextKey).(*cacheContext); ok {
if c.Expired() {
// The warning means that the cache context is misused for long-life task,
// it can be resolved with WithNoCacheContext(ctx).
log.Warn("cache context is expired, is highly likely to be misused for long-life tasks: %v", c)
return
}
func setContextData(ctx context.Context, tp, key, value any) {

Check failure on line 73 in modules/cache/context.go

View workflow job for this annotation

GitHub Actions / lint-backend

setContextData - tp always receives field ("system_setting") (unparam)

Check failure on line 73 in modules/cache/context.go

View workflow job for this annotation

GitHub Actions / lint-go-gogit

setContextData - tp always receives field ("system_setting") (unparam)

Check failure on line 73 in modules/cache/context.go

View workflow job for this annotation

GitHub Actions / lint-go-windows

setContextData - tp always receives field ("system_setting") (unparam)
if c, ok := ctx.Value(cacheContextKey).(*EphemeralCache); ok {
c.Put(tp, key, value)
return
}
}

func RemoveContextData(ctx context.Context, tp, key any) {
if c, ok := ctx.Value(cacheContextKey).(*cacheContext); ok {
if c.Expired() {
// The warning means that the cache context is misused for long-life task,
// it can be resolved with WithNoCacheContext(ctx).
log.Warn("cache context is expired, is highly likely to be misused for long-life tasks: %v", c)
return
}
func removeContextData(ctx context.Context, tp, key any) {
if c, ok := ctx.Value(cacheContextKey).(*EphemeralCache); ok {
c.Delete(tp, key)
}
}

// GetWithContextCache returns the cache value of the given key in the given context.
// FIXME: in most cases, the "context cache" should not be used, because it has uncontrollable behaviors
// For example, these calls:
// * GetWithContextCache(TargetID) -> OtherCodeCreateModel(TargetID) -> GetWithContextCache(TargetID)
// Will cause the second call is not able to get the correct created target.
// UNLESS it is certain that the target won't be changed during the request, DO NOT use it.
func GetWithContextCache[T, K any](ctx context.Context, groupKey string, targetKey K, f func(context.Context, K) (T, error)) (T, error) {
v := GetContextData(ctx, groupKey, targetKey)
if vv, ok := v.(T); ok {
return vv, nil
}
t, err := f(ctx, targetKey)
if err != nil {
return t, err
if c, ok := ctx.Value(cacheContextKey).(*EphemeralCache); ok {
return GetWithEphemeralCache(ctx, c, groupKey, targetKey, f)
}
SetContextData(ctx, groupKey, targetKey, t)
return t, nil
return f(ctx, targetKey)
}
50 changes: 24 additions & 26 deletions modules/cache/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,29 @@ import (
"testing"
"time"

"code.gitea.io/gitea/modules/test"

"github.com/stretchr/testify/assert"
)

func TestWithCacheContext(t *testing.T) {
ctx := WithCacheContext(t.Context())

v := GetContextData(ctx, "empty_field", "my_config1")
v, _ := getContextData(ctx, "empty_field", "my_config1")
assert.Nil(t, v)

const field = "system_setting"
v = GetContextData(ctx, field, "my_config1")
v, _ = getContextData(ctx, field, "my_config1")
assert.Nil(t, v)
SetContextData(ctx, field, "my_config1", 1)
v = GetContextData(ctx, field, "my_config1")
setContextData(ctx, field, "my_config1", 1)
v, _ = getContextData(ctx, field, "my_config1")
assert.NotNil(t, v)
assert.Equal(t, 1, v.(int))

RemoveContextData(ctx, field, "my_config1")
RemoveContextData(ctx, field, "my_config2") // remove a non-exist key
removeContextData(ctx, field, "my_config1")
removeContextData(ctx, field, "my_config2") // remove a non-exist key

v = GetContextData(ctx, field, "my_config1")
v, _ = getContextData(ctx, field, "my_config1")
assert.Nil(t, v)

vInt, err := GetWithContextCache(ctx, field, "my_config1", func(context.Context, string) (int, error) {
Expand All @@ -37,17 +39,13 @@ func TestWithCacheContext(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, 1, vInt)

v = GetContextData(ctx, field, "my_config1")
v, _ = getContextData(ctx, field, "my_config1")
assert.EqualValues(t, 1, v)

now := timeNow
defer func() {
timeNow = now
}()
timeNow = func() time.Time {
return now().Add(5 * time.Minute)
}
v = GetContextData(ctx, field, "my_config1")
defer test.MockVariableValue(&timeNow, func() time.Time {
return time.Now().Add(5 * time.Minute)
})()
v, _ = getContextData(ctx, field, "my_config1")
assert.Nil(t, v)
}

Expand All @@ -56,23 +54,23 @@ func TestWithNoCacheContext(t *testing.T) {

const field = "system_setting"

v := GetContextData(ctx, field, "my_config1")
v, _ := getContextData(ctx, field, "my_config1")
assert.Nil(t, v)
SetContextData(ctx, field, "my_config1", 1)
v = GetContextData(ctx, field, "my_config1")
setContextData(ctx, field, "my_config1", 1)
v, _ = getContextData(ctx, field, "my_config1")
assert.Nil(t, v) // still no cache

ctx = WithCacheContext(ctx)
v = GetContextData(ctx, field, "my_config1")
v, _ = getContextData(ctx, field, "my_config1")
assert.Nil(t, v)
SetContextData(ctx, field, "my_config1", 1)
v = GetContextData(ctx, field, "my_config1")
setContextData(ctx, field, "my_config1", 1)
v, _ = getContextData(ctx, field, "my_config1")
assert.NotNil(t, v)

ctx = WithNoCacheContext(ctx)
v = GetContextData(ctx, field, "my_config1")
ctx = withNoCacheContext(ctx)
v, _ = getContextData(ctx, field, "my_config1")
assert.Nil(t, v)
SetContextData(ctx, field, "my_config1", 1)
v = GetContextData(ctx, field, "my_config1")
setContextData(ctx, field, "my_config1", 1)
v, _ = getContextData(ctx, field, "my_config1")
assert.Nil(t, v) // still no cache
}
Loading
Loading