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
This commit is contained in:
Maksim Totmin
2026-06-25 22:23:16 +07:00
parent 54259e51b9
commit 110289a093
11 changed files with 266 additions and 86 deletions
+2
View File
@@ -47,6 +47,8 @@ func (a *API) handleToggleNode(w http.ResponseWriter, r *http.Request) {
return
}
a.PushRouteCache()
info, _ := a.engine.GetNodeInfo(nodeID)
writeJSON(w, http.StatusOK, info)
}
+72
View File
@@ -1,8 +1,11 @@
package api
import (
"encoding/json"
"net/http"
"os"
"github.com/pulse-lets-go/internal/esl"
"github.com/pulse-lets-go/internal/models"
)
@@ -90,6 +93,45 @@ func (a *API) handleSetRouteMode(w http.ResponseWriter, r *http.Request) {
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()
@@ -105,6 +147,36 @@ func (a *API) cachedFallbackGateway() string {
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()
+2
View File
@@ -34,6 +34,8 @@ type API struct {
gatewayStates map[string]string // trunkID → "up"/"down"
fsStats esl.FsStats
fsStatsMu sync.RWMutex
routeCachePath string // путь к файловому кэшу маршрута (для Lua)
}
// NewAPI создаёт новый HTTP API с заданными зависимостями.