Maksim Totmin 78785e54e1 feat: production-grade routing cache + stale detection fixes
Three-tier routing cache (ESL global -> file -> HTTP) eliminates HTTP
from call path for 2500-5000 concurrent calls. Lua reads cached route
in ~0.1us instead of blocking on api:execute('curl', ...).

Engine fixes:
- recalcBestLocked now skips stale nodes (was 5 min bug -> now ~10-15s)
- PickNodeForCall action_type='node' checks staleness for consistency
- onMetric callback pushes cache on every metric (no ticker delay)

Config:
- stale_threshold_sec default 20 -> 10 (industry standard)
- contrib/ included in Makefile deploy target
- route.lua paths updated for /opt/pulse-lets-go
2026-06-25 22:23:16 +07:00

204 lines
5.9 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 (
"encoding/json"
"net/http"
"os"
"github.com/pulse-lets-go/internal/esl"
"github.com/pulse-lets-go/internal/models"
)
// handleRoute — GET /api/route — главный эндпоинт маршрутизации вызова.
// Принимает query-параметры: caller_id, dest, ingress_trunk (передаются из route.lua).
// Использует routing rules engine (PickNodeForCall) с priority-based first-match разрешением.
func (a *API) handleRoute(w http.ResponseWriter, r *http.Request) {
a.engine.IncrementRouteRequests()
callerID := r.URL.Query().Get("caller_id")
dest := r.URL.Query().Get("dest")
ingressTrunk := r.URL.Query().Get("ingress_trunk")
nodeID, score, fallback, matchedRule := a.engine.PickNodeForCall(callerID, dest, ingressTrunk)
// Нет зарегистрированных нод
if nodeID == "" {
a.engine.IncrementRouteFallbacks()
writeJSON(w, http.StatusServiceUnavailable, models.RouteResponse{
Error: "no_nodes_registered",
MatchedRule: matchedRule,
})
return
}
// Все ноды unhealthy — fallback
if fallback || score < 0 {
a.engine.IncrementRouteFallbacks()
fallbackGW := a.cachedFallbackGateway()
nodes := a.getRouteNodeInfo()
writeJSON(w, http.StatusOK, models.RouteResponse{
Fallback: true,
SIPGateway: fallbackGW,
Reason: "all_nodes_unhealthy",
Nodes: nodes,
MatchedRule: matchedRule,
})
return
}
// Нормальный маршрут — O(1) из in-memory кэша
gw, ok := a.cachedBalanceGateway(nodeID)
if !ok {
writeError(w, http.StatusInternalServerError, "gateway не найден для ноды")
return
}
writeJSON(w, http.StatusOK, models.RouteResponse{
NodeID: nodeID,
Score: score,
SIPGateway: gw,
MatchedRule: matchedRule,
})
}
// handleSetRouteMode — PUT /api/route/mode (admin only).
func (a *API) handleSetRouteMode(w http.ResponseWriter, r *http.Request) {
var req struct {
Mode string `json:"mode"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "некорректный JSON")
return
}
if req.Mode != "best" && req.Mode != "weighted_random" {
writeError(w, http.StatusBadRequest, "mode должен быть 'best' или 'weighted_random'")
return
}
// Сохраняем в config.json атомарно
if a.configManager != nil {
cfg, err := a.configManager.ReadConfig()
if err == nil {
cfg.BalanceMode = req.Mode
a.configManager.SaveConfig(cfg)
}
}
a.engine.SetBalancingMode(req.Mode)
// Немедленный WS-push для обновления UI
go a.broadcastBalancerUpdate()
writeJSON(w, http.StatusOK, map[string]string{"mode": req.Mode})
}
// GetBalanceGateway возвращает gateway для ноды из in-memory кэша (O(1)).
func (a *API) GetBalanceGateway(nodeID string) (string, bool) {
return a.cachedBalanceGateway(nodeID)
}
// GetFallbackGateway возвращает fallback gateway из in-memory кэша (O(1)).
func (a *API) GetFallbackGateway() string {
return a.cachedFallbackGateway()
}
// SetRouteCachePath устанавливает путь к файловому кэшу маршрута.
func (a *API) SetRouteCachePath(path string) {
a.routeCachePath = path
}
// PushRouteCache читает best-node из engine, резолвит gateway из trunk cache,
// атомарно пишет файловый кэш и пушит маршрут в FreeSWITCH через ESL.
func (a *API) PushRouteCache() {
nodeID, score, fallback := a.engine.GetBestNode()
var gw string
if nodeID != "" && !fallback {
gw, _ = a.cachedBalanceGateway(nodeID)
}
if gw == "" {
gw = a.cachedFallbackGateway()
}
entry := buildRouteCacheEntry(nodeID, gw, score, fallback)
if a.routeCachePath != "" {
writeRouteCacheFile(a.routeCachePath, entry)
}
if a.eslClient != nil && a.eslClient.IsConnected() {
esl.PushRoute(a.eslClient, entry)
}
}
// cachedBalanceGateway возвращает gateway для balance-транка из in-memory кэша (O(1)).
func (a *API) cachedBalanceGateway(nodeID string) (string, bool) {
a.trunkCacheMu.RLock()
defer a.trunkCacheMu.RUnlock()
gw, ok := a.balanceGateways[nodeID]
return gw, ok
}
// cachedFallbackGateway возвращает fallback gateway из in-memory кэша (O(1)).
func (a *API) cachedFallbackGateway() string {
a.trunkCacheMu.RLock()
defer a.trunkCacheMu.RUnlock()
return a.fallbackGateway
}
// buildRouteCacheEntry строит запись для кэширования в ESL global / файл.
func buildRouteCacheEntry(nodeID, gw string, score float64, fallback bool) esl.RouteCacheEntry {
entry := esl.RouteCacheEntry{
SIPGateway: gw,
Score: score,
Fallback: fallback,
}
if nodeID == "" {
entry.Error = "no_nodes_registered"
} else if fallback {
entry.Reason = "all_nodes_unhealthy"
} else {
entry.NodeID = nodeID
}
return entry
}
// writeRouteCacheFile атомарно записывает файловый кэш маршрута.
func writeRouteCacheFile(path string, entry esl.RouteCacheEntry) {
b, err := json.Marshal(entry)
if err != nil {
return
}
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, b, 0644); err != nil {
return
}
os.Rename(tmpPath, path)
}
// getRouteNodeInfo возвращает список нод с их скорами для ответа маршрутизации.
func (a *API) getRouteNodeInfo() []models.RouteNodeInfo {
nodes := a.engine.GetAllNodes()
result := make([]models.RouteNodeInfo, 0, len(nodes))
for _, n := range nodes {
info := models.RouteNodeInfo{
ID: n.NodeID,
Score: n.Score,
Reason: n.LethalReason,
Status: n.Status,
}
if n.Disabled {
info.Status = "disabled"
} else if n.IsStale {
info.Status = "stale"
} else if n.Score < 0 {
info.Status = "unhealthy"
} else {
info.Status = "healthy"
}
result = append(result, info)
}
return result
}