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:
parent
a7722af29e
commit
78785e54e1
4
Makefile
4
Makefile
@ -53,14 +53,16 @@ build-prod:
|
|||||||
# Деплой в PREFIX: бинарник + статика + data + log
|
# Деплой в PREFIX: бинарник + статика + data + log
|
||||||
deploy: build-prod
|
deploy: build-prod
|
||||||
@echo "→ Деплой в $(PREFIX)"
|
@echo "→ Деплой в $(PREFIX)"
|
||||||
install -d $(PREFIX)/bin $(PREFIX)/data $(PREFIX)/web $(PREFIX)/log
|
install -d $(PREFIX)/bin $(PREFIX)/data $(PREFIX)/web $(PREFIX)/log $(PREFIX)/contrib
|
||||||
install -m 755 $(BINDIR)/$(APP) $(PREFIX)/bin/
|
install -m 755 $(BINDIR)/$(APP) $(PREFIX)/bin/
|
||||||
cp -r $(WEB_DIR)/build/. $(PREFIX)/web/
|
cp -r $(WEB_DIR)/build/. $(PREFIX)/web/
|
||||||
|
cp contrib/*.lua contrib/*.json $(PREFIX)/contrib/ 2>/dev/null || true
|
||||||
@echo "✓ Готово: $(PREFIX)"
|
@echo "✓ Готово: $(PREFIX)"
|
||||||
@echo " $(PREFIX)/bin/$(APP)"
|
@echo " $(PREFIX)/bin/$(APP)"
|
||||||
@echo " $(PREFIX)/web/ (SvelteKit статика)"
|
@echo " $(PREFIX)/web/ (SvelteKit статика)"
|
||||||
@echo " $(PREFIX)/data/ (config.json, trunks.json, users.json)"
|
@echo " $(PREFIX)/data/ (config.json, trunks.json, users.json)"
|
||||||
@echo " $(PREFIX)/log/ (ASCII-лог метрик)"
|
@echo " $(PREFIX)/log/ (ASCII-лог метрик)"
|
||||||
|
@echo " $(PREFIX)/contrib/ (route.lua, конфиги агентов)"
|
||||||
|
|
||||||
# Установка systemd unit
|
# Установка systemd unit
|
||||||
install-systemd:
|
install-systemd:
|
||||||
|
|||||||
@ -139,8 +139,13 @@ func main() {
|
|||||||
|
|
||||||
// 6. HTTP API
|
// 6. HTTP API
|
||||||
apiHandler = api.NewAPI(eng, cfgMgr, cfg.GetJWTSecret(), cfg.MonitoringAPIKey, sub.IsConnected, cfg)
|
apiHandler = api.NewAPI(eng, cfgMgr, cfg.GetJWTSecret(), cfg.MonitoringAPIKey, sub.IsConnected, cfg)
|
||||||
|
apiHandler.SetRouteCachePath(filepath.Join(dataDir, "route_cache.json"))
|
||||||
apiHandler.RebuildTrunkCache() // первичное построение in-memory кэша gateway
|
apiHandler.RebuildTrunkCache() // первичное построение in-memory кэша gateway
|
||||||
|
|
||||||
|
sub.SetOnMetric(func() {
|
||||||
|
apiHandler.PushRouteCache()
|
||||||
|
})
|
||||||
|
|
||||||
// При эвикции stale-ноды — удаляем её balance-транк из trunks.json
|
// При эвикции stale-ноды — удаляем её balance-транк из trunks.json
|
||||||
eng.SetOnNodeEvicted(func(nodeID string) {
|
eng.SetOnNodeEvicted(func(nodeID string) {
|
||||||
trunks, err := cfgMgr.ReadTrunks()
|
trunks, err := cfgMgr.ReadTrunks()
|
||||||
@ -232,6 +237,7 @@ func main() {
|
|||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(5 * time.Second)
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
|
apiHandler.PushRouteCache()
|
||||||
if eslClient != nil && eslClient.IsConnected() {
|
if eslClient != nil && eslClient.IsConnected() {
|
||||||
if stats, err := eslClient.FetchFsStats(); err == nil {
|
if stats, err := eslClient.FetchFsStats(); err == nil {
|
||||||
apiHandler.UpdateFsStats(stats)
|
apiHandler.UpdateFsStats(stats)
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
Телефонный балансировщик нагрузки на базе FreeSWITCH + NATS.
|
Телефонный балансировщик нагрузки на базе FreeSWITCH + NATS.
|
||||||
|
|
||||||
┌─ Размещение ─────────────────────────────────────────────────────┐
|
┌─ Размещение ─────────────────────────────────────────────────────┐
|
||||||
│ /etc/freeswitch/scripts/route.lua │
|
│ /opt/pulse-lets-go/contrib/route.lua │
|
||||||
│ │
|
│ │
|
||||||
│ ┌─ Использование в dialplan ────────────────────────────────────┐│
|
│ ┌─ Использование в dialplan ────────────────────────────────────┐│
|
||||||
│ │ <extension name="route_call"> ││
|
│ │ <extension name="route_call"> ││
|
||||||
@ -13,24 +13,30 @@
|
|||||||
│ │ </extension> ││
|
│ │ </extension> ││
|
||||||
│ └───────────────────────────────────────────────────────────────┘│
|
│ └───────────────────────────────────────────────────────────────┘│
|
||||||
│ │
|
│ │
|
||||||
|
│ ┌─ Кэширование (защита от блокировки dialplan-тредов) ───────────┐│
|
||||||
|
│ │ Уровень 1: FS global variable (ESL push от balancer, ~0.1µs) ││
|
||||||
|
│ │ Уровень 2: File cache (/tmp/pulse_route_cache.json, ~50µs) ││
|
||||||
|
│ │ Уровень 3: HTTP API (/api/route, ~1-5ms, safety net) ││
|
||||||
|
│ └───────────────────────────────────────────────────────────────┘│
|
||||||
|
│ │
|
||||||
│ ┌─ Переменные канала (опционально) ─────────────────────────────┐│
|
│ ┌─ Переменные канала (опционально) ─────────────────────────────┐│
|
||||||
│ │ balancer_url — URL pulse-lets-go (default: localhost:8080) ││
|
│ │ balancer_url — URL pulse-lets-go (default: localhost:8080)││
|
||||||
│ │ ingress_trunk — ID входящего транка ││
|
│ │ ingress_trunk — ID входящего транка ││
|
||||||
|
│ │ route_timeout — таймаут HTTP fallback (default: 3с) ││
|
||||||
|
│ │ route_cache_path — путь к файловому кэшу (default: <prefix>/data/…) ││
|
||||||
│ └───────────────────────────────────────────────────────────────┘│
|
│ └───────────────────────────────────────────────────────────────┘│
|
||||||
│ │
|
│ │
|
||||||
│ ┌─ Требования FreeSWITCH ───────────────────────────────────────┐│
|
│ ┌─ Требования FreeSWITCH ───────────────────────────────────────┐│
|
||||||
│ │ mod_curl — <load module="mod_curl"/> в modules.conf.xml ││
|
│ │ mod_curl — <load module="mod_curl"/> (только для уровня 3) ││
|
||||||
│ │ cjson — входит в стандартную поставку FS Lua ││
|
│ │ cjson — входит в стандартную поставку FS Lua ││
|
||||||
│ └───────────────────────────────────────────────────────────────┘│
|
│ └───────────────────────────────────────────────────────────────┘│
|
||||||
└───────────────────────────────────────────────────────────────────┘
|
└───────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
Логика:
|
Логика:
|
||||||
1. Извлечь caller_id + destination из переменных сессии
|
1. Попробовать FS global variable pulse_route_json (ESL push)
|
||||||
2. HTTP GET /api/route?caller_id=X&dest=Y&ingress_trunk=Z
|
2. Попробовать файловый кэш
|
||||||
3. Разобрать JSON-ответ
|
3. HTTP API /api/route (fallback)
|
||||||
4. При fallback=true → bridge на fallback gateway (оператор)
|
4. Разобрать JSON → bridge на sip_gateway
|
||||||
5. При ошибке → кладём трубку с NORMAL_TEMPORARY_FAILURE
|
|
||||||
6. Нормальный маршрут → bridge на sip_gateway выбранной ноды
|
|
||||||
]]--
|
]]--
|
||||||
|
|
||||||
-- ===================================================================
|
-- ===================================================================
|
||||||
@ -40,13 +46,16 @@
|
|||||||
-- URL балансировщика (переопределяется через переменную канала)
|
-- URL балансировщика (переопределяется через переменную канала)
|
||||||
local BALANCER_URL = session:getVariable("balancer_url") or "http://localhost:8080"
|
local BALANCER_URL = session:getVariable("balancer_url") or "http://localhost:8080"
|
||||||
|
|
||||||
-- Таймаут HTTP-запроса в секундах (через параметр curl)
|
-- Таймаут HTTP-запроса в секундах
|
||||||
local HTTP_TIMEOUT = tonumber(session:getVariable("route_timeout") or "3")
|
local HTTP_TIMEOUT = tonumber(session:getVariable("route_timeout") or "3")
|
||||||
|
|
||||||
|
-- Путь к файловому кэшу маршрута
|
||||||
|
local ROUTE_CACHE_PATH = session:getVariable("route_cache_path") or "/opt/pulse-lets-go/data/route_cache.json"
|
||||||
|
|
||||||
-- JSON-парсер
|
-- JSON-парсер
|
||||||
local cjson = require("cjson")
|
local cjson = require("cjson")
|
||||||
|
|
||||||
-- API FreeSWITCH для вызова mod_curl
|
-- API FreeSWITCH для вызова curl
|
||||||
local api = freeswitch.API()
|
local api = freeswitch.API()
|
||||||
|
|
||||||
-- ===================================================================
|
-- ===================================================================
|
||||||
@ -58,26 +67,6 @@ local function log(level, msg)
|
|||||||
freeswitch.consoleLog(level, "[route] " .. msg .. "\n")
|
freeswitch.consoleLog(level, "[route] " .. msg .. "\n")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Выполнить HTTP GET и вернуть тело ответа (без заголовков).
|
|
||||||
-- mod_curl API возвращает сырой HTTP-ответ: заголовки + \r?\n\r?\n + тело.
|
|
||||||
local function http_get(url)
|
|
||||||
local cmd = "curl " .. url
|
|
||||||
local raw = api:execute("curl", cmd)
|
|
||||||
|
|
||||||
if not raw or raw == "" then
|
|
||||||
return nil, "пустой ответ от curl"
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Отделяем заголовки от тела
|
|
||||||
-- Ищем двойной перенос строки (CRLF или LF)
|
|
||||||
local body = raw:match("\r?\n\r?\n(.+)")
|
|
||||||
if not body then
|
|
||||||
-- Если нет заголовков — весь ответ это тело
|
|
||||||
return raw, nil
|
|
||||||
end
|
|
||||||
return body, nil
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Безопасная отправка трубки с кодом причины
|
-- Безопасная отправка трубки с кодом причины
|
||||||
local function safe_hangup(cause)
|
local function safe_hangup(cause)
|
||||||
if session:ready() then
|
if session:ready() then
|
||||||
@ -85,8 +74,95 @@ local function safe_hangup(cause)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Попробовать получить JSON-маршрут из FS global variable (уровень 1).
|
||||||
|
local function try_global_cache()
|
||||||
|
-- session:getVariable с префиксом global_ читает глобальные переменные FS
|
||||||
|
local val = session:getVariable("global_pulse_route_json")
|
||||||
|
if val and val ~= "" then
|
||||||
|
return val
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Попробовать получить JSON-маршрут из файлового кэша (уровень 2).
|
||||||
|
local function try_file_cache()
|
||||||
|
local f, err = io.open(ROUTE_CACHE_PATH, "r")
|
||||||
|
if not f then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local content = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
if content and content ~= "" then
|
||||||
|
return content
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Попробовать получить JSON-маршрут через HTTP API (уровень 3, fallback).
|
||||||
|
local function try_http(caller_id, dest, ing_trunk)
|
||||||
|
local query = string.format("caller_id=%s&dest=%s", caller_id, dest)
|
||||||
|
if ing_trunk ~= "" then
|
||||||
|
query = query .. "&ingress_trunk=" .. ing_trunk
|
||||||
|
end
|
||||||
|
local url = BALANCER_URL .. "/api/route?" .. query
|
||||||
|
|
||||||
|
local cmd = "curl " .. url
|
||||||
|
local raw = api:execute("curl", cmd)
|
||||||
|
|
||||||
|
if not raw or raw == "" then
|
||||||
|
return nil, "пустой ответ от curl"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Отделяем заголовки от тела (mod_curl возвращает сырой HTTP)
|
||||||
|
local body = raw:match("\r?\n\r?\n(.+)")
|
||||||
|
if not body then
|
||||||
|
body = raw
|
||||||
|
end
|
||||||
|
return body, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Разобрать JSON и выполнить bridge.
|
||||||
|
-- Возвращает true если звонок обработан (bridge или error).
|
||||||
|
local function process_route_json(json_str, source)
|
||||||
|
if not json_str then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, route = pcall(cjson.decode, json_str)
|
||||||
|
if not ok or type(route) ~= "table" then
|
||||||
|
log("warn", source .. ": некорректный JSON")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Ошибка балансировщика (нет зарегистрированных нод)
|
||||||
|
if route.error then
|
||||||
|
log("err", source .. ": " .. tostring(route.error))
|
||||||
|
safe_hangup()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Фолбэк (все ноды unhealthy)
|
||||||
|
if route.fallback and route.sip_gateway and route.sip_gateway ~= "" then
|
||||||
|
log("info", string.format("%s: FALLBACK → %s (причина: %s)",
|
||||||
|
source, route.sip_gateway, route.reason or "all_nodes_unhealthy"))
|
||||||
|
session:execute("bridge", route.sip_gateway)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Успешный маршрут
|
||||||
|
if route.sip_gateway and route.sip_gateway ~= "" then
|
||||||
|
log("info", string.format("%s: ROUTE %s score=%s → %s",
|
||||||
|
source, route.node_id or "?", tostring(route.score or 0), route.sip_gateway))
|
||||||
|
session:execute("bridge", route.sip_gateway)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
log("warn", source .. ": нет sip_gateway в ответе")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
-- ===================================================================
|
-- ===================================================================
|
||||||
-- Основная логика
|
-- Основная логика (трёхуровневый кэш)
|
||||||
-- ===================================================================
|
-- ===================================================================
|
||||||
|
|
||||||
-- Извлекаем параметры вызова
|
-- Извлекаем параметры вызова
|
||||||
@ -94,63 +170,29 @@ local caller_id = session:getVariable("caller_id_number") or ""
|
|||||||
local dest = session:getVariable("destination_number") or ""
|
local dest = session:getVariable("destination_number") or ""
|
||||||
local ing_trunk = session:getVariable("ingress_trunk") or ""
|
local ing_trunk = session:getVariable("ingress_trunk") or ""
|
||||||
|
|
||||||
-- Собираем URL
|
-- Уровень 1: FS global variable (ESL push, sub-µs)
|
||||||
local query = string.format("caller_id=%s&dest=%s", caller_id, dest)
|
local json_str = try_global_cache()
|
||||||
if ing_trunk ~= "" then
|
if process_route_json(json_str, "global") then
|
||||||
query = query .. "&ingress_trunk=" .. ing_trunk
|
return
|
||||||
end
|
end
|
||||||
local url = BALANCER_URL .. "/api/route?" .. query
|
|
||||||
|
|
||||||
log("info", "запрос маршрута: " .. url)
|
-- Уровень 2: файловый кэш (локальный, ~50µs)
|
||||||
|
json_str = try_file_cache()
|
||||||
|
if process_route_json(json_str, "file") then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
-- Запрашиваем маршрут у pulse-lets-go
|
-- Уровень 3: HTTP API (fallback, ~1-5ms)
|
||||||
local body, curl_err = http_get(url)
|
log("info", "запрос маршрута HTTP: " .. BALANCER_URL .. "/api/route?caller_id=" .. caller_id .. "&dest=" .. dest)
|
||||||
|
|
||||||
|
local body, http_err = try_http(caller_id, dest, ing_trunk)
|
||||||
if not body then
|
if not body then
|
||||||
log("err", "ошибка HTTP: " .. (curl_err or "unknown"))
|
log("err", "все уровни кэша недоступны: " .. (http_err or "unknown"))
|
||||||
safe_hangup()
|
safe_hangup()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Парсим JSON
|
if not process_route_json(body, "http") then
|
||||||
local ok, route = pcall(cjson.decode, body)
|
log("err", "непредвиденный формат ответа балансировщика")
|
||||||
if not ok or type(route) ~= "table" then
|
|
||||||
log("err", "ошибка парсинга JSON: " .. tostring(route))
|
|
||||||
safe_hangup()
|
safe_hangup()
|
||||||
return
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ── Фолбэк (все ноды unhealthy) ──────────────────────────────────
|
|
||||||
if route.fallback and route.sip_gateway then
|
|
||||||
log("info", string.format("FALLBACK → %s (причина: %s)",
|
|
||||||
route.sip_gateway, route.reason or "all_nodes_unhealthy"))
|
|
||||||
|
|
||||||
if route.nodes then
|
|
||||||
for _, n in ipairs(route.nodes) do
|
|
||||||
log("info", string.format(" %s: score=%d reason=%s",
|
|
||||||
n.id or "?", n.score or 0, n.reason or "-"))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
session:execute("bridge", route.sip_gateway)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Ошибка API (нет зарегистрированных нод) ──────────────────────
|
|
||||||
if route.error then
|
|
||||||
log("err", "ошибка балансировщика: " .. route.error)
|
|
||||||
safe_hangup()
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Успешный маршрут ─────────────────────────────────────────────
|
|
||||||
if route.sip_gateway then
|
|
||||||
log("info", string.format("ROUTE: %s score=%d → %s",
|
|
||||||
route.node_id or "?", route.score or 0, route.sip_gateway))
|
|
||||||
|
|
||||||
session:execute("bridge", route.sip_gateway)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ── Непредвиденная ситуация ──────────────────────────────────────
|
|
||||||
log("err", "нет sip_gateway в ответе балансировщика")
|
|
||||||
safe_hangup()
|
|
||||||
|
|||||||
@ -47,6 +47,8 @@ func (a *API) handleToggleNode(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.PushRouteCache()
|
||||||
|
|
||||||
info, _ := a.engine.GetNodeInfo(nodeID)
|
info, _ := a.engine.GetNodeInfo(nodeID)
|
||||||
writeJSON(w, http.StatusOK, info)
|
writeJSON(w, http.StatusOK, info)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/pulse-lets-go/internal/esl"
|
||||||
"github.com/pulse-lets-go/internal/models"
|
"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})
|
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)).
|
// cachedBalanceGateway возвращает gateway для balance-транка из in-memory кэша (O(1)).
|
||||||
func (a *API) cachedBalanceGateway(nodeID string) (string, bool) {
|
func (a *API) cachedBalanceGateway(nodeID string) (string, bool) {
|
||||||
a.trunkCacheMu.RLock()
|
a.trunkCacheMu.RLock()
|
||||||
@ -105,6 +147,36 @@ func (a *API) cachedFallbackGateway() string {
|
|||||||
return a.fallbackGateway
|
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 возвращает список нод с их скорами для ответа маршрутизации.
|
// getRouteNodeInfo возвращает список нод с их скорами для ответа маршрутизации.
|
||||||
func (a *API) getRouteNodeInfo() []models.RouteNodeInfo {
|
func (a *API) getRouteNodeInfo() []models.RouteNodeInfo {
|
||||||
nodes := a.engine.GetAllNodes()
|
nodes := a.engine.GetAllNodes()
|
||||||
|
|||||||
@ -34,6 +34,8 @@ type API struct {
|
|||||||
gatewayStates map[string]string // trunkID → "up"/"down"
|
gatewayStates map[string]string // trunkID → "up"/"down"
|
||||||
fsStats esl.FsStats
|
fsStats esl.FsStats
|
||||||
fsStatsMu sync.RWMutex
|
fsStatsMu sync.RWMutex
|
||||||
|
|
||||||
|
routeCachePath string // путь к файловому кэшу маршрута (для Lua)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAPI создаёт новый HTTP API с заданными зависимостями.
|
// NewAPI создаёт новый HTTP API с заданными зависимостями.
|
||||||
|
|||||||
@ -420,7 +420,7 @@ func (m *Manager) createDefaultConfig() error {
|
|||||||
ListenAddr: ":8080",
|
ListenAddr: ":8080",
|
||||||
JWTSecret: jwtSecret,
|
JWTSecret: jwtSecret,
|
||||||
MonitoringAPIKey: apiKey,
|
MonitoringAPIKey: apiKey,
|
||||||
StaleThresholdSec: 20,
|
StaleThresholdSec: 10,
|
||||||
Scoring: ScoringConfig{
|
Scoring: ScoringConfig{
|
||||||
IdleCPUMin: 5.0,
|
IdleCPUMin: 5.0,
|
||||||
CallFailureRateLethal: 15.0,
|
CallFailureRateLethal: 15.0,
|
||||||
|
|||||||
@ -456,12 +456,18 @@ func (e *Engine) scoreNodeLocked(ns *models.NodeState, firstMetric bool) {
|
|||||||
|
|
||||||
// recalcBestLocked пересчитывает кэш лучшей ноды.
|
// recalcBestLocked пересчитывает кэш лучшей ноды.
|
||||||
// Вызывающий держит write lock.
|
// Вызывающий держит write lock.
|
||||||
|
// Проверяет staleness (now - ns.TS > staleThreshold) — исключает ноды без свежих метрик.
|
||||||
func (e *Engine) recalcBestLocked() {
|
func (e *Engine) recalcBestLocked() {
|
||||||
bestID := ""
|
bestID := ""
|
||||||
bestScore := -999.0
|
bestScore := -999.0
|
||||||
anyHealthy := false
|
anyHealthy := false
|
||||||
|
now := time.Now().Unix()
|
||||||
|
staleThreshold := int64(e.cfg.StaleThresholdSec)
|
||||||
|
|
||||||
for id, ns := range e.nodes {
|
for id, ns := range e.nodes {
|
||||||
|
if now-ns.TS > staleThreshold {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if ns.Score >= 0 {
|
if ns.Score >= 0 {
|
||||||
anyHealthy = true
|
anyHealthy = true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -318,8 +318,10 @@ func (e *Engine) PickNodeForCall(callerID, dest, ingress string) (nodeID string,
|
|||||||
case "node":
|
case "node":
|
||||||
e.mu.RLock()
|
e.mu.RLock()
|
||||||
ns, ok := e.nodes[cr.ActionNodeID]
|
ns, ok := e.nodes[cr.ActionNodeID]
|
||||||
|
now := time.Now().Unix()
|
||||||
|
staleThreshold := int64(e.cfg.StaleThresholdSec)
|
||||||
e.mu.RUnlock()
|
e.mu.RUnlock()
|
||||||
if ok && ns.Score >= 0 {
|
if ok && ns.Score >= 0 && now-ns.TS <= staleThreshold {
|
||||||
nodeID, score, matchedRule = cr.ActionNodeID, 100, cr.Name
|
nodeID, score, matchedRule = cr.ActionNodeID, 100, cr.Name
|
||||||
resolved = true
|
resolved = true
|
||||||
}
|
}
|
||||||
|
|||||||
36
internal/esl/routepush.go
Normal file
36
internal/esl/routepush.go
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package esl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RouteCacheEntry — запись для кэширования маршрута в FS global variable и файле.
|
||||||
|
type RouteCacheEntry struct {
|
||||||
|
NodeID string `json:"node_id,omitempty"`
|
||||||
|
SIPGateway string `json:"sip_gateway,omitempty"`
|
||||||
|
Score float64 `json:"score,omitempty"`
|
||||||
|
Fallback bool `json:"fallback,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
TS int64 `json:"ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushRoute пушит текущий маршрут в FreeSWITCH через global variable.
|
||||||
|
// Если клиент не подключён — возвращает ошибку молча (вызывающий игнорирует).
|
||||||
|
func PushRoute(c *Client, entry RouteCacheEntry) error {
|
||||||
|
if c == nil || !c.IsConnected() {
|
||||||
|
return fmt.Errorf("esl не подключён")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.TS = time.Now().Unix()
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(entry)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("маршалинг route cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := fmt.Sprintf("api set_global pulse_route_json=%s", string(jsonBytes))
|
||||||
|
return c.SendAsync(cmd)
|
||||||
|
}
|
||||||
@ -23,6 +23,7 @@ type Subscriber struct {
|
|||||||
engine *engine.Engine
|
engine *engine.Engine
|
||||||
logger *filelog.Logger
|
logger *filelog.Logger
|
||||||
onNewNode func(nodeID, sipGateway string) // вызывается при первой метрике от новой ноды
|
onNewNode func(nodeID, sipGateway string) // вызывается при первой метрике от новой ноды
|
||||||
|
onMetric func() // вызывается после каждой обработанной метрики
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,6 +64,11 @@ func (s *Subscriber) SetOnNewNode(fn func(nodeID, sipGateway string)) {
|
|||||||
s.onNewNode = fn
|
s.onNewNode = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetOnMetric устанавливает callback, вызываемый после каждой обработанной метрики.
|
||||||
|
func (s *Subscriber) SetOnMetric(fn func()) {
|
||||||
|
s.onMetric = fn
|
||||||
|
}
|
||||||
|
|
||||||
// handleMetric — обработчик входящих сообщений NATS.
|
// handleMetric — обработчик входящих сообщений NATS.
|
||||||
func (s *Subscriber) handleMetric(msg *natsgo.Msg) {
|
func (s *Subscriber) handleMetric(msg *natsgo.Msg) {
|
||||||
var metric models.NodeMetric
|
var metric models.NodeMetric
|
||||||
@ -86,6 +92,10 @@ func (s *Subscriber) handleMetric(msg *natsgo.Msg) {
|
|||||||
ns := s.engine.UpdateMetric(&metric)
|
ns := s.engine.UpdateMetric(&metric)
|
||||||
s.logger.Log(ns)
|
s.logger.Log(ns)
|
||||||
|
|
||||||
|
if s.onMetric != nil {
|
||||||
|
s.onMetric()
|
||||||
|
}
|
||||||
|
|
||||||
// Авто-создание balance-транка для новой ноды
|
// Авто-создание balance-транка для новой ноды
|
||||||
if isNew && metric.SIPGateway != "" && s.onNewNode != nil {
|
if isNew && metric.SIPGateway != "" && s.onNewNode != nil {
|
||||||
s.onNewNode(metric.NodeID, metric.SIPGateway)
|
s.onNewNode(metric.NodeID, metric.SIPGateway)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user