3d9ccff6e9
实现 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>
259 lines
9.3 KiB
Go
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)
|
|
}
|
|
}
|