Files
tbphp 515c958dd1 feat(config): 支持运行策略与数据库连接池配置 (#492)
* feat(config): 增加重试拉黑及数据库连接池配置

* fix(settings): 恢复已提交配置快照
2026-08-29 18:23:34 +08:00

288 lines
7.8 KiB
Go

package storage
import (
"fmt"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gpt-load/internal/platform/config"
"gpt-load/internal/platform/securefile"
)
const sqliteBusyTimeoutMS = 5000
type sqliteTarget struct {
fileBacked bool
databasePath string
directory string
}
var hardenManagedFileIfExists = securefile.HardenManagedFileIfExists
func openSQLite(
dsn string,
source config.DatabaseSource,
pool config.DatabasePoolConfig,
) (*gorm.DB, error) {
target, err := parseSQLiteTarget(dsn)
if err != nil {
return nil, err
}
if err := rejectExistingDatabaseWithoutMigrationLedger(target); err != nil {
return nil, err
}
switch source {
case config.DatabaseSourceManaged:
if target.fileBacked {
if err := hardenSQLiteRecoverySet(target); err != nil {
return nil, err
}
}
case config.DatabaseSourceExternal:
default:
return nil, fmt.Errorf("open SQLite database: unsupported database source")
}
runtimeDSN, err := withSQLiteRuntimeOptions(dsn, target.fileBacked)
if err != nil {
return nil, err
}
dialector, err := newDatabaseDialector(config.DatabaseConfig{
Driver: config.DatabaseDriverSQLite,
DSN: runtimeDSN,
})
if err != nil {
return nil, err
}
db, err := openDatabase(config.DatabaseDriverSQLite, dialector, pool)
if err != nil {
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("get SQLite connection pool: %w", err)
}
if err := verifySQLiteRuntime(db, target.fileBacked); err != nil {
_ = sqlDB.Close()
return nil, err
}
if source == config.DatabaseSourceManaged && target.fileBacked {
if err := hardenSQLiteRecoverySet(target); err != nil {
_ = sqlDB.Close()
return nil, err
}
}
return db, nil
}
// rejectExistingDatabaseWithoutMigrationLedger inspects the first-install
// sentinel with SQLite read-only mode before runtime pragmas can change
// journal state. External tables are intentionally not enumerated.
func rejectExistingDatabaseWithoutMigrationLedger(target sqliteTarget) error {
if !target.fileBacked {
return nil
}
if _, err := os.Lstat(target.databasePath); os.IsNotExist(err) {
return nil
} else if err != nil {
return fmt.Errorf("inspect SQLite database before migration: %w", err)
}
absolutePath, err := filepath.Abs(target.databasePath)
if err != nil {
return fmt.Errorf("resolve SQLite database path before migration: %w", err)
}
uriPath := filepath.ToSlash(absolutePath)
if runtime.GOOS == "windows" && len(uriPath) >= 2 && uriPath[1] == ':' {
// A Windows drive path must be an absolute file-URI path. Without
// the leading slash, net/url treats the drive letter as the URI host.
uriPath = "/" + uriPath
}
readOnlyDSN := (&url.URL{
Scheme: "file",
Path: uriPath,
RawQuery: "mode=ro&immutable=1",
}).String()
db, err := gorm.Open(sqlite.Open(readOnlyDSN), &gorm.Config{Logger: databaseLogger})
if err != nil {
return fmt.Errorf("inspect existing SQLite database before migration: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("inspect existing SQLite database connection: %w", err)
}
defer func() { _ = sqlDB.Close() }()
if db.Migrator().HasTable(migrationLedgerTable) {
return nil
}
if db.Migrator().HasTable(initialSchemaSentinelTable) {
return fmt.Errorf(
"open SQLite database: %s table already exists",
initialSchemaSentinelTable,
)
}
return nil
}
func withSQLiteRuntimeOptions(dsn string, fileBacked bool) (string, error) {
base, rawQuery, _ := strings.Cut(dsn, "?")
query, err := url.ParseQuery(rawQuery)
if err != nil {
return "", fmt.Errorf("open SQLite database: invalid DSN query: %w", err)
}
query.Set("_txlock", "immediate")
pragmas := make([]string, 0, len(query["_pragma"])+3)
for _, pragma := range query["_pragma"] {
name := strings.ToLower(strings.TrimSpace(pragma))
if index := strings.IndexAny(name, "(="); index >= 0 {
name = strings.TrimSpace(name[:index])
}
switch name {
case "foreign_keys", "busy_timeout", "journal_mode":
continue
default:
pragmas = append(pragmas, pragma)
}
}
pragmas = append(pragmas,
"foreign_keys(1)",
fmt.Sprintf("busy_timeout(%d)", sqliteBusyTimeoutMS),
)
if fileBacked {
pragmas = append(pragmas, "journal_mode(WAL)")
}
query["_pragma"] = pragmas
return base + "?" + query.Encode(), nil
}
func isSQLiteMemoryDSN(dsn string) bool {
base, _, _ := strings.Cut(dsn, "?")
if base == ":memory:" {
return true
}
if !strings.HasPrefix(dsn, "file:") {
return false
}
parsed, err := url.Parse(dsn)
if err != nil {
return false
}
if strings.EqualFold(parsed.Query().Get("mode"), "memory") {
return true
}
return parsed.Path == ":memory:" || parsed.Opaque == ":memory:"
}
func verifySQLiteRuntime(db *gorm.DB, fileBacked bool) error {
var foreignKeys, busyTimeout int
if err := db.Raw("PRAGMA foreign_keys").Scan(&foreignKeys).Error; err != nil {
return fmt.Errorf("verify SQLite foreign_keys: %w", err)
}
if foreignKeys != 1 {
return fmt.Errorf("verify SQLite foreign_keys: got %d, want 1", foreignKeys)
}
if err := db.Raw("PRAGMA busy_timeout").Scan(&busyTimeout).Error; err != nil {
return fmt.Errorf("verify SQLite busy_timeout: %w", err)
}
if busyTimeout != sqliteBusyTimeoutMS {
return fmt.Errorf("verify SQLite busy_timeout: got %d, want %d", busyTimeout, sqliteBusyTimeoutMS)
}
if fileBacked {
var journalMode string
if err := db.Raw("PRAGMA journal_mode").Scan(&journalMode).Error; err != nil {
return fmt.Errorf("verify SQLite journal_mode: %w", err)
}
if !strings.EqualFold(journalMode, "wal") {
return fmt.Errorf("verify SQLite journal_mode: got %q, want wal", journalMode)
}
}
return nil
}
func parseSQLiteTarget(dsn string) (sqliteTarget, error) {
if isSQLiteMemoryDSN(dsn) {
return sqliteTarget{}, nil
}
if err := validateSQLiteDSN(dsn); err != nil {
return sqliteTarget{}, err
}
databasePath, _, _ := strings.Cut(dsn, "?")
parsed, err := url.Parse(dsn)
if err != nil {
return sqliteTarget{}, fmt.Errorf("open SQLite database: invalid DSN: %w", err)
}
if strings.HasPrefix(dsn, "file:") {
databasePath = parsed.Path
if databasePath == "" {
databasePath = parsed.Opaque
databasePath, err = url.PathUnescape(databasePath)
if err != nil {
return sqliteTarget{}, fmt.Errorf("open SQLite database: invalid file URI: %w", err)
}
}
if runtime.GOOS == "windows" &&
len(databasePath) >= 3 &&
databasePath[0] == '/' &&
databasePath[2] == ':' &&
(databasePath[1] >= 'A' && databasePath[1] <= 'Z' ||
databasePath[1] >= 'a' && databasePath[1] <= 'z') {
databasePath = databasePath[1:]
}
databasePath = filepath.FromSlash(databasePath)
}
if databasePath == "" {
return sqliteTarget{}, fmt.Errorf("open SQLite database: file path is empty")
}
directory := filepath.Dir(databasePath)
return sqliteTarget{
fileBacked: true,
databasePath: databasePath,
directory: directory,
}, nil
}
func hardenSQLiteRecoverySet(target sqliteTarget) error {
for _, path := range []string{
target.databasePath,
target.databasePath + "-wal",
target.databasePath + "-shm",
} {
if err := hardenManagedFileIfExists(path); err != nil {
return fmt.Errorf("secure managed SQLite recovery set: %w", err)
}
}
return nil
}
func validateSQLiteDSN(dsn string) error {
if dsn == "" {
return fmt.Errorf("open SQLite database: DSN is empty")
}
if dsn == ":memory:" || filepath.VolumeName(dsn) != "" {
return nil
}
parsed, err := url.Parse(dsn)
if err != nil {
return fmt.Errorf("open SQLite database: invalid DSN: %w", err)
}
if parsed.Scheme == "file" && !strings.HasPrefix(dsn, "file:") {
return fmt.Errorf("open SQLite database: unsupported non-canonical DSN scheme")
}
if parsed.Scheme != "" && parsed.Scheme != "file" {
return fmt.Errorf("open SQLite database: unsupported DSN scheme %q", parsed.Scheme)
}
return nil
}