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>
329 lines
12 KiB
Go
329 lines
12 KiB
Go
package detect
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log/slog"
|
||
"time"
|
||
|
||
"github.com/redis/go-redis/v9"
|
||
|
||
"github.com/wangjia/pangolin/server/internal/alert"
|
||
"github.com/wangjia/pangolin/server/internal/idgen"
|
||
)
|
||
|
||
// Notifier is the 15G alert outlet used by the detection engine.
|
||
// It is satisfied by alert.Notifier (the real TG implementation) and by
|
||
// alert.LogNotifier (the fallback / development stub).
|
||
type Notifier = alert.Notifier
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Redis key constants
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
const (
|
||
// replaceQueueKey is the Redis list that 15E (Replenisher) drains to
|
||
// provision replacement nodes. The queue entry shape is replaceRequest JSON.
|
||
replaceQueueKey = "detect:replace:queue"
|
||
|
||
// probeFreqKeyPrefix is the per-node Redis key prefix written when a node
|
||
// enters "blocked_suspect". The value "60" (seconds) tells 15B to probe
|
||
// at 1-minute instead of the default 5-minute interval.
|
||
probeFreqKeyPrefix = "probe:freq:"
|
||
|
||
// probeFreqTTL keeps the elevated-frequency marker alive for 45 minutes —
|
||
// long enough to cover a full suspect → confirmed window (6 × 5 min + buffer).
|
||
probeFreqTTL = 45 * time.Minute
|
||
)
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Engine
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// Engine executes one detection cycle per Tick call.
|
||
//
|
||
// 15H (DetectLoop) is responsible for:
|
||
// - scheduling Ticks at 5-minute intervals, and
|
||
// - leader election (only the elected leader should call Tick).
|
||
//
|
||
// Engine itself holds no goroutines and no in-process state beyond its
|
||
// constructor arguments; all persistent state lives in Redis (via StreakStore).
|
||
type Engine struct {
|
||
probeStore ProbeSnapshotter
|
||
lc LifecycleService
|
||
streaks *StreakStore
|
||
rdb *redis.Client
|
||
notifier Notifier
|
||
cfg *DetectConfig
|
||
}
|
||
|
||
// NewEngine creates an Engine. If cfg is nil, DefaultConfig() is used.
|
||
// If notifier is nil, LogNotifier is used.
|
||
func NewEngine(
|
||
probeStore ProbeSnapshotter,
|
||
lc LifecycleService,
|
||
streaks *StreakStore,
|
||
rdb *redis.Client,
|
||
notifier Notifier,
|
||
cfg *DetectConfig,
|
||
) *Engine {
|
||
if cfg == nil {
|
||
d := DefaultConfig()
|
||
cfg = &d
|
||
}
|
||
if notifier == nil {
|
||
notifier = alert.LogNotifier{}
|
||
}
|
||
return &Engine{
|
||
probeStore: probeStore,
|
||
lc: lc,
|
||
streaks: streaks,
|
||
rdb: rdb,
|
||
notifier: notifier,
|
||
cfg: cfg,
|
||
}
|
||
}
|
||
|
||
// Tick executes one full detection cycle.
|
||
//
|
||
// It lists nodes in "up" and "blocked_suspect" states, reads their probe
|
||
// snapshots, computes signals, and applies the five classification rules.
|
||
// Errors from individual nodes are logged but do not halt the cycle for other
|
||
// nodes.
|
||
func (e *Engine) Tick(ctx context.Context) error {
|
||
nodes, err := e.lc.ListNodes(ctx, NodeFilter{
|
||
Statuses: []NodeStatus{StatusUp, StatusBlockedSuspect},
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("detect: list nodes: %w", err)
|
||
}
|
||
|
||
for _, node := range nodes {
|
||
if err := e.processNode(ctx, node); err != nil {
|
||
slog.Error("detect: process node",
|
||
"node_id", node.ID,
|
||
"status", node.Status,
|
||
"error", err,
|
||
)
|
||
// Continue — one node failure must not halt the full cycle.
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// processNode applies all five rules to a single node.
|
||
func (e *Engine) processNode(ctx context.Context, node NodeInfo) error {
|
||
snapshots, err := e.probeStore.SnapshotsByNode(ctx, node.ID)
|
||
if err != nil {
|
||
return fmt.Errorf("snapshots: %w", err)
|
||
}
|
||
|
||
sig := computeSignals(ctx, node.ID, snapshots, e.lc)
|
||
|
||
sk, err := e.streaks.Load(ctx, node.ID)
|
||
if err != nil {
|
||
return fmt.Errorf("streak load: %w", err)
|
||
}
|
||
|
||
// ── Rule 5: fault ─────────────────────────────────────────────────────────
|
||
// Both domestic AND overseas probes failing → node-level outage, not GFW.
|
||
// Do NOT transition status; send an alert event for human review.
|
||
// Streaks are left unchanged so that when the node recovers the engine
|
||
// resumes from its current position rather than re-triggering immediately.
|
||
if isFault(sig) {
|
||
ev := alert.NewEvent(alert.EventTypeFault, node.ID, map[string]string{
|
||
"domestic_fail_isps": fmt.Sprintf("%d", sig.DomesticFailISPs),
|
||
"overseas_ok": "false",
|
||
})
|
||
if notifyErr := e.notifier.Notify(ctx, ev); notifyErr != nil {
|
||
slog.Error("detect: notify fault", "node_id", node.ID, "error", notifyErr)
|
||
}
|
||
return nil // do not persist streak changes
|
||
}
|
||
|
||
switch node.Status {
|
||
case StatusUp:
|
||
return e.processUp(ctx, node, sig, sk)
|
||
case StatusBlockedSuspect:
|
||
return e.processSuspect(ctx, node, sig, sk)
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// processUp applies the suspect and traffic-warning rules to an "up" node.
|
||
func (e *Engine) processUp(ctx context.Context, node NodeInfo, sig NodeSignal, sk Streak) error {
|
||
domesticFailing := e.cfg.isSuspectTriggered(sig)
|
||
overseasBarrier := sig.OverseasHasData && sig.OverseasOK
|
||
|
||
if domesticFailing && overseasBarrier {
|
||
// Domestic probes failing, overseas OK → potential GFW block.
|
||
threshold := e.cfg.effectiveSuspectStreakMin(sig)
|
||
|
||
if sk.FailStreak+1 >= threshold {
|
||
// ── Rule 1: suspect ───────────────────────────────────────────────
|
||
detail := map[string]any{
|
||
"from": "up",
|
||
"to": "blocked_suspect",
|
||
"domestic_fail_isps": sig.DomesticFailISPs,
|
||
"domestic_total": sig.DomesticTotalISPs,
|
||
"fail_streak": sk.FailStreak + 1,
|
||
}
|
||
affected, err := e.lc.TransitionStatus(ctx, node.ID, StatusUp, StatusBlockedSuspect, detail)
|
||
if err != nil {
|
||
return fmt.Errorf("transition up→suspect: %w", err)
|
||
}
|
||
if affected == 0 {
|
||
// Optimistic lock conflict: another writer changed the state
|
||
// concurrently. Leave streak unchanged; the engine is idempotent.
|
||
slog.Info("detect: suspect transition skipped (lock conflict)", "node_id", node.ID)
|
||
return e.streaks.Save(ctx, node.ID, sk)
|
||
}
|
||
|
||
// Transition succeeded: apply side-effects.
|
||
if err := e.lc.SetWeight(ctx, node.ID, e.cfg.SuspectWeight); err != nil {
|
||
slog.Error("detect: set suspect weight", "node_id", node.ID, "error", err)
|
||
}
|
||
if err := e.writeProbeFreqMark(ctx, node.ID); err != nil {
|
||
slog.Error("detect: write probe freq mark", "node_id", node.ID, "error", err)
|
||
}
|
||
if err := e.lc.BumpVersion(ctx); err != nil {
|
||
slog.Error("detect: bump version after suspect", "node_id", node.ID, "error", err)
|
||
}
|
||
|
||
// Reset fail streak; suspect streak begins on the next call to
|
||
// processSuspect.
|
||
sk.FailStreak = 0
|
||
sk.SuspectStreak = 0
|
||
sk.RecoverStreak = 0
|
||
} else {
|
||
// Below the threshold: increment and wait for next cycle.
|
||
sk.FailStreak++
|
||
sk.RecoverStreak = 0
|
||
}
|
||
} else {
|
||
// Domestic probes OK (or overseas data absent / also failing — fault is
|
||
// handled above before this function is called).
|
||
sk.FailStreak = 0
|
||
sk.RecoverStreak = 0
|
||
}
|
||
|
||
return e.streaks.Save(ctx, node.ID, sk)
|
||
}
|
||
|
||
// processSuspect applies the confirmed and recover rules to a "blocked_suspect" node.
|
||
func (e *Engine) processSuspect(ctx context.Context, node NodeInfo, sig NodeSignal, sk Streak) error {
|
||
domesticFailing := e.cfg.isSuspectTriggered(sig)
|
||
overseasBarrier := sig.OverseasHasData && sig.OverseasOK
|
||
|
||
if domesticFailing && overseasBarrier {
|
||
// Still looks like a GFW block: advance the suspect streak.
|
||
sk.SuspectStreak++
|
||
sk.RecoverStreak = 0
|
||
sk.FailStreak = 0
|
||
|
||
if sk.SuspectStreak >= e.cfg.ConfirmedStreakMin {
|
||
// ── Rule 3: confirmed ─────────────────────────────────────────────
|
||
confirmedDetail := map[string]any{
|
||
"from": "blocked_suspect",
|
||
"to": "blocked_confirmed",
|
||
"suspect_streak": sk.SuspectStreak,
|
||
}
|
||
n1, err := e.lc.TransitionStatus(ctx, node.ID, StatusBlockedSuspect, StatusBlockedConfirmed, confirmedDetail)
|
||
if err != nil {
|
||
return fmt.Errorf("transition suspect→confirmed: %w", err)
|
||
}
|
||
if n1 == 0 {
|
||
slog.Info("detect: confirmed transition skipped (lock conflict)", "node_id", node.ID)
|
||
return e.streaks.Save(ctx, node.ID, sk)
|
||
}
|
||
|
||
// Emit 判封确认 alert (15G exit channel).
|
||
confirmedEv := alert.NewEvent(alert.EventTypeBlockConfirmed, node.ID, map[string]string{
|
||
"suspect_streak": fmt.Sprintf("%d", sk.SuspectStreak),
|
||
})
|
||
if notifyErr := e.notifier.Notify(ctx, confirmedEv); notifyErr != nil {
|
||
slog.Error("detect: notify block confirmed", "node_id", node.ID, "error", notifyErr)
|
||
}
|
||
|
||
// Immediately mark down — skip draining per lifecycle policy.
|
||
downDetail := map[string]any{
|
||
"from": "blocked_confirmed",
|
||
"to": "down",
|
||
"note": "skipped draining (confirmed block policy)",
|
||
}
|
||
if _, err := e.lc.TransitionStatus(ctx, node.ID, StatusBlockedConfirmed, StatusDown, downDetail); err != nil {
|
||
return fmt.Errorf("transition confirmed→down: %w", err)
|
||
}
|
||
|
||
// Push to 15E replace queue.
|
||
if err := e.pushReplaceQueue(ctx, node.ID); err != nil {
|
||
slog.Error("detect: push replace queue", "node_id", node.ID, "error", err)
|
||
}
|
||
|
||
// Reset all streaks; the node is now down and will not be processed
|
||
// again (ListNodes only returns "up" and "blocked_suspect").
|
||
sk = Streak{}
|
||
}
|
||
} else {
|
||
// Domestic probes passing (or overseas data absent — treat as recovery).
|
||
// ── Rule 4: recover ───────────────────────────────────────────────────
|
||
sk.RecoverStreak++
|
||
sk.SuspectStreak = 0
|
||
sk.FailStreak = 0
|
||
|
||
if sk.RecoverStreak >= e.cfg.RecoverStreakMin {
|
||
recoverDetail := map[string]any{
|
||
"from": "blocked_suspect",
|
||
"to": "up",
|
||
"recover_streak": sk.RecoverStreak,
|
||
}
|
||
affected, err := e.lc.TransitionStatus(ctx, node.ID, StatusBlockedSuspect, StatusUp, recoverDetail)
|
||
if err != nil {
|
||
return fmt.Errorf("transition suspect→up: %w", err)
|
||
}
|
||
if affected == 0 {
|
||
slog.Info("detect: recover transition skipped (lock conflict)", "node_id", node.ID)
|
||
return e.streaks.Save(ctx, node.ID, sk)
|
||
}
|
||
// Weight recovery is delegated to 15E's warmup/gradual-ramp channel.
|
||
// The engine intentionally does NOT call SetWeight here.
|
||
sk = Streak{}
|
||
}
|
||
}
|
||
|
||
return e.streaks.Save(ctx, node.ID, sk)
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Helpers
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// writeProbeFreqMark sets a Redis key that tells 15B to probe this node at
|
||
// 1-minute instead of the default 5-minute interval while it is suspect.
|
||
func (e *Engine) writeProbeFreqMark(ctx context.Context, nodeID string) error {
|
||
key := probeFreqKeyPrefix + nodeID
|
||
return e.rdb.Set(ctx, key, "60", probeFreqTTL).Err()
|
||
}
|
||
|
||
// replaceRequest is the payload pushed to the 15E replace queue.
|
||
type replaceRequest struct {
|
||
NodeID string `json:"nodeId"`
|
||
ReplacementUUID string `json:"replacementUuid"`
|
||
}
|
||
|
||
// pushReplaceQueue enqueues a replacement request for 15E (Replenisher).
|
||
// The replacement UUID is pre-allocated here so 15E can reference it without
|
||
// generating its own ID.
|
||
func (e *Engine) pushReplaceQueue(ctx context.Context, nodeID string) error {
|
||
payload, err := json.Marshal(replaceRequest{
|
||
NodeID: nodeID,
|
||
ReplacementUUID: idgen.NewString(),
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("detect: marshal replace request: %w", err)
|
||
}
|
||
return e.rdb.LPush(ctx, replaceQueueKey, payload).Err()
|
||
}
|