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:
+125
-83
@@ -3,7 +3,7 @@
|
||||
Телефонный балансировщик нагрузки на базе FreeSWITCH + NATS.
|
||||
|
||||
┌─ Размещение ─────────────────────────────────────────────────────┐
|
||||
│ /etc/freeswitch/scripts/route.lua │
|
||||
│ /opt/pulse-lets-go/contrib/route.lua │
|
||||
│ │
|
||||
│ ┌─ Использование в dialplan ────────────────────────────────────┐│
|
||||
│ │ <extension name="route_call"> ││
|
||||
@@ -13,24 +13,30 @@
|
||||
│ │ </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) ││
|
||||
│ │ ingress_trunk — ID входящего транка ││
|
||||
│ │ balancer_url — URL pulse-lets-go (default: localhost:8080)││
|
||||
│ │ ingress_trunk — ID входящего транка ││
|
||||
│ │ route_timeout — таймаут HTTP fallback (default: 3с) ││
|
||||
│ │ route_cache_path — путь к файловому кэшу (default: <prefix>/data/…) ││
|
||||
│ └───────────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
│ ┌─ Требования FreeSWITCH ───────────────────────────────────────┐│
|
||||
│ │ mod_curl — <load module="mod_curl"/> в modules.conf.xml ││
|
||||
│ │ mod_curl — <load module="mod_curl"/> (только для уровня 3) ││
|
||||
│ │ cjson — входит в стандартную поставку FS Lua ││
|
||||
│ └───────────────────────────────────────────────────────────────┘│
|
||||
└───────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Логика:
|
||||
1. Извлечь caller_id + destination из переменных сессии
|
||||
2. HTTP GET /api/route?caller_id=X&dest=Y&ingress_trunk=Z
|
||||
3. Разобрать JSON-ответ
|
||||
4. При fallback=true → bridge на fallback gateway (оператор)
|
||||
5. При ошибке → кладём трубку с NORMAL_TEMPORARY_FAILURE
|
||||
6. Нормальный маршрут → bridge на sip_gateway выбранной ноды
|
||||
1. Попробовать FS global variable pulse_route_json (ESL push)
|
||||
2. Попробовать файловый кэш
|
||||
3. HTTP API /api/route (fallback)
|
||||
4. Разобрать JSON → bridge на sip_gateway
|
||||
]]--
|
||||
|
||||
-- ===================================================================
|
||||
@@ -40,13 +46,16 @@
|
||||
-- URL балансировщика (переопределяется через переменную канала)
|
||||
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 ROUTE_CACHE_PATH = session:getVariable("route_cache_path") or "/opt/pulse-lets-go/data/route_cache.json"
|
||||
|
||||
-- JSON-парсер
|
||||
local cjson = require("cjson")
|
||||
|
||||
-- API FreeSWITCH для вызова mod_curl
|
||||
-- API FreeSWITCH для вызова curl
|
||||
local api = freeswitch.API()
|
||||
|
||||
-- ===================================================================
|
||||
@@ -58,26 +67,6 @@ local function log(level, msg)
|
||||
freeswitch.consoleLog(level, "[route] " .. msg .. "\n")
|
||||
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)
|
||||
if session:ready() then
|
||||
@@ -85,8 +74,95 @@ local function safe_hangup(cause)
|
||||
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 ing_trunk = session:getVariable("ingress_trunk") or ""
|
||||
|
||||
-- Собираем URL
|
||||
local query = string.format("caller_id=%s&dest=%s", caller_id, dest)
|
||||
if ing_trunk ~= "" then
|
||||
query = query .. "&ingress_trunk=" .. ing_trunk
|
||||
-- Уровень 1: FS global variable (ESL push, sub-µs)
|
||||
local json_str = try_global_cache()
|
||||
if process_route_json(json_str, "global") then
|
||||
return
|
||||
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
|
||||
local body, curl_err = http_get(url)
|
||||
-- Уровень 3: HTTP API (fallback, ~1-5ms)
|
||||
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
|
||||
log("err", "ошибка HTTP: " .. (curl_err or "unknown"))
|
||||
log("err", "все уровни кэша недоступны: " .. (http_err or "unknown"))
|
||||
safe_hangup()
|
||||
return
|
||||
end
|
||||
|
||||
-- Парсим JSON
|
||||
local ok, route = pcall(cjson.decode, body)
|
||||
if not ok or type(route) ~= "table" then
|
||||
log("err", "ошибка парсинга JSON: " .. tostring(route))
|
||||
if not process_route_json(body, "http") then
|
||||
log("err", "непредвиденный формат ответа балансировщика")
|
||||
safe_hangup()
|
||||
return
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user