5d4b484646
新增 server/internal/alert 包(15G): - 定义 Notifier 接口及 7 种 EventType(判封确认/补新失败/水位低/熔断/ 探针失联/心跳缺失/故障态) - TGNotifier:Bot API 发送,Critical 事件不去重,Warning/Info 事件 10min SETNX 去重窗口,失败重试 ≤2 次后降级至 LogNotifier - LogNotifier:slog 结构化降级实现 - 单测:7 种事件模板 + runbook 锚点正确性;去重窗口内第二条被抑制; TG 5xx 重试后 fallback 且 Notify() 返回 nil; runbook 文件锚点与枚举一致性 接入 scheduler(替换旧的 NotifyFault 桩): - detect/engine.go:故障态(Rule 5)→ EventTypeFault; 判封确认(Rule 3)→ EventTypeBlockConfirmed - orchestrate/deps.go:Notifier 类型别名指向 alert.Notifier - orchestrate/replacer.go:补新失败 → EventTypeReplenishFailed; 熔断触发 → EventTypeBreakerTripped - probe/prober_agent.go:failCount ≥3 → EventTypeProbeAgentLost - probe/store.go:新增 CheckHeartbeats() 供 15H 检测心跳缺失>90s 新增 docs/runbook-scheduler.md:7 节各含含义/先查什么/处置/升级条件, 锚点与代码枚举对应。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
262 lines
8.9 KiB
Go
262 lines
8.9 KiB
Go
package probe
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/redis/go-redis/v9"
|
||
|
||
"github.com/wangjia/pangolin/server/internal/alert"
|
||
)
|
||
|
||
// Redis TTL constants for the probe subsystem.
|
||
const (
|
||
// snapshotTTL is how long a per-(node,vantage) probe snapshot is retained.
|
||
// After this window the key expires and 15D must treat it as "no data".
|
||
snapshotTTL = 30 * time.Minute
|
||
|
||
// heartbeatTTL is how long a probe heartbeat key lives without renewal.
|
||
// After this window the key expires — meaning "no recent data", NOT "failure".
|
||
heartbeatTTL = 15 * time.Minute
|
||
|
||
// seenTTL is the lifetime of a replay-prevention key.
|
||
// Must exceed 2 × timeWindow (2 × 300 s = 600 s); 700 s adds a 100 s buffer.
|
||
seenTTL = 700 * time.Second
|
||
)
|
||
|
||
// Store handles Redis reads and writes for the probe subsystem.
|
||
//
|
||
// # Key schema
|
||
//
|
||
// probe:{nodeID}:{vantageKey} → ProbeSnapshot JSON, TTL snapshotTTL (30 min)
|
||
// probe:hb:{probeID} → Unix timestamp string, TTL heartbeatTTL (15 min)
|
||
// probe:seen:{probeID}:{ts} → "1", TTL seenTTL (700 s), replay prevention
|
||
//
|
||
// # Missing-key semantics (IMPORTANT – must not be violated by callers)
|
||
//
|
||
// A missing probe:hb:{probeID} key means "no recent heartbeat data".
|
||
// It must NEVER be interpreted as "probe is down" or folded into a failure
|
||
// signal. Determination logic (15D) must treat an absent key as unknown,
|
||
// not as a negative result.
|
||
//
|
||
// # Node-ID constraint
|
||
//
|
||
// NodeIDs must not be the literal string "hb" or "seen", as those are used as
|
||
// key-namespace prefixes. In practice node IDs are UUIDs so this is safe.
|
||
type Store struct {
|
||
rdb *redis.Client
|
||
}
|
||
|
||
// NewStore creates a Store backed by the given Redis client.
|
||
func NewStore(rdb *redis.Client) *Store {
|
||
return &Store{rdb: rdb}
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Key helpers
|
||
// --------------------------------------------------------------------------
|
||
|
||
// snapshotKey returns the Redis key for the latest probe snapshot
|
||
// for the given (nodeID, vantage) pair.
|
||
//
|
||
// Format: probe:{nodeID}:{country}:{region}:{isp}
|
||
func snapshotKey(nodeID string, v VantagePoint) string {
|
||
return "probe:" + nodeID + ":" + vantageKey(v)
|
||
}
|
||
|
||
// vantageKey returns a canonical, colon-safe string for a VantagePoint.
|
||
// It is used as the trailing segment of a snapshot Redis key.
|
||
func vantageKey(v VantagePoint) string {
|
||
return sanitizeKeySegment(v.Country) + ":" +
|
||
sanitizeKeySegment(v.Region) + ":" +
|
||
sanitizeKeySegment(v.ISP)
|
||
}
|
||
|
||
// sanitizeKeySegment replaces characters that would interfere with Redis key
|
||
// parsing (space, colon) with underscores so they are safe in compound keys.
|
||
func sanitizeKeySegment(s string) string {
|
||
s = strings.ReplaceAll(s, " ", "_")
|
||
s = strings.ReplaceAll(s, ":", "_")
|
||
return s
|
||
}
|
||
|
||
// heartbeatKey returns the Redis key for a probe agent heartbeat.
|
||
func heartbeatKey(probeID string) string {
|
||
return "probe:hb:" + probeID
|
||
}
|
||
|
||
// seenKey returns the Redis key used for replay-prevention on a
|
||
// (probeID, ts) tuple.
|
||
func seenKey(probeID, ts string) string {
|
||
return "probe:seen:" + probeID + ":" + ts
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Write path
|
||
// --------------------------------------------------------------------------
|
||
|
||
// SaveReports persists one ProbeSnapshot per (nodeID, vantage) pair from the
|
||
// given report batch and updates the probe heartbeat.
|
||
// All writes are issued in a single pipeline for efficiency.
|
||
func (s *Store) SaveReports(ctx context.Context, probeID string, vantage VantagePoint, reports []NodeReport) error {
|
||
now := time.Now().Unix()
|
||
pipe := s.rdb.Pipeline()
|
||
|
||
for _, rep := range reports {
|
||
snap := ProbeSnapshot{
|
||
ProbeID: probeID,
|
||
Vantage: vantage,
|
||
Report: rep,
|
||
ReceivedAt: now,
|
||
}
|
||
data, err := json.Marshal(snap)
|
||
if err != nil {
|
||
return fmt.Errorf("probe: marshal snapshot for node %s: %w", rep.NodeID, err)
|
||
}
|
||
pipe.Set(ctx, snapshotKey(rep.NodeID, vantage), data, snapshotTTL)
|
||
}
|
||
|
||
// Update heartbeat: value is the ingest timestamp so readers can compute
|
||
// staleness without needing a separate TTL query.
|
||
pipe.Set(ctx, heartbeatKey(probeID), strconv.FormatInt(now, 10), heartbeatTTL)
|
||
|
||
if _, err := pipe.Exec(ctx); err != nil {
|
||
return fmt.Errorf("probe: redis pipeline exec: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// CheckAndMarkSeen atomically checks whether the (probeID, ts) pair has been
|
||
// seen before, and marks it as seen if not.
|
||
//
|
||
// Returns (true, nil) – this is a replay; the caller should handle idempotently.
|
||
// Returns (false, nil) – first time seen; the caller should process normally.
|
||
func (s *Store) CheckAndMarkSeen(ctx context.Context, probeID, ts string) (bool, error) {
|
||
key := seenKey(probeID, ts)
|
||
// SET … NX EX: atomically set iff the key does not exist.
|
||
set, err := s.rdb.SetNX(ctx, key, "1", seenTTL).Result()
|
||
if err != nil {
|
||
return false, fmt.Errorf("probe: seen check: %w", err)
|
||
}
|
||
// SetNX returns true if the key was newly created (not a replay).
|
||
return !set, nil
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Read interfaces consumed by 15D (determination logic)
|
||
// --------------------------------------------------------------------------
|
||
|
||
// SnapshotsByNode returns all cached probe snapshots for the given nodeID,
|
||
// indexed by vantage key string (Country:Region:ISP).
|
||
//
|
||
// A missing key — and therefore an empty map — means "no recent probe data
|
||
// from any vantage point". Callers (15D) must treat this as "unknown", not
|
||
// as a failure signal.
|
||
//
|
||
// Uses SCAN + GET in two passes. For the expected scale (dozens of vantages
|
||
// per node) this is efficient enough; 15D may add caching on top if needed.
|
||
func (s *Store) SnapshotsByNode(ctx context.Context, nodeID string) (map[string]ProbeSnapshot, error) {
|
||
pattern := "probe:" + nodeID + ":*"
|
||
|
||
var keys []string
|
||
iter := s.rdb.Scan(ctx, 0, pattern, 0).Iterator()
|
||
for iter.Next(ctx) {
|
||
keys = append(keys, iter.Val())
|
||
}
|
||
if err := iter.Err(); err != nil {
|
||
return nil, fmt.Errorf("probe: scan snapshots for node %s: %w", nodeID, err)
|
||
}
|
||
|
||
result := make(map[string]ProbeSnapshot, len(keys))
|
||
prefix := "probe:" + nodeID + ":"
|
||
|
||
for _, k := range keys {
|
||
val, err := s.rdb.Get(ctx, k).Result()
|
||
if err == redis.Nil {
|
||
// Key expired between SCAN and GET – not an error.
|
||
continue
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("probe: get snapshot %s: %w", k, err)
|
||
}
|
||
var snap ProbeSnapshot
|
||
if err := json.Unmarshal([]byte(val), &snap); err != nil {
|
||
// Corrupted data – log-worthy but non-fatal; skip this entry.
|
||
continue
|
||
}
|
||
vk := strings.TrimPrefix(k, prefix)
|
||
result[vk] = snap
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// AliveProbes returns the IDs of probe agents that have sent a heartbeat
|
||
// within the last heartbeatTTL window (15 min).
|
||
//
|
||
// An empty result means "no probes have reported recently" – not that all
|
||
// probes are down. Missing heartbeat keys must never be folded into a
|
||
// failure signal by callers.
|
||
func (s *Store) AliveProbes(ctx context.Context) ([]string, error) {
|
||
var ids []string
|
||
iter := s.rdb.Scan(ctx, 0, "probe:hb:*", 0).Iterator()
|
||
for iter.Next(ctx) {
|
||
k := iter.Val()
|
||
ids = append(ids, strings.TrimPrefix(k, "probe:hb:"))
|
||
}
|
||
if err := iter.Err(); err != nil {
|
||
return nil, fmt.Errorf("probe: scan heartbeats: %w", err)
|
||
}
|
||
return ids, nil
|
||
}
|
||
|
||
// CheckHeartbeats scans all known probe heartbeat keys and fires an
|
||
// EventTypeHeartbeatMissing alert via notifier for every probe whose last
|
||
// heartbeat timestamp is older than threshold.
|
||
//
|
||
// This is called by 15H (DetectLoop / assembly) on a periodic basis.
|
||
// Missing-key semantics apply: a key that expired (TTL elapsed) is not seen
|
||
// at all — only keys that exist but carry a stale timestamp are reported.
|
||
//
|
||
// threshold should be ≥90 s per the operational SLO.
|
||
func (s *Store) CheckHeartbeats(ctx context.Context, threshold time.Duration, notifier alert.Notifier) error {
|
||
now := time.Now().Unix()
|
||
cutoff := now - int64(threshold.Seconds())
|
||
|
||
var scanErr error
|
||
iter := s.rdb.Scan(ctx, 0, "probe:hb:*", 0).Iterator()
|
||
for iter.Next(ctx) {
|
||
k := iter.Val()
|
||
probeID := strings.TrimPrefix(k, "probe:hb:")
|
||
|
||
val, err := s.rdb.Get(ctx, k).Result()
|
||
if err != nil {
|
||
// Key may have expired between SCAN and GET; skip.
|
||
continue
|
||
}
|
||
ts, err := strconv.ParseInt(val, 10, 64)
|
||
if err != nil {
|
||
continue // corrupt value; ignore
|
||
}
|
||
if ts < cutoff {
|
||
// Heartbeat is stale: emit alert.
|
||
staleSecs := now - ts
|
||
ev := alert.NewEvent(alert.EventTypeHeartbeatMissing, probeID, map[string]string{
|
||
"stale_seconds": strconv.FormatInt(staleSecs, 10),
|
||
"threshold_s": strconv.FormatInt(int64(threshold.Seconds()), 10),
|
||
})
|
||
if notifyErr := notifier.Notify(ctx, ev); notifyErr != nil {
|
||
// Log but continue checking other probes.
|
||
scanErr = fmt.Errorf("probe: notify heartbeat missing for %s: %w", probeID, notifyErr)
|
||
}
|
||
}
|
||
}
|
||
if err := iter.Err(); err != nil {
|
||
return fmt.Errorf("probe: scan heartbeats: %w", err)
|
||
}
|
||
return scanErr
|
||
}
|