// selector.go:P2C 轮换 + sticky session(design §5.2)。 // // P2C:随机取 2 候选取低延迟者;主备不同区域组(优先美国组、备日本组, // 按区域关键词规则选择,节点名不硬编码——proxy-probe §6.4)。 // sticky:map[session_or_domain]→出口 TTL 10min,进程内 LRU 容量 1000。 package proxymanager import ( "fmt" "sync" "time" ) // sticky 参数(design §5.2 锁定值)。 const ( stickyTTL = 10 * time.Minute stickyMaxSize = 1000 ) // stickyEntry sticky 映射条目。 type stickyEntry struct { Node string Region string ExpireAt time.Time } // StickyTable 进程内 sticky LRU(并发安全;LRU 淘汰 + TTL 双重收敛)。 type StickyTable struct { mu sync.Mutex m map[string]stickyEntry ttl time.Duration max int // lru 简化为访问序队列(map+环;容量 1000 直接用切片记录访问序足够)。 order []string } // NewStickyTable 构造(默认 TTL 10min 容量 1000;测试可自定义)。 func NewStickyTable(ttl time.Duration, max int) *StickyTable { if ttl <= 0 { ttl = stickyTTL } if max <= 0 { max = stickyMaxSize } return &StickyTable{m: map[string]stickyEntry{}, ttl: ttl, max: max} } // Get 取 sticky 出口;过期/不存在给 false 并惰性清理。 func (s *StickyTable) Get(key string) (string, bool) { s.mu.Lock() defer s.mu.Unlock() e, ok := s.m[key] if !ok { return "", false } if time.Now().After(e.ExpireAt) { delete(s.m, key) s.removeOrder(key) return "", false } s.touch(key) return e.Node, true } // Set 写 sticky(超容量时 LRU 淘汰最久未访问项)。 func (s *StickyTable) Set(key, node string) { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.m[key]; !ok && len(s.m) >= s.max { s.evictOldest() } s.m[key] = stickyEntry{Node: node, ExpireAt: time.Now().Add(s.ttl)} s.touch(key) } // touch 更新访问序。 func (s *StickyTable) touch(key string) { s.removeOrder(key) s.order = append(s.order, key) } // removeOrder 从访问序移除。 func (s *StickyTable) removeOrder(key string) { for i, k := range s.order { if k == key { s.order = append(s.order[:i], s.order[i+1:]...) return } } } // evictOldest 淘汰最久未访问项。 func (s *StickyTable) evictOldest() { if len(s.order) == 0 { return } oldest := s.order[0] s.order = s.order[1:] delete(s.m, oldest) } // Len 当前条目数(含未过期;测试观测用)。 func (s *StickyTable) Len() int { s.mu.Lock() defer s.mu.Unlock() return len(s.m) } // Reset 清空全部 sticky(活跃出口失败后重选用)。 func (s *StickyTable) Reset() { s.mu.Lock() defer s.mu.Unlock() s.m = map[string]stickyEntry{} s.order = nil } // Selector P2C + sticky 轮换器。 type Selector struct { sticky *StickyTable // now 可注入时钟(测试 TTL 用)。 now func() time.Time } // NewSelector 构造。 func NewSelector() *Selector { return &Selector{sticky: NewStickyTable(stickyTTL, stickyMaxSize), now: time.Now} } // StickyKey sticky 键:session 优先、否则 domain(design §5.2 粘滞语义)。 func StickyKey(session, domain string) string { if session != "" { return "s:" + session } return "d:" + domain } // ExitDecision 单次出口决策结果(/api/exit 响应形状;W3 对齐面)。 type ExitDecision struct { Proxy string `json:"proxy"` // 统一 mixed 出口(http://mihomo:17890) Node string `json:"node"` // 选定节点名(provenance 显示用) Region string `json:"region"` // 节点区域 Sticky bool `json:"sticky"` // 是否命中 sticky Blocked bool `json:"blocked"` // deny 域=true Reason string `json:"reason,omitempty"` // deny_rule / unhealthy 等 } // Pick P2C 选择:随机取 2 候选取低延迟者(延迟并列/单候选时取首者)。 // candidates 已按状态机过滤为 candidate 状态;nodeDelay 提供延迟查询 // (从未探测过的节点视为最差延迟,避免「零延迟未知节点」霸占选择)。 func Pick(candidates []string, nodeDelay func(string) int, rnd func(n int) int) string { if len(candidates) == 0 { return "" } if len(candidates) == 1 { return candidates[0] } i := rnd(len(candidates)) j := rnd(len(candidates)) if j == i { j = (j + 1) % len(candidates) } a, b := candidates[i], candidates[j] da, db := delayOf(nodeDelay(a)), delayOf(nodeDelay(b)) if db < da { return b } return a } // delayOf 归一延迟:未探测(<=0)视为最差(int max 语义由调用方约定)。 func delayOf(d int) int { if d <= 0 { return 1 << 30 } return d } // PickWithFallback P2C 失败(无候选)时给兜底说明。 func PickWithFallback(candidates []string, nodeDelay func(string) int, rnd func(n int) int) (string, error) { n := Pick(candidates, nodeDelay, rnd) if n == "" { return "", fmt.Errorf("selector: 无候选节点") } return n, nil }