Files
pangolin/server/internal/scheduler/detect/engine.go
T
wangjia 88757b2ac4 feat(detect): 判定引擎 signals+rules+streak (tsk_OYEiDCzM9_0Y)
新建 server/internal/scheduler/detect/ 包,实现任务 15D:

## 核心模块

**lifecycle.go**
- 定义 LifecycleService Go interface(ListNodes / TransitionStatus / SetWeight /
  BumpVersion / GetLoad / GetLoadHistory),供 #5 真实实现,内含乐观锁语义(0 行
  受影响即本周期放弃,幂等可重入)
- 提供 MockLifecycle 内存 stub(SetConflict / Events 等测试辅助方法)

**signals.go**
- computeSignals:读 SnapshotsByNode,归一化为 NodeSignal
  - domesticFailISPs:按 ISP 聚合(stripPrefix "3rd-" 合并 15B/15C 同 ISP 视角),
    L3 失败权重最高(L3 挂即记该 ISP 失败,即使 L1/L2 通),无数据 vantage 不计分母
  - overseasOK:境外对照点全部 L1 通为真
  - trafficDropPct:15min 前后半段在线数降幅百分比
- ProbeSnapshotter interface(解耦 probe.Store,便于 mock)

**rules.go**
- DetectConfig:全量阈值常量(DomesticFailNumerator/Denominator=2/3、
  SuspectStreakMin=2、TrafficDropThreshold=80%、TrafficBaselineMin=20、
  ConfirmedStreakMin=6、RecoverStreakMin=2、SuspectWeight=10)
- isSuspectTriggered:整数算术避免浮点误差(fail×den >= total×num)
- isFault / effectiveSuspectStreakMin / isTrafficWarning 规则助手

**streak.go**
- StreakStore:Redis hash detect:streak:{node}(fail_streak / suspect_streak /
  recover_streak),TTL 2h(宽于 6×5min=30min),进程重启后自动恢复

**engine.go**
- Engine.Tick(ctx):列举 up/blocked_suspect 节点,按节点依序运行五条规则
- Rule 1 suspect:≥2/3 ISP 失败 + 境外正常 → 连续 2 周期 → up→blocked_suspect,
  weight→10,写 probe:freq:{node}=60(1min 提频标记),BumpVersion
- Rule 2 流量预警:trafficDrop≥80% + 基线≥20 → suspect 门槛放宽至 1 周期
- Rule 3 confirmed:blocked_suspect 连续 6 周期 → blocked_confirmed → down(跳过
  draining),入队 detect:replace:queue(JSON: nodeId + replacementUuid)
- Rule 4 recover:suspect 期间境内连续 2 周期恢复 → up(weight 交 15E warmup)
- Rule 5 fault:境内+境外同时失败 → 不迁移,调 Notifier.NotifyFault(log stub)
- Notifier interface + LogNotifier stub(15G 就绪前输出 slog.Warn)

**engine_test.go**(19 个测试,全部通过)
- 表驱动覆盖:恰好 2/3 失败、1/3 失败不触发、9min vs 10min streak、
  流量预警 1 周期即 suspect、suspect 第 6 周期 confirmed、境内外同挂 fault 不迁移、
  恢复 2 周期回 up、3rd- 前缀合并、L3 覆盖 L1/L2
- 乐观锁冲突:SetConflict → 放弃本周期 → 无副作用 → 解冲突后正常重试
- 进程重启:同一 miniredis,新 Engine 读取已存在 streak 继续计数不误清零
- 产出验证:replace queue JSON 结构、probe:freq 标记、weight 变更、事件 detail 字段

**migrations/000010_node_blocked_statuses.{up,down}.sql**
- 扩展 nodes.status ENUM 加入 blocked_suspect / blocked_confirmed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 20:15:13 +08:00

336 lines
13 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 detect
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/redis/go-redis/v9"
"github.com/wangjia/pangolin/server/internal/idgen"
)
// ─────────────────────────────────────────────────────────────────────────────
// Notifier — 15G interface stub
// ─────────────────────────────────────────────────────────────────────────────
// Notifier is the 15G event sink for fault notifications.
// The real implementation (15G) sends a Telegram/alerting message; the stub
// below writes to the structured logger and is used until 15G is ready.
type Notifier interface {
NotifyFault(ctx context.Context, nodeID, reason string) error
}
// LogNotifier is a Notifier stub that logs via slog.
// It is used when no real Notifier is wired up.
type LogNotifier struct{}
// NotifyFault implements Notifier.
func (LogNotifier) NotifyFault(_ context.Context, nodeID, reason string) error {
slog.Warn("node fault detected — manual review required",
"node_id", nodeID,
"reason", reason,
)
return nil
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 = 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) {
reason := fmt.Sprintf("domestic_fail_isps=%d overseas_ok=false", sig.DomesticFailISPs)
if notifyErr := e.notifier.NotifyFault(ctx, node.ID, reason); 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)
}
// 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()
}