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
199 lines
9.4 KiB
Lua
199 lines
9.4 KiB
Lua
--[[
|
||
route.lua — запрашивает pulse-lets-go для маршрутизации вызова.
|
||
Телефонный балансировщик нагрузки на базе FreeSWITCH + NATS.
|
||
|
||
┌─ Размещение ─────────────────────────────────────────────────────┐
|
||
│ /opt/pulse-lets-go/contrib/route.lua │
|
||
│ │
|
||
│ ┌─ Использование в dialplan ────────────────────────────────────┐│
|
||
│ │ <extension name="route_call"> ││
|
||
│ │ <condition field="destination_number" expression="^.*$"> ││
|
||
│ │ <action application="lua" data="route.lua"/> ││
|
||
│ │ </condition> ││
|
||
│ │ </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 входящего транка ││
|
||
│ │ route_timeout — таймаут HTTP fallback (default: 3с) ││
|
||
│ │ route_cache_path — путь к файловому кэшу (default: <prefix>/data/…) ││
|
||
│ └───────────────────────────────────────────────────────────────┘│
|
||
│ │
|
||
│ ┌─ Требования FreeSWITCH ───────────────────────────────────────┐│
|
||
│ │ mod_curl — <load module="mod_curl"/> (только для уровня 3) ││
|
||
│ │ cjson — входит в стандартную поставку FS Lua ││
|
||
│ └───────────────────────────────────────────────────────────────┘│
|
||
└───────────────────────────────────────────────────────────────────┘
|
||
|
||
Логика:
|
||
1. Попробовать FS global variable pulse_route_json (ESL push)
|
||
2. Попробовать файловый кэш
|
||
3. HTTP API /api/route (fallback)
|
||
4. Разобрать JSON → bridge на sip_gateway
|
||
]]--
|
||
|
||
-- ===================================================================
|
||
-- Конфигурация
|
||
-- ===================================================================
|
||
|
||
-- URL балансировщика (переопределяется через переменную канала)
|
||
local BALANCER_URL = session:getVariable("balancer_url") or "http://localhost:8080"
|
||
|
||
-- Таймаут 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 для вызова curl
|
||
local api = freeswitch.API()
|
||
|
||
-- ===================================================================
|
||
-- Вспомогательные функции
|
||
-- ===================================================================
|
||
|
||
-- Логирование с префиксом [route]
|
||
local function log(level, msg)
|
||
freeswitch.consoleLog(level, "[route] " .. msg .. "\n")
|
||
end
|
||
|
||
-- Безопасная отправка трубки с кодом причины
|
||
local function safe_hangup(cause)
|
||
if session:ready() then
|
||
session:hangup(cause or "NORMAL_TEMPORARY_FAILURE")
|
||
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
|
||
|
||
-- ===================================================================
|
||
-- Основная логика (трёхуровневый кэш)
|
||
-- ===================================================================
|
||
|
||
-- Извлекаем параметры вызова
|
||
local caller_id = session:getVariable("caller_id_number") or ""
|
||
local dest = session:getVariable("destination_number") or ""
|
||
local ing_trunk = session:getVariable("ingress_trunk") or ""
|
||
|
||
-- Уровень 1: FS global variable (ESL push, sub-µs)
|
||
local json_str = try_global_cache()
|
||
if process_route_json(json_str, "global") then
|
||
return
|
||
end
|
||
|
||
-- Уровень 2: файловый кэш (локальный, ~50µs)
|
||
json_str = try_file_cache()
|
||
if process_route_json(json_str, "file") then
|
||
return
|
||
end
|
||
|
||
-- Уровень 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_err or "unknown"))
|
||
safe_hangup()
|
||
return
|
||
end
|
||
|
||
if not process_route_json(body, "http") then
|
||
log("err", "непредвиденный формат ответа балансировщика")
|
||
safe_hangup()
|
||
end
|