将 Worker 发现、路由画像、容量与执行传输抽象为平台无关接口,新增 Kubernetes 和静态容量适配器,并以 shadow 模式接入生产配置。 实现网络与容量评分、路由防抖、池队列、同步 Worker 租约、一次性执行令牌,以及提交状态不明时禁止重复分配的安全语义。 新增 0105 兼容迁移、管理接口、指标、OpenAPI 和回归测试。已执行全量 Go 测试、go vet、OpenAPI、迁移安全、Compose 与 Kustomize 验证。
203 lines
5.7 KiB
Go
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))
|
|
}
|