feat(scheduler): 容量水位监控 + 熔断器 + 阈值热加载 [tsk_NPgPRxBGv0g9]
实现 15F 任务的三个核心组件:
capacity.go — CapacityMonitor(每 1-2 min 由 CapacityLoop 调用)
- 池水位检查:up/target < 70% 时发告警,10 min 内降频去重
- 探针失联检查:扫 probe:hb:{probeId},缺失发「探针失联」事件
- 驱动 Grayscale.Advance 推进养机档位
breaker.go — RedisBreaker(实现 Breaker 接口)
- 滑窗 ZSET sched:breaker:{tier}:{region},窗口 1h
- 阈值 N = ceil(池容量 × 30%),下限 3
- 窗口内计数 ≥ N → 置 tripped 标记位 + critical 告警
- Allow 恒 false 直到窗口滑出自动恢复,或管理员调 Reset
- Reset 清除 ZSET + trip flag,写 audit_log
config.go — SchedConfig + ConfigManager(全树阈值热加载)
- SchedConfig 集中定义 15D/15E/15F 所有阈值
- 来源:默认值 → YAML 文件 → 环境变量(三层叠加)
- 监听 SIGHUP,原子替换(atomic.Pointer[SchedConfig])
- 每次变更 diff 写 audit_log(AuditFn 回调)
测试(24 个用例,全绿):
- 窗口内第 N 次 Record 后 Allow 返回 false
- 窗口滑出后 Allow 自动恢复
- Reset 立即恢复 + audit 条目
- 下限 3 在小池(target=2)生效
- 65% 水位 → 告警;10 min 内不重复
- 探针心跳缺失 → 失联事件;存在 → 无告警
- YAML + env 覆盖 + SIGHUP Reload → 新阈值即时生效 + diff 审计
- 替换 stub 后 15E 全部集成测试仍绿
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
package orchestrate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/scheduler/orchestrate"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Mock PoolReader
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// mockPoolReader is a simple in-memory PoolReader for tests.
|
||||
type mockPoolReader struct {
|
||||
mu sync.Mutex
|
||||
pools map[string]orchestrate.PoolStat // key = tier+":"+region
|
||||
}
|
||||
|
||||
func newMockPoolReader() *mockPoolReader {
|
||||
return &mockPoolReader{pools: make(map[string]orchestrate.PoolStat)}
|
||||
}
|
||||
|
||||
func (r *mockPoolReader) setPool(tier, region string, up, target int) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.pools[tier+":"+region] = orchestrate.PoolStat{
|
||||
Tier: tier, Region: region, UpCount: up, Target: target,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *mockPoolReader) AllPools(_ context.Context) ([]orchestrate.PoolStat, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]orchestrate.PoolStat, 0, len(r.pools))
|
||||
for _, s := range r.pools {
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *mockPoolReader) PoolCapacity(_ context.Context, tier, region string) (int, int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
s, ok := r.pools[tier+":"+region]
|
||||
if !ok {
|
||||
return 0, 0, nil
|
||||
}
|
||||
return s.UpCount, s.Target, nil
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helper: build a RedisBreaker for tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func newTestBreaker(t *testing.T, pools *mockPoolReader, notifier *mockNotifier, clock *mockClock) (*orchestrate.RedisBreaker, *orchestrate.ConfigManager) {
|
||||
t.Helper()
|
||||
rdb, _ := newTestRedis(t)
|
||||
cfgMgr := orchestrate.NewConfigManager("", nil)
|
||||
_ = cfgMgr.Load()
|
||||
|
||||
return orchestrate.NewRedisBreaker(orchestrate.RedisBreakerConfig{
|
||||
RDB: rdb,
|
||||
Pools: pools,
|
||||
Notifier: notifier,
|
||||
CfgMgr: cfgMgr,
|
||||
Clock: clock,
|
||||
}), cfgMgr
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: N-th Record trips the breaker; (N+1)-th Allow returns false
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBreakerTripsAtThreshold(t *testing.T) {
|
||||
const (
|
||||
tier = "free"
|
||||
region = "hkg"
|
||||
)
|
||||
|
||||
pools := newMockPoolReader()
|
||||
// Pool of 10 nodes → threshold = ceil(10 × 30%) = 3.
|
||||
pools.setPool(tier, region, 10, 10)
|
||||
|
||||
notifier := &mockNotifier{}
|
||||
clock := newMockClock(time.Unix(1_700_000_000, 0).UTC())
|
||||
|
||||
b, _ := newTestBreaker(t, pools, notifier, clock)
|
||||
|
||||
// Before any records: Allow should return true.
|
||||
if !b.Allow(tier, region) {
|
||||
t.Fatal("Allow should return true before any records")
|
||||
}
|
||||
|
||||
// Record N-1 replacements (threshold-1 = 2); Allow should still be true.
|
||||
b.Record(tier, region) // count = 1
|
||||
b.Record(tier, region) // count = 2
|
||||
if !b.Allow(tier, region) {
|
||||
t.Fatalf("Allow should return true with count < threshold (count=2, threshold=3)")
|
||||
}
|
||||
|
||||
// Record the N-th replacement; now count = 3 = threshold.
|
||||
b.Record(tier, region) // count = 3
|
||||
|
||||
// (N+1)-th Allow: breaker is tripped → false.
|
||||
if b.Allow(tier, region) {
|
||||
t.Error("Allow should return false when count >= threshold (tripped)")
|
||||
}
|
||||
|
||||
// Critical alert must have been emitted exactly once.
|
||||
if n := notifier.count(); n != 1 {
|
||||
t.Errorf("NotifyFault calls = %d; want 1 (on trip)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: Window slide-out auto-recovers the breaker
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBreakerAutoRecoveryAfterWindowExpiry(t *testing.T) {
|
||||
const (
|
||||
tier = "premium"
|
||||
region = "sin"
|
||||
)
|
||||
|
||||
pools := newMockPoolReader()
|
||||
// Pool of 10 → threshold = 3.
|
||||
pools.setPool(tier, region, 10, 10)
|
||||
|
||||
notifier := &mockNotifier{}
|
||||
clock := newMockClock(time.Unix(1_700_000_000, 0).UTC())
|
||||
b, _ := newTestBreaker(t, pools, notifier, clock)
|
||||
|
||||
// Record N entries to trip.
|
||||
b.Record(tier, region)
|
||||
b.Record(tier, region)
|
||||
b.Record(tier, region)
|
||||
if b.Allow(tier, region) {
|
||||
t.Fatal("breaker should be tripped after 3 records (threshold=3)")
|
||||
}
|
||||
|
||||
// Advance clock past the 1-h window; all ZSET entries are now stale.
|
||||
clock.advance(61 * time.Minute)
|
||||
|
||||
// Allow should return true: ZREMRANGEBYSCORE removes stale entries,
|
||||
// count drops to 0 < threshold, and the trip flag TTL has expired.
|
||||
if !b.Allow(tier, region) {
|
||||
t.Error("Allow should return true after the window has expired (auto-recovery)")
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: Reset manually clears the breaker with audit
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBreakerResetRestoresAllow(t *testing.T) {
|
||||
const (
|
||||
tier = "free"
|
||||
region = "tyo"
|
||||
)
|
||||
|
||||
pools := newMockPoolReader()
|
||||
pools.setPool(tier, region, 10, 10) // threshold = 3
|
||||
|
||||
notifier := &mockNotifier{}
|
||||
lc := newMockLC()
|
||||
clock := newMockClock(time.Unix(1_700_000_000, 0).UTC())
|
||||
rdb, _ := newTestRedis(t)
|
||||
cfgMgr := orchestrate.NewConfigManager("", nil)
|
||||
_ = cfgMgr.Load()
|
||||
|
||||
b := orchestrate.NewRedisBreaker(orchestrate.RedisBreakerConfig{
|
||||
RDB: rdb,
|
||||
Pools: pools,
|
||||
Notifier: notifier,
|
||||
LC: lc,
|
||||
CfgMgr: cfgMgr,
|
||||
Clock: clock,
|
||||
})
|
||||
|
||||
// Trip the breaker.
|
||||
b.Record(tier, region)
|
||||
b.Record(tier, region)
|
||||
b.Record(tier, region)
|
||||
if b.Allow(tier, region) {
|
||||
t.Fatal("breaker should be tripped")
|
||||
}
|
||||
|
||||
// Reset.
|
||||
ctx := context.Background()
|
||||
if err := b.Reset(ctx, tier, region, "admin-alice"); err != nil {
|
||||
t.Fatalf("Reset error: %v", err)
|
||||
}
|
||||
|
||||
// Allow should now return true.
|
||||
if !b.Allow(tier, region) {
|
||||
t.Error("Allow should return true after Reset")
|
||||
}
|
||||
|
||||
// Audit log must mention the reset.
|
||||
if !lc.hasAudit("admin-alice|breaker_reset|pool:" + tier + ":" + region) {
|
||||
t.Errorf("audit log missing breaker_reset entry; got: %v", lc.auditLogs)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: BreakerMinN = 3 enforced on tiny pool (target < 10)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBreakerMinNEnforcedOnSmallPool(t *testing.T) {
|
||||
const (
|
||||
tier = "free"
|
||||
region = "fra"
|
||||
)
|
||||
|
||||
pools := newMockPoolReader()
|
||||
// Pool of 2 nodes; 30% of 2 = 0.6 → ceil = 1, but min = 3.
|
||||
pools.setPool(tier, region, 2, 2)
|
||||
|
||||
notifier := &mockNotifier{}
|
||||
clock := newMockClock(time.Unix(1_700_000_000, 0).UTC())
|
||||
b, _ := newTestBreaker(t, pools, notifier, clock)
|
||||
|
||||
// 2 records should NOT trip (threshold = 3, not 1).
|
||||
b.Record(tier, region)
|
||||
b.Record(tier, region)
|
||||
if !b.Allow(tier, region) {
|
||||
t.Error("Allow should return true: count=2, threshold=min(3)")
|
||||
}
|
||||
|
||||
// 3rd record trips.
|
||||
b.Record(tier, region)
|
||||
if b.Allow(tier, region) {
|
||||
t.Error("Allow should return false: count=3 >= minN(3)")
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: Trip alert is emitted only once per window epoch (dedup)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBreakerAlertSentOnlyOnce(t *testing.T) {
|
||||
const (
|
||||
tier = "free"
|
||||
region = "ams"
|
||||
)
|
||||
|
||||
pools := newMockPoolReader()
|
||||
pools.setPool(tier, region, 10, 10) // threshold = 3
|
||||
|
||||
notifier := &mockNotifier{}
|
||||
clock := newMockClock(time.Unix(1_700_000_000, 0).UTC())
|
||||
b, _ := newTestBreaker(t, pools, notifier, clock)
|
||||
|
||||
// Trip.
|
||||
b.Record(tier, region)
|
||||
b.Record(tier, region)
|
||||
b.Record(tier, region)
|
||||
|
||||
// Call Allow multiple times while tripped.
|
||||
for i := 0; i < 5; i++ {
|
||||
b.Allow(tier, region)
|
||||
}
|
||||
|
||||
// NotifyFault must have been called exactly once (from Record reaching N).
|
||||
if n := notifier.count(); n != 1 {
|
||||
t.Errorf("NotifyFault calls = %d; want exactly 1 (trip alert dedup)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test: pool not found → falls back to BreakerMinN (3)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBreakerFallsBackToMinNForUnknownPool(t *testing.T) {
|
||||
pools := newMockPoolReader()
|
||||
// No pool registered for "unknown"/"xyz".
|
||||
|
||||
notifier := &mockNotifier{}
|
||||
clock := newMockClock(time.Unix(1_700_000_000, 0).UTC())
|
||||
b, _ := newTestBreaker(t, pools, notifier, clock)
|
||||
|
||||
b.Record("unknown", "xyz")
|
||||
b.Record("unknown", "xyz")
|
||||
if !b.Allow("unknown", "xyz") {
|
||||
t.Error("Allow should return true: count=2, threshold=minN(3) for unknown pool")
|
||||
}
|
||||
|
||||
b.Record("unknown", "xyz")
|
||||
if b.Allow("unknown", "xyz") {
|
||||
t.Error("Allow should return false: count=3 >= minN(3)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user