348 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
routeFallbacks 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()
}
// HasNode возвращает true, если нода уже зарегистрирована в engine.
func (e *Engine) HasNode(nodeID string) bool {
e.mu.RLock()
defer e.mu.RUnlock()
_, exists := e.nodes[nodeID]
return exists
}
// 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)
}
// IncrementRouteFallbacks увеличивает счётчик fallback-запросов.
func (e *Engine) IncrementRouteFallbacks() {
e.routeFallbacks.Add(1)
}
// HealthStats возвращает статистику для health endpoint.
type HealthStats struct {
TotalNodes int `json:"total_nodes"`
HealthyNodes int `json:"healthy_nodes"`
RouteRequests int64 `json:"route_requests_total"`
RouteFallbacks int64 `json:"route_fallbacks_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(),
RouteFallbacks: e.routeFallbacks.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
}