Maksim Totmin 938c9e9362 feat: weighted random routing + mode switching + EWMA smoothing + stale eviction
- Weighted random routing (math/rand/v2 per-goroutine ChaCha8)
- PUT /api/route/mode for runtime mode switch (best / weighted_random)
- EWMA score smoothing (configurable smoothing_factor, default 0.3)
- Periodic stale node eviction (5 min threshold, 60s interval)
- Non-blocking WS broadcast (per-client buffered channel + writePump)
- Background rate limiter cleanup goroutine
- In-memory trunk gateway cache (zero disk I/O in hot path)
- Configurable load_avg multiplier (default 50.0, backward compat)
- UI mode indicator + admin Switch button in dashboard
- 42 tests: weighted random, mode switching, EWMA, eviction, API integration
2026-06-25 20:42:46 +07:00

219 lines
5.6 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 api
import (
"fmt"
"log"
"net/http"
"sync"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/websocket"
"github.com/pulse-lets-go/internal/models"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // Разрешаем все origins для dev
},
}
const wsSendBufferSize = 32 // размер буфера per-client канала
// wsClient — соединение WebSocket одного клиента с выделенной write-горутиной.
type wsClient struct {
conn *websocket.Conn
send chan *models.WsMetricsMessage
}
// wsHub управляет подключёнными WebSocket-клиентами и рассылает метрики.
type wsHub struct {
mu sync.RWMutex
clients map[*wsClient]bool
}
// newWSHub создаёт хаб WebSocket.
func newWSHub() *wsHub {
return &wsHub{
clients: make(map[*wsClient]bool),
}
}
// add добавляет клиента в хаб.
func (h *wsHub) add(c *wsClient) {
h.mu.Lock()
h.clients[c] = true
h.mu.Unlock()
}
// remove удаляет клиента из хаба и закрывает его канал.
// Идемпотентно — может вызываться многократно (writePump + readPump).
func (h *wsHub) remove(c *wsClient) {
h.mu.Lock()
if h.clients[c] {
delete(h.clients, c)
close(c.send)
c.conn.Close()
}
h.mu.Unlock()
}
// broadcast рассылает метрики всем клиентам неблокирующе.
// Медленные клиенты пропускают сообщения (best-effort delivery).
func (h *wsHub) broadcast(msg *models.WsMetricsMessage) {
h.mu.RLock()
defer h.mu.RUnlock()
for client := range h.clients {
select {
case client.send <- msg:
default:
// Клиент слишком медленный — пропускаем сообщение
go h.remove(client)
}
}
}
// writePump — выделенная горутина записи для одного клиента.
// Читает из client.send и пишет в WebSocket.
func (c *wsClient) writePump(hub *wsHub) {
defer hub.remove(c)
for msg := range c.send {
if err := c.conn.WriteJSON(msg); err != nil {
return
}
}
}
// handleWS — WebSocket /ws/metrics.
func (a *API) handleWS(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
// пробуем заголовок
token = extractBearerToken(r)
}
if token == "" {
writeError(w, http.StatusUnauthorized, "требуется токен")
return
}
// Валидация JWT
userInfo, err := a.validateToken(token)
if err != nil {
writeError(w, http.StatusUnauthorized, "недействительный токен")
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("[ws] ошибка upgrade: %v", err)
return
}
client := &wsClient{
conn: conn,
send: make(chan *models.WsMetricsMessage, wsSendBufferSize),
}
a.wsHub.add(client)
log.Printf("[ws] клиент подключён: %s (%s)", userInfo.Username, r.RemoteAddr)
// Запускаем write-горутину
go client.writePump(a.wsHub)
// Отправляем текущее состояние при подключении
go func() {
a.BroadcastAll()
}()
// Читаем из вебсокета (ping/pong + закрытие)
go func() {
defer a.wsHub.remove(client)
for {
_, _, err := conn.ReadMessage()
if err != nil {
log.Printf("[ws] клиент отключён: %s", r.RemoteAddr)
return
}
}
}()
}
// BroadcastMetrics рассылает метрики нод всем WS-клиентам.
func (a *API) BroadcastMetrics() {
nodes := a.engine.GetAllNodes()
msg := &models.WsMetricsMessage{
Type: "nodes_update",
Payload: nodes,
}
a.wsHub.broadcast(msg)
}
// BroadcastAll рассылает ноды и состояние балансировщика.
func (a *API) BroadcastAll() {
a.BroadcastMetrics()
a.broadcastBalancerUpdate()
}
func (a *API) broadcastBalancerUpdate() {
stats := a.engine.GetHealthStats()
gwTotal, gwUp, gwDown := a.gatewayStats()
info := models.BalancerInfo{
NatsConnected: a.natsConnected != nil && a.natsConnected(),
GatewaysTotal: gwTotal,
GatewaysUp: gwUp,
GatewaysDown: gwDown,
RouteTotal: stats.RouteRequests,
RouteFallbacks: stats.RouteFallbacks,
UptimeSec: stats.UptimeSeconds,
BalanceMode: a.engine.GetBalancingMode(),
}
if a.eslClient != nil {
eslStats := a.eslClient.GetStats()
info.EslConnected = eslStats.Status == "connected"
info.EslUptimeSec = eslStats.UptimeSec
info.EslReconnects = eslStats.Reconnects
a.fsStatsMu.RLock()
info.FsActiveCalls = a.fsStats.ActiveCalls
info.FsMaxSessions = a.fsStats.MaxSessions
info.FsUptime = a.fsStats.FsUptime
a.fsStatsMu.RUnlock()
}
msg := &models.WsMetricsMessage{
Type: "balancer_update",
Payload: info,
}
a.wsHub.broadcast(msg)
}
// validateToken проверяет JWT и возвращает UserInfo.
func (a *API) validateToken(tokenStr string) (*UserInfo, error) {
token, err := parseJWT(tokenStr, a.jwtSecret)
if err != nil {
return nil, err
}
return token, nil
}
// parseJWT парсит JWT токен.
func parseJWT(tokenStr string, secret string) (*UserInfo, error) {
c := &claims{}
token, err := jwt.ParseWithClaims(tokenStr, c, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil || !token.Valid {
return nil, fmt.Errorf("недействительный токен: %w", err)
}
return &UserInfo{
UserID: c.UserID,
Username: c.Username,
Role: c.Role,
}, nil
}