feat(setting): add shared [redis] section as default for redis-backed subsystems (#38550)

Adds a shared `[redis]` config section with a single `CONN_STR` key that
acts as the default Redis connection for every redis-backed subsystem
whose own connection string is empty: `[cache].HOST`,
`[session].PROVIDER_CONFIG`, `[queue].CONN_STR` and
`[global_lock].SERVICE_CONN_STR`.

Precedence: per-subsystem value > shared `[redis].CONN_STR` > existing
built-in defaults. Fully backward compatible: when `[redis]` is not set,
every code path behaves exactly as before.

Motivation: today an admin with one Redis instance has to repeat the
same connection string in up to four places (five once WebSocket lands).
Requested by @wxiaoguang in [this review
discussion](https://github.com/go-gitea/gitea/pull/36965#discussion_r3613306976)
as a prerequisite for #36965. This follows the pattern used by GitLab
and Mastodon: one global Redis definition, per-subsystem overrides fall
back to it.

Since #12385 all identical connection strings already share one client
via `nosql.Manager`, so pointing all subsystems at `[redis].CONN_STR`
naturally converges onto a single shared connection pool.

Covered by table-driven unit tests per subsystem (fallback applies, own
value wins, absent `[redis]` = unchanged behavior). A config-cheat-sheet
update for gitea/docs will follow once this is merged.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
This commit is contained in:
mohammad rahimi
2026-07-24 11:11:20 +03:00
committed by GitHub
parent 5db58f930e
commit 1e42317b66
12 changed files with 352 additions and 24 deletions

View File

@@ -1603,6 +1603,19 @@ LEVEL = Info
;; If you'd like to enable it, you can set it to a value between 0 and 2.
;TYPE_BLEVE_MAX_FUZZINESS = 0
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;[redis]
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;
;; Shared redis connection, used as the default for every redis-backed subsystem
;; (cache, session, queue, global lock, etc.) whose own connection string is left empty.
;; A per-subsystem connection value always takes precedence over this shared default.
;; When left empty, each subsystem keeps its previous behavior (no shared default).
;; e.g. `redis://127.0.0.1:6379/0` (or `redis+cluster://127.0.0.1:6379/0` for a Redis cluster)
;CONN_STR =
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;[queue]
@@ -1628,10 +1641,10 @@ LEVEL = Info
;; Batch size to send for batched queues
;BATCH_LENGTH = 20
;;
;; Connection string for redis queues this will store the redis (or Redis cluster) connection string.
;; When `TYPE` is `persistable-channel`, this provides a directory for the underlying leveldb
;; or additional options of the form `leveldb://path/to/db?option=value&....`, and will override `DATADIR`.
;CONN_STR = "redis://127.0.0.1:6379/0"
;; When `TYPE` is `redis` and this is left empty, it falls back to the shared [redis] CONN_STR.
;CONN_STR =
;;
;; Provides the suffix of the default redis/disk queue name - specific queues can be overridden within in their [queue.name] sections.
;QUEUE_NAME = "_queue"
@@ -1929,7 +1942,7 @@ LEVEL = Info
;INTERVAL = 60
;;
;; For "redis" and "memcache", connection host address
;; redis: `redis://127.0.0.1:6379/0?pool_size=100&idle_timeout=180s` (or `redis+cluster://127.0.0.1:6379/0?pool_size=100&idle_timeout=180s` for a Redis cluster)
;; redis: default to shared [redis] CONN_STR
;; memcache: `127.0.0.1:11211`
;; twoqueue: `{"size":50000,"recent_ratio":0.25,"ghost_ratio":0.5}` or `50000`
;HOST =
@@ -1965,10 +1978,10 @@ LEVEL = Info
;;
;; Provider config options
;; memory: doesn't have any config yet
;; file: session file path, e.g. `data/sessions`
;; redis: `redis://127.0.0.1:6379/0?pool_size=100&idle_timeout=180s` (or `redis+cluster://127.0.0.1:6379/0?pool_size=100&idle_timeout=180s` for a Redis cluster)
;; file: session file path, e.g. `data/sessions`, relative paths will be made absolute against _`AppWorkPath`_.
;; redis: default to [redis] CONN_STR
;; mysql: go-sql-driver/mysql dsn config string, e.g. `root:password@/session_table`
;PROVIDER_CONFIG = data/sessions ; Relative paths will be made absolute against _`AppWorkPath`_.
;PROVIDER_CONFIG =
;;
;; Session cookie name
;COOKIE_NAME = i_like_gitea
@@ -3027,5 +3040,5 @@ LEVEL = Info
;[global_lock]
;; Lock service type, could be memory or redis
;SERVICE_TYPE = memory
;; Ignored for the "memory" type. For "redis" use something like `redis://127.0.0.1:6379/0`
;; Ignored for the "memory" type. For "redis", default to [redis] CONN_STR.
;SERVICE_CONN_STR =

View File

@@ -4,7 +4,6 @@
package setting
import (
"strings"
"time"
"gitea.dev/modules/log"
@@ -14,7 +13,7 @@ import (
type Cache struct {
Adapter string
Interval int
Conn string
Conn string `ini:"-"`
TTL time.Duration `ini:"ITEM_TTL"`
}
@@ -53,13 +52,12 @@ func loadCacheFrom(rootCfg ConfigProvider) {
CacheService.Adapter = sec.Key("ADAPTER").In("memory", []string{"memory", "redis", "memcache", "twoqueue"})
switch CacheService.Adapter {
case "memory":
case "redis", "memcache":
CacheService.Conn = strings.Trim(sec.Key("HOST").String(), "\" ")
case "memcache":
CacheService.Conn = sec.Key("HOST").String()
case "redis":
CacheService.Conn = sec.Key("HOST").MustString(Redis.ConnStr)
case "twoqueue":
CacheService.Conn = strings.TrimSpace(sec.Key("HOST").String())
if CacheService.Conn == "" {
CacheService.Conn = "50000"
}
CacheService.Conn = sec.Key("HOST").MustString("50000")
default:
log.Fatal("Unknown cache adapter: %s", CacheService.Adapter)
}

View File

@@ -0,0 +1,71 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"testing"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
func TestCacheRedisSharedConnFallback(t *testing.T) {
tests := []struct {
name string
iniStr string
wantConn string
}{
{
name: "redis adapter with empty HOST falls back to shared [redis]",
iniStr: `
[redis]
CONN_STR = redis://127.0.0.1:6379/0
[cache]
ADAPTER = redis
`,
wantConn: "redis://127.0.0.1:6379/0",
},
{
name: "cache HOST wins over shared [redis]",
iniStr: `
[redis]
CONN_STR = redis://127.0.0.1:6379/0
[cache]
ADAPTER = redis
HOST = redis://10.0.0.1:6379/1
`,
wantConn: "redis://10.0.0.1:6379/1",
},
{
name: "no shared [redis] keeps previous behavior (empty conn)",
iniStr: `
[cache]
ADAPTER = redis
`,
wantConn: "",
},
{
name: "memcache adapter is never affected by shared [redis]",
iniStr: `
[redis]
CONN_STR = redis://127.0.0.1:6379/0
[cache]
ADAPTER = memcache
`,
wantConn: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer test.MockVariableValue(&Redis)()
cfg, err := NewConfigProviderFromData(tt.iniStr)
assert.NoError(t, err)
loadRedisFrom(cfg)
loadCacheFrom(cfg)
assert.Equal(t, tt.wantConn, CacheService.Conn)
})
}
}

View File

@@ -22,7 +22,7 @@ func loadGlobalLockFrom(rootCfg ConfigProvider) {
switch GlobalLock.ServiceType {
case "memory":
case "redis":
connStr := sec.Key("SERVICE_CONN_STR").String()
connStr := sec.Key("SERVICE_CONN_STR").MustString(Redis.ConnStr)
if connStr == "" {
log.Fatal("SERVICE_CONN_STR is empty for redis")
}

View File

@@ -6,20 +6,24 @@ package setting
import (
"testing"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
func TestLoadGlobalLockConfig(t *testing.T) {
t.Run("DefaultGlobalLockConfig", func(t *testing.T) {
defer test.MockVariableValue(&Redis)()
iniStr := ``
cfg, err := NewConfigProviderFromData(iniStr)
assert.NoError(t, err)
loadRedisFrom(cfg)
loadGlobalLockFrom(cfg)
assert.Equal(t, "memory", GlobalLock.ServiceType)
})
t.Run("RedisGlobalLockConfig", func(t *testing.T) {
defer test.MockVariableValue(&Redis)()
iniStr := `
[global_lock]
SERVICE_TYPE = redis
@@ -27,9 +31,44 @@ SERVICE_CONN_STR = addrs=127.0.0.1:6379 db=0
`
cfg, err := NewConfigProviderFromData(iniStr)
assert.NoError(t, err)
loadRedisFrom(cfg)
loadGlobalLockFrom(cfg)
assert.Equal(t, "redis", GlobalLock.ServiceType)
assert.Equal(t, "addrs=127.0.0.1:6379 db=0", GlobalLock.ServiceConnStr)
})
t.Run("RedisGlobalLockFallsBackToSharedRedis", func(t *testing.T) {
defer test.MockVariableValue(&Redis)()
iniStr := `
[redis]
CONN_STR = redis://127.0.0.1:6379/0
[global_lock]
SERVICE_TYPE = redis
`
cfg, err := NewConfigProviderFromData(iniStr)
assert.NoError(t, err)
loadRedisFrom(cfg)
loadGlobalLockFrom(cfg)
assert.Equal(t, "redis", GlobalLock.ServiceType)
assert.Equal(t, "redis://127.0.0.1:6379/0", GlobalLock.ServiceConnStr)
})
t.Run("RedisGlobalLockOwnConnWinsOverSharedRedis", func(t *testing.T) {
defer test.MockVariableValue(&Redis)()
iniStr := `
[redis]
CONN_STR = redis://127.0.0.1:6379/0
[global_lock]
SERVICE_TYPE = redis
SERVICE_CONN_STR = redis://10.0.0.1:6379/1
`
cfg, err := NewConfigProviderFromData(iniStr)
assert.NoError(t, err)
loadRedisFrom(cfg)
loadGlobalLockFrom(cfg)
assert.Equal(t, "redis", GlobalLock.ServiceType)
assert.Equal(t, "redis://10.0.0.1:6379/1", GlobalLock.ServiceConnStr)
})
}

View File

@@ -21,6 +21,10 @@ var (
// AppWorkPath is the "working directory" of Gitea. It maps to the: WORK_PATH in app.ini, "--work-path" flag, environment variable GITEA_WORK_DIR.
// If that is not set it is the default set here by the linker or failing that the directory of AppPath.
// It is used as the base path for several other paths.
// Do remember:
// * Work path might not be the POSIX current working directory or the Gitea's binary directory, Gitea binary is not FHS-compliant
// * Work path sometimes is not writable (e.g.: /usr/local/bin) or not persistent (https://github.com/go-gitea/gitea/pull/35851)
// * Work path, custom path and data path, some of them are the same sometimes (e.g.: docker image, some downstream packages, some manual installations)
AppWorkPath string
CustomPath string // Custom directory path. Env: GITEA_CUSTOM
CustomConf string

View File

@@ -9,6 +9,7 @@ import (
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/util"
)
// QueueSettings represent the settings for a queue from the ini
@@ -77,8 +78,10 @@ func GetQueueSettings(rootCfg ConfigProvider, name string) (QueueSettings, error
}
cfg.Datadir = filepath.ToSlash(cfg.Datadir)
// redis fallback order: [queue]/[queue.name] CONN_STR (already merged above) wins,
// then the shared [redis] conn, then the built-in localhost default for compatibility
if cfg.Type == "redis" && cfg.ConnStr == "" {
cfg.ConnStr = "redis://127.0.0.1:6379/0"
cfg.ConnStr = util.IfZero(Redis.ConnStr, "redis://127.0.0.1:6379/0")
}
if cfg.Length <= 0 {

View File

@@ -0,0 +1,92 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"testing"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
func TestQueueRedisSharedConnFallback(t *testing.T) {
tests := []struct {
name string
iniStr string
queueName string
wantConn string
}{
{
name: "redis queue with empty CONN_STR falls back to shared [redis]",
iniStr: `
[redis]
CONN_STR = redis://127.0.0.1:6379/0
[queue]
TYPE = redis
`,
queueName: "test",
wantConn: "redis://127.0.0.1:6379/0",
},
{
name: "base [queue] CONN_STR wins over shared [redis]",
iniStr: `
[redis]
CONN_STR = redis://127.0.0.1:6379/0
[queue]
TYPE = redis
CONN_STR = redis://10.0.0.1:6379/1
`,
queueName: "test",
wantConn: "redis://10.0.0.1:6379/1",
},
{
name: "per-queue CONN_STR wins over shared [redis]",
iniStr: `
[redis]
CONN_STR = redis://127.0.0.1:6379/0
[queue]
TYPE = redis
[queue.test]
CONN_STR = redis://10.0.0.1:6379/2
`,
queueName: "test",
wantConn: "redis://10.0.0.1:6379/2",
},
{
name: "other queues still fall back to shared [redis] when only one is overridden",
iniStr: `
[redis]
CONN_STR = redis://127.0.0.1:6379/0
[queue]
TYPE = redis
[queue.test]
CONN_STR = redis://10.0.0.1:6379/2
`,
queueName: "other",
wantConn: "redis://127.0.0.1:6379/0",
},
{
name: "no shared [redis] keeps previous behavior (built-in localhost default)",
iniStr: `
[queue]
TYPE = redis
`,
queueName: "test",
wantConn: "redis://127.0.0.1:6379/0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer test.MockVariableValue(&Redis)()
cfg, err := NewConfigProviderFromData(tt.iniStr)
assert.NoError(t, err)
loadRedisFrom(cfg)
q, err := GetQueueSettings(cfg, tt.queueName)
assert.NoError(t, err)
assert.Equal(t, tt.wantConn, q.ConnStr)
})
}
}

18
modules/setting/redis.go Normal file
View File

@@ -0,0 +1,18 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
type RedisType struct {
ConnStr string
}
// Redis represents the shared redis configuration. Its connection string is used
// as the default for every redis-backed subsystem (cache, session, queue, global
// lock) whose own connection string is empty. A per-subsystem value always wins.
var Redis *RedisType
func loadRedisFrom(rootCfg ConfigProvider) {
Redis = &RedisType{} // make sure the Redis config is correctly loaded before it is accessed by other subsystems
Redis.ConnStr = rootCfg.Section("redis").Key("CONN_STR").String()
}

View File

@@ -42,13 +42,23 @@ var SessionConfig = struct {
func loadSessionFrom(rootCfg ConfigProvider) {
sec := rootCfg.Section("session")
SessionConfig.Provider = sec.Key("PROVIDER").In("memory",
[]string{"memory", "file", "redis", "mysql", "postgres", "couchbase", "memcache", "db"})
SessionConfig.ProviderConfig = strings.Trim(sec.Key("PROVIDER_CONFIG").MustString(filepath.Join(AppDataPath, "sessions")), "\" ")
if SessionConfig.Provider == "file" && !filepath.IsAbs(SessionConfig.ProviderConfig) {
SessionConfig.ProviderConfig = filepath.Join(AppWorkPath, SessionConfig.ProviderConfig)
SessionConfig.Provider = sec.Key("PROVIDER").In("memory", []string{"memory", "file", "redis", "mysql", "postgres", "couchbase", "memcache", "db"})
switch SessionConfig.Provider {
case "redis":
SessionConfig.ProviderConfig = sec.Key("PROVIDER_CONFIG").MustString(Redis.ConnStr)
case "file":
SessionConfig.ProviderConfig = sec.Key("PROVIDER_CONFIG").MustString(filepath.Join(AppDataPath, "sessions"))
if !filepath.IsAbs(SessionConfig.ProviderConfig) {
// Although the "data path" should be used as Gitea's "data" base directory (work path sometimes is not writable),
// document says the relative session path is based on the "work path", so keep the behavior
SessionConfig.ProviderConfig = filepath.Join(AppWorkPath, SessionConfig.ProviderConfig)
}
checkOverlappedPath("[session].PROVIDER_CONFIG", SessionConfig.ProviderConfig)
default:
SessionConfig.ProviderConfig = sec.Key("PROVIDER_CONFIG").String()
}
SessionConfig.CookieName = sec.Key("COOKIE_NAME").MustString("i_like_gitea")
// HINT: INSTALL-PAGE-COOKIE-INIT: the cookie system is not properly initialized on the Install page, so there is no CookiePath
SessionConfig.CookiePath = util.IfZero(AppSubURL, "/")

View File

@@ -0,0 +1,79 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"testing"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
func TestSessionRedisSharedConnFallback(t *testing.T) {
tests := []struct {
name string
iniStr string
wantContain string
wantMissing string
}{
{
name: "redis provider with empty PROVIDER_CONFIG falls back to shared [redis]",
iniStr: `
[redis]
CONN_STR = redis://127.0.0.1:6379/0
[session]
PROVIDER = redis
`,
wantContain: "redis://127.0.0.1:6379/0",
},
{
name: "session PROVIDER_CONFIG wins over shared [redis]",
iniStr: `
[redis]
CONN_STR = redis://127.0.0.1:6379/0
[session]
PROVIDER = redis
PROVIDER_CONFIG = redis://10.0.0.1:6379/1
`,
wantContain: "redis://10.0.0.1:6379/1",
wantMissing: "127.0.0.1",
},
{
name: "no shared [redis]",
iniStr: `
[session]
PROVIDER = redis
`,
wantContain: "",
wantMissing: "redis://",
},
{
name: "file provider default path is untouched by shared [redis]",
iniStr: `
[redis]
CONN_STR = redis://127.0.0.1:6379/0
[session]
PROVIDER = file
`,
wantContain: "sessions",
wantMissing: "redis://",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer test.MockVariableValue(&Redis)()
cfg, err := NewConfigProviderFromData(tt.iniStr)
assert.NoError(t, err)
loadRedisFrom(cfg)
loadSessionFrom(cfg)
// ProviderConfig is shadowed into a JSON blob at the end of loadSessionFrom
assert.Contains(t, SessionConfig.ProviderConfig, tt.wantContain)
if tt.wantMissing != "" {
assert.NotContains(t, SessionConfig.ProviderConfig, tt.wantMissing)
}
})
}
}

View File

@@ -156,6 +156,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
loadGitFrom(cfg)
loadMirrorFrom(cfg)
loadMarkupFrom(cfg)
loadRedisFrom(cfg)
loadGlobalLockFrom(cfg)
loadOtherFrom(cfg)
return nil