first commit

This commit is contained in:
Maksim Totmin
2026-06-25 14:27:41 +07:00
commit 44f5fa88e5
44 changed files with 8664 additions and 0 deletions
+331
View File
@@ -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
}
+340
View File
@@ -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)
}
}
}
+52
View File
@@ -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
}