Files
pangolin/server/internal/scheduler/orchestrate/capacity.go
T
wangjia 469c92191f fix(scheduler): 熔断器/容量监控改用统一 alert.Notify(Event) 接口
容量水位(NPgPRxBGv0g9)与告警出口(9YMHMTfWJyNB)两条并行分支合并后接口
不兼容:前者调用旧的 NotifyFault(ctx,id,reason),后者把 alert.Notifier 统一
为 Notify(ctx, Event)。将熔断触发/水位过低/探针失联三处改用 alert.NewEvent +
Notify,对齐 alert 包的统一事件出口设计(EventTypeBreakerTripped/WatermarkLow/
ProbeAgentLost),mockNotifier 既有 count() 断言无需改动即通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:44:27 +08:00

248 lines
8.9 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"
"github.com/wangjia/pangolin/server/internal/alert"
)
// ─────────────────────────────────────────────────────────────────────────────
// 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 {
event := alert.NewEvent(alert.EventTypeWatermarkLow, "", map[string]string{"reason": reason})
event.Pool = poolID
if notifyErr := m.notifier.Notify(ctx, event); 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 {
event := alert.NewEvent(alert.EventTypeProbeAgentLost, probeNodeID, map[string]string{"reason": reason})
if notifyErr := m.notifier.Notify(ctx, event); notifyErr != nil {
slog.Error("capacity: notify probe lost contact",
"probe_id", pid, "error", notifyErr)
}
}
}
return nil
}