summaryrefslogtreecommitdiffstats
path: root/modules/cache
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-10-11 10:27:00 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-10-11 10:27:00 +0000
commit65aa53fc52ff15efe54df4147564828d535837f8 (patch)
tree31c51dad04fdcca80e6d3043c8bd49d2f1a51f83 /modules/cache
parentInitial commit. (diff)
downloadforgejo-debian.tar.xz
forgejo-debian.zip
Adding upstream version 8.0.3.HEADupstream/8.0.3upstreamdebian
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'modules/cache')
-rw-r--r--modules/cache/cache.go184
-rw-r--r--modules/cache/cache_redis.go162
-rw-r--r--modules/cache/cache_test.go163
-rw-r--r--modules/cache/cache_twoqueue.go208
-rw-r--r--modules/cache/context.go179
-rw-r--r--modules/cache/context_test.go79
6 files changed, 975 insertions, 0 deletions
diff --git a/modules/cache/cache.go b/modules/cache/cache.go
new file mode 100644
index 00000000..546c54df
--- /dev/null
+++ b/modules/cache/cache.go
@@ -0,0 +1,184 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cache
+
+import (
+ "fmt"
+ "strconv"
+ "time"
+
+ "code.gitea.io/gitea/modules/setting"
+
+ mc "gitea.com/go-chi/cache"
+
+ _ "gitea.com/go-chi/cache/memcache" // memcache plugin for cache
+)
+
+var conn mc.Cache
+
+func newCache(cacheConfig setting.Cache) (mc.Cache, error) {
+ return mc.NewCacher(mc.Options{
+ Adapter: cacheConfig.Adapter,
+ AdapterConfig: cacheConfig.Conn,
+ Interval: cacheConfig.Interval,
+ })
+}
+
+// Init start cache service
+func Init() error {
+ var err error
+
+ if conn == nil {
+ if conn, err = newCache(setting.CacheService.Cache); err != nil {
+ return err
+ }
+ if err = conn.Ping(); err != nil {
+ return err
+ }
+ }
+
+ return err
+}
+
+const (
+ testCacheKey = "DefaultCache.TestKey"
+ SlowCacheThreshold = 100 * time.Microsecond
+)
+
+func Test() (time.Duration, error) {
+ if conn == nil {
+ return 0, fmt.Errorf("default cache not initialized")
+ }
+
+ testData := fmt.Sprintf("%x", make([]byte, 500))
+
+ start := time.Now()
+
+ if err := conn.Delete(testCacheKey); err != nil {
+ return 0, fmt.Errorf("expect cache to delete data based on key if exist but got: %w", err)
+ }
+ if err := conn.Put(testCacheKey, testData, 10); err != nil {
+ return 0, fmt.Errorf("expect cache to store data but got: %w", err)
+ }
+ testVal := conn.Get(testCacheKey)
+ if testVal == nil {
+ return 0, fmt.Errorf("expect cache hit but got none")
+ }
+ if testVal != testData {
+ return 0, fmt.Errorf("expect cache to return same value as stored but got other")
+ }
+
+ return time.Since(start), nil
+}
+
+// GetCache returns the currently configured cache
+func GetCache() mc.Cache {
+ return conn
+}
+
+// GetString returns the key value from cache with callback when no key exists in cache
+func GetString(key string, getFunc func() (string, error)) (string, error) {
+ if conn == nil || setting.CacheService.TTL == 0 {
+ return getFunc()
+ }
+
+ cached := conn.Get(key)
+
+ if cached == nil {
+ value, err := getFunc()
+ if err != nil {
+ return value, err
+ }
+ return value, conn.Put(key, value, setting.CacheService.TTLSeconds())
+ }
+
+ if value, ok := cached.(string); ok {
+ return value, nil
+ }
+
+ if stringer, ok := cached.(fmt.Stringer); ok {
+ return stringer.String(), nil
+ }
+
+ return fmt.Sprintf("%s", cached), nil
+}
+
+// GetInt returns key value from cache with callback when no key exists in cache
+func GetInt(key string, getFunc func() (int, error)) (int, error) {
+ if conn == nil || setting.CacheService.TTL == 0 {
+ return getFunc()
+ }
+
+ cached := conn.Get(key)
+
+ if cached == nil {
+ value, err := getFunc()
+ if err != nil {
+ return value, err
+ }
+
+ return value, conn.Put(key, value, setting.CacheService.TTLSeconds())
+ }
+
+ switch v := cached.(type) {
+ case int:
+ return v, nil
+ case string:
+ value, err := strconv.Atoi(v)
+ if err != nil {
+ return 0, err
+ }
+ return value, nil
+ default:
+ value, err := getFunc()
+ if err != nil {
+ return value, err
+ }
+ return value, conn.Put(key, value, setting.CacheService.TTLSeconds())
+ }
+}
+
+// GetInt64 returns key value from cache with callback when no key exists in cache
+func GetInt64(key string, getFunc func() (int64, error)) (int64, error) {
+ if conn == nil || setting.CacheService.TTL == 0 {
+ return getFunc()
+ }
+
+ cached := conn.Get(key)
+
+ if cached == nil {
+ value, err := getFunc()
+ if err != nil {
+ return value, err
+ }
+
+ return value, conn.Put(key, value, setting.CacheService.TTLSeconds())
+ }
+
+ switch v := conn.Get(key).(type) {
+ case int64:
+ return v, nil
+ case string:
+ value, err := strconv.ParseInt(v, 10, 64)
+ if err != nil {
+ return 0, err
+ }
+ return value, nil
+ default:
+ value, err := getFunc()
+ if err != nil {
+ return value, err
+ }
+
+ return value, conn.Put(key, value, setting.CacheService.TTLSeconds())
+ }
+}
+
+// Remove key from cache
+func Remove(key string) {
+ if conn == nil {
+ return
+ }
+ _ = conn.Delete(key)
+}
diff --git a/modules/cache/cache_redis.go b/modules/cache/cache_redis.go
new file mode 100644
index 00000000..6c358b0a
--- /dev/null
+++ b/modules/cache/cache_redis.go
@@ -0,0 +1,162 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cache
+
+import (
+ "fmt"
+ "strconv"
+ "time"
+
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/nosql"
+
+ "gitea.com/go-chi/cache"
+ "github.com/redis/go-redis/v9"
+)
+
+// RedisCacher represents a redis cache adapter implementation.
+type RedisCacher struct {
+ c redis.UniversalClient
+ prefix string
+ hsetName string
+ occupyMode bool
+}
+
+// toStr convert string/int/int64 interface to string. it's only used by the RedisCacher.Put internally
+func toStr(v any) string {
+ if v == nil {
+ return ""
+ }
+ switch v := v.(type) {
+ case string:
+ return v
+ case []byte:
+ return string(v)
+ case int:
+ return strconv.FormatInt(int64(v), 10)
+ case int64:
+ return strconv.FormatInt(v, 10)
+ default:
+ return fmt.Sprint(v) // as what the old com.ToStr does in most cases
+ }
+}
+
+// Put puts value (string type) into cache with key and expire time.
+// If expired is 0, it lives forever.
+func (c *RedisCacher) Put(key string, val any, expire int64) error {
+ // this function is not well-designed, it only puts string values into cache
+ key = c.prefix + key
+ if expire == 0 {
+ if err := c.c.Set(graceful.GetManager().HammerContext(), key, toStr(val), 0).Err(); err != nil {
+ return err
+ }
+ } else {
+ dur := time.Duration(expire) * time.Second
+ if err := c.c.Set(graceful.GetManager().HammerContext(), key, toStr(val), dur).Err(); err != nil {
+ return err
+ }
+ }
+
+ if c.occupyMode {
+ return nil
+ }
+ return c.c.HSet(graceful.GetManager().HammerContext(), c.hsetName, key, "0").Err()
+}
+
+// Get gets cached value by given key.
+func (c *RedisCacher) Get(key string) any {
+ val, err := c.c.Get(graceful.GetManager().HammerContext(), c.prefix+key).Result()
+ if err != nil {
+ return nil
+ }
+ return val
+}
+
+// Delete deletes cached value by given key.
+func (c *RedisCacher) Delete(key string) error {
+ key = c.prefix + key
+ if err := c.c.Del(graceful.GetManager().HammerContext(), key).Err(); err != nil {
+ return err
+ }
+
+ if c.occupyMode {
+ return nil
+ }
+ return c.c.HDel(graceful.GetManager().HammerContext(), c.hsetName, key).Err()
+}
+
+// Incr increases cached int-type value by given key as a counter.
+func (c *RedisCacher) Incr(key string) error {
+ if !c.IsExist(key) {
+ return fmt.Errorf("key '%s' not exist", key)
+ }
+ return c.c.Incr(graceful.GetManager().HammerContext(), c.prefix+key).Err()
+}
+
+// Decr decreases cached int-type value by given key as a counter.
+func (c *RedisCacher) Decr(key string) error {
+ if !c.IsExist(key) {
+ return fmt.Errorf("key '%s' not exist", key)
+ }
+ return c.c.Decr(graceful.GetManager().HammerContext(), c.prefix+key).Err()
+}
+
+// IsExist returns true if cached value exists.
+func (c *RedisCacher) IsExist(key string) bool {
+ if c.c.Exists(graceful.GetManager().HammerContext(), c.prefix+key).Val() == 1 {
+ return true
+ }
+
+ if !c.occupyMode {
+ c.c.HDel(graceful.GetManager().HammerContext(), c.hsetName, c.prefix+key)
+ }
+ return false
+}
+
+// Flush deletes all cached data.
+func (c *RedisCacher) Flush() error {
+ if c.occupyMode {
+ return c.c.FlushDB(graceful.GetManager().HammerContext()).Err()
+ }
+
+ keys, err := c.c.HKeys(graceful.GetManager().HammerContext(), c.hsetName).Result()
+ if err != nil {
+ return err
+ }
+ if err = c.c.Del(graceful.GetManager().HammerContext(), keys...).Err(); err != nil {
+ return err
+ }
+ return c.c.Del(graceful.GetManager().HammerContext(), c.hsetName).Err()
+}
+
+// StartAndGC starts GC routine based on config string settings.
+// AdapterConfig: network=tcp,addr=:6379,password=macaron,db=0,pool_size=100,idle_timeout=180,hset_name=MacaronCache,prefix=cache:
+func (c *RedisCacher) StartAndGC(opts cache.Options) error {
+ c.hsetName = "MacaronCache"
+ c.occupyMode = opts.OccupyMode
+
+ uri := nosql.ToRedisURI(opts.AdapterConfig)
+
+ c.c = nosql.GetManager().GetRedisClient(uri.String())
+
+ for k, v := range uri.Query() {
+ switch k {
+ case "hset_name":
+ c.hsetName = v[0]
+ case "prefix":
+ c.prefix = v[0]
+ }
+ }
+
+ return c.c.Ping(graceful.GetManager().HammerContext()).Err()
+}
+
+// Ping tests if the cache is alive.
+func (c *RedisCacher) Ping() error {
+ return c.c.Ping(graceful.GetManager().HammerContext()).Err()
+}
+
+func init() {
+ cache.Register("redis", &RedisCacher{})
+}
diff --git a/modules/cache/cache_test.go b/modules/cache/cache_test.go
new file mode 100644
index 00000000..0b031814
--- /dev/null
+++ b/modules/cache/cache_test.go
@@ -0,0 +1,163 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cache
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func createTestCache() {
+ conn, _ = newCache(setting.Cache{
+ Adapter: "memory",
+ TTL: time.Minute,
+ })
+ setting.CacheService.TTL = 24 * time.Hour
+}
+
+func TestNewContext(t *testing.T) {
+ require.NoError(t, Init())
+
+ setting.CacheService.Cache = setting.Cache{Adapter: "redis", Conn: "some random string"}
+ con, err := newCache(setting.Cache{
+ Adapter: "rand",
+ Conn: "false conf",
+ Interval: 100,
+ })
+ require.Error(t, err)
+ assert.Nil(t, con)
+}
+
+func TestTest(t *testing.T) {
+ defer test.MockVariableValue(&conn, nil)()
+ _, err := Test()
+ require.Error(t, err)
+
+ createTestCache()
+ elapsed, err := Test()
+ require.NoError(t, err)
+ // mem cache should take from 300ns up to 1ms on modern hardware ...
+ assert.Less(t, elapsed, SlowCacheThreshold)
+}
+
+func TestGetCache(t *testing.T) {
+ createTestCache()
+
+ assert.NotNil(t, GetCache())
+}
+
+func TestGetString(t *testing.T) {
+ createTestCache()
+
+ data, err := GetString("key", func() (string, error) {
+ return "", fmt.Errorf("some error")
+ })
+ require.Error(t, err)
+ assert.Equal(t, "", data)
+
+ data, err = GetString("key", func() (string, error) {
+ return "", nil
+ })
+ require.NoError(t, err)
+ assert.Equal(t, "", data)
+
+ data, err = GetString("key", func() (string, error) {
+ return "some data", nil
+ })
+ require.NoError(t, err)
+ assert.Equal(t, "", data)
+ Remove("key")
+
+ data, err = GetString("key", func() (string, error) {
+ return "some data", nil
+ })
+ require.NoError(t, err)
+ assert.Equal(t, "some data", data)
+
+ data, err = GetString("key", func() (string, error) {
+ return "", fmt.Errorf("some error")
+ })
+ require.NoError(t, err)
+ assert.Equal(t, "some data", data)
+ Remove("key")
+}
+
+func TestGetInt(t *testing.T) {
+ createTestCache()
+
+ data, err := GetInt("key", func() (int, error) {
+ return 0, fmt.Errorf("some error")
+ })
+ require.Error(t, err)
+ assert.Equal(t, 0, data)
+
+ data, err = GetInt("key", func() (int, error) {
+ return 0, nil
+ })
+ require.NoError(t, err)
+ assert.Equal(t, 0, data)
+
+ data, err = GetInt("key", func() (int, error) {
+ return 100, nil
+ })
+ require.NoError(t, err)
+ assert.Equal(t, 0, data)
+ Remove("key")
+
+ data, err = GetInt("key", func() (int, error) {
+ return 100, nil
+ })
+ require.NoError(t, err)
+ assert.Equal(t, 100, data)
+
+ data, err = GetInt("key", func() (int, error) {
+ return 0, fmt.Errorf("some error")
+ })
+ require.NoError(t, err)
+ assert.Equal(t, 100, data)
+ Remove("key")
+}
+
+func TestGetInt64(t *testing.T) {
+ createTestCache()
+
+ data, err := GetInt64("key", func() (int64, error) {
+ return 0, fmt.Errorf("some error")
+ })
+ require.Error(t, err)
+ assert.EqualValues(t, 0, data)
+
+ data, err = GetInt64("key", func() (int64, error) {
+ return 0, nil
+ })
+ require.NoError(t, err)
+ assert.EqualValues(t, 0, data)
+
+ data, err = GetInt64("key", func() (int64, error) {
+ return 100, nil
+ })
+ require.NoError(t, err)
+ assert.EqualValues(t, 0, data)
+ Remove("key")
+
+ data, err = GetInt64("key", func() (int64, error) {
+ return 100, nil
+ })
+ require.NoError(t, err)
+ assert.EqualValues(t, 100, data)
+
+ data, err = GetInt64("key", func() (int64, error) {
+ return 0, fmt.Errorf("some error")
+ })
+ require.NoError(t, err)
+ assert.EqualValues(t, 100, data)
+ Remove("key")
+}
diff --git a/modules/cache/cache_twoqueue.go b/modules/cache/cache_twoqueue.go
new file mode 100644
index 00000000..f9de2563
--- /dev/null
+++ b/modules/cache/cache_twoqueue.go
@@ -0,0 +1,208 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cache
+
+import (
+ "strconv"
+ "sync"
+ "time"
+
+ "code.gitea.io/gitea/modules/json"
+
+ mc "gitea.com/go-chi/cache"
+ lru "github.com/hashicorp/golang-lru/v2"
+)
+
+// TwoQueueCache represents a LRU 2Q cache adapter implementation
+type TwoQueueCache struct {
+ lock sync.Mutex
+ cache *lru.TwoQueueCache[string, any]
+ interval int
+}
+
+// TwoQueueCacheConfig describes the configuration for TwoQueueCache
+type TwoQueueCacheConfig struct {
+ Size int `ini:"SIZE" json:"size"`
+ RecentRatio float64 `ini:"RECENT_RATIO" json:"recent_ratio"`
+ GhostRatio float64 `ini:"GHOST_RATIO" json:"ghost_ratio"`
+}
+
+// MemoryItem represents a memory cache item.
+type MemoryItem struct {
+ Val any
+ Created int64
+ Timeout int64
+}
+
+func (item *MemoryItem) hasExpired() bool {
+ return item.Timeout > 0 &&
+ (time.Now().Unix()-item.Created) >= item.Timeout
+}
+
+var _ mc.Cache = &TwoQueueCache{}
+
+// Put puts value into cache with key and expire time.
+func (c *TwoQueueCache) Put(key string, val any, timeout int64) error {
+ item := &MemoryItem{
+ Val: val,
+ Created: time.Now().Unix(),
+ Timeout: timeout,
+ }
+ c.lock.Lock()
+ defer c.lock.Unlock()
+ c.cache.Add(key, item)
+ return nil
+}
+
+// Get gets cached value by given key.
+func (c *TwoQueueCache) Get(key string) any {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+ cached, ok := c.cache.Get(key)
+ if !ok {
+ return nil
+ }
+ item, ok := cached.(*MemoryItem)
+
+ if !ok || item.hasExpired() {
+ c.cache.Remove(key)
+ return nil
+ }
+
+ return item.Val
+}
+
+// Delete deletes cached value by given key.
+func (c *TwoQueueCache) Delete(key string) error {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+ c.cache.Remove(key)
+ return nil
+}
+
+// Incr increases cached int-type value by given key as a counter.
+func (c *TwoQueueCache) Incr(key string) error {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+ cached, ok := c.cache.Get(key)
+ if !ok {
+ return nil
+ }
+ item, ok := cached.(*MemoryItem)
+
+ if !ok || item.hasExpired() {
+ c.cache.Remove(key)
+ return nil
+ }
+
+ var err error
+ item.Val, err = mc.Incr(item.Val)
+ return err
+}
+
+// Decr decreases cached int-type value by given key as a counter.
+func (c *TwoQueueCache) Decr(key string) error {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+ cached, ok := c.cache.Get(key)
+ if !ok {
+ return nil
+ }
+ item, ok := cached.(*MemoryItem)
+
+ if !ok || item.hasExpired() {
+ c.cache.Remove(key)
+ return nil
+ }
+
+ var err error
+ item.Val, err = mc.Decr(item.Val)
+ return err
+}
+
+// IsExist returns true if cached value exists.
+func (c *TwoQueueCache) IsExist(key string) bool {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+ cached, ok := c.cache.Peek(key)
+ if !ok {
+ return false
+ }
+ item, ok := cached.(*MemoryItem)
+ if !ok || item.hasExpired() {
+ c.cache.Remove(key)
+ return false
+ }
+
+ return true
+}
+
+// Flush deletes all cached data.
+func (c *TwoQueueCache) Flush() error {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+ c.cache.Purge()
+ return nil
+}
+
+func (c *TwoQueueCache) checkAndInvalidate(key string) {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+ cached, ok := c.cache.Peek(key)
+ if !ok {
+ return
+ }
+ item, ok := cached.(*MemoryItem)
+ if !ok || item.hasExpired() {
+ c.cache.Remove(key)
+ }
+}
+
+func (c *TwoQueueCache) startGC() {
+ if c.interval < 0 {
+ return
+ }
+ for _, key := range c.cache.Keys() {
+ c.checkAndInvalidate(key)
+ }
+ time.AfterFunc(time.Duration(c.interval)*time.Second, c.startGC)
+}
+
+// StartAndGC starts GC routine based on config string settings.
+func (c *TwoQueueCache) StartAndGC(opts mc.Options) error {
+ var err error
+ size := 50000
+ if opts.AdapterConfig != "" {
+ size, err = strconv.Atoi(opts.AdapterConfig)
+ }
+ if err != nil {
+ if !json.Valid([]byte(opts.AdapterConfig)) {
+ return err
+ }
+
+ cfg := &TwoQueueCacheConfig{
+ Size: 50000,
+ RecentRatio: lru.Default2QRecentRatio,
+ GhostRatio: lru.Default2QGhostEntries,
+ }
+ _ = json.Unmarshal([]byte(opts.AdapterConfig), cfg)
+ c.cache, err = lru.New2QParams[string, any](cfg.Size, cfg.RecentRatio, cfg.GhostRatio)
+ } else {
+ c.cache, err = lru.New2Q[string, any](size)
+ }
+ c.interval = opts.Interval
+ if c.interval > 0 {
+ go c.startGC()
+ }
+ return err
+}
+
+// Ping tests if the cache is alive.
+func (c *TwoQueueCache) Ping() error {
+ return mc.GenericPing(c)
+}
+
+func init() {
+ mc.Register("twoqueue", &TwoQueueCache{})
+}
diff --git a/modules/cache/context.go b/modules/cache/context.go
new file mode 100644
index 00000000..62bbf5dc
--- /dev/null
+++ b/modules/cache/context.go
@@ -0,0 +1,179 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cache
+
+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, 10s is enough.
+// If a cacheContext is used more than 10s, it's probably misuse.
+const cacheContextLifetime = 10 * time.Second
+
+var timeNow = time.Now
+
+func (cc *cacheContext) Expired() bool {
+ return timeNow().Sub(cc.created) > cacheContextLifetime
+}
+
+var cacheContextKey = struct{}{}
+
+/*
+Since there are both WithCacheContext and WithNoCacheContext,
+it may be confusing when there is nesting.
+
+Some cases to explain the design:
+
+When:
+- A, B or C means a cache context.
+- A', B' or C' means a discard cache context.
+- ctx means context.Backgrand().
+- A(ctx) means a cache context with ctx as the parent context.
+- B(A(ctx)) means a cache context with A(ctx) as the parent context.
+- With is alias of WithCacheContext.
+- WithNo is alias of WithNoCacheContext.
+
+So:
+- With(ctx) -> A(ctx)
+- With(With(ctx)) -> A(ctx), not B(A(ctx)), always reuse parent cache context if possible.
+- With(With(With(ctx))) -> A(ctx), not C(B(A(ctx))), ditto.
+- WithNo(ctx) -> ctx, not A'(ctx), don't create new cache context if we don't have to.
+- WithNo(With(ctx)) -> A'(ctx)
+- WithNo(WithNo(With(ctx))) -> A'(ctx), not B'(A'(ctx)), don't create new cache context if we don't have to.
+- With(WithNo(With(ctx))) -> B(A'(ctx)), not A(ctx), never reuse a discard cache context.
+- WithNo(With(WithNo(With(ctx)))) -> B'(A'(ctx))
+- With(WithNo(With(WithNo(With(ctx))))) -> C(B'(A'(ctx))), so there's always only one not-discard cache context.
+*/
+
+func WithCacheContext(ctx context.Context) context.Context {
+ if c, ok := ctx.Value(cacheContextKey).(*cacheContext); ok {
+ if !c.isDiscard() {
+ // reuse parent context
+ return ctx
+ }
+ }
+ return context.WithValue(ctx, cacheContextKey, &cacheContext{
+ data: make(map[any]map[any]any),
+ created: timeNow(),
+ })
+}
+
+func WithNoCacheContext(ctx context.Context) context.Context {
+ if c, ok := ctx.Value(cacheContextKey).(*cacheContext); 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()
+ 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, may be misused for long-life tasks: %v", c)
+ return nil
+ }
+ return c.Get(tp, key)
+ }
+ return nil
+}
+
+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, may be misused for long-life tasks: %v", c)
+ return
+ }
+ 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, may be misused for long-life tasks: %v", c)
+ return
+ }
+ c.Delete(tp, key)
+ }
+}
+
+// GetWithContextCache returns the cache value of the given key in the given context.
+func GetWithContextCache[T any](ctx context.Context, cacheGroupKey string, cacheTargetID any, f func() (T, error)) (T, error) {
+ v := GetContextData(ctx, cacheGroupKey, cacheTargetID)
+ if vv, ok := v.(T); ok {
+ return vv, nil
+ }
+ t, err := f()
+ if err != nil {
+ return t, err
+ }
+ SetContextData(ctx, cacheGroupKey, cacheTargetID, t)
+ return t, nil
+}
diff --git a/modules/cache/context_test.go b/modules/cache/context_test.go
new file mode 100644
index 00000000..1ee3d2dd
--- /dev/null
+++ b/modules/cache/context_test.go
@@ -0,0 +1,79 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cache
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWithCacheContext(t *testing.T) {
+ ctx := WithCacheContext(context.Background())
+
+ v := GetContextData(ctx, "empty_field", "my_config1")
+ assert.Nil(t, v)
+
+ const field = "system_setting"
+ v = GetContextData(ctx, field, "my_config1")
+ assert.Nil(t, v)
+ SetContextData(ctx, field, "my_config1", 1)
+ v = GetContextData(ctx, field, "my_config1")
+ assert.NotNil(t, v)
+ assert.EqualValues(t, 1, v.(int))
+
+ RemoveContextData(ctx, field, "my_config1")
+ RemoveContextData(ctx, field, "my_config2") // remove a non-exist key
+
+ v = GetContextData(ctx, field, "my_config1")
+ assert.Nil(t, v)
+
+ vInt, err := GetWithContextCache(ctx, field, "my_config1", func() (int, error) {
+ return 1, nil
+ })
+ require.NoError(t, err)
+ assert.EqualValues(t, 1, vInt)
+
+ v = GetContextData(ctx, field, "my_config1")
+ assert.EqualValues(t, 1, v)
+
+ now := timeNow
+ defer func() {
+ timeNow = now
+ }()
+ timeNow = func() time.Time {
+ return now().Add(10 * time.Second)
+ }
+ v = GetContextData(ctx, field, "my_config1")
+ assert.Nil(t, v)
+}
+
+func TestWithNoCacheContext(t *testing.T) {
+ ctx := context.Background()
+
+ const field = "system_setting"
+
+ v := GetContextData(ctx, field, "my_config1")
+ assert.Nil(t, v)
+ 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")
+ assert.Nil(t, v)
+ 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")
+ assert.Nil(t, v)
+ SetContextData(ctx, field, "my_config1", 1)
+ v = GetContextData(ctx, field, "my_config1")
+ assert.Nil(t, v) // still no cache
+}