feat(routing): 引入多执行池智能调度
将 Worker 发现、路由画像、容量与执行传输抽象为平台无关接口,新增 Kubernetes 和静态容量适配器,并以 shadow 模式接入生产配置。 实现网络与容量评分、路由防抖、池队列、同步 Worker 租约、一次性执行令牌,以及提交状态不明时禁止重复分配的安全语义。 新增 0105 兼容迁移、管理接口、指标、OpenAPI 和回归测试。已执行全量 Go 测试、go vet、OpenAPI、迁移安全、Compose 与 Kustomize 验证。
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user