Files
pangolin/server/internal/scheduler/orchestrate/config.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

403 lines
14 KiB
Go

package orchestrate
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/signal"
"strconv"
"strings"
"sync/atomic"
"syscall"
"gopkg.in/yaml.v3"
)
// ─────────────────────────────────────────────────────────────────────────────
// SchedConfig — master configuration tree
// ─────────────────────────────────────────────────────────────────────────────
// SchedConfig is the single source of truth for all scheduler thresholds.
// It covers: 15D detection, 15F capacity/breaker, and 15E probe/grayscale.
//
// Values are layered in order:
// 1. Defaults (DefaultSchedConfig).
// 2. Optional YAML file (path set via SCHED_CONFIG_FILE env var, or passed
// to NewConfigManager).
// 3. Environment variable overrides (applied on top of YAML).
//
// The live config is accessed via ConfigManager.Current() — a lock-free
// atomic.Pointer read. Hot reload is triggered by SIGHUP; each reload
// computes a JSON diff and calls the provided AuditFn.
type SchedConfig struct {
// Detect holds 15D detection-engine thresholds.
Detect DetectSection `yaml:"detect"`
// Capacity holds 15F capacity-monitor and breaker thresholds.
Capacity CapacitySection `yaml:"capacity"`
// Probe holds 15E probe-window and grayscale-ramp settings.
Probe ProbeSection `yaml:"probe"`
}
// DetectSection mirrors detect.DetectConfig; task 15H bridges the two at
// wiring time.
type DetectSection struct {
// DomesticFailNumerator / DomesticFailDenominator define the ISP-failure
// fraction that triggers the suspect rule (default: 2/3).
DomesticFailNumerator int `yaml:"domestic_fail_numerator"`
DomesticFailDenominator int `yaml:"domestic_fail_denominator"`
// SuspectStreakMin is the number of consecutive failing cycles before a
// node enters blocked_suspect (default: 2).
SuspectStreakMin int `yaml:"suspect_streak_min"`
// TrafficDropThreshold is the minimum percentage drop in online connections
// over 15 min that activates the traffic-warning rule (default: 80.0).
TrafficDropThreshold float64 `yaml:"traffic_drop_threshold"`
// TrafficBaselineMin is the minimum current online-connection count for
// the traffic-warning relaxation to apply (default: 20).
TrafficBaselineMin int `yaml:"traffic_baseline_min"`
// ConfirmedStreakMin is the number of consecutive cycles in blocked_suspect
// before promotion to blocked_confirmed (default: 6).
ConfirmedStreakMin int `yaml:"confirmed_streak_min"`
// RecoverStreakMin is the number of consecutive passing cycles while in
// blocked_suspect required to recover to up (default: 2).
RecoverStreakMin int `yaml:"recover_streak_min"`
// SuspectWeight is the routing weight applied when a node first enters
// blocked_suspect (default: 10).
SuspectWeight int `yaml:"suspect_weight"`
}
// CapacitySection holds 15F capacity-monitor and circuit-breaker thresholds.
type CapacitySection struct {
// WatermarkThreshold is the minimum pool fill-rate (upCount/target) before
// an alert is emitted (default: 0.70 = 70%).
WatermarkThreshold float64 `yaml:"watermark_threshold"`
// AlertSuppressMin is the minimum gap in minutes between repeated alerts
// for the same pool or probe (default: 10 min).
AlertSuppressMin int `yaml:"alert_suppress_min"`
// BreakerWindowMin is the sliding-window duration in minutes for the
// circuit breaker (default: 60 min = 1 h).
BreakerWindowMin int `yaml:"breaker_window_min"`
// BreakerFractionPct is the percentage of pool target capacity that, when
// replaced within one window, trips the breaker (default: 30).
BreakerFractionPct int `yaml:"breaker_fraction_pct"`
// BreakerMinN is the absolute lower bound for the breaker trip threshold
// (default: 3). Overrides fraction when fraction yields a smaller number.
BreakerMinN int `yaml:"breaker_min_n"`
// ManualAlertThresh is the consecutive probe-failure count for a single
// node that triggers a 转人工 (escalate-to-human) alert. Should match
// 15E's MaxAttempts (default: 3).
ManualAlertThresh int `yaml:"manual_alert_thresh"`
// ExpectedProbeIDs is the list of probe-agent IDs that must maintain live
// heartbeat keys in Redis. A missing key triggers a 「探针失联」alert.
ExpectedProbeIDs []string `yaml:"expected_probe_ids"`
}
// ProbeSection holds 15E probe-window and grayscale-ramp settings.
type ProbeSection struct {
// GrayscaleIntervalHours is the time in hours between successive grayscale
// weight-ramp steps (default: 6 h).
GrayscaleIntervalHours int `yaml:"grayscale_interval_hours"`
// ProbeTimeoutMin is the maximum time in minutes allowed for a node to
// pass probing per attempt (default: 15 min).
ProbeTimeoutMin int `yaml:"probe_timeout_min"`
// MaxAttempts is the maximum number of create+probe attempts per
// replacement record before marking it failed (default: 3).
MaxAttempts int `yaml:"max_attempts"`
// ProbeCyclesRequired is the number of consecutive passing probe ticks
// needed before a new node is promoted to up (default: 2).
ProbeCyclesRequired int `yaml:"probe_cycles_required"`
}
// DefaultSchedConfig returns a SchedConfig pre-filled with production defaults.
func DefaultSchedConfig() SchedConfig {
return SchedConfig{
Detect: DetectSection{
DomesticFailNumerator: 2,
DomesticFailDenominator: 3,
SuspectStreakMin: 2,
TrafficDropThreshold: 80.0,
TrafficBaselineMin: 20,
ConfirmedStreakMin: 6,
RecoverStreakMin: 2,
SuspectWeight: 10,
},
Capacity: CapacitySection{
WatermarkThreshold: 0.70,
AlertSuppressMin: 10,
BreakerWindowMin: 60,
BreakerFractionPct: 30,
BreakerMinN: 3,
ManualAlertThresh: 3,
},
Probe: ProbeSection{
GrayscaleIntervalHours: 6,
ProbeTimeoutMin: 15,
MaxAttempts: 3,
ProbeCyclesRequired: 2,
},
}
}
// ─────────────────────────────────────────────────────────────────────────────
// ConfigManager
// ─────────────────────────────────────────────────────────────────────────────
// AuditFn is called after each successful config reload with the old and new
// configurations serialised as JSON strings. The caller is responsible for
// deciding how to persist the diff (e.g. via LifecycleService.WriteAuditLog).
// May be nil to skip auditing.
type AuditFn func(ctx context.Context, oldJSON, newJSON string)
// ConfigManager holds the live SchedConfig and supports SIGHUP-triggered
// hot reload. Config reads are lock-free (atomic.Pointer).
//
// Usage:
//
// mgr := NewConfigManager("/etc/pangolin/sched.yaml", auditFn)
// if err := mgr.Load(); err != nil { log.Fatal(err) }
// mgr.WatchSIGHUP(ctx)
// cfg := mgr.Current()
type ConfigManager struct {
path string // optional YAML file path (may be empty)
cfg atomic.Pointer[SchedConfig]
auditFn AuditFn
}
// NewConfigManager creates a ConfigManager.
//
// path: optional path to the YAML config file. If SCHED_CONFIG_FILE is
// set in the environment it overrides this argument.
// auditFn: called on every reload when the config changes; may be nil.
//
// The manager is seeded with DefaultSchedConfig so Current() is never nil.
func NewConfigManager(path string, fn AuditFn) *ConfigManager {
if p := os.Getenv("SCHED_CONFIG_FILE"); p != "" {
path = p
}
m := &ConfigManager{path: path, auditFn: fn}
dflt := DefaultSchedConfig()
m.cfg.Store(&dflt)
return m
}
// Current returns the live config. Never nil.
func (m *ConfigManager) Current() *SchedConfig {
return m.cfg.Load()
}
// Load reads the config (YAML file if configured, then env overrides) and
// atomically replaces the live config. Safe to call multiple times.
func (m *ConfigManager) Load() error {
next, err := loadSchedConfig(m.path)
if err != nil {
return err
}
m.swap(context.Background(), next)
return nil
}
// WatchSIGHUP starts a goroutine that calls Reload whenever SIGHUP is
// received. The goroutine stops when ctx is cancelled.
func (m *ConfigManager) WatchSIGHUP(ctx context.Context) {
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGHUP)
go func() {
defer signal.Stop(ch)
for {
select {
case <-ctx.Done():
return
case <-ch:
if err := m.Reload(ctx); err != nil {
slog.Error("sched config: SIGHUP reload failed", "error", err)
} else {
slog.Info("sched config: reloaded via SIGHUP")
}
}
}
}()
}
// Reload re-reads the config and hot-swaps the live value. Idempotent.
func (m *ConfigManager) Reload(ctx context.Context) error {
next, err := loadSchedConfig(m.path)
if err != nil {
return err
}
m.swap(ctx, next)
return nil
}
// swap atomically replaces the live config and records a diff in the audit
// log when the config actually changed.
func (m *ConfigManager) swap(ctx context.Context, next *SchedConfig) {
old := m.cfg.Swap(next)
if m.auditFn == nil {
return
}
oldJSON, _ := json.Marshal(old)
newJSON, _ := json.Marshal(next)
if string(oldJSON) == string(newJSON) {
return // no change; skip audit
}
m.auditFn(ctx, string(oldJSON), string(newJSON))
}
// ─────────────────────────────────────────────────────────────────────────────
// Loading logic
// ─────────────────────────────────────────────────────────────────────────────
// loadSchedConfig builds a SchedConfig using the layering strategy:
// defaults → YAML file → env-var overrides.
func loadSchedConfig(path string) (*SchedConfig, error) {
cfg := DefaultSchedConfig()
// Layer 2: optional YAML file.
if path != "" {
data, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("sched config: read %s: %w", path, err)
}
if err == nil {
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("sched config: parse %s: %w", path, err)
}
}
}
// Layer 3: environment variable overrides.
applySchedEnvOverrides(&cfg)
return &cfg, nil
}
// applySchedEnvOverrides overlays environment-variable values on top of cfg.
// Only non-empty env vars are applied; empty vars preserve the current value.
func applySchedEnvOverrides(cfg *SchedConfig) {
// ── 15D detect ────────────────────────────────────────────────────────────
if v := posIntEnv("DETECT_DOMESTIC_FAIL_NUM"); v > 0 {
cfg.Detect.DomesticFailNumerator = v
}
if v := posIntEnv("DETECT_DOMESTIC_FAIL_DEN"); v > 0 {
cfg.Detect.DomesticFailDenominator = v
}
if v := posIntEnv("DETECT_SUSPECT_STREAK_MIN"); v > 0 {
cfg.Detect.SuspectStreakMin = v
}
if v := posFloatEnv("DETECT_TRAFFIC_DROP_THRESHOLD"); v > 0 {
cfg.Detect.TrafficDropThreshold = v
}
if v := posIntEnv("DETECT_TRAFFIC_BASELINE_MIN"); v > 0 {
cfg.Detect.TrafficBaselineMin = v
}
if v := posIntEnv("DETECT_CONFIRMED_STREAK_MIN"); v > 0 {
cfg.Detect.ConfirmedStreakMin = v
}
if v := posIntEnv("DETECT_RECOVER_STREAK_MIN"); v > 0 {
cfg.Detect.RecoverStreakMin = v
}
if v := posIntEnv("DETECT_SUSPECT_WEIGHT"); v > 0 {
cfg.Detect.SuspectWeight = v
}
// ── 15F capacity / breaker ────────────────────────────────────────────────
if v := posFloatEnv("CAP_WATERMARK_THRESHOLD"); v > 0 {
cfg.Capacity.WatermarkThreshold = v
}
if v := posIntEnv("CAP_ALERT_SUPPRESS_MIN"); v > 0 {
cfg.Capacity.AlertSuppressMin = v
}
if v := posIntEnv("CAP_BREAKER_WINDOW_MIN"); v > 0 {
cfg.Capacity.BreakerWindowMin = v
}
if v := posIntEnv("CAP_BREAKER_FRACTION_PCT"); v > 0 {
cfg.Capacity.BreakerFractionPct = v
}
if v := posIntEnv("CAP_BREAKER_MIN_N"); v > 0 {
cfg.Capacity.BreakerMinN = v
}
if v := posIntEnv("CAP_MANUAL_ALERT_THRESH"); v > 0 {
cfg.Capacity.ManualAlertThresh = v
}
if v := os.Getenv("CAP_EXPECTED_PROBE_IDS"); v != "" {
cfg.Capacity.ExpectedProbeIDs = splitCSVEnv(v)
}
// ── 15E probe / grayscale ─────────────────────────────────────────────────
if v := posIntEnv("PROBE_GRAYSCALE_INTERVAL_H"); v > 0 {
cfg.Probe.GrayscaleIntervalHours = v
}
if v := posIntEnv("PROBE_TIMEOUT_MIN"); v > 0 {
cfg.Probe.ProbeTimeoutMin = v
}
if v := posIntEnv("PROBE_MAX_ATTEMPTS"); v > 0 {
cfg.Probe.MaxAttempts = v
}
if v := posIntEnv("PROBE_CYCLES_REQUIRED"); v > 0 {
cfg.Probe.ProbeCyclesRequired = v
}
}
// posIntEnv reads key as a positive integer. Returns 0 if the env var is
// unset, empty, zero, or non-parseable (logs a warning for parse errors).
func posIntEnv(key string) int {
v := os.Getenv(key)
if v == "" {
return 0
}
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
if err != nil {
slog.Warn("sched config: invalid integer env var", "key", key, "value", v)
}
return 0
}
return n
}
// posFloatEnv reads key as a positive float64. Returns 0 on error.
func posFloatEnv(key string) float64 {
v := os.Getenv(key)
if v == "" {
return 0
}
f, err := strconv.ParseFloat(v, 64)
if err != nil || f <= 0 {
if err != nil {
slog.Warn("sched config: invalid float env var", "key", key, "value", v)
}
return 0
}
return f
}
// splitCSVEnv splits a comma-separated env-var value, trimming whitespace and
// dropping empty fields.
func splitCSVEnv(s string) []string {
parts := strings.Split(s, ",")
out := parts[:0]
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}