Files
pangolin/server/internal/scheduler/detect/engine_test.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

654 lines
24 KiB
Go

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)
}
}