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>
This commit is contained in:
wangjia
2026-06-13 20:15:13 +08:00
parent 628440b3f9
commit 88757b2ac4
8 changed files with 1675 additions and 0 deletions
+335
View File
@@ -0,0 +1,335 @@
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()
}
@@ -0,0 +1,653 @@
package detect_test
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/wangjia/pangolin/server/internal/scheduler/detect"
"github.com/wangjia/pangolin/server/internal/scheduler/probe"
)
// ─────────────────────────────────────────────────────────────────────────────
// Test helpers
// ─────────────────────────────────────────────────────────────────────────────
// mockSnapshotter implements detect.ProbeSnapshotter for unit tests.
type mockSnapshotter struct {
data map[string]map[string]probe.ProbeSnapshot
}
func (m *mockSnapshotter) SnapshotsByNode(_ context.Context, nodeID string) (map[string]probe.ProbeSnapshot, error) {
if snaps, ok := m.data[nodeID]; ok {
return snaps, nil
}
return nil, nil
}
// recordingNotifier records NotifyFault calls for assertion.
type recordingNotifier struct {
calls []string // nodeID values
}
func (r *recordingNotifier) NotifyFault(_ context.Context, nodeID, _ string) error {
r.calls = append(r.calls, nodeID)
return nil
}
// newTestRedis creates an in-process Redis (miniredis) and returns a connected
// client plus a cleanup function. Tests must call cleanup() at the end.
func newTestRedis(t *testing.T) (*redis.Client, *miniredis.Miniredis) {
t.Helper()
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
return rdb, mr
}
// newEngine wires up an Engine with the given mock components.
func newEngine(
t *testing.T,
rdb *redis.Client,
lc *detect.MockLifecycle,
snaps *mockSnapshotter,
notifier detect.Notifier,
cfg *detect.DetectConfig,
) *detect.Engine {
t.Helper()
streaks := detect.NewStreakStore(rdb)
return detect.NewEngine(snaps, lc, streaks, rdb, notifier, cfg)
}
// snap builds a probe.ProbeSnapshot for one (node, vantage) pair.
func snap(country, isp string, l1OK bool, l3OK *bool) probe.ProbeSnapshot {
r := probe.NodeReport{L1: probe.L1Result{OK: l1OK}}
if l3OK != nil {
r.L3 = &probe.L3Result{OK: *l3OK}
}
return probe.ProbeSnapshot{
Vantage: probe.VantagePoint{Country: country, ISP: isp},
Report: r,
}
}
func boolPtr(b bool) *bool { return &b }
// cnFail returns a domestic (CN) probe snapshot where L3 is failing.
func cnFail(isp string) probe.ProbeSnapshot {
return snap("CN", isp, true, boolPtr(false))
}
// cnOK returns a domestic (CN) probe snapshot where L3 passes.
func cnOK(isp string) probe.ProbeSnapshot {
return snap("CN", isp, true, boolPtr(true))
}
// overseasOK returns an overseas probe snapshot where L1 passes.
func overseasOK() probe.ProbeSnapshot {
return snap("SG", "AWS", true, nil)
}
// overseasFail returns an overseas probe snapshot where L1 fails.
func overseasFail() probe.ProbeSnapshot {
return snap("SG", "AWS", false, nil)
}
// snapsForNode builds the map that mockSnapshotter.data["nodeID"] expects.
func snapsForNode(snaps ...probe.ProbeSnapshot) map[string]probe.ProbeSnapshot {
m := make(map[string]probe.ProbeSnapshot, len(snaps))
for i, s := range snaps {
key := s.Vantage.Country + ":" + s.Vantage.ISP
_ = i
m[key] = s
}
return m
}
// ─────────────────────────────────────────────────────────────────────────────
// Core rule tests (table-driven)
// ─────────────────────────────────────────────────────────────────────────────
const nodeID = "node-001"
// TestRules covers the five classification rules and their boundary conditions
// through table-driven subtests.
func TestRules(t *testing.T) {
cfg := detect.DefaultConfig()
ctx := context.Background()
type tick struct {
// snaps to present on this tick (keyed by node ID)
snaps map[string]probe.ProbeSnapshot
}
tests := []struct {
name string
// initial node status before any ticks
initialStatus detect.NodeStatus
// ticks is the sequence of probe states to feed to the engine
ticks []tick
// wantStatus is the expected node status after all ticks
wantStatus detect.NodeStatus
// wantEvents are (From, To) pairs expected in the lifecycle event log
wantEvents [][2]detect.NodeStatus
// wantFaultNotified indicates whether NotifyFault should have been called
wantFaultNotified bool
}{
{
// Exactly 2 out of 3 ISPs fail → meets the ≥2/3 threshold.
// After 2 consecutive failing cycles (10 min) the node becomes suspect.
name: "exactly_2_of_3_ISPs_fail_after_2_cycles",
initialStatus: detect.StatusUp,
ticks: []tick{
{snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
{snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
},
wantStatus: detect.StatusBlockedSuspect,
wantEvents: [][2]detect.NodeStatus{
{detect.StatusUp, detect.StatusBlockedSuspect},
},
},
{
// 1 out of 3 ISPs fail → below the 2/3 threshold → no transition.
name: "1_of_3_ISPs_fail_no_suspect",
initialStatus: detect.StatusUp,
ticks: []tick{
{snaps: snapsForNode(cnFail("ChinaTelecom"), cnOK("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
{snaps: snapsForNode(cnFail("ChinaTelecom"), cnOK("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
},
wantStatus: detect.StatusUp,
wantEvents: nil,
},
{
// 9 min (= 1 complete 5-min cycle): fail_streak reaches 1, not yet 2.
// No transition expected after exactly one failing tick.
name: "9min_one_cycle_no_transition",
initialStatus: detect.StatusUp,
ticks: []tick{
{snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
},
wantStatus: detect.StatusUp,
wantEvents: nil,
},
{
// 10 min (= 2 complete 5-min cycles): fail_streak reaches 2 ≥ SuspectStreakMin.
// Transition to blocked_suspect expected.
name: "10min_two_cycles_triggers_suspect",
initialStatus: detect.StatusUp,
ticks: []tick{
{snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
{snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
},
wantStatus: detect.StatusBlockedSuspect,
wantEvents: [][2]detect.NodeStatus{
{detect.StatusUp, detect.StatusBlockedSuspect},
},
},
{
// Traffic warning: drop ≥ 80 % + baseline ≥ 20 → effectiveSuspectStreakMin = 1.
// A single failing tick is enough to enter blocked_suspect.
name: "traffic_warning_relaxes_threshold_to_1_cycle",
initialStatus: detect.StatusUp,
ticks: []tick{
// The MockLifecycle will have loads set before running this test;
// we pass snaps with 2/3 ISPs failing + overseas OK.
{snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
},
wantStatus: detect.StatusBlockedSuspect,
wantEvents: [][2]detect.NodeStatus{
{detect.StatusUp, detect.StatusBlockedSuspect},
},
},
{
// Confirmed: node spends 6 consecutive cycles in blocked_suspect
// with domestic still failing → transitions to blocked_confirmed → down.
name: "suspect_6_cycles_triggers_confirmed",
initialStatus: detect.StatusBlockedSuspect,
ticks: []tick{
{snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
{snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
{snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
{snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
{snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
{snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
},
wantStatus: detect.StatusDown,
wantEvents: [][2]detect.NodeStatus{
{detect.StatusBlockedSuspect, detect.StatusBlockedConfirmed},
{detect.StatusBlockedConfirmed, detect.StatusDown},
},
},
{
// Fault: domestic + overseas both fail → fault rule fires, no state
// transition, NotifyFault is called exactly once.
name: "domestic_and_overseas_fail_triggers_fault_not_block",
initialStatus: detect.StatusUp,
ticks: []tick{
{snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), overseasFail())},
{snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), overseasFail())},
{snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), overseasFail())},
},
wantStatus: detect.StatusUp, // no transition
wantEvents: nil, // no lifecycle transitions
wantFaultNotified: true,
},
{
// Recover: 2 consecutive cycles with domestic passing while in
// blocked_suspect → transitions back to up.
name: "recover_2_cycles_back_to_up",
initialStatus: detect.StatusBlockedSuspect,
ticks: []tick{
{snaps: snapsForNode(cnOK("ChinaTelecom"), cnOK("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
{snaps: snapsForNode(cnOK("ChinaTelecom"), cnOK("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
},
wantStatus: detect.StatusUp,
wantEvents: [][2]detect.NodeStatus{
{detect.StatusBlockedSuspect, detect.StatusUp},
},
},
{
// Recovery not yet reached: 1 cycle passing while in blocked_suspect
// → still in blocked_suspect.
name: "recover_1_cycle_not_yet_recovered",
initialStatus: detect.StatusBlockedSuspect,
ticks: []tick{
{snaps: snapsForNode(cnOK("ChinaTelecom"), cnOK("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
},
wantStatus: detect.StatusBlockedSuspect,
wantEvents: nil,
},
{
// Third-party ("3rd-") ISP prefix is stripped before grouping:
// "3rd-ChinaTelecom" and "ChinaTelecom" must count as the same ISP.
// Two ISPs fail out of three → triggers suspect.
name: "third_party_isp_prefix_merged",
initialStatus: detect.StatusUp,
ticks: []tick{
{snaps: snapsForNode(cnFail("3rd-ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
{snaps: snapsForNode(cnFail("3rd-ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
},
wantStatus: detect.StatusBlockedSuspect,
wantEvents: [][2]detect.NodeStatus{
{detect.StatusUp, detect.StatusBlockedSuspect},
},
},
{
// L3 failure overrides L1/L2: even if L1 is OK the ISP is counted
// as failing when L3 is explicitly false.
name: "l3_failure_overrides_l1_l2",
initialStatus: detect.StatusUp,
ticks: []tick{
// L1 passes but L3 fails for two ISPs → ≥2/3 threshold met.
{snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
{snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())},
},
wantStatus: detect.StatusBlockedSuspect,
wantEvents: [][2]detect.NodeStatus{
{detect.StatusUp, detect.StatusBlockedSuspect},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
rdb, _ := newTestRedis(t)
lc := detect.NewMockLifecycle([]detect.NodeInfo{
{ID: nodeID, Status: tc.initialStatus, Weight: 100},
})
// For the traffic-warning test, set up load history showing a large drop.
// Older half: 100 connections. Newer half: 20 connections.
// Drop = 80 % ≥ TrafficDropThreshold (80 %).
// Current baseline = 20 ≥ TrafficBaselineMin (20) → warning applies.
if tc.name == "traffic_warning_relaxes_threshold_to_1_cycle" {
now := time.Now().Unix()
lc.SetLoads(nodeID, []detect.LoadPoint{
{Timestamp: now - 14*60, Online: 100},
{Timestamp: now - 10*60, Online: 100},
{Timestamp: now - 5*60, Online: 20},
{Timestamp: now - 60, Online: 20},
})
}
notifier := &recordingNotifier{}
snapper := &mockSnapshotter{data: make(map[string]map[string]probe.ProbeSnapshot)}
eng := newEngine(t, rdb, lc, snapper, notifier, &cfg)
for i, tick := range tc.ticks {
snapper.data[nodeID] = tick.snaps
if err := eng.Tick(ctx); err != nil {
t.Fatalf("tick %d: Tick() error: %v", i, err)
}
}
// Verify final node status.
if got := lc.NodeStatus(nodeID); got != tc.wantStatus {
t.Errorf("node status = %q, want %q", got, tc.wantStatus)
}
// Verify lifecycle events (from/to pairs only; detail checked separately).
events := lc.Events()
if len(events) != len(tc.wantEvents) {
t.Errorf("got %d events, want %d: %v", len(events), len(tc.wantEvents), events)
} else {
for i, ev := range events {
want := tc.wantEvents[i]
if ev.From != want[0] || ev.To != want[1] {
t.Errorf("event[%d]: from=%q to=%q, want from=%q to=%q",
i, ev.From, ev.To, want[0], want[1])
}
}
}
// Verify fault notification.
if tc.wantFaultNotified && len(notifier.calls) == 0 {
t.Error("expected NotifyFault to be called, but it was not")
}
if !tc.wantFaultNotified && len(notifier.calls) > 0 {
t.Errorf("unexpected NotifyFault calls: %v", notifier.calls)
}
})
}
}
// TestEventDetailContainsFromTo verifies that every TransitionStatus call
// includes "from" and "to" keys in its detail map (node_events audit trail).
func TestEventDetailContainsFromTo(t *testing.T) {
ctx := context.Background()
cfg := detect.DefaultConfig()
rdb, _ := newTestRedis(t)
lc := detect.NewMockLifecycle([]detect.NodeInfo{
{ID: nodeID, Status: detect.StatusUp, Weight: 100},
})
snapper := &mockSnapshotter{
data: map[string]map[string]probe.ProbeSnapshot{
nodeID: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK()),
},
}
eng := newEngine(t, rdb, lc, snapper, nil, &cfg)
// Two ticks to trigger suspect.
if err := eng.Tick(ctx); err != nil {
t.Fatal(err)
}
if err := eng.Tick(ctx); err != nil {
t.Fatal(err)
}
events := lc.Events()
if len(events) == 0 {
t.Fatal("no events recorded")
}
for _, ev := range events {
if _, ok := ev.Detail["from"]; !ok {
t.Errorf("event %v→%v detail missing 'from'", ev.From, ev.To)
}
if _, ok := ev.Detail["to"]; !ok {
t.Errorf("event %v→%v detail missing 'to'", ev.From, ev.To)
}
}
}
// TestOptimisticLockConflict verifies that when TransitionStatus returns 0
// (lock conflict), the current cycle is abandoned without any side-effects,
// and the engine is idempotent — the next tick re-attempts cleanly.
func TestOptimisticLockConflict(t *testing.T) {
ctx := context.Background()
cfg := detect.DefaultConfig()
rdb, _ := newTestRedis(t)
lc := detect.NewMockLifecycle([]detect.NodeInfo{
{ID: nodeID, Status: detect.StatusUp, Weight: 100},
})
// Register a conflict for the up→suspect transition.
lc.SetConflict(nodeID, detect.StatusUp, detect.StatusBlockedSuspect)
failSnaps := snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())
snapper := &mockSnapshotter{data: map[string]map[string]probe.ProbeSnapshot{nodeID: failSnaps}}
eng := newEngine(t, rdb, lc, snapper, nil, &cfg)
// Run two ticks — normally enough to trigger suspect.
if err := eng.Tick(ctx); err != nil {
t.Fatal(err)
}
if err := eng.Tick(ctx); err != nil {
t.Fatal(err)
}
// Conflict registered: status must remain "up", no events logged.
if got := lc.NodeStatus(nodeID); got != detect.StatusUp {
t.Errorf("status = %q after conflict; want %q", got, detect.StatusUp)
}
if events := lc.Events(); len(events) != 0 {
t.Errorf("expected 0 events after conflict, got %d: %v", len(events), events)
}
// Clear conflict, run one more tick → should transition now (streak already at 1).
lc.ClearConflict(nodeID, detect.StatusUp, detect.StatusBlockedSuspect)
if err := eng.Tick(ctx); err != nil {
t.Fatal(err)
}
if got := lc.NodeStatus(nodeID); got != detect.StatusBlockedSuspect {
t.Errorf("status = %q after conflict cleared; want %q", got, detect.StatusBlockedSuspect)
}
}
// TestStreakPersistenceAcrossRestart verifies that streak counters survive a
// simulated process restart: a new Engine reading the same Redis should
// continue from the saved streak rather than starting from zero.
func TestStreakPersistenceAcrossRestart(t *testing.T) {
ctx := context.Background()
cfg := detect.DefaultConfig() // SuspectStreakMin = 2
rdb, _ := newTestRedis(t)
lc := detect.NewMockLifecycle([]detect.NodeInfo{
{ID: nodeID, Status: detect.StatusUp, Weight: 100},
})
failSnaps := snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())
snapper := &mockSnapshotter{data: map[string]map[string]probe.ProbeSnapshot{nodeID: failSnaps}}
// First "process instance": run one tick (fail_streak becomes 1).
eng1 := newEngine(t, rdb, lc, snapper, nil, &cfg)
if err := eng1.Tick(ctx); err != nil {
t.Fatal(err)
}
if got := lc.NodeStatus(nodeID); got != detect.StatusUp {
t.Errorf("after tick 1 status = %q; want up", got)
}
// "Restart": create a brand-new Engine pointing at the same Redis.
// The streak (fail_streak=1) must still be present.
eng2 := newEngine(t, rdb, lc, snapper, nil, &cfg)
if err := eng2.Tick(ctx); err != nil {
t.Fatal(err)
}
// The second engine picks up fail_streak=1, increments to 2, and fires
// the suspect transition.
if got := lc.NodeStatus(nodeID); got != detect.StatusBlockedSuspect {
t.Errorf("after restart tick status = %q; want blocked_suspect", got)
}
}
// TestReplaceQueuePopulated verifies that when a node is confirmed, the
// detect:replace:queue Redis list receives a valid JSON entry.
func TestReplaceQueuePopulated(t *testing.T) {
ctx := context.Background()
cfg := detect.DefaultConfig()
rdb, _ := newTestRedis(t)
lc := detect.NewMockLifecycle([]detect.NodeInfo{
{ID: nodeID, Status: detect.StatusBlockedSuspect, Weight: 10},
})
failSnaps := snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())
snapper := &mockSnapshotter{data: map[string]map[string]probe.ProbeSnapshot{nodeID: failSnaps}}
eng := newEngine(t, rdb, lc, snapper, nil, &cfg)
// Run ConfirmedStreakMin (6) ticks.
for i := 0; i < cfg.ConfirmedStreakMin; i++ {
if err := eng.Tick(ctx); err != nil {
t.Fatalf("tick %d: %v", i, err)
}
}
if got := lc.NodeStatus(nodeID); got != detect.StatusDown {
t.Errorf("node status = %q after 6 suspect cycles; want down", got)
}
// Verify the replace queue has one entry with the correct shape.
qlen, err := rdb.LLen(ctx, "detect:replace:queue").Result()
if err != nil {
t.Fatalf("LLEN: %v", err)
}
if qlen != 1 {
t.Fatalf("replace queue length = %d; want 1", qlen)
}
raw, err := rdb.LIndex(ctx, "detect:replace:queue", 0).Result()
if err != nil {
t.Fatalf("LINDEX: %v", err)
}
var entry struct {
NodeID string `json:"nodeId"`
ReplacementUUID string `json:"replacementUuid"`
}
if err := json.Unmarshal([]byte(raw), &entry); err != nil {
t.Fatalf("unmarshal replace queue entry: %v", err)
}
if entry.NodeID != nodeID {
t.Errorf("replace queue nodeId = %q; want %q", entry.NodeID, nodeID)
}
if entry.ReplacementUUID == "" {
t.Error("replace queue replacementUuid is empty")
}
}
// TestProbeFreqMarkWritten verifies that the probe:freq:{nodeID} Redis key is
// set when a node transitions to blocked_suspect.
func TestProbeFreqMarkWritten(t *testing.T) {
ctx := context.Background()
cfg := detect.DefaultConfig()
rdb, _ := newTestRedis(t)
lc := detect.NewMockLifecycle([]detect.NodeInfo{
{ID: nodeID, Status: detect.StatusUp, Weight: 100},
})
failSnaps := snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())
snapper := &mockSnapshotter{data: map[string]map[string]probe.ProbeSnapshot{nodeID: failSnaps}}
eng := newEngine(t, rdb, lc, snapper, nil, &cfg)
// Two ticks to trigger suspect.
for i := 0; i < 2; i++ {
if err := eng.Tick(ctx); err != nil {
t.Fatalf("tick %d: %v", i, err)
}
}
if got := lc.NodeStatus(nodeID); got != detect.StatusBlockedSuspect {
t.Errorf("status = %q; want blocked_suspect", got)
}
freqKey := "probe:freq:" + nodeID
val, err := rdb.Get(ctx, freqKey).Result()
if err != nil {
t.Fatalf("probe freq key not found: %v", err)
}
if val != "60" {
t.Errorf("probe:freq value = %q; want 60", val)
}
}
// TestFaultNoTransitionNoStreak verifies that the fault rule leaves the node
// status and streaks completely unchanged across multiple ticks, even though
// the domestic probes are failing.
func TestFaultNoTransitionNoStreak(t *testing.T) {
ctx := context.Background()
cfg := detect.DefaultConfig()
rdb, _ := newTestRedis(t)
lc := detect.NewMockLifecycle([]detect.NodeInfo{
{ID: nodeID, Status: detect.StatusUp, Weight: 100},
})
// Both domestic and overseas fail → fault.
faultSnaps := snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), overseasFail())
snapper := &mockSnapshotter{data: map[string]map[string]probe.ProbeSnapshot{nodeID: faultSnaps}}
notifier := &recordingNotifier{}
eng := newEngine(t, rdb, lc, snapper, notifier, &cfg)
// Run many ticks — should never transition.
for i := 0; i < 10; i++ {
if err := eng.Tick(ctx); err != nil {
t.Fatalf("tick %d: %v", i, err)
}
}
if got := lc.NodeStatus(nodeID); got != detect.StatusUp {
t.Errorf("status = %q; want up (fault must not transition)", got)
}
if events := lc.Events(); len(events) != 0 {
t.Errorf("unexpected events: %v", events)
}
if len(notifier.calls) == 0 {
t.Error("expected NotifyFault to be called at least once")
}
}
// TestSuspectWeightApplied verifies that when a node enters blocked_suspect,
// its routing weight is reduced to cfg.SuspectWeight (default 10).
func TestSuspectWeightApplied(t *testing.T) {
ctx := context.Background()
cfg := detect.DefaultConfig()
rdb, _ := newTestRedis(t)
lc := detect.NewMockLifecycle([]detect.NodeInfo{
{ID: nodeID, Status: detect.StatusUp, Weight: 100},
})
failSnaps := snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())
snapper := &mockSnapshotter{data: map[string]map[string]probe.ProbeSnapshot{nodeID: failSnaps}}
eng := newEngine(t, rdb, lc, snapper, nil, &cfg)
for i := 0; i < 2; i++ {
if err := eng.Tick(ctx); err != nil {
t.Fatalf("tick %d: %v", i, err)
}
}
if got := lc.NodeWeight(nodeID); got != cfg.SuspectWeight {
t.Errorf("weight after suspect = %d; want %d", got, cfg.SuspectWeight)
}
}
// TestNoOverseasDataNoSuspect verifies that when no overseas probe data is
// available, the engine does not trigger the suspect rule (we cannot confirm
// the node is globally reachable, so we cannot attribute a domestic failure
// to GFW censorship).
func TestNoOverseasDataNoSuspect(t *testing.T) {
ctx := context.Background()
cfg := detect.DefaultConfig()
rdb, _ := newTestRedis(t)
lc := detect.NewMockLifecycle([]detect.NodeInfo{
{ID: nodeID, Status: detect.StatusUp, Weight: 100},
})
// Only domestic probes, no overseas vantage.
domesticOnlySnaps := snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"))
snapper := &mockSnapshotter{data: map[string]map[string]probe.ProbeSnapshot{nodeID: domesticOnlySnaps}}
eng := newEngine(t, rdb, lc, snapper, nil, &cfg)
for i := 0; i < 5; i++ {
if err := eng.Tick(ctx); err != nil {
t.Fatalf("tick %d: %v", i, err)
}
}
if got := lc.NodeStatus(nodeID); got != detect.StatusUp {
t.Errorf("status = %q; want up (no overseas data → no suspect)", got)
}
}
@@ -0,0 +1,288 @@
// Package detect implements the detection engine that consumes probe snapshots,
// normalises them into per-node signals, applies five classification rules
// (suspect / traffic-warning / confirmed / recover / fault), and drives
// lifecycle-state transitions via the LifecycleService interface.
//
// This package only exposes Engine.Tick(ctx); the caller (15H DetectLoop) is
// responsible for the 5-minute schedule and for leader election.
package detect
import (
"context"
"fmt"
"sync"
"time"
)
// NodeStatus is the lifecycle state of a node as used by this package.
type NodeStatus string
const (
StatusProvisioning NodeStatus = "provisioning"
StatusProbing NodeStatus = "probing"
StatusUp NodeStatus = "up"
StatusDraining NodeStatus = "draining"
StatusDown NodeStatus = "down"
StatusDestroyed NodeStatus = "destroyed"
StatusBlockedSuspect NodeStatus = "blocked_suspect"
StatusBlockedConfirmed NodeStatus = "blocked_confirmed"
)
// NodeFilter restricts which nodes ListNodes returns.
// An empty Statuses slice means "all statuses".
type NodeFilter struct {
Statuses []NodeStatus
}
// NodeInfo is the minimal node descriptor required by the detection engine.
type NodeInfo struct {
ID string
UUID string
Status NodeStatus
Weight int
Version int64
}
// LoadInfo is the most recent load sample for a node.
type LoadInfo struct {
Online int
BandwidthMbps float64
Timestamp int64
}
// LoadPoint is a single load sample in a historical series.
type LoadPoint struct {
Timestamp int64
Online int
BandwidthMbps float64
}
// LifecycleService is the interface the detection engine uses to read and mutate
// node lifecycle state.
//
// The real implementation (#5 LifecycleService) executes all mutations inside
// MySQL transactions with optimistic locks. MockLifecycle is the in-memory
// stub used in tests.
//
// TransitionStatus convention: the underlying store executes
//
// UPDATE nodes SET status=to, version=version+1
// WHERE id=nodeID AND status=from
//
// and returns the number of rows affected. A return value of 0 means the node
// was already in a different state (concurrent change); the caller must treat
// this as a no-op for the current tick (idempotent, safe to retry next cycle).
type LifecycleService interface {
// ListNodes returns nodes matching the filter.
ListNodes(ctx context.Context, filter NodeFilter) ([]NodeInfo, error)
// TransitionStatus attempts an optimistic-lock status transition.
// On success it writes a node_events record with from/to/detail and bumps
// the node version. Returns (1, nil) on success, (0, nil) on lock conflict.
TransitionStatus(ctx context.Context, nodeID string, from, to NodeStatus, detail map[string]any) (int, error)
// SetWeight updates the routing weight for a node.
SetWeight(ctx context.Context, nodeID string, weight int) error
// BumpVersion increments the global directory version so clients refetch.
BumpVersion(ctx context.Context) error
// GetLoad returns the latest load sample for a node.
GetLoad(ctx context.Context, nodeID string) (LoadInfo, error)
// GetLoadHistory returns load samples recorded within the given window
// (oldest to newest), enabling a drop-percentage computation.
GetLoadHistory(ctx context.Context, nodeID string, window time.Duration) ([]LoadPoint, error)
}
// ─────────────────────────────────────────────────────────────────────────────
// MockLifecycle — in-memory stub for unit tests
// ─────────────────────────────────────────────────────────────────────────────
// MockEvent records a single TransitionStatus call for assertion in tests.
type MockEvent struct {
NodeID string
From NodeStatus
To NodeStatus
Detail map[string]any
}
// MockLifecycle is an in-memory LifecycleService implementation used in tests.
// All fields are safe for concurrent use.
type MockLifecycle struct {
mu sync.Mutex
nodes map[string]*NodeInfo // nodeID → node
events []MockEvent // recorded TransitionStatus calls
version int64 // global directory version
loads map[string][]LoadPoint // nodeID → ordered load samples
// conflictKeys is a set of "nodeID:from:to" strings that should simulate
// an optimistic-lock conflict (TransitionStatus returns 0 rows).
conflictKeys map[string]bool
}
// NewMockLifecycle creates a MockLifecycle pre-populated with the given nodes.
func NewMockLifecycle(nodes []NodeInfo) *MockLifecycle {
m := &MockLifecycle{
nodes: make(map[string]*NodeInfo, len(nodes)),
loads: make(map[string][]LoadPoint),
conflictKeys: make(map[string]bool),
}
for _, n := range nodes {
nn := n
m.nodes[n.ID] = &nn
}
return m
}
// SetConflict registers a (nodeID, from, to) tuple that should simulate a
// concurrent-modification conflict on the next matching TransitionStatus call.
func (m *MockLifecycle) SetConflict(nodeID string, from, to NodeStatus) {
m.mu.Lock()
defer m.mu.Unlock()
m.conflictKeys[conflictKey(nodeID, from, to)] = true
}
// ClearConflict removes a previously registered conflict.
func (m *MockLifecycle) ClearConflict(nodeID string, from, to NodeStatus) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.conflictKeys, conflictKey(nodeID, from, to))
}
func conflictKey(nodeID string, from, to NodeStatus) string {
return fmt.Sprintf("%s:%s:%s", nodeID, from, to)
}
// SetLoads sets the load history for a node (oldest to newest).
func (m *MockLifecycle) SetLoads(nodeID string, points []LoadPoint) {
m.mu.Lock()
defer m.mu.Unlock()
m.loads[nodeID] = append([]LoadPoint{}, points...)
}
// NodeStatus returns the current status of a node (test helper).
func (m *MockLifecycle) NodeStatus(nodeID string) NodeStatus {
m.mu.Lock()
defer m.mu.Unlock()
if n, ok := m.nodes[nodeID]; ok {
return n.Status
}
return ""
}
// NodeWeight returns the current weight of a node (test helper).
func (m *MockLifecycle) NodeWeight(nodeID string) int {
m.mu.Lock()
defer m.mu.Unlock()
if n, ok := m.nodes[nodeID]; ok {
return n.Weight
}
return 0
}
// Events returns a copy of all recorded TransitionStatus calls.
func (m *MockLifecycle) Events() []MockEvent {
m.mu.Lock()
defer m.mu.Unlock()
return append([]MockEvent{}, m.events...)
}
// Version returns the current global directory version (test helper).
func (m *MockLifecycle) Version() int64 {
m.mu.Lock()
defer m.mu.Unlock()
return m.version
}
// ListNodes implements LifecycleService.
func (m *MockLifecycle) ListNodes(_ context.Context, filter NodeFilter) ([]NodeInfo, error) {
m.mu.Lock()
defer m.mu.Unlock()
statusSet := make(map[NodeStatus]bool, len(filter.Statuses))
for _, s := range filter.Statuses {
statusSet[s] = true
}
var result []NodeInfo
for _, n := range m.nodes {
if len(statusSet) == 0 || statusSet[n.Status] {
result = append(result, *n)
}
}
return result, nil
}
// TransitionStatus implements LifecycleService.
func (m *MockLifecycle) TransitionStatus(_ context.Context, nodeID string, from, to NodeStatus, detail map[string]any) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.conflictKeys[conflictKey(nodeID, from, to)] {
return 0, nil
}
n, ok := m.nodes[nodeID]
if !ok {
return 0, fmt.Errorf("mock: node %s not found", nodeID)
}
if n.Status != from {
// Optimistic lock failed: node is in a different state.
return 0, nil
}
n.Status = to
n.Version++
m.events = append(m.events, MockEvent{
NodeID: nodeID,
From: from,
To: to,
Detail: detail,
})
return 1, nil
}
// SetWeight implements LifecycleService.
func (m *MockLifecycle) SetWeight(_ context.Context, nodeID string, weight int) error {
m.mu.Lock()
defer m.mu.Unlock()
n, ok := m.nodes[nodeID]
if !ok {
return fmt.Errorf("mock: node %s not found", nodeID)
}
n.Weight = weight
return nil
}
// BumpVersion implements LifecycleService.
func (m *MockLifecycle) BumpVersion(_ context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
m.version++
return nil
}
// GetLoad implements LifecycleService.
func (m *MockLifecycle) GetLoad(_ context.Context, nodeID string) (LoadInfo, error) {
m.mu.Lock()
defer m.mu.Unlock()
pts := m.loads[nodeID]
if len(pts) == 0 {
return LoadInfo{}, nil
}
p := pts[len(pts)-1]
return LoadInfo{Online: p.Online, BandwidthMbps: p.BandwidthMbps, Timestamp: p.Timestamp}, nil
}
// GetLoadHistory implements LifecycleService.
func (m *MockLifecycle) GetLoadHistory(_ context.Context, nodeID string, window time.Duration) ([]LoadPoint, error) {
m.mu.Lock()
defer m.mu.Unlock()
cutoff := time.Now().Add(-window).Unix()
var result []LoadPoint
for _, p := range m.loads[nodeID] {
if p.Timestamp >= cutoff {
result = append(result, p)
}
}
return result, nil
}
+101
View File
@@ -0,0 +1,101 @@
package detect
// DetectConfig holds all tunable thresholds for the detection engine.
// All fields are filled with production defaults by DefaultConfig().
// 15F (hot-reload) may overwrite fields at runtime; the Engine reads via a
// pointer so updates take effect on the next Tick without a restart.
type DetectConfig struct {
// DomesticFailNumerator and DomesticFailDenominator define the ISP-failure
// fraction that triggers the suspect rule.
// Default: 2/3 (two out of every three domestic ISPs must fail).
DomesticFailNumerator int // 2
DomesticFailDenominator int // 3
// SuspectStreakMin is the number of consecutive failing cycles required to
// transition a node from "up" to "blocked_suspect".
// Default: 2 cycles (10 min at the 5-min tick period).
SuspectStreakMin int
// TrafficDropThreshold is the minimum percentage drop in online-connection
// count over the 15-minute window that activates the traffic-warning rule.
// Default: 80.0 %.
TrafficDropThreshold float64
// TrafficBaselineMin is the minimum current online-connection count for
// the traffic-warning relaxation to apply. Below this threshold the node
// may simply be idle rather than experiencing user flight.
// Default: 20 connections.
TrafficBaselineMin int
// ConfirmedStreakMin is the number of consecutive cycles that a node must
// spend in "blocked_suspect" before being promoted to "blocked_confirmed".
// Default: 6 cycles (30 min).
ConfirmedStreakMin int
// RecoverStreakMin is the number of consecutive passing cycles while in
// "blocked_suspect" required to recover the node back to "up".
// Default: 2 cycles (10 min).
RecoverStreakMin int
// SuspectWeight is the routing weight applied when a node first enters
// "blocked_suspect", reducing traffic directed to it.
// Default: 10.
SuspectWeight int
}
// DefaultConfig returns a DetectConfig pre-filled with production defaults.
func DefaultConfig() DetectConfig {
return DetectConfig{
DomesticFailNumerator: 2,
DomesticFailDenominator: 3,
SuspectStreakMin: 2,
TrafficDropThreshold: 80.0,
TrafficBaselineMin: 20,
ConfirmedStreakMin: 6,
RecoverStreakMin: 2,
SuspectWeight: 10,
}
}
// isSuspectTriggered reports whether the domestic ISP-failure rate meets or
// exceeds the configured fraction (default ≥ 2/3).
//
// Integer arithmetic avoids floating-point rounding:
//
// fail/total >= num/den ↔ fail × den >= total × num
func (cfg *DetectConfig) isSuspectTriggered(sig NodeSignal) bool {
if sig.DomesticTotalISPs == 0 {
return false // no domestic probe data this cycle; cannot judge
}
return sig.DomesticFailISPs*cfg.DomesticFailDenominator >=
sig.DomesticTotalISPs*cfg.DomesticFailNumerator
}
// isFault reports whether the node appears to have a local outage rather than
// a domestic censorship event.
//
// Condition: domestic probes failing AND overseas probes also failing.
// When both sides are down the most likely cause is a node-level failure
// (hardware, network, crashed process) rather than GFW interference.
//
// The fault rule takes precedence over all other rules and must be evaluated
// first in the processing loop.
func isFault(sig NodeSignal) bool {
return sig.DomesticFailISPs > 0 && sig.OverseasHasData && !sig.OverseasOK
}
// effectiveSuspectStreakMin returns the cycle threshold required to enter
// "blocked_suspect", relaxed to 1 when the traffic-warning rule is active.
func (cfg *DetectConfig) effectiveSuspectStreakMin(sig NodeSignal) int {
if cfg.isTrafficWarning(sig) {
return 1
}
return cfg.SuspectStreakMin
}
// isTrafficWarning reports whether the 15-minute traffic-drop signal exceeds
// the threshold and the baseline is large enough to be meaningful.
func (cfg *DetectConfig) isTrafficWarning(sig NodeSignal) bool {
return sig.TrafficDropPct >= cfg.TrafficDropThreshold &&
sig.TrafficBaseline >= cfg.TrafficBaselineMin
}
+177
View File
@@ -0,0 +1,177 @@
package detect
import (
"context"
"strings"
"time"
"github.com/wangjia/pangolin/server/internal/scheduler/probe"
)
// NodeSignal is the normalised set of detection signals for a single node
// produced by one Tick.
type NodeSignal struct {
NodeID string
// DomesticTotalISPs is the count of distinct domestic (CN) ISPs for which
// probe data was available this cycle. Vantages with no data are excluded
// from both numerator and denominator per spec.
DomesticTotalISPs int
// DomesticFailISPs is the count of domestic ISPs judged to be failing.
// An ISP is failing if ANY vantage from that ISP indicates a failure at
// the highest available layer (L3 > L2 > L1; see isReportFailed).
DomesticFailISPs int
// OverseasHasData is true when at least one non-CN vantage reported this cycle.
OverseasHasData bool
// OverseasOK is true when every overseas vantage passed L1 this cycle.
// Only meaningful when OverseasHasData is true.
OverseasOK bool
// TrafficDropPct is the percentage drop in online-connection count computed
// by comparing the first half vs the second half of the 15-minute
// GetLoadHistory window. Zero when insufficient history is available.
TrafficDropPct float64
// TrafficBaseline is the online-connection count from the most recent load
// sample. Used by the traffic-warning rule's minimum-baseline guard.
TrafficBaseline int
}
// ProbeSnapshotter lets the Engine read probe snapshots without depending on
// the concrete *probe.Store (facilitates mocking in unit tests).
type ProbeSnapshotter interface {
SnapshotsByNode(ctx context.Context, nodeID string) (map[string]probe.ProbeSnapshot, error)
}
// computeSignals builds a NodeSignal for nodeID from the current probe
// snapshots and the 15-minute load history.
//
// ISP normalisation: third-party probes (15C) use a "3rd-" prefix on their
// ISP names (e.g. "3rd-ChinaTelecom"). This prefix is stripped before
// grouping so that first-party (15B) and third-party vantages from the same
// ISP are merged, per spec.
func computeSignals(
ctx context.Context,
nodeID string,
snapshots map[string]probe.ProbeSnapshot,
lc LifecycleService,
) NodeSignal {
sig := NodeSignal{NodeID: nodeID}
// domesticISPs: normalised ISP name → whether any vantage from that ISP
// shows a failure this cycle.
domesticISPs := make(map[string]bool)
overseasTotal := 0
overseasOKCount := 0
for _, snap := range snapshots {
v := snap.Vantage
r := snap.Report
switch {
case v.Country == "CN":
// Domestic vantage. Normalise ISP name and merge failures:
// any failing vantage from the same ISP marks that ISP as failed.
isp := normaliseISP(v.ISP)
failed := isReportFailed(r)
if prev, seen := domesticISPs[isp]; seen {
domesticISPs[isp] = prev || failed
} else {
domesticISPs[isp] = failed
}
case v.Country != "":
// Overseas vantage (any country other than CN).
overseasTotal++
if r.L1.OK {
overseasOKCount++
}
// Empty Country: vantage identity unknown excluded from both pools.
}
}
sig.DomesticTotalISPs = len(domesticISPs)
for _, failed := range domesticISPs {
if failed {
sig.DomesticFailISPs++
}
}
sig.OverseasHasData = overseasTotal > 0
sig.OverseasOK = overseasTotal > 0 && overseasOKCount == overseasTotal
// Traffic drop: compare the older half of the 15-minute window to the
// newer half. Zero is returned when there is too little history.
history, err := lc.GetLoadHistory(ctx, nodeID, 15*time.Minute)
if err == nil && len(history) > 0 {
sig.TrafficBaseline = history[len(history)-1].Online
if len(history) >= 2 {
sig.TrafficDropPct = trafficDropPct(history)
}
}
return sig
}
// isReportFailed determines whether a single NodeReport indicates a failure
// at the highest available measurement layer.
//
// Priority (highest to lowest):
// - L3 present → L3.OK is the authoritative verdict.
// A failing L3 is recorded even when L1/L2 passed (e.g. DPI/SNI reset
// at the application layer after a successful TCP/TLS handshake).
// - L3 absent → check L1 then L2.
func isReportFailed(r probe.NodeReport) bool {
if r.L3 != nil {
return !r.L3.OK
}
if !r.L1.OK {
return true
}
if r.L2 != nil && !r.L2.OK {
return true
}
return false
}
// normaliseISP strips the "3rd-" prefix that AliyunSyntheticAgent (15C) adds
// to distinguish third-party vantages, yielding the canonical ISP name used
// for grouping.
func normaliseISP(isp string) string {
return strings.TrimPrefix(isp, "3rd-")
}
// trafficDropPct computes the percentage drop between the first and second
// halves of the load-history slice (oldest → newest).
// Returns 0 when the baseline (first-half average) is zero or when the
// second half average is higher (traffic increased).
func trafficDropPct(history []LoadPoint) float64 {
n := len(history)
if n < 2 {
return 0
}
half := n / 2
older := avgOnline(history[:half])
newer := avgOnline(history[half:])
if older == 0 {
return 0
}
drop := older - newer
if drop <= 0 {
return 0
}
return float64(drop) / float64(older) * 100
}
func avgOnline(pts []LoadPoint) int {
if len(pts) == 0 {
return 0
}
sum := 0
for _, p := range pts {
sum += p.Online
}
return sum / len(pts)
}
+101
View File
@@ -0,0 +1,101 @@
package detect
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
const (
// streakTTL is the Redis TTL for a per-node streak key.
//
// Must be comfortably wider than the longest streak window, which is the
// confirmed threshold: 6 cycles × 5 min = 30 min. Two hours provides
// enough margin to survive a process restart or a short leader-election gap
// without zeroing out a valid streak mid-observation.
streakTTL = 2 * time.Hour
)
// Streak holds the consecutive-cycle counters for a single node.
// All three counters are stored together under one Redis key to make the
// read-modify-write loop atomic with a single GET + SET round-trip.
type Streak struct {
// FailStreak is the number of consecutive detection cycles in which the
// node's domestic probes failed while the node was in "up" state.
// It is reset to zero once the suspect threshold is reached (transition
// attempted) or when domestic probes recover.
FailStreak int `json:"fail_streak"`
// SuspectStreak counts consecutive cycles spent in "blocked_suspect" where
// the GFW-block condition (domestic fail + overseas OK) still holds.
// Reaching ConfirmedStreakMin triggers the confirmed transition.
SuspectStreak int `json:"suspect_streak"`
// RecoverStreak counts consecutive cycles in "blocked_suspect" where
// domestic probes pass. Reaching RecoverStreakMin triggers recovery.
RecoverStreak int `json:"recover_streak"`
}
// StreakStore persists per-node Streak values in Redis.
//
// Key format: detect:streak:{nodeID} (JSON-encoded Streak, TTL 2 h)
//
// Missing-key semantics: a missing key means "no streak data" and is treated
// as a zero Streak. After a process restart, the first Tick loads whichever
// streaks survived in Redis and continues from that point — streaks are NOT
// re-derived from probe history on restart, which keeps the restart path
// simple at the cost of a potential single-cycle window of uncertainty.
type StreakStore struct {
rdb *redis.Client
}
// NewStreakStore creates a StreakStore backed by the given Redis client.
func NewStreakStore(rdb *redis.Client) *StreakStore {
return &StreakStore{rdb: rdb}
}
// streakKey returns the Redis key for nodeID's streak data.
func streakKey(nodeID string) string {
return "detect:streak:" + nodeID
}
// Load reads the current Streak for nodeID.
// A missing or corrupted key returns a zero Streak without error.
func (s *StreakStore) Load(ctx context.Context, nodeID string) (Streak, error) {
val, err := s.rdb.Get(ctx, streakKey(nodeID)).Result()
if err == redis.Nil {
return Streak{}, nil
}
if err != nil {
return Streak{}, fmt.Errorf("detect: streak get %s: %w", nodeID, err)
}
var sk Streak
if err := json.Unmarshal([]byte(val), &sk); err != nil {
// Corrupted entry: treat as zero (non-fatal; we overwrite on the next Save).
return Streak{}, nil
}
return sk, nil
}
// Save persists sk for nodeID with the standard streakTTL.
func (s *StreakStore) Save(ctx context.Context, nodeID string, sk Streak) error {
data, err := json.Marshal(sk)
if err != nil {
return fmt.Errorf("detect: streak marshal %s: %w", nodeID, err)
}
if err := s.rdb.Set(ctx, streakKey(nodeID), data, streakTTL).Err(); err != nil {
return fmt.Errorf("detect: streak set %s: %w", nodeID, err)
}
return nil
}
// Reset deletes the streak key for nodeID (e.g. after a confirmed transition).
func (s *StreakStore) Reset(ctx context.Context, nodeID string) error {
if err := s.rdb.Del(ctx, streakKey(nodeID)).Err(); err != nil {
return fmt.Errorf("detect: streak del %s: %w", nodeID, err)
}
return nil
}
@@ -0,0 +1,7 @@
-- Revert nodes.status ENUM to the original six values.
-- WARNING: any rows currently in 'blocked_suspect' or 'blocked_confirmed'
-- will be rejected; run only after verifying no nodes are in those states.
ALTER TABLE nodes
MODIFY COLUMN status
ENUM('provisioning','probing','up','draining','down','destroyed')
NOT NULL DEFAULT 'provisioning';
@@ -0,0 +1,13 @@
-- Extend nodes.status ENUM to include the two intermediate blocking states
-- added by the detection engine (task 15D).
--
-- blocked_suspect : domestic probes failing + overseas OK for ≥ 2 cycles
-- blocked_confirmed: suspect held for ≥ 6 cycles → immediately taken off-directory
--
-- Note: MySQL ENUM modification rebuilds the table in-place for InnoDB;
-- on production, run during a low-traffic window.
ALTER TABLE nodes
MODIFY COLUMN status
ENUM('provisioning','probing','up','draining','down','destroyed',
'blocked_suspect','blocked_confirmed')
NOT NULL DEFAULT 'provisioning';