Files
pangolin/server/internal/scheduler/orchestrate/capacity_test.go
T
wangjia 3d9ccff6e9 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>
2026-06-16 00:31:58 +08:00

259 lines
9.3 KiB
Go

package orchestrate_test
import (
"context"
"os"
"testing"
"time"
"github.com/wangjia/pangolin/server/internal/scheduler/orchestrate"
)
// ─────────────────────────────────────────────────────────────────────────────
// Utility: write a temporary YAML config file
// ─────────────────────────────────────────────────────────────────────────────
func writeTempYAML(t *testing.T, content string) (string, error) {
t.Helper()
f, err := os.CreateTemp("", "sched_config_*.yaml")
if err != nil {
return "", err
}
t.Cleanup(func() { os.Remove(f.Name()) })
if _, err := f.WriteString(content); err != nil {
f.Close()
return "", err
}
f.Close()
return f.Name(), nil
}
// defaultCfgMgr returns a ConfigManager loaded with production defaults.
func defaultCfgMgr(t *testing.T) *orchestrate.ConfigManager {
t.Helper()
m := orchestrate.NewConfigManager("", nil)
if err := m.Load(); err != nil {
t.Fatalf("ConfigManager.Load: %v", err)
}
return m
}
// ─────────────────────────────────────────────────────────────────────────────
// Test: Pool below 70% watermark → alert emitted
// ─────────────────────────────────────────────────────────────────────────────
func TestCapacityWatermarkAlert(t *testing.T) {
rdb, _ := newTestRedis(t)
pools := newMockPoolReader()
// 6 out of 10 up = 60% < 70% → alert expected.
pools.setPool("free", "hkg", 6, 10)
notifier := &mockNotifier{}
cm := orchestrate.NewCapacityMonitor(orchestrate.CapacityMonitorConfig{
RDB: rdb,
Pools: pools,
Notifier: notifier,
CfgMgr: defaultCfgMgr(t),
})
if err := cm.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce: %v", err)
}
if notifier.count() != 1 {
t.Errorf("NotifyFault calls = %d; want 1 (watermark alert)", notifier.count())
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Test: Pool at exactly 70% → no alert
// ─────────────────────────────────────────────────────────────────────────────
func TestCapacityWatermarkNoAlertAtThreshold(t *testing.T) {
rdb, _ := newTestRedis(t)
pools := newMockPoolReader()
// 7/10 = 70% == threshold → healthy, no alert.
pools.setPool("free", "hkg", 7, 10)
notifier := &mockNotifier{}
cm := orchestrate.NewCapacityMonitor(orchestrate.CapacityMonitorConfig{
RDB: rdb,
Pools: pools,
Notifier: notifier,
CfgMgr: defaultCfgMgr(t),
})
if err := cm.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce: %v", err)
}
if notifier.count() != 0 {
t.Errorf("NotifyFault calls = %d; want 0 (fill rate = 70%% >= threshold)", notifier.count())
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Test: Repeated RunOnce within suppress window → only 1 alert
// ─────────────────────────────────────────────────────────────────────────────
func TestCapacityWatermarkAlertSuppression(t *testing.T) {
rdb, mr := newTestRedis(t)
pools := newMockPoolReader()
pools.setPool("free", "sin", 5, 10) // 50% < 70%
notifier := &mockNotifier{}
cm := orchestrate.NewCapacityMonitor(orchestrate.CapacityMonitorConfig{
RDB: rdb,
Pools: pools,
Notifier: notifier,
CfgMgr: defaultCfgMgr(t),
})
ctx := context.Background()
// Three RunOnce calls within the 10-min suppress window.
for i := 0; i < 3; i++ {
if err := cm.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce %d: %v", i, err)
}
}
if notifier.count() != 1 {
t.Errorf("NotifyFault calls = %d; want 1 (suppressed repeats)", notifier.count())
}
// Advance miniredis TTL past the 10-min suppress window.
mr.FastForward(11 * time.Minute)
// After suppress window expires → another alert.
if err := cm.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce after expire: %v", err)
}
if notifier.count() != 2 {
t.Errorf("NotifyFault calls = %d; want 2 after suppress window expires", notifier.count())
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Test: Missing probe heartbeat → alert; present probe → no alert
// ─────────────────────────────────────────────────────────────────────────────
func TestCapacityProbeHeartbeatMissingAlert(t *testing.T) {
rdb, _ := newTestRedis(t)
pools := newMockPoolReader()
notifier := &mockNotifier{}
ctx := context.Background()
// probe-b has a live heartbeat; probe-a does not.
if err := rdb.Set(ctx, "probe:hb:probe-b", "1", 15*time.Minute).Err(); err != nil {
t.Fatalf("seed probe-b heartbeat: %v", err)
}
yamlContent := `
capacity:
expected_probe_ids:
- probe-a
- probe-b
alert_suppress_min: 10
`
tmpFile, err := writeTempYAML(t, yamlContent)
if err != nil {
t.Fatalf("write temp YAML: %v", err)
}
cfgMgr := orchestrate.NewConfigManager(tmpFile, nil)
if err := cfgMgr.Load(); err != nil {
t.Fatalf("Load YAML config: %v", err)
}
cm := orchestrate.NewCapacityMonitor(orchestrate.CapacityMonitorConfig{
RDB: rdb,
Pools: pools,
Notifier: notifier,
CfgMgr: cfgMgr,
})
if err := cm.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
// Only probe-a should trigger an alert.
if notifier.count() != 1 {
t.Errorf("NotifyFault calls = %d; want 1 (only probe-a missing)", notifier.count())
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Test: Both probes alive → no alert
// ─────────────────────────────────────────────────────────────────────────────
func TestCapacityProbeHeartbeatBothAlive(t *testing.T) {
rdb, _ := newTestRedis(t)
pools := newMockPoolReader()
notifier := &mockNotifier{}
ctx := context.Background()
rdb.Set(ctx, "probe:hb:probe-a", "1", 15*time.Minute)
rdb.Set(ctx, "probe:hb:probe-b", "1", 15*time.Minute)
yamlContent := `
capacity:
expected_probe_ids:
- probe-a
- probe-b
`
tmpFile, err := writeTempYAML(t, yamlContent)
if err != nil {
t.Fatalf("write temp YAML: %v", err)
}
cfgMgr := orchestrate.NewConfigManager(tmpFile, nil)
if err := cfgMgr.Load(); err != nil {
t.Fatalf("Load YAML: %v", err)
}
cm := orchestrate.NewCapacityMonitor(orchestrate.CapacityMonitorConfig{
RDB: rdb,
Pools: pools,
Notifier: notifier,
CfgMgr: cfgMgr,
})
if err := cm.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
if notifier.count() != 0 {
t.Errorf("NotifyFault calls = %d; want 0 (both probes alive)", notifier.count())
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Test: Multiple pools — only below-threshold pools alert
// ─────────────────────────────────────────────────────────────────────────────
func TestCapacityMultiplePoolsSelectiveAlert(t *testing.T) {
rdb, _ := newTestRedis(t)
pools := newMockPoolReader()
// hkg: 60% → alert
pools.setPool("free", "hkg", 6, 10)
// sin: 90% → no alert
pools.setPool("free", "sin", 9, 10)
// tyo: 50% → alert
pools.setPool("free", "tyo", 5, 10)
notifier := &mockNotifier{}
cm := orchestrate.NewCapacityMonitor(orchestrate.CapacityMonitorConfig{
RDB: rdb,
Pools: pools,
Notifier: notifier,
CfgMgr: defaultCfgMgr(t),
})
if err := cm.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce: %v", err)
}
// Expect 2 alerts: hkg and tyo; sin is healthy.
if n := notifier.count(); n != 2 {
t.Errorf("NotifyFault calls = %d; want 2 (hkg + tyo)", n)
}
}