88757b2ac4
新建 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>
289 lines
8.9 KiB
Go
289 lines
8.9 KiB
Go
// 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
|
|
}
|