单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
185 lines
4.9 KiB
Go
185 lines
4.9 KiB
Go
// 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
|
||
}
|