Files
easyai-ai-gateway/apps/api/internal/executionpool/selector.go
T
wangbo 7786692d32 feat(routing): 引入多执行池智能调度
将 Worker 发现、路由画像、容量与执行传输抽象为平台无关接口,新增 Kubernetes 和静态容量适配器,并以 shadow 模式接入生产配置。

实现网络与容量评分、路由防抖、池队列、同步 Worker 租约、一次性执行令牌,以及提交状态不明时禁止重复分配的安全语义。

新增 0105 兼容迁移、管理接口、指标、OpenAPI 和回归测试。已执行全量 Go 测试、go vet、OpenAPI、迁移安全、Compose 与 Kustomize 验证。
2026-08-05 22:25:37 +08:00

203 lines
5.7 KiB
Go

package executionpool
import (
"errors"
"math"
"sort"
"strings"
"time"
)
const (
defaultHealthMaxAge = 45 * time.Second
defaultMinimumDwell = 2 * time.Minute
defaultSwitchImprovement = 0.20
defaultBetterWindowCount = 3
)
var ErrNoEligiblePool = errors.New("no eligible execution pool")
type SelectionCandidate struct {
Pool ExecutionPool
Health RouteHealth
Capacity CapacitySnapshot
CapabilityMatched bool
BetterWindows int
}
type SelectionRequest struct {
Candidates []SelectionCandidate
CurrentPoolID string
CurrentPoolSince time.Time
Now time.Time
}
type Decision struct {
PoolID string
Score float64
Reason string
Components map[string]float64
Rejected map[string]string
}
type Selector struct {
HealthMaxAge time.Duration
MinimumDwell time.Duration
SwitchImprovement float64
BetterWindows int
}
func NewSelector() Selector {
return Selector{
HealthMaxAge: defaultHealthMaxAge, MinimumDwell: defaultMinimumDwell,
SwitchImprovement: defaultSwitchImprovement, BetterWindows: defaultBetterWindowCount,
}
}
type scoredCandidate struct {
candidate SelectionCandidate
score float64
components map[string]float64
}
func (s Selector) Select(request SelectionRequest) (Decision, error) {
now := request.Now
if now.IsZero() {
now = time.Now()
}
if s.HealthMaxAge <= 0 {
s.HealthMaxAge = defaultHealthMaxAge
}
if s.MinimumDwell <= 0 {
s.MinimumDwell = defaultMinimumDwell
}
if s.SwitchImprovement <= 0 {
s.SwitchImprovement = defaultSwitchImprovement
}
if s.BetterWindows <= 0 {
s.BetterWindows = defaultBetterWindowCount
}
rejected := make(map[string]string)
scored := make([]scoredCandidate, 0, len(request.Candidates))
for _, candidate := range request.Candidates {
if reason := rejectionReason(candidate, now, s.HealthMaxAge); reason != "" {
rejected[candidate.Pool.ID] = reason
continue
}
network := routeQuality(candidate.Health)
queue := queueQuality(candidate.Capacity)
resources := clamp01(candidate.Capacity.ResourceHeadroom)
stability := clamp01(candidate.Capacity.Stability)
components := map[string]float64{
"network": network, "queue": queue, "resources": resources, "stability": stability,
}
scored = append(scored, scoredCandidate{
candidate: candidate,
score: 0.55*network + 0.25*queue + 0.15*resources + 0.05*stability,
components: components,
})
}
if len(scored) == 0 {
return Decision{Rejected: rejected}, ErrNoEligiblePool
}
sort.SliceStable(scored, func(i, j int) bool {
if math.Abs(scored[i].score-scored[j].score) > 0.000001 {
return scored[i].score > scored[j].score
}
return scored[i].candidate.Pool.ID < scored[j].candidate.Pool.ID
})
selected := scored[0]
if currentIndex := indexOfPool(scored, strings.TrimSpace(request.CurrentPoolID)); currentIndex >= 0 && currentIndex != 0 {
current := scored[currentIndex]
withinDwell := !request.CurrentPoolSince.IsZero() && now.Sub(request.CurrentPoolSince) < s.MinimumDwell
improvement := relativeImprovement(selected.score, current.score)
if withinDwell || improvement < s.SwitchImprovement || selected.candidate.BetterWindows < s.BetterWindows {
selected = current
}
}
return Decision{
PoolID: selected.candidate.Pool.ID,
Score: selected.score,
Reason: "network_quality_then_safe_capacity",
Components: selected.components,
Rejected: rejected,
}, nil
}
func rejectionReason(candidate SelectionCandidate, now time.Time, maxAge time.Duration) string {
if strings.TrimSpace(candidate.Pool.ID) == "" {
return "invalid_pool"
}
if candidate.Pool.State != PoolActive {
return "pool_not_active"
}
if !candidate.CapabilityMatched {
return "capability_mismatch"
}
if candidate.Health.State == RouteUnreachable {
return "route_unreachable"
}
if candidate.Health.State == RouteUnknown || candidate.Health.State == "" {
return "route_unknown"
}
if candidate.Health.SampledAt.IsZero() || now.Sub(candidate.Health.SampledAt) > maxAge || (!candidate.Health.ExpiresAt.IsZero() && !now.Before(candidate.Health.ExpiresAt)) {
return "route_stale"
}
if candidate.Capacity.Critical {
return "resource_critical"
}
if candidate.Capacity.WorkerCount < 1 {
return "no_worker"
}
if candidate.Capacity.SafeCapacity-candidate.Capacity.ActiveTasks < 1 {
return "capacity_exhausted"
}
return ""
}
func routeQuality(health RouteHealth) float64 {
success := clamp01(health.SuccessRate)
connect := latencyQuality(health.ConnectTLSP95, 100*time.Millisecond)
firstByte := latencyQuality(health.FirstByteP95, 250*time.Millisecond)
throughput := clamp01(health.UploadBytesPerSecond / (10 * 1024 * 1024))
jitter := latencyQuality(health.JitterP95, 100*time.Millisecond)
return 0.35*success + 0.25*connect + 0.25*firstByte + 0.10*throughput + 0.05*jitter
}
func queueQuality(capacity CapacitySnapshot) float64 {
available := capacity.SafeCapacity - capacity.ActiveTasks
if available <= 0 {
return 0
}
headroom := float64(available) / float64(max(capacity.SafeCapacity, 1))
waitPenalty := 1 / (1 + capacity.EstimatedQueueWait.Seconds())
return clamp01(0.6*headroom + 0.4*waitPenalty)
}
func latencyQuality(value, target time.Duration) float64 {
if value <= 0 {
return 0
}
return clamp01(1 / (1 + float64(value)/float64(target)))
}
func relativeImprovement(candidate, current float64) float64 {
if current <= 0 {
return 1
}
return (candidate - current) / current
}
func indexOfPool(candidates []scoredCandidate, poolID string) int {
for index := range candidates {
if candidates[index].candidate.Pool.ID == poolID {
return index
}
}
return -1
}
func clamp01(value float64) float64 {
return math.Max(0, math.Min(1, value))
}