96ce883d41
完成 instance+facade 一致性收尾,闭合三个原始问题: - cron/storage/cache/ratelimit 实例化(App 持 per-App 实例 + SwapDefault* 提升全局默认 + 包级 facade 代理,照 database.Manager/jwt.Manager 模式),消除单进程多 App 互相污染。 - trace 纳入 App 生命周期(WithTrace + doInit trace.Init + doShutdown trace.Close + config.TraceConfig + 自动装 Middleware)。不做实例隔离(OTel 进程全局)。 - 删除 WithoutWire 空函数技术债。 附带修复: - cache/ratelimit redis 注入(jwt 模型:注入优先、database.GetRedis() 兜底)。 - commitReplacedResources 不再关闭 previousDB/previousRedis/loggerSnapshot (多 App 下 previous 可能是另一 App 的活跃资源,关闭致 redis: client is closed)。 - app.RateLimitRegistry() + App-bound 限流中间件(多 App per-App 计数隔离)。 - StorageManager.Close() 闭合 Init/Close 对称性(io.Closer 断言,预留扩展点), closeResources 与 rollbackReplacedResources 对称收口。 新增公开 API:WithTrace / WithCronTask;app.Scheduler()/Cache()/RedisClient()/ RateLimitRegistry();cron/storage/cache/middleware 各 SwapDefault*;config.TraceConfig; middleware.RateLimitRegistry + App-bound 方法;middleware.WithRedisClient; storage.StorageManager.Close。 Breaking:删 WithoutWire;cron.AddTask pre-Init 注册失效(改用 WithCronTask); middleware.StopRateLimiters 语义收窄;commitReplacedResources 不再关 previous; cache.NewRedisCacheWithRedis / CacheManager.InitWithRedis 等签名变更。 详见 CHANGELOG [1.4.0] 升级说明。 验证:go build + go vet + go test -race ./... 全绿(-buildvcs=false)。 独立对抗性复审无 CRITICAL/HIGH。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
136 lines
4.2 KiB
Go
136 lines
4.2 KiB
Go
package xlgo_test
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
xlgo "github.com/EthanCodeCraft/xlgo-core"
|
||
"github.com/EthanCodeCraft/xlgo-core/cron"
|
||
)
|
||
|
||
// TestAppCronSchedulerInstance 固化 Phase 3:WithCronTask 把任务注册到 App 专属调度器,
|
||
// app.Scheduler() 返回该实例且含注册的任务。
|
||
func TestAppCronSchedulerInstance(t *testing.T) {
|
||
saved := cron.SwapDefaultScheduler(cron.NewScheduler())
|
||
defer cron.SwapDefaultScheduler(saved)
|
||
|
||
app := xlgo.New(
|
||
xlgo.WithConfig(testConfig(18112)),
|
||
xlgo.WithCronTask("my-task", cron.Every(time.Hour), func(context.Context) error { return nil }),
|
||
)
|
||
if err := app.Init(); err != nil {
|
||
t.Fatalf("Init: %v", err)
|
||
}
|
||
defer app.Shutdown()
|
||
|
||
s := app.Scheduler()
|
||
if s == nil {
|
||
t.Fatal("app.Scheduler() = nil after Init with WithCronTask")
|
||
}
|
||
found := false
|
||
for _, tk := range s.ListTasks() {
|
||
if tk.Name == "my-task" {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatal("WithCronTask task not registered on App scheduler")
|
||
}
|
||
}
|
||
|
||
// TestAppCronRollbackRestoresDefault 固化 Phase 3 回滚:Init 失败后全局默认调度器
|
||
// 必须恢复为 Init 前的实例(不含 App 注册的任务),而非残留 App 的调度器。
|
||
func TestAppCronRollbackRestoresDefault(t *testing.T) {
|
||
saved := cron.SwapDefaultScheduler(cron.NewScheduler())
|
||
defer cron.SwapDefaultScheduler(saved)
|
||
|
||
app := xlgo.New(
|
||
xlgo.WithConfig(testConfig(18113)),
|
||
xlgo.WithCronTask("rollback-task", cron.Every(time.Hour), func(context.Context) error { return nil }),
|
||
xlgo.WithHook(xlgo.Hook{Name: "fail", OnInit: func(*xlgo.App) error { return errors.New("boom") }}),
|
||
)
|
||
if err := app.Init(); err == nil {
|
||
_ = app.Shutdown()
|
||
t.Fatal("expected Init to fail via OnInit hook")
|
||
}
|
||
|
||
// 回滚后全局默认不应含 App 的 "rollback-task"
|
||
for _, tk := range cron.GetScheduler().ListTasks() {
|
||
if tk.Name == "rollback-task" {
|
||
t.Fatal("rollback did not restore global default scheduler (App task still on global)")
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestAppCronMultiAppIsolation 固化 Phase 3 核心契约:单进程多 App 时,App A 的 Shutdown
|
||
// 只停 A 自己的调度器,不影响 App B 的调度器继续运行(修复前 cron 是全局单例,A.Shutdown
|
||
// 会经 cron.StopGlobalWithTimeout 把 B 的调度器也停了)。
|
||
//
|
||
// 调度器内部 ticker 为 1s,故任务每 1s tick 触发一次(schedule=Every(1ms) 仅决定 due 判定,
|
||
// 实际 spawn 受 1s ticker 节拍)。测试因此需要跨 1s tick 观测。
|
||
func TestAppCronMultiAppIsolation(t *testing.T) {
|
||
saved := cron.SwapDefaultScheduler(cron.NewScheduler())
|
||
defer cron.SwapDefaultScheduler(saved)
|
||
|
||
var ranA, ranB atomic.Int32
|
||
appA := xlgo.New(
|
||
xlgo.WithConfig(testConfig(18110)),
|
||
xlgo.WithCronTask("taskA", cron.Every(time.Millisecond), func(context.Context) error {
|
||
ranA.Add(1)
|
||
return nil
|
||
}),
|
||
)
|
||
appB := xlgo.New(
|
||
xlgo.WithConfig(testConfig(18111)),
|
||
xlgo.WithCronTask("taskB", cron.Every(time.Millisecond), func(context.Context) error {
|
||
ranB.Add(1)
|
||
return nil
|
||
}),
|
||
)
|
||
if err := appA.Init(); err != nil {
|
||
t.Fatalf("appA Init: %v", err)
|
||
}
|
||
if err := appB.Init(); err != nil {
|
||
t.Fatalf("appB Init: %v", err)
|
||
}
|
||
defer appB.Shutdown()
|
||
|
||
// 轮询等第一个 1s tick:两个调度器都应各跑至少一次(deadline 3s,容忍慢机/CI 漂移)
|
||
deadline := time.Now().Add(3 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
if ranA.Load() > 0 && ranB.Load() > 0 {
|
||
break
|
||
}
|
||
time.Sleep(50 * time.Millisecond)
|
||
}
|
||
aAtTick := ranA.Load()
|
||
bAtTick := ranB.Load()
|
||
if aAtTick == 0 || bAtTick == 0 {
|
||
t.Fatalf("expected both schedulers ran at least once: A=%d B=%d", aAtTick, bAtTick)
|
||
}
|
||
|
||
// 停 App A
|
||
if err := appA.Shutdown(); err != nil {
|
||
t.Fatalf("appA Shutdown: %v", err)
|
||
}
|
||
|
||
// 轮询等 B 再跑一次(deadline 3s),证明 B 调度器未被 A.Shutdown 停掉
|
||
deadline = time.Now().Add(3 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
if ranB.Load() > bAtTick {
|
||
break
|
||
}
|
||
time.Sleep(50 * time.Millisecond)
|
||
}
|
||
if ranA.Load() != aAtTick {
|
||
t.Errorf("App A scheduler ran after its Shutdown (not isolated): before=%d after=%d", aAtTick, ranA.Load())
|
||
}
|
||
if ranB.Load() <= bAtTick {
|
||
t.Errorf("App B scheduler did not continue after App A Shutdown (not isolated): before=%d after=%d", bAtTick, ranB.Load())
|
||
}
|
||
}
|