332 lines
9.3 KiB
Go
332 lines
9.3 KiB
Go
// Package engine содержит scoring engine и router выбора лучшей ноды.
|
||
// Потокобезопасен: RWMutex разделяет чтение (роутинг) и запись (обновление метрик).
|
||
package engine
|
||
|
||
import (
|
||
"fmt"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
"github.com/pulse-lets-go/internal/config"
|
||
"github.com/pulse-lets-go/internal/models"
|
||
)
|
||
|
||
const defaultRingCapacity = 360 // 30 минут истории при метриках каждые 5 сек
|
||
|
||
// Engine управляет состоянием нод и вычислением скора.
|
||
type Engine struct {
|
||
mu sync.RWMutex
|
||
nodes map[string]*models.NodeState
|
||
rings map[string]*MetricsRingBuffer // история метрик на каждую ноду
|
||
cfg *config.Config
|
||
|
||
// Кэш для O(1) доступа из /api/route
|
||
bestNodeID string
|
||
bestScore float64
|
||
fallbackActive bool // true, если все ноды имеют score < 0
|
||
|
||
// Статистика для health endpoint
|
||
startTime time.Time
|
||
routeRequests atomic.Int64
|
||
lastMetricTime atomic.Int64 // unix ts последней полученной метрики
|
||
}
|
||
|
||
// NewEngine создаёт новый engine с заданной конфигурацией.
|
||
func NewEngine(cfg *config.Config) *Engine {
|
||
return &Engine{
|
||
nodes: make(map[string]*models.NodeState),
|
||
rings: make(map[string]*MetricsRingBuffer),
|
||
cfg: cfg,
|
||
bestScore: -1,
|
||
startTime: time.Now(),
|
||
}
|
||
}
|
||
|
||
// UpdateConfig обновляет конфигурацию скоринга (при изменении через API).
|
||
func (e *Engine) UpdateConfig(cfg *config.Config) {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
e.cfg = cfg
|
||
e.recalculateAll()
|
||
}
|
||
|
||
// UpdateMetric принимает метрику из NATS, обновляет состояние ноды и пересчитывает score.
|
||
// disabled-флаг НЕ трогается — он управляется только через ToggleNode.
|
||
func (e *Engine) UpdateMetric(m *models.NodeMetric) *models.NodeState {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
|
||
ns, exists := e.nodes[m.NodeID]
|
||
if !exists {
|
||
ns = &models.NodeState{NodeMetric: *m}
|
||
e.nodes[m.NodeID] = ns
|
||
} else {
|
||
// Сохраняем admin-флаги
|
||
disabled := ns.Disabled
|
||
disabledReason := ns.DisabledReason
|
||
ns.NodeMetric = *m
|
||
ns.Disabled = disabled
|
||
ns.DisabledReason = disabledReason
|
||
}
|
||
|
||
e.scoreNodeLocked(ns)
|
||
e.recalcBestLocked()
|
||
|
||
// Обновляем время последней метрики
|
||
e.lastMetricTime.Store(m.TS)
|
||
|
||
// Добавляем снапшот в кольцевой буфер истории
|
||
ring, ok := e.rings[m.NodeID]
|
||
if !ok {
|
||
ring = newMetricsRingBuffer(defaultRingCapacity)
|
||
e.rings[m.NodeID] = ring
|
||
}
|
||
ring.push(NodeSnapshot{
|
||
ActiveCalls: ns.ActiveCalls,
|
||
MaxCalls: ns.MaxCalls,
|
||
IdleCPU: ns.IdleCPU,
|
||
LoadAvg: ns.LoadAvg,
|
||
CallFailureRate: ns.CallFailureRate,
|
||
Score: ns.Score,
|
||
TS: ns.TS,
|
||
})
|
||
|
||
return ns
|
||
}
|
||
|
||
// GetBestNode возвращает ID лучшей ноды и fallback-статус.
|
||
// O(1) — читает кэш, не сканирует все ноды.
|
||
func (e *Engine) GetBestNode() (nodeID string, score float64, fallback bool) {
|
||
e.mu.RLock()
|
||
defer e.mu.RUnlock()
|
||
|
||
if len(e.nodes) == 0 {
|
||
return "", 0, true
|
||
}
|
||
return e.bestNodeID, e.bestScore, e.fallbackActive
|
||
}
|
||
|
||
// GetNodeInfo возвращает информацию о конкретной ноде.
|
||
func (e *Engine) GetNodeInfo(nodeID string) (*models.NodeInfo, bool) {
|
||
e.mu.RLock()
|
||
defer e.mu.RUnlock()
|
||
|
||
ns, exists := e.nodes[nodeID]
|
||
if !exists {
|
||
return nil, false
|
||
}
|
||
return stateToNodeInfo(ns, e.cfg.StaleThresholdSec, time.Now()), true
|
||
}
|
||
|
||
// GetAllNodes возвращает массив NodeInfo для всех нод.
|
||
func (e *Engine) GetAllNodes() []*models.NodeInfo {
|
||
e.mu.RLock()
|
||
defer e.mu.RUnlock()
|
||
|
||
now := time.Now()
|
||
result := make([]*models.NodeInfo, 0, len(e.nodes))
|
||
for _, ns := range e.nodes {
|
||
result = append(result, stateToNodeInfo(ns, e.cfg.StaleThresholdSec, now))
|
||
}
|
||
return result
|
||
}
|
||
|
||
// GetNodeHistory возвращает историю метрик ноды из ring buffer.
|
||
func (e *Engine) GetNodeHistory(nodeID string) []NodeSnapshot {
|
||
e.mu.RLock()
|
||
defer e.mu.RUnlock()
|
||
|
||
ring, ok := e.rings[nodeID]
|
||
if !ok {
|
||
return nil
|
||
}
|
||
return ring.snapshot()
|
||
}
|
||
|
||
// ToggleNode включает/выключает ноду из распределения.
|
||
func (e *Engine) ToggleNode(nodeID string, disabled bool, reason string) error {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
|
||
ns, exists := e.nodes[nodeID]
|
||
if !exists {
|
||
return fmt.Errorf("нода %s не найдена", nodeID)
|
||
}
|
||
|
||
ns.Disabled = disabled
|
||
if disabled {
|
||
ns.DisabledReason = reason
|
||
} else {
|
||
ns.DisabledReason = ""
|
||
}
|
||
|
||
e.scoreNodeLocked(ns)
|
||
e.recalcBestLocked()
|
||
return nil
|
||
}
|
||
|
||
// HasRecentMetrics возвращает true, если была получена хотя бы одна метрика за последние N секунд.
|
||
func (e *Engine) HasRecentMetrics(seconds int64) bool {
|
||
lastTS := e.lastMetricTime.Load()
|
||
if lastTS == 0 {
|
||
return false
|
||
}
|
||
return time.Now().Unix()-lastTS <= seconds
|
||
}
|
||
|
||
// IncrementRouteRequests увеличивает счётчик запросов /api/route.
|
||
func (e *Engine) IncrementRouteRequests() {
|
||
e.routeRequests.Add(1)
|
||
}
|
||
|
||
// HealthStats возвращает статистику для health endpoint.
|
||
type HealthStats struct {
|
||
TotalNodes int `json:"total_nodes"`
|
||
HealthyNodes int `json:"healthy_nodes"`
|
||
RouteRequests int64 `json:"route_requests_total"`
|
||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||
LastMetricTS int64 `json:"last_metric_ts"`
|
||
}
|
||
|
||
// GetHealthStats возвращает текущую HealthStats (потокобезопасно).
|
||
func (e *Engine) GetHealthStats() HealthStats {
|
||
e.mu.RLock()
|
||
defer e.mu.RUnlock()
|
||
|
||
healthy := 0
|
||
for _, ns := range e.nodes {
|
||
if ns.Score >= 0 && !ns.Disabled {
|
||
healthy++
|
||
}
|
||
}
|
||
return HealthStats{
|
||
TotalNodes: len(e.nodes),
|
||
HealthyNodes: healthy,
|
||
RouteRequests: e.routeRequests.Load(),
|
||
UptimeSeconds: int64(time.Since(e.startTime).Seconds()),
|
||
LastMetricTS: e.lastMetricTime.Load(),
|
||
}
|
||
}
|
||
|
||
// --- Приватные методы ---
|
||
|
||
// scoreNodeLocked вычисляет score для одной ноды.
|
||
// Вызывающий держит write lock.
|
||
func (e *Engine) scoreNodeLocked(ns *models.NodeState) {
|
||
sc := &e.cfg.Scoring
|
||
|
||
// 1. Проверка lethal-условий
|
||
if ns.Disabled {
|
||
ns.Score = -100
|
||
ns.LethalReason = fmt.Sprintf("disabled: %s", ns.DisabledReason)
|
||
return
|
||
}
|
||
if ns.Status != "ok" {
|
||
ns.Score = -100
|
||
ns.LethalReason = fmt.Sprintf("status: %s", ns.Status)
|
||
return
|
||
}
|
||
|
||
now := time.Now().Unix()
|
||
staleSec := int(now - ns.TS)
|
||
if staleSec > e.cfg.StaleThresholdSec {
|
||
ns.Score = -100
|
||
ns.LethalReason = fmt.Sprintf("stale: %d сек без метрик", staleSec)
|
||
return
|
||
}
|
||
|
||
if ns.ActiveCalls >= ns.MaxCalls && ns.MaxCalls > 0 {
|
||
ns.Score = -100
|
||
ns.LethalReason = "active_calls >= max_calls"
|
||
return
|
||
}
|
||
|
||
if ns.IdleCPU < sc.IdleCPUMin {
|
||
ns.Score = -100
|
||
ns.LethalReason = fmt.Sprintf("idle_cpu %.1f < %.0f", ns.IdleCPU, sc.IdleCPUMin)
|
||
return
|
||
}
|
||
|
||
if ns.CallFailureRate > sc.CallFailureRateLethal {
|
||
ns.Score = -100
|
||
ns.LethalReason = fmt.Sprintf("call_failure_rate %.1f > %.0f", ns.CallFailureRate, sc.CallFailureRateLethal)
|
||
return
|
||
}
|
||
|
||
// 2. Weighted score
|
||
ns.LethalReason = ""
|
||
|
||
callScore := clamp(100.0-(float64(ns.ActiveCalls)/float64(ns.MaxCalls)*100.0), 0, 100)
|
||
loadScore := clamp(100.0-(ns.LoadAvg*50.0), 0, 100)
|
||
idleScore := clamp(ns.IdleCPU, 0, 100)
|
||
failScore := clamp(100.0-ns.CallFailureRate, 0, 100)
|
||
|
||
w := &sc.Weights
|
||
ns.Score = callScore*w.CallScore + loadScore*w.LoadScore + idleScore*w.IdleScore + failScore*w.FailScore
|
||
}
|
||
|
||
// recalcBestLocked пересчитывает кэш лучшей ноды.
|
||
// Вызывающий держит write lock.
|
||
func (e *Engine) recalcBestLocked() {
|
||
bestID := ""
|
||
bestScore := -999.0
|
||
anyHealthy := false
|
||
|
||
for id, ns := range e.nodes {
|
||
if ns.Score >= 0 {
|
||
anyHealthy = true
|
||
}
|
||
if ns.Score > bestScore {
|
||
bestScore = ns.Score
|
||
bestID = id
|
||
}
|
||
}
|
||
|
||
e.bestNodeID = bestID
|
||
e.bestScore = bestScore
|
||
e.fallbackActive = !anyHealthy
|
||
}
|
||
|
||
// recalculateAll пересчитывает score всех нод и обновляет кэш.
|
||
func (e *Engine) recalculateAll() {
|
||
for _, ns := range e.nodes {
|
||
e.scoreNodeLocked(ns)
|
||
}
|
||
e.recalcBestLocked()
|
||
}
|
||
|
||
// stateToNodeInfo конвертирует NodeState в NodeInfo для API.
|
||
func stateToNodeInfo(ns *models.NodeState, staleThresholdSec int, now time.Time) *models.NodeInfo {
|
||
secondsAgo := now.Sub(time.Unix(ns.TS, 0)).Seconds()
|
||
isStale := int(secondsAgo) > staleThresholdSec
|
||
|
||
return &models.NodeInfo{
|
||
NodeID: ns.NodeID,
|
||
TS: ns.TS,
|
||
Status: ns.Status,
|
||
ActiveCalls: ns.ActiveCalls,
|
||
MaxCalls: ns.MaxCalls,
|
||
IdleCPU: ns.IdleCPU,
|
||
LoadAvg: ns.LoadAvg,
|
||
CallFailureRate: ns.CallFailureRate,
|
||
Disabled: ns.Disabled,
|
||
DisabledReason: ns.DisabledReason,
|
||
Score: ns.Score,
|
||
LethalReason: ns.LethalReason,
|
||
IsStale: isStale,
|
||
SecondsAgo: secondsAgo,
|
||
}
|
||
}
|
||
|
||
// clamp ограничивает значение диапазоном [minVal, maxVal].
|
||
func clamp(v, minVal, maxVal float64) float64 {
|
||
if v < minVal {
|
||
return minVal
|
||
}
|
||
if v > maxVal {
|
||
return maxVal
|
||
}
|
||
return v
|
||
}
|