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
204 lines
5.9 KiB
Go
204 lines
5.9 KiB
Go
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
|
||
}
|