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

243 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package orchestrate
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/redis/go-redis/v9"
)
// ─────────────────────────────────────────────────────────────────────────────
// PoolReader — pool capacity interface
// ─────────────────────────────────────────────────────────────────────────────
// PoolStat holds capacity metrics for a single tier+region pool.
type PoolStat struct {
Tier string
Region string
UpCount int // number of nodes currently in "up" status
Target int // configured target node count for this pool
}
// PoolReader provides pool capacity information for the capacity monitor and
// circuit breaker. The real implementation (wired by task 15H) queries the
// lifecycle service.
type PoolReader interface {
// AllPools returns all known pools with their current UpCount and Target.
AllPools(ctx context.Context) ([]PoolStat, error)
// PoolCapacity returns (upCount, target) for the named pool.
// Returns (0, 0, nil) when the pool is not found.
PoolCapacity(ctx context.Context, tier, region string) (int, int, error)
}
// ─────────────────────────────────────────────────────────────────────────────
// Redis key constants
// ─────────────────────────────────────────────────────────────────────────────
const (
// capAlertPoolPrefix is the dedup key prefix for pool-watermark alerts.
// Full key: sched:cap:pool:{tier}:{region} TTL = AlertSuppressMin
capAlertPoolPrefix = "sched:cap:pool:"
// capAlertProbePrefix is the dedup key prefix for probe-lost-contact alerts.
// Full key: sched:cap:probe:{probeId} TTL = AlertSuppressMin
capAlertProbePrefix = "sched:cap:probe:"
// probeHBKeyPrefix is the heartbeat key prefix written by the probe package.
// Full key: probe:hb:{probeId} TTL = 15 min (from probe.heartbeatTTL)
probeHBKeyPrefix = "probe:hb:"
)
// ─────────────────────────────────────────────────────────────────────────────
// CapacityMonitor
// ─────────────────────────────────────────────────────────────────────────────
// CapacityMonitorConfig holds all dependencies for CapacityMonitor.
type CapacityMonitorConfig struct {
RDB *redis.Client
Pools PoolReader
Notifier Notifier
Gray *Grayscale // grayscale weight-ramp driver
LC LifecycleService // for WriteAuditLog; may be nil
CfgMgr *ConfigManager
Clock Clock
}
// CapacityMonitor checks pool watermarks and probe heartbeats, and drives
// the grayscale weight ramp. It is called by the CapacityLoop (task 15H)
// approximately every 12 minutes.
//
// Each RunOnce call:
// 1. Checks pool fill-rates; emits a throttled alert per pool below threshold.
// 2. Checks probe heartbeats for every configured probe ID; emits a throttled
// 「探针失联」alert for each probe whose heartbeat key is absent.
// 3. Calls Grayscale.Advance to advance any in-progress weight ramps.
type CapacityMonitor struct {
rdb *redis.Client
pools PoolReader
notifier Notifier
gray *Grayscale
lc LifecycleService
cfgMgr *ConfigManager
clock Clock
}
// NewCapacityMonitor constructs a CapacityMonitor from the given config.
// If Clock is nil, RealClock is used.
func NewCapacityMonitor(cfg CapacityMonitorConfig) *CapacityMonitor {
if cfg.Clock == nil {
cfg.Clock = RealClock{}
}
return &CapacityMonitor{
rdb: cfg.RDB,
pools: cfg.Pools,
notifier: cfg.Notifier,
gray: cfg.Gray,
lc: cfg.LC,
cfgMgr: cfg.CfgMgr,
clock: cfg.Clock,
}
}
// RunOnce executes one full capacity-monitoring cycle. Errors from individual
// steps are logged but do not abort subsequent steps.
func (m *CapacityMonitor) RunOnce(ctx context.Context) error {
cfg := m.cfgMgr.Current()
// Step 1: pool watermark checks.
if err := m.checkWatermarks(ctx, cfg); err != nil {
slog.Error("capacity: watermark check error", "error", err)
}
// Step 2: probe heartbeat checks.
if err := m.checkProbeHeartbeats(ctx, cfg); err != nil {
slog.Error("capacity: probe heartbeat check error", "error", err)
}
// Step 3: advance grayscale weight ramps (Grayscale.Advance is 15E's
// warm-up entry point; CapacityLoop drives it per the spec).
if m.gray != nil {
if err := m.gray.Advance(ctx); err != nil {
slog.Error("capacity: grayscale advance error", "error", err)
}
}
return nil
}
// checkWatermarks iterates all known pools. For each pool whose fill-rate
// (upCount/target) is below WatermarkThreshold, an alert is emitted — but
// at most once per AlertSuppressMin to suppress repeated notifications.
func (m *CapacityMonitor) checkWatermarks(ctx context.Context, cfg *SchedConfig) error {
stats, err := m.pools.AllPools(ctx)
if err != nil {
return fmt.Errorf("capacity: list pools: %w", err)
}
threshold := cfg.Capacity.WatermarkThreshold
suppressTTL := time.Duration(cfg.Capacity.AlertSuppressMin) * time.Minute
for _, stat := range stats {
if stat.Target <= 0 {
continue // skip misconfigured pools with no target
}
fillRate := float64(stat.UpCount) / float64(stat.Target)
if fillRate >= threshold {
continue // pool is healthy
}
// Throttle: at most one alert per pool per AlertSuppressMin.
dedupKey := capAlertPoolPrefix + stat.Tier + ":" + stat.Region
set, setErr := m.rdb.SetNX(ctx, dedupKey, "1", suppressTTL).Result()
if setErr != nil {
slog.Error("capacity: watermark dedup key error",
"key", dedupKey, "error", setErr)
continue
}
if !set {
continue // alert already sent within suppression window
}
poolID := "pool:" + stat.Tier + ":" + stat.Region
reason := fmt.Sprintf(
"capacity watermark below %.0f%%: %d/%d up (fill_rate=%.1f%%)",
threshold*100, stat.UpCount, stat.Target, fillRate*100,
)
slog.Warn("capacity: pool below watermark",
"tier", stat.Tier,
"region", stat.Region,
"up", stat.UpCount,
"target", stat.Target,
"fill_rate_pct", fmt.Sprintf("%.1f", fillRate*100),
)
if m.notifier != nil {
if notifyErr := m.notifier.NotifyFault(ctx, poolID, reason); notifyErr != nil {
slog.Error("capacity: notify watermark alert",
"pool", poolID, "error", notifyErr)
}
}
}
return nil
}
// checkProbeHeartbeats verifies that every configured probe agent has a live
// heartbeat key in Redis. A missing key means the probe has not reported
// within the 15-minute heartbeat TTL (probe package constant heartbeatTTL).
//
// Semantics: absence of the key is "no recent data", NOT "node blocked". It
// must NOT feed into any determination logic — only ops alerting.
func (m *CapacityMonitor) checkProbeHeartbeats(ctx context.Context, cfg *SchedConfig) error {
probeIDs := cfg.Capacity.ExpectedProbeIDs
if len(probeIDs) == 0 {
return nil // no probes configured; nothing to check
}
suppressTTL := time.Duration(cfg.Capacity.AlertSuppressMin) * time.Minute
for _, pid := range probeIDs {
hbKey := probeHBKeyPrefix + pid
exists, err := m.rdb.Exists(ctx, hbKey).Result()
if err != nil {
slog.Error("capacity: probe heartbeat check error",
"probe_id", pid, "error", err)
continue
}
if exists > 0 {
continue // heartbeat key present — probe is alive
}
// Heartbeat key absent: throttled 「探针失联」alert.
dedupKey := capAlertProbePrefix + pid
set, setErr := m.rdb.SetNX(ctx, dedupKey, "1", suppressTTL).Result()
if setErr != nil {
slog.Error("capacity: probe alert dedup key error",
"probe_id", pid, "error", setErr)
continue
}
if !set {
continue // alert already sent within suppression window
}
probeNodeID := "probe:" + pid
reason := "探针失联: no heartbeat received in last 15 min (no data, not a block signal)"
slog.Warn("capacity: probe lost contact",
"probe_id", pid,
"hb_key", hbKey,
)
if m.notifier != nil {
if notifyErr := m.notifier.NotifyFault(ctx, probeNodeID, reason); notifyErr != nil {
slog.Error("capacity: notify probe lost contact",
"probe_id", pid, "error", notifyErr)
}
}
}
return nil
}