feat: use XORM EngineGroup instead of single Engine connection (#7212)

Resolves #7207

Add new configuration to make XORM work with a main and replicas database instances. The follow configuration parameters were added:

- `HOST_PRIMARY`
- `HOST_REPLICAS`
- `LOAD_BALANCE_POLICY`. Options:
    - `"WeightRandom"` -> `xorm.WeightRandomPolicy`
    - `"WeightRoundRobin`  -> `WeightRoundRobinPolicy`
    - `"LeastCon"` -> `LeastConnPolicy`
    - `"RoundRobin"` -> `xorm.RoundRobinPolicy()`
    - default: `xorm.RandomPolicy()`
- `LOAD_BALANCE_WEIGHTS`

Co-authored-by: pat-s <patrick.schratz@gmail.com@>
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7212
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: pat-s <patrick.schratz@gmail.com>
Co-committed-by: pat-s <patrick.schratz@gmail.com>
This commit is contained in:
pat-s 2025-03-30 11:34:02 +00:00 committed by Gusted
parent a23d0453a3
commit 63a80bf2b9
19 changed files with 463 additions and 129 deletions

View file

@ -10,8 +10,13 @@ import (
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"forgejo.org/modules/log"
"xorm.io/xorm"
)
var (
@ -24,35 +29,41 @@ var (
EnableSQLite3 bool
// Database holds the database settings
Database = struct {
Type DatabaseType
Host string
Name string
User string
Passwd string
Schema string
SSLMode string
Path string
LogSQL bool
MysqlCharset string
CharsetCollation string
Timeout int // seconds
SQLiteJournalMode string
DBConnectRetries int
DBConnectBackoff time.Duration
MaxIdleConns int
MaxOpenConns int
ConnMaxIdleTime time.Duration
ConnMaxLifetime time.Duration
IterateBufferSize int
AutoMigration bool
SlowQueryThreshold time.Duration
}{
Database = DatabaseSettings{
Timeout: 500,
IterateBufferSize: 50,
}
)
type DatabaseSettings struct {
Type DatabaseType
Host string
HostPrimary string
HostReplica string
LoadBalancePolicy string
LoadBalanceWeights string
Name string
User string
Passwd string
Schema string
SSLMode string
Path string
LogSQL bool
MysqlCharset string
CharsetCollation string
Timeout int // seconds
SQLiteJournalMode string
DBConnectRetries int
DBConnectBackoff time.Duration
MaxIdleConns int
MaxOpenConns int
ConnMaxIdleTime time.Duration
ConnMaxLifetime time.Duration
IterateBufferSize int
AutoMigration bool
SlowQueryThreshold time.Duration
}
// LoadDBSetting loads the database settings
func LoadDBSetting() {
loadDBSetting(CfgProvider)
@ -63,6 +74,10 @@ func loadDBSetting(rootCfg ConfigProvider) {
Database.Type = DatabaseType(sec.Key("DB_TYPE").String())
Database.Host = sec.Key("HOST").String()
Database.HostPrimary = sec.Key("HOST_PRIMARY").String()
Database.HostReplica = sec.Key("HOST_REPLICA").String()
Database.LoadBalancePolicy = sec.Key("LOAD_BALANCE_POLICY").String()
Database.LoadBalanceWeights = sec.Key("LOAD_BALANCE_WEIGHTS").String()
Database.Name = sec.Key("NAME").String()
Database.User = sec.Key("USER").String()
if len(Database.Passwd) == 0 {
@ -99,8 +114,93 @@ func loadDBSetting(rootCfg ConfigProvider) {
}
}
// DBConnStr returns database connection string
func DBConnStr() (string, error) {
// DBMasterConnStr returns the connection string for the master (primary) database.
// If a primary host is defined in the configuration, it is used;
// otherwise, it falls back to Database.Host.
// Returns an error if no master host is provided but a slave is defined.
func DBMasterConnStr() (string, error) {
var host string
if Database.HostPrimary != "" {
host = Database.HostPrimary
} else {
host = Database.Host
}
if host == "" && Database.HostReplica != "" {
return "", errors.New("master host is not defined while slave is defined; cannot proceed")
}
// For SQLite, no host is needed
if host == "" && !Database.Type.IsSQLite3() {
return "", errors.New("no database host defined")
}
return dbConnStrWithHost(host)
}
// DBSlaveConnStrs returns one or more connection strings for the replica databases.
// If a replica host is defined (possibly as a comma-separated list) then those DSNs are returned.
// Otherwise, this function falls back to the master DSN (with a warning log).
func DBSlaveConnStrs() ([]string, error) {
var dsns []string
if Database.HostReplica != "" {
// support multiple replica hosts separated by commas
replicas := strings.SplitSeq(Database.HostReplica, ",")
for r := range replicas {
trimmed := strings.TrimSpace(r)
if trimmed == "" {
continue
}
dsn, err := dbConnStrWithHost(trimmed)
if err != nil {
return nil, err
}
dsns = append(dsns, dsn)
}
}
// Fall back to master if no slave DSN was provided.
if len(dsns) == 0 {
master, err := DBMasterConnStr()
if err != nil {
return nil, err
}
log.Debug("Database: No dedicated replica host defined; falling back to primary DSN for replica connections")
dsns = append(dsns, master)
}
return dsns, nil
}
func BuildLoadBalancePolicy(settings *DatabaseSettings, slaveEngines []*xorm.Engine) xorm.GroupPolicy {
var policy xorm.GroupPolicy
switch settings.LoadBalancePolicy { // Use the settings parameter directly
case "WeightRandom":
var weights []int
if settings.LoadBalanceWeights != "" { // Use the settings parameter directly
for part := range strings.SplitSeq(settings.LoadBalanceWeights, ",") {
w, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil {
w = 1 // use a default weight if conversion fails
}
weights = append(weights, w)
}
}
// If no valid weights were provided, default each slave to weight 1
if len(weights) == 0 {
weights = make([]int, len(slaveEngines))
for i := range weights {
weights[i] = 1
}
}
policy = xorm.WeightRandomPolicy(weights)
case "RoundRobin":
policy = xorm.RoundRobinPolicy()
default:
policy = xorm.RandomPolicy()
}
return policy
}
// dbConnStrWithHost constructs the connection string, given a host value.
func dbConnStrWithHost(host string) (string, error) {
var connStr string
paramSep := "?"
if strings.Contains(Database.Name, paramSep) {
@ -109,23 +209,25 @@ func DBConnStr() (string, error) {
switch Database.Type {
case "mysql":
connType := "tcp"
if len(Database.Host) > 0 && Database.Host[0] == '/' { // looks like a unix socket
// if the host starts with '/' it is assumed to be a unix socket path
if len(host) > 0 && host[0] == '/' {
connType = "unix"
}
tls := Database.SSLMode
if tls == "disable" { // allow (Postgres-inspired) default value to work in MySQL
// allow the "disable" value (borrowed from Postgres defaults) to behave as false
if tls == "disable" {
tls = "false"
}
connStr = fmt.Sprintf("%s:%s@%s(%s)/%s%sparseTime=true&tls=%s",
Database.User, Database.Passwd, connType, Database.Host, Database.Name, paramSep, tls)
Database.User, Database.Passwd, connType, host, Database.Name, paramSep, tls)
case "postgres":
connStr = getPostgreSQLConnectionString(Database.Host, Database.User, Database.Passwd, Database.Name, Database.SSLMode)
connStr = getPostgreSQLConnectionString(host, Database.User, Database.Passwd, Database.Name, Database.SSLMode)
case "sqlite3":
if !EnableSQLite3 {
return "", errors.New("this Gitea binary was not built with SQLite3 support")
}
if err := os.MkdirAll(filepath.Dir(Database.Path), os.ModePerm); err != nil {
return "", fmt.Errorf("Failed to create directories: %w", err)
return "", fmt.Errorf("failed to create directories: %w", err)
}
journalMode := ""
if Database.SQLiteJournalMode != "" {
@ -136,7 +238,6 @@ func DBConnStr() (string, error) {
default:
return "", fmt.Errorf("unknown database type: %s", Database.Type)
}
return connStr, nil
}

View file

@ -4,6 +4,7 @@
package setting
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
@ -107,3 +108,104 @@ func Test_getPostgreSQLConnectionString(t *testing.T) {
assert.Equal(t, test.Output, connStr)
}
}
func getPostgreSQLEngineGroupConnectionStrings(primaryHost, replicaHosts, user, passwd, name, sslmode string) (string, []string) {
// Determine the primary connection string.
primary := primaryHost
if strings.TrimSpace(primary) == "" {
primary = "127.0.0.1:5432"
}
primaryConn := getPostgreSQLConnectionString(primary, user, passwd, name, sslmode)
// Build the replica connection strings.
replicaConns := []string{}
if strings.TrimSpace(replicaHosts) != "" {
// Split comma-separated replica host values.
hosts := strings.Split(replicaHosts, ",")
for _, h := range hosts {
trimmed := strings.TrimSpace(h)
if trimmed != "" {
replicaConns = append(replicaConns,
getPostgreSQLConnectionString(trimmed, user, passwd, name, sslmode))
}
}
}
return primaryConn, replicaConns
}
func Test_getPostgreSQLEngineGroupConnectionStrings(t *testing.T) {
tests := []struct {
primaryHost string // primary host setting (e.g. "localhost" or "[::1]:1234")
replicaHosts string // comma-separated replica hosts (e.g. "replica1,replica2:2345")
user string
passwd string
name string
sslmode string
outputPrimary string
outputReplicas []string
}{
{
// No primary override (empty => default) and no replicas.
primaryHost: "",
replicaHosts: "",
user: "",
passwd: "",
name: "",
sslmode: "",
outputPrimary: "postgres://:@127.0.0.1:5432?sslmode=",
outputReplicas: []string{},
},
{
// Primary set and one replica.
primaryHost: "localhost",
replicaHosts: "replicahost",
user: "user",
passwd: "pass",
name: "gitea",
sslmode: "disable",
outputPrimary: "postgres://user:pass@localhost:5432/gitea?sslmode=disable",
outputReplicas: []string{"postgres://user:pass@replicahost:5432/gitea?sslmode=disable"},
},
{
// Primary with explicit port; multiple replicas (one without and one with an explicit port).
primaryHost: "localhost:5433",
replicaHosts: "replica1,replica2:5434",
user: "test",
passwd: "secret",
name: "db",
sslmode: "require",
outputPrimary: "postgres://test:secret@localhost:5433/db?sslmode=require",
outputReplicas: []string{
"postgres://test:secret@replica1:5432/db?sslmode=require",
"postgres://test:secret@replica2:5434/db?sslmode=require",
},
},
{
// IPv6 addresses for primary and replica.
primaryHost: "[::1]:1234",
replicaHosts: "[::2]:2345",
user: "ipv6",
passwd: "ipv6pass",
name: "ipv6db",
sslmode: "disable",
outputPrimary: "postgres://ipv6:ipv6pass@[::1]:1234/ipv6db?sslmode=disable",
outputReplicas: []string{
"postgres://ipv6:ipv6pass@[::2]:2345/ipv6db?sslmode=disable",
},
},
}
for _, test := range tests {
primary, replicas := getPostgreSQLEngineGroupConnectionStrings(
test.primaryHost,
test.replicaHosts,
test.user,
test.passwd,
test.name,
test.sslmode,
)
assert.Equal(t, test.outputPrimary, primary)
assert.Equal(t, test.outputReplicas, replicas)
}
}

View file

@ -364,6 +364,9 @@ var ignoredErrorMessage = []string{
// TestDatabaseCollation
`[E] [Error SQL Query] INSERT INTO test_collation_tbl (txt) VALUES ('main') []`,
// Test_CmdForgejo_Actions
`DB: No dedicated replica host defined; falling back to primary DSN for replica connections`,
// TestDevtestErrorpages
`ErrorPage() [E] Example error: Example error`,
}