first commit
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pulse-lets-go/internal/config"
|
||||
"github.com/pulse-lets-go/internal/models"
|
||||
)
|
||||
|
||||
func defaultCfg() *config.Config {
|
||||
return &config.Config{
|
||||
StaleThresholdSec: 20,
|
||||
Scoring: config.ScoringConfig{
|
||||
IdleCPUMin: 5.0,
|
||||
CallFailureRateLethal: 15.0,
|
||||
Weights: config.ScoringWeights{
|
||||
CallScore: 0.40,
|
||||
LoadScore: 0.30,
|
||||
IdleScore: 0.20,
|
||||
FailScore: 0.10,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func freshMetric(nodeID string) *models.NodeMetric {
|
||||
return &models.NodeMetric{
|
||||
NodeID: nodeID,
|
||||
TS: time.Now().Unix(),
|
||||
Status: "ok",
|
||||
ActiveCalls: 42,
|
||||
MaxCalls: 250,
|
||||
IdleCPU: 35,
|
||||
LoadAvg: 0.85,
|
||||
CallFailureRate: 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEngine(t *testing.T) {
|
||||
eng := NewEngine(defaultCfg())
|
||||
if eng == nil {
|
||||
t.Fatal("ожидался engine, получен nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMetric_CreatesNode(t *testing.T) {
|
||||
eng := NewEngine(defaultCfg())
|
||||
m := freshMetric("pbx-01")
|
||||
ns := eng.UpdateMetric(m)
|
||||
|
||||
if ns.NodeID != "pbx-01" {
|
||||
t.Errorf("ожидался node_id=pbx-01, получен %s", ns.NodeID)
|
||||
}
|
||||
if len(eng.nodes) != 1 {
|
||||
t.Errorf("ожидалась 1 нода, получено %d", len(eng.nodes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMetric_PreservesDisabled(t *testing.T) {
|
||||
eng := NewEngine(defaultCfg())
|
||||
m := freshMetric("pbx-01")
|
||||
eng.UpdateMetric(m)
|
||||
|
||||
// Отключаем ноду вручную
|
||||
eng.ToggleNode("pbx-01", true, "test")
|
||||
eng.UpdateMetric(m)
|
||||
|
||||
ns, _ := eng.GetNodeInfo("pbx-01")
|
||||
if !ns.Disabled {
|
||||
t.Error("ожидалось disabled=true")
|
||||
}
|
||||
if ns.DisabledReason != "test" {
|
||||
t.Errorf("ожидалась причина 'test', получена '%s'", ns.DisabledReason)
|
||||
}
|
||||
|
||||
// Включаем обратно
|
||||
eng.ToggleNode("pbx-01", false, "")
|
||||
eng.UpdateMetric(m)
|
||||
|
||||
ns, _ = eng.GetNodeInfo("pbx-01")
|
||||
if ns.Disabled {
|
||||
t.Error("ожидалось disabled=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_LethalDisabled(t *testing.T) {
|
||||
eng := NewEngine(defaultCfg())
|
||||
m := freshMetric("pbx-01")
|
||||
eng.UpdateMetric(m)
|
||||
eng.ToggleNode("pbx-01", true, "maintenance")
|
||||
|
||||
ns, _ := eng.GetNodeInfo("pbx-01")
|
||||
if ns.Score != -100 {
|
||||
t.Errorf("disabled нода должна иметь score=-100, получен %.0f", ns.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_LethalStatusNotOk(t *testing.T) {
|
||||
eng := NewEngine(defaultCfg())
|
||||
m := freshMetric("pbx-01")
|
||||
m.Status = "down"
|
||||
eng.UpdateMetric(m)
|
||||
|
||||
ns, _ := eng.GetNodeInfo("pbx-01")
|
||||
if ns.Score != -100 {
|
||||
t.Errorf("status==down нода должна иметь score=-100, получен %.0f", ns.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_LethalStale(t *testing.T) {
|
||||
eng := NewEngine(defaultCfg())
|
||||
m := freshMetric("pbx-01")
|
||||
// Устанавливаем ts на 30 секунд назад (stale_threshold=20)
|
||||
m.TS = time.Now().Unix() - 30
|
||||
eng.UpdateMetric(m)
|
||||
|
||||
ns, _ := eng.GetNodeInfo("pbx-01")
|
||||
if ns.Score != -100 {
|
||||
t.Errorf("stale нода должна иметь score=-100, получен %.0f", ns.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_LethalMaxCalls(t *testing.T) {
|
||||
eng := NewEngine(defaultCfg())
|
||||
m := freshMetric("pbx-01")
|
||||
m.ActiveCalls = 250
|
||||
m.MaxCalls = 250
|
||||
eng.UpdateMetric(m)
|
||||
|
||||
ns, _ := eng.GetNodeInfo("pbx-01")
|
||||
if ns.Score != -100 {
|
||||
t.Errorf("нода с исчерпанной ёмкостью должна иметь score=-100, получен %.0f", ns.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_LethalIdleCPU(t *testing.T) {
|
||||
eng := NewEngine(defaultCfg())
|
||||
m := freshMetric("pbx-01")
|
||||
m.IdleCPU = 2.0
|
||||
eng.UpdateMetric(m)
|
||||
|
||||
ns, _ := eng.GetNodeInfo("pbx-01")
|
||||
if ns.Score != -100 {
|
||||
t.Errorf("нода с idle_cpu < 5 должна иметь score=-100, получен %.0f", ns.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_LethalFailureRate(t *testing.T) {
|
||||
eng := NewEngine(defaultCfg())
|
||||
m := freshMetric("pbx-01")
|
||||
m.CallFailureRate = 20.0
|
||||
eng.UpdateMetric(m)
|
||||
|
||||
ns, _ := eng.GetNodeInfo("pbx-01")
|
||||
if ns.Score != -100 {
|
||||
t.Errorf("нода с call_failure_rate > 15 должна иметь score=-100, получен %.0f", ns.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_Healthy(t *testing.T) {
|
||||
eng := NewEngine(defaultCfg())
|
||||
m := freshMetric("pbx-01")
|
||||
ns := eng.UpdateMetric(m)
|
||||
|
||||
if ns.Score <= 0 {
|
||||
t.Errorf("здоровая нода должна иметь положительный score, получен %.0f", ns.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_HealthyFormula(t *testing.T) {
|
||||
eng := NewEngine(defaultCfg())
|
||||
m := freshMetric("pbx-01")
|
||||
m.ActiveCalls = 0
|
||||
m.MaxCalls = 100
|
||||
m.IdleCPU = 100
|
||||
m.LoadAvg = 0
|
||||
m.CallFailureRate = 0
|
||||
|
||||
ns := eng.UpdateMetric(m)
|
||||
|
||||
// Все компоненты = 100
|
||||
// score = 100*0.4 + 100*0.3 + 100*0.2 + 100*0.1 = 100
|
||||
if ns.Score != 100.0 {
|
||||
t.Errorf("идеальная нода должна иметь score=100, получен %.1f", ns.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_WeightedFormula(t *testing.T) {
|
||||
eng := NewEngine(defaultCfg())
|
||||
m := freshMetric("pbx-01")
|
||||
m.ActiveCalls = 50
|
||||
m.MaxCalls = 100
|
||||
m.IdleCPU = 50
|
||||
m.LoadAvg = 1.0
|
||||
m.CallFailureRate = 5.0
|
||||
|
||||
ns := eng.UpdateMetric(m)
|
||||
|
||||
// call_score = 100 - (50/100 * 100) = 50
|
||||
// load_score = 100 - (1.0 * 50) = 50
|
||||
// idle_score = 50
|
||||
// fail_score = 100 - 5 = 95
|
||||
// score = 50*0.4 + 50*0.3 + 50*0.2 + 95*0.1 = 20+15+10+9.5 = 54.5
|
||||
expected := 54.5
|
||||
|
||||
if ns.Score < expected-0.1 || ns.Score > expected+0.1 {
|
||||
t.Errorf("ожидался score=%.1f, получен %.1f", expected, ns.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBestNode_NoNodes(t *testing.T) {
|
||||
eng := NewEngine(defaultCfg())
|
||||
_, _, fallback := eng.GetBestNode()
|
||||
if !fallback {
|
||||
t.Error("без нод должен быть fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBestNode_OneNode(t *testing.T) {
|
||||
eng := NewEngine(defaultCfg())
|
||||
eng.UpdateMetric(freshMetric("pbx-01"))
|
||||
|
||||
id, score, fallback := eng.GetBestNode()
|
||||
if fallback {
|
||||
t.Error("не должно быть fallback с одной здоровой нодой")
|
||||
}
|
||||
if id != "pbx-01" {
|
||||
t.Errorf("ожидалась pbx-01, получена %s", id)
|
||||
}
|
||||
if score <= 0 {
|
||||
t.Errorf("ожидался положительный score, получен %.0f", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBestNode_AllUnhealthy(t *testing.T) {
|
||||
eng := NewEngine(defaultCfg())
|
||||
m1 := freshMetric("pbx-01")
|
||||
m1.Status = "down"
|
||||
eng.UpdateMetric(m1)
|
||||
|
||||
m2 := freshMetric("pbx-02")
|
||||
m2.ActiveCalls = 500
|
||||
m2.MaxCalls = 500
|
||||
eng.UpdateMetric(m2)
|
||||
|
||||
_, _, fallback := eng.GetBestNode()
|
||||
if !fallback {
|
||||
t.Error("все ноды unhealthy — должен быть fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBestNode_MultipleNodes(t *testing.T) {
|
||||
eng := NewEngine(defaultCfg())
|
||||
|
||||
// pbx-01: 80% загружена (меньше score)
|
||||
m1 := freshMetric("pbx-01")
|
||||
m1.ActiveCalls = 200
|
||||
m1.MaxCalls = 250
|
||||
eng.UpdateMetric(m1)
|
||||
|
||||
// pbx-02: 20% загружена (выше score)
|
||||
m2 := freshMetric("pbx-02")
|
||||
m2.ActiveCalls = 20
|
||||
m2.MaxCalls = 250
|
||||
eng.UpdateMetric(m2)
|
||||
|
||||
id, _, _ := eng.GetBestNode()
|
||||
if id != "pbx-02" {
|
||||
t.Errorf("ожидалась pbx-02 (меньше загрузки), получена %s", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNodeHistory(t *testing.T) {
|
||||
eng := NewEngine(defaultCfg())
|
||||
eng.UpdateMetric(freshMetric("pbx-01"))
|
||||
|
||||
// Повторяем обновление для накопления истории
|
||||
for i := 0; i < 5; i++ {
|
||||
m := freshMetric("pbx-01")
|
||||
m.ActiveCalls = i * 10
|
||||
eng.UpdateMetric(m)
|
||||
}
|
||||
|
||||
history := eng.GetNodeHistory("pbx-01")
|
||||
if len(history) < 3 {
|
||||
t.Errorf("ожидалось минимум 3 снапшота, получено %d", len(history))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNodeHistory_NoNode(t *testing.T) {
|
||||
eng := NewEngine(defaultCfg())
|
||||
history := eng.GetNodeHistory("nonexistent")
|
||||
if history != nil {
|
||||
t.Error("для несуществующей ноды ожидался nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleNode_NotFound(t *testing.T) {
|
||||
eng := NewEngine(defaultCfg())
|
||||
err := eng.ToggleNode("nonexistent", true, "test")
|
||||
if err == nil {
|
||||
t.Error("ожидалась ошибка для несуществующей ноды")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfig(t *testing.T) {
|
||||
eng := NewEngine(defaultCfg())
|
||||
m := freshMetric("pbx-01")
|
||||
eng.UpdateMetric(m)
|
||||
|
||||
// Меняем конфиг на более строгий
|
||||
newCfg := defaultCfg()
|
||||
newCfg.Scoring.CallFailureRateLethal = 0.1
|
||||
newCfg.Scoring.IdleCPUMin = 50
|
||||
eng.UpdateConfig(newCfg)
|
||||
|
||||
ns, _ := eng.GetNodeInfo("pbx-01")
|
||||
if ns.Score != -100 {
|
||||
t.Errorf("после ужесточения конфига нода должна стать unhealthy, получен score=%.0f", ns.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClamp(t *testing.T) {
|
||||
tests := []struct {
|
||||
v, min, max, expected float64
|
||||
}{
|
||||
{50, 0, 100, 50},
|
||||
{-10, 0, 100, 0},
|
||||
{150, 0, 100, 100},
|
||||
{0, 0, 0, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := clamp(tt.v, tt.min, tt.max)
|
||||
if got != tt.expected {
|
||||
t.Errorf("clamp(%.0f, %.0f, %.0f) = %.0f, ожидалось %.0f",
|
||||
tt.v, tt.min, tt.max, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package engine
|
||||
|
||||
// MetricsRingBuffer — кольцевой буфер истории метрик для одной ноды.
|
||||
// Хранит до `capacity` последних снапшотов NodeState.
|
||||
// Потокобезопасен: используется внутри Engine (защищён engine.mu).
|
||||
type MetricsRingBuffer struct {
|
||||
buf []NodeSnapshot
|
||||
capacity int
|
||||
head int // позиция следующей записи
|
||||
size int // текущее количество элементов
|
||||
}
|
||||
|
||||
// NodeSnapshot — снапшот метрик на момент времени.
|
||||
type NodeSnapshot struct {
|
||||
ActiveCalls int
|
||||
MaxCalls int
|
||||
IdleCPU float64
|
||||
LoadAvg float64
|
||||
CallFailureRate float64
|
||||
Score float64
|
||||
TS int64
|
||||
}
|
||||
|
||||
// newMetricsRingBuffer создаёт кольцевой буфер заданной ёмкости.
|
||||
func newMetricsRingBuffer(capacity int) *MetricsRingBuffer {
|
||||
return &MetricsRingBuffer{
|
||||
buf: make([]NodeSnapshot, capacity),
|
||||
capacity: capacity,
|
||||
}
|
||||
}
|
||||
|
||||
// push добавляет снапшот в буфер. Если буфер заполнен, затирает самый старый.
|
||||
func (b *MetricsRingBuffer) push(snapshot NodeSnapshot) {
|
||||
b.buf[b.head] = snapshot
|
||||
b.head = (b.head + 1) % b.capacity
|
||||
if b.size < b.capacity {
|
||||
b.size++
|
||||
}
|
||||
}
|
||||
|
||||
// snapshot возвращает копию всех снапшотов в хронологическом порядке.
|
||||
func (b *MetricsRingBuffer) snapshot() []NodeSnapshot {
|
||||
result := make([]NodeSnapshot, b.size)
|
||||
start := b.head - b.size
|
||||
if start < 0 {
|
||||
start += b.capacity
|
||||
}
|
||||
for i := 0; i < b.size; i++ {
|
||||
result[i] = b.buf[(start+i)%b.capacity]
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user