fix database lifecycle cancellation
This commit is contained in:
+5
-3
@@ -28,6 +28,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
|
||||
- **`config.Set()` / `(*config.Manager).Set()` 现在返回 `error`**:非 nil 配置会先执行 `Validate()`,非法配置不会覆盖旧配置,并返回 `ErrInvalidConfig` 包装错误。旧代码可以继续忽略返回值,但建议测试和启动路径显式检查。
|
||||
- **`config.GetViper()` / `(*config.Manager).GetViper()` 改为返回 viper 快照**:旧行为暴露内部可变 `*viper.Viper`;现在修改返回对象不会影响全局配置。常规读取请使用 `GetString` / `GetInt` / `GetBool` / `GetStringMap`。
|
||||
- **未知数据库 driver 不再静默回退 MySQL**:`config.DatabaseConfig.DSN()` 对非空但未注册的 `driver` 返回空字符串,`database.Dialector` 返回初始化即失败的 Dialector;空 `driver` 仍保持默认 MySQL。下游若使用自定义数据库驱动,需先通过 `database.RegisterDialect` 或 `config.RegisterDSNBuilder` 注册。
|
||||
- **`database.InitDB` / `(*database.Manager).InitDB` / `InitDBWithReplicas` 必须显式传入 `context.Context`**:旧 API 无法取消初始化过程中的 Ping 与重试等待,shutdown 或启动失败回滚时可能长时间卡住。现在调用方必须传入生命周期 ctx;普通测试或一次性脚本可使用 `context.Background()`,App 初始化会使用 App root ctx。
|
||||
- **`database.SetDefaultManager` / `database.SetDefaultRedisManager` 会关闭被替换的旧 manager**:旧行为只做 atomic 替换,直接调用会遗留旧 DB/Redis 连接池。现在普通 Set 表示“接管全局默认资源并释放旧资源”;需要失败回滚或延迟释放旧资源的初始化流程请改用新增 `database.SwapDefaultManager` / `database.SwapDefaultRedisManager`。
|
||||
- **`jwt.ParseToken` 开始校验 issuer,`RefreshToken` 使用 `jwt.refresh_expire`**:签发者与当前配置不一致的 token 会被拒绝;刷新后的 token 过期时间优先使用 `refresh_expire`,未配置时回退 `expire`。`GenerateTokenWithCustomExpiry` 现在拒绝非正过期时间,`InvalidateTokenByID("")` 返回 `ErrEmptyJTI`。
|
||||
- **`App.Init()` 由 `sync.Once` 改为生命周期状态机**(app.go,M1):5 态 `stateCreated/Initializing/Initialized/Stopping/Stopped` + `lifecycleMu`(RWMutex) + `initMu`(Mutex)。`Shutdown` 后或 `Init` 失败后再调 `Init()` 返回新增导出错误 **`xlgo.ErrAppClosed`**(原 `sync.Once` "多次调用返回首次结果"语义不再适用——已关闭的 App 不可再 Init,需新建 App)。
|
||||
@@ -47,8 +48,9 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
|
||||
- **M10 分布式锁参数与取消传播修复**:锁 TTL 统一校验到 Redis 毫秒粒度;`TryLock` 的非正 retry interval 不再 busy-loop;`WithLockAutoExtend` 的非正 extend interval 不再触发 goroutine panic;`UnlockByKey` 在 Redis 未初始化时与 `ForceUnlock` 一样返回 `ErrRedisNotReady`;`WithLock` / `WithLockAutoExtend` 现在把调用方 ctx 传入业务函数,避免取消后业务函数继续运行。
|
||||
- **M10 cache 剩余错误语义收口**:新增 `cache.ExistsE` 与可选 `CacheExistChecker`,让调用方能区分 key 不存在和 Redis/backend 故障;保留旧 `Exists` bool-only 兼容方法但记录后端错误;`KeyBuilder` 现在忽略 nil option,`WithPrefix` / `WithSeparator` / `WithCacheType` 直接作用于 nil builder 时 no-op,避免扩展配置路径 panic。
|
||||
- **M2 config 热重载生命周期修复**:`StopWatcher` 会等待已触发的 reload/回调结束;包级 `Load` / `LoadWithWatch` 只有在新配置成功加载并启动 watcher 后才替换默认 manager,失败时保留旧 watcher;`SetDefaultManager` 会停止旧 manager 的 watcher,避免全局置换后遗留 goroutine;数据库配置出现字段时会校验 driver/host/name/port,未知 driver 不再静默回退 MySQL,`database.Dialector` 对未知 driver fail-closed;MySQL DSN 转义用户名/库名,Postgres DSN 统一转义字符串字段。
|
||||
- **M4 database nil 边界修复**:`InitDB(nil)` / `InitDBWithReplicas(nil, ...)` / `InitRedis(nil)` 现在返回中文错误,不再空指针 panic;`UseMaster(nil)` / `UseReplica(nil)` / `GetDBFromContext(nil)` / `WithTx(nil, ...)` / `TxFromContext(nil)` / `TransactionWithContext(nil, ...)` / `ReadQuery(nil, ...)` / `WriteQuery(nil, ...)` / Redis health check 会把 nil context 归一化为 `context.Background()`,避免异常调用路径触发 panic;`Dialector(nil)` 安全回退到 MySQL 空 DSN。
|
||||
- **M4 database nil 边界修复**:`InitDB(ctx, nil)` / `InitDBWithReplicas(ctx, nil, ...)` / `InitRedis(nil)` 现在返回中文错误,不再空指针 panic;`UseMaster(nil)` / `UseReplica(nil)` / `GetDBFromContext(nil)` / `WithTx(nil, ...)` / `TxFromContext(nil)` / `TransactionWithContext(nil, ...)` / `ReadQuery(nil, ...)` / `WriteQuery(nil, ...)` / Redis health check 会把 nil context 归一化为 `context.Background()`,避免异常调用路径触发 panic;`Dialector(nil)` 安全回退到 MySQL 空 DSN。
|
||||
- **M4 database 全局置换资源释放修复**:`SetDefaultManager` / `SetDefaultRedisManager` 替换全局默认 manager 时会关闭旧 DB/Redis manager,避免包级默认资源反复置换后连接池泄漏;App 初始化改用 `SwapDefaultManager` / `SwapDefaultRedisManager` 暂存旧资源,保证失败回滚仍能恢复旧默认资源。
|
||||
- **M4 database 初始化生命周期修复**:DB 初始化与主从库初始化统一接收 ctx,主库/从库 Ping 使用 `PingContext`,重试等待改为 `select ctx.Done()/time.After`;`Manager` 用生命周期锁串行化 Init/Close,避免 shutdown 与运行期重建交错;运行期重建从库后会立即重建健康标记,探活循环也能在发现健康标记缺失时自愈;包级 `HealthCheck()` 固定读取一次默认 manager 快照。
|
||||
- **M11 SSE 换行注入修复**:`WriteEvent` 拒绝带 CR/LF 的 event 名,`WriteMessage` / `WriteEvent` 的 data 按 SSE 多行格式逐行输出,避免用户数据伪造额外 `event:`/`id:` 字段。
|
||||
- **M15 utils/validation 资源与错误边界修复**:`HTTPClient.Upload` 改为流式 multipart 上传,不再把文件请求体完整缓存在内存中;`AppendFile` / `CopyFile` 返回写侧 `Close` 错误;`CheckPasswordAndUpgrade` 归一化非法 `targetCost`,避免异常配置触发超高 bcrypt cost;`ValidateStruct(nil)` 直接返回 nil。
|
||||
- **M16 测试工具、脚手架与示例闭环修复**:`MockDB` / `MockCache` / `MockStorage` 改为并发安全;`MockCache` 与 `MockStorage.UploadFromBytes` 复制字节切片,避免调用方修改污染内部状态;`MockStorage` 拒绝 nil 文件与超过 32MiB 的输入,避免测试 helper 被误用成无上限内存缓冲;`xlgo make` 对资源名做显式标识符校验,非法名称(路径穿越、连字符、数字开头等)直接返回中文错误,不再静默转义后生成不可预期代码;`examples/full` 启动时初始化 `alice/secret`,登录校验 bcrypt 哈希,创建用户也保存哈希,避免示例首次运行无法登录或传播不验密/明文密码模式;README/GUIDE 限流示例不再引用不存在的 `handler.Login` / `handler.Upload`。
|
||||
@@ -743,8 +745,8 @@ database.InitMySQL(cfg)
|
||||
database.InitMySQLWithReplicas(cfg, replicas)
|
||||
|
||||
// ✅ 新(驱动由 cfg.Database.Driver 决定,可以是 mysql / postgres / 自定义注册的方言)
|
||||
database.InitDB(cfg)
|
||||
database.InitDBWithReplicas(cfg, replicas)
|
||||
database.InitDB(ctx, cfg)
|
||||
database.InitDBWithReplicas(ctx, cfg, replicas)
|
||||
```
|
||||
|
||||
**为什么现在动手**:
|
||||
|
||||
@@ -649,7 +649,7 @@ func (a *App) doInit() error {
|
||||
|
||||
if a.enableMySQL {
|
||||
dbm := database.NewManager(cfg)
|
||||
if err := dbm.InitDB(cfg); err != nil {
|
||||
if err := dbm.InitDB(a.rootCtx, cfg); err != nil {
|
||||
return fmt.Errorf("初始化数据库失败: %w", err)
|
||||
}
|
||||
a.previousDB = database.SwapDefaultManager(dbm)
|
||||
|
||||
+66
-17
@@ -86,6 +86,9 @@ type Manager struct {
|
||||
replicas []*gorm.DB
|
||||
picker ReplicaPicker
|
||||
mu sync.Mutex
|
||||
// opMu 串行化 InitDB / InitDBWithReplicas / Close 这类资源生命周期操作。
|
||||
// 避免关闭已开始后初始化又发布新连接,或初始化发布后被并发 Close 置空。
|
||||
opMu sync.Mutex
|
||||
|
||||
// #21 健康自愈
|
||||
healthy atomic.Bool // 主库是否健康
|
||||
@@ -206,6 +209,15 @@ func (m *Manager) initReplicaHealth() {
|
||||
m.replicaHealthSet = true
|
||||
}
|
||||
|
||||
// ensureReplicaHealthLocked 按当前 replicas 重建健康标记。调用方须持有 m.mu。
|
||||
func (m *Manager) ensureReplicaHealthLocked() {
|
||||
m.replicaHealthy = make([]atomic.Bool, len(m.replicas))
|
||||
for i := range m.replicaHealthy {
|
||||
m.replicaHealthy[i].Store(true)
|
||||
}
|
||||
m.replicaHealthSet = true
|
||||
}
|
||||
|
||||
// resetReplicaHealth 清空从库健康标记,使下次 initReplicaHealth 按新 replicas 长度重建。
|
||||
// 重建从库(InitDBWithReplicas)/Close 前必须调用,避免健康切片与新 replicas 长度错位(C11a)。
|
||||
// 调用方须持有 m.mu。
|
||||
@@ -272,6 +284,9 @@ func (m *Manager) probeOnce(ctx context.Context, threshold int) {
|
||||
m.mu.Lock()
|
||||
replicas := make([]*gorm.DB, len(m.replicas))
|
||||
copy(replicas, m.replicas)
|
||||
if len(replicas) > 0 && !m.replicaHealthSet {
|
||||
m.ensureReplicaHealthLocked()
|
||||
}
|
||||
healthSet := m.replicaHealthSet
|
||||
replicaHealthy := m.replicaHealthy // 快照切片头,避免与 resetReplicaHealth 写竞争
|
||||
m.mu.Unlock()
|
||||
@@ -327,7 +342,7 @@ func (m *Manager) Open(ctx context.Context) error {
|
||||
if cfg == nil {
|
||||
return errors.New("数据库配置未设置")
|
||||
}
|
||||
return m.InitDB(cfg)
|
||||
return m.InitDB(ctx, cfg)
|
||||
}
|
||||
|
||||
// OpenWithReplicas 打开主库与从库连接
|
||||
@@ -336,7 +351,7 @@ func (m *Manager) OpenWithReplicas(ctx context.Context, replicaDSNs []string) er
|
||||
if cfg == nil {
|
||||
return errors.New("数据库配置未设置")
|
||||
}
|
||||
return m.InitDBWithReplicas(cfg, replicaDSNs)
|
||||
return m.InitDBWithReplicas(ctx, cfg, replicaDSNs)
|
||||
}
|
||||
|
||||
// closeDB 关闭 gorm.DB 底层连接池。nil 或未初始化(无 ConnPool)时返回 nil,不 panic。
|
||||
@@ -361,6 +376,8 @@ func warnCloseDB(db *gorm.DB, context string) {
|
||||
// Close 关闭主库与全部从库连接,并重置从库健康状态。
|
||||
// 字段置空在锁内完成(保证新读取得到 nil),实际关闭在锁外执行避免持锁阻塞。
|
||||
func (m *Manager) Close() error {
|
||||
m.opMu.Lock()
|
||||
defer m.opMu.Unlock()
|
||||
m.mu.Lock()
|
||||
master := m.master
|
||||
replicas := m.replicas
|
||||
@@ -446,11 +463,22 @@ func GetDefaultManager() *Manager {
|
||||
return DefaultManager.Load()
|
||||
}
|
||||
|
||||
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定
|
||||
func (m *Manager) InitDB(cfg *config.Config) error {
|
||||
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定。
|
||||
// ctx 控制 Ping 与重试等待;调用方取消 ctx 时初始化会尽快返回。
|
||||
func (m *Manager) InitDB(ctx context.Context, cfg *config.Config) error {
|
||||
ctx = normalizeContext(ctx)
|
||||
m.opMu.Lock()
|
||||
defer m.opMu.Unlock()
|
||||
return m.initDB(ctx, cfg)
|
||||
}
|
||||
|
||||
func (m *Manager) initDB(ctx context.Context, cfg *config.Config) error {
|
||||
if cfg == nil {
|
||||
return errors.New("数据库配置未设置")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("数据库初始化已取消: %w", err)
|
||||
}
|
||||
m.setCfg(cfg) // P1 #11:锁内写入,避免与 StartProbing/Open 读竞态
|
||||
|
||||
// GORM 日志配置
|
||||
@@ -471,6 +499,9 @@ func (m *Manager) InitDB(cfg *config.Config) error {
|
||||
|
||||
var lastErr error
|
||||
for i := range maxRetries {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("数据库初始化已取消: %w", err)
|
||||
}
|
||||
// 连接主库:先打开到局部变量,仅 Ping 成功后才安装为 m.master,
|
||||
// 避免 Ping 失败时下轮覆盖 m.master 泄漏旧池(C11b)。
|
||||
db, err := gorm.Open(Dialector(cfg), gormConfig)
|
||||
@@ -493,7 +524,7 @@ func (m *Manager) InitDB(cfg *config.Config) error {
|
||||
sqlDB.SetConnMaxIdleTime(cfg.Database.ConnMaxIdleTime)
|
||||
}
|
||||
|
||||
if err := sqlDB.Ping(); err == nil {
|
||||
if err := sqlDB.PingContext(ctx); err == nil {
|
||||
// 成功:安装为新主库,关闭旧主库池(重建路径覆盖前先释放旧资源,C11b)
|
||||
m.mu.Lock()
|
||||
old := m.master
|
||||
@@ -515,7 +546,14 @@ func (m *Manager) InitDB(cfg *config.Config) error {
|
||||
}
|
||||
|
||||
logger.Warnf("数据库连接失败,第 %d/%d 次重试: %v", i+1, maxRetries, lastErr)
|
||||
time.Sleep(retryDelay)
|
||||
if i == maxRetries-1 {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("数据库初始化已取消: %w", ctx.Err())
|
||||
case <-time.After(retryDelay):
|
||||
}
|
||||
retryDelay *= 2
|
||||
if retryDelay > 30*time.Second {
|
||||
retryDelay = 30 * time.Second
|
||||
@@ -572,12 +610,15 @@ func replicaMaxOpenConns(masterMax int) int {
|
||||
|
||||
// InitDBWithReplicas 初始化数据库主从连接,驱动由配置决定
|
||||
// replicaDSNs: 从库连接字符串列表(需与主库驱动匹配)
|
||||
func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) error {
|
||||
func (m *Manager) InitDBWithReplicas(ctx context.Context, cfg *config.Config, replicaDSNs []string) error {
|
||||
ctx = normalizeContext(ctx)
|
||||
m.opMu.Lock()
|
||||
defer m.opMu.Unlock()
|
||||
if cfg == nil {
|
||||
return errors.New("数据库配置未设置")
|
||||
}
|
||||
// 先初始化主库
|
||||
if err := m.InitDB(cfg); err != nil {
|
||||
if err := m.initDB(ctx, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -607,6 +648,12 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e
|
||||
// 先构建到局部切片,全部成功后再安装,避免部分构建期间外部读到中间态
|
||||
var newReplicas []*gorm.DB
|
||||
for i, dsn := range replicaDSNs {
|
||||
if err := ctx.Err(); err != nil {
|
||||
for _, r := range newReplicas {
|
||||
warnCloseDB(r, "关闭已打开的数据库从库连接池失败")
|
||||
}
|
||||
return fmt.Errorf("数据库从库初始化已取消: %w", err)
|
||||
}
|
||||
replicaDB, err := gorm.Open(dialectorForDSN(cfg.Database.Driver, dsn), gormConfig)
|
||||
if err != nil {
|
||||
logger.Warnf("数据库从库 %d 连接失败: %v", i+1, err)
|
||||
@@ -629,7 +676,7 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e
|
||||
sqlDB.SetMaxOpenConns(replicaMaxOpenConns(cfg.Database.MaxOpenConns))
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
logger.Warnf("数据库从库 %d Ping 失败: %v", i+1, err)
|
||||
warnCloseDB(replicaDB, "关闭 Ping 失败的数据库从库连接池失败") // C11c: 关闭刚打开的池避免泄漏
|
||||
continue
|
||||
@@ -641,20 +688,21 @@ func (m *Manager) InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) e
|
||||
|
||||
m.mu.Lock()
|
||||
m.replicas = newReplicas
|
||||
m.ensureReplicaHealthLocked()
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定
|
||||
func InitDB(cfg *config.Config) error {
|
||||
return DefaultManager.Load().InitDB(cfg)
|
||||
// InitDB 初始化数据库连接(带重试机制),驱动由配置决定。
|
||||
func InitDB(ctx context.Context, cfg *config.Config) error {
|
||||
return DefaultManager.Load().InitDB(ctx, cfg)
|
||||
}
|
||||
|
||||
// InitDBWithReplicas 初始化数据库主从连接,驱动由配置决定
|
||||
func InitDBWithReplicas(cfg *config.Config, replicaDSNs []string) error {
|
||||
return DefaultManager.Load().InitDBWithReplicas(cfg, replicaDSNs)
|
||||
func InitDBWithReplicas(ctx context.Context, cfg *config.Config, replicaDSNs []string) error {
|
||||
return DefaultManager.Load().InitDBWithReplicas(ctx, cfg, replicaDSNs)
|
||||
}
|
||||
|
||||
// GetReadDB 获取读库实例(按策略选择从库)
|
||||
@@ -764,7 +812,7 @@ func TransactionWithContext(ctx context.Context, fn func(tx *gorm.DB) error) err
|
||||
return db.WithContext(ctx).Transaction(fn)
|
||||
}
|
||||
|
||||
// ReadQuery 读查询(自动路由到从库)
|
||||
// ReadQuery 读查询。遵循 ctx 中的数据库路由标记;未指定时默认走从库。
|
||||
func ReadQuery(ctx context.Context, model any, query string, args ...any) error {
|
||||
ctx = normalizeContext(ctx)
|
||||
db := GetDBFromContext(ctx)
|
||||
@@ -801,9 +849,10 @@ func pingWithTimeout(sqlDB *sql.DB, parent context.Context) error {
|
||||
func HealthCheck() map[string]bool {
|
||||
result := make(map[string]bool)
|
||||
ctx := context.Background()
|
||||
m := DefaultManager.Load()
|
||||
|
||||
// 检查主库
|
||||
if master := DefaultManager.Load().Master(); master != nil {
|
||||
if master := m.Master(); master != nil {
|
||||
sqlDB, err := master.DB()
|
||||
if err == nil && pingWithTimeout(sqlDB, ctx) == nil {
|
||||
result["master"] = true
|
||||
@@ -815,7 +864,7 @@ func HealthCheck() map[string]bool {
|
||||
}
|
||||
|
||||
// 检查从库
|
||||
for i, replica := range DefaultManager.Load().Replicas() {
|
||||
for i, replica := range m.Replicas() {
|
||||
if replica != nil {
|
||||
sqlDB, err := replica.DB()
|
||||
if err == nil && pingWithTimeout(sqlDB, ctx) == nil {
|
||||
|
||||
@@ -2,13 +2,17 @@ package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -131,6 +135,25 @@ func TestC11ReplicaHealthResetOnRebuild(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestM4ProbeOnceReinitializesReplicaHealthAfterRebuild(t *testing.T) {
|
||||
m := &Manager{picker: &RandomPicker{}}
|
||||
m.mu.Lock()
|
||||
m.replicas = []*gorm.DB{sentinelDB(), sentinelDB()}
|
||||
m.resetReplicaHealth()
|
||||
m.mu.Unlock()
|
||||
|
||||
m.probeOnce(context.Background(), 1)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if !m.replicaHealthSet {
|
||||
t.Fatal("probeOnce 应在从库重建后自动重建健康标记")
|
||||
}
|
||||
if len(m.replicaHealthy) != 2 {
|
||||
t.Fatalf("健康标记长度应与从库数量一致,实际 %d", len(m.replicaHealthy))
|
||||
}
|
||||
}
|
||||
|
||||
// TestC11ReplicaHealthStaleWithoutReset 反向验证:不调 resetReplicaHealth 时
|
||||
// initReplicaHealth 早返回,健康切片长度不随新 replicas 变化(复现 C11a 缺陷根因)。
|
||||
func TestC11ReplicaHealthStaleWithoutReset(t *testing.T) {
|
||||
@@ -308,3 +331,74 @@ func TestSwapDefaultManagerPreservesReplacedManager(t *testing.T) {
|
||||
t.Fatal("SwapDefaultManager 不应关闭旧 manager,旧资源需可用于失败回滚")
|
||||
}
|
||||
}
|
||||
|
||||
type transientDialector struct{}
|
||||
|
||||
func (d transientDialector) Name() string { return "m4_transient" }
|
||||
|
||||
func (d transientDialector) Initialize(*gorm.DB) error {
|
||||
return errors.New("temporary connection failure")
|
||||
}
|
||||
|
||||
func (d transientDialector) Migrator(*gorm.DB) gorm.Migrator { return nil }
|
||||
|
||||
func (d transientDialector) DataTypeOf(*schema.Field) string { return "" }
|
||||
|
||||
func (d transientDialector) DefaultValueOf(*schema.Field) clause.Expression { return nil }
|
||||
|
||||
func (d transientDialector) BindVarTo(clause.Writer, *gorm.Statement, any) {}
|
||||
|
||||
func (d transientDialector) QuoteTo(writer clause.Writer, str string) {
|
||||
_, _ = writer.WriteString(str)
|
||||
}
|
||||
|
||||
func (d transientDialector) Explain(sql string, _ ...any) string { return sql }
|
||||
|
||||
func TestM4InitDBHonorsContextDuringRetrySleep(t *testing.T) {
|
||||
const driver = "m4_transient_retry"
|
||||
RegisterDialect(DialectSpec{
|
||||
Name: driver,
|
||||
Dialector: func(string) gorm.Dialector { return transientDialector{} },
|
||||
DSN: func(*config.DatabaseConfig) string { return "m4://retry" },
|
||||
})
|
||||
|
||||
m := NewManager(nil)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
err := m.InitDB(ctx, &config.Config{Database: config.DatabaseConfig{Driver: driver}})
|
||||
if err == nil || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("InitDB 应返回 context.Canceled,实际: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
|
||||
t.Fatalf("InitDB 不应等待完整 retry sleep,耗时 %s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM4CloseWaitsForLifecycleOperation(t *testing.T) {
|
||||
m := NewManager(nil)
|
||||
m.opMu.Lock()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = m.Close()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("Close 不应越过正在执行的生命周期操作")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
m.opMu.Unlock()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("释放生命周期锁后 Close 未返回")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,16 +148,16 @@ func TestNilContextHelpersDoNotPanic(t *testing.T) {
|
||||
|
||||
func TestNilConfigInitializationReturnsError(t *testing.T) {
|
||||
mgr := database.NewManager(nil)
|
||||
if err := mgr.InitDB(nil); err == nil {
|
||||
if err := mgr.InitDB(context.Background(), nil); err == nil {
|
||||
t.Fatal("InitDB(nil) 应返回错误")
|
||||
}
|
||||
if err := mgr.InitDBWithReplicas(nil, nil); err == nil {
|
||||
if err := mgr.InitDBWithReplicas(context.Background(), nil, nil); err == nil {
|
||||
t.Fatal("InitDBWithReplicas(nil) 应返回错误")
|
||||
}
|
||||
if err := database.InitDB(nil); err == nil {
|
||||
if err := database.InitDB(context.Background(), nil); err == nil {
|
||||
t.Fatal("包级 InitDB(nil) 应返回错误")
|
||||
}
|
||||
if err := database.InitDBWithReplicas(nil, nil); err == nil {
|
||||
if err := database.InitDBWithReplicas(context.Background(), nil, nil); err == nil {
|
||||
t.Fatal("包级 InitDBWithReplicas(nil) 应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func setupExampleRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
}
|
||||
|
||||
mgr := database.NewManager(cfg)
|
||||
if err := mgr.InitDB(cfg); err != nil {
|
||||
if err := mgr.InitDB(context.Background(), cfg); err != nil {
|
||||
t.Fatalf("初始化测试数据库失败: %v", err)
|
||||
}
|
||||
db := mgr.Master()
|
||||
|
||||
@@ -403,7 +403,7 @@ func setupH6Manager(t *testing.T) (masterFile, replicaFile string) {
|
||||
cfg.Database.Driver = "sqlite"
|
||||
cfg.Database.CustomDSN = masterFile
|
||||
|
||||
if err := database.InitDBWithReplicas(cfg, []string{replicaFile}); err != nil {
|
||||
if err := database.InitDBWithReplicas(context.Background(), cfg, []string{replicaFile}); err != nil {
|
||||
t.Fatalf("InitDBWithReplicas: %v", err)
|
||||
}
|
||||
if err := database.GetWriteDB().AutoMigrate(&h6User{}); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user