469c92191f
容量水位(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>
248 lines
8.9 KiB
Go
248 lines
8.9 KiB
Go
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 1–2 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
|
||
}
|