Maksim Totmin 78785e54e1 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
2026-06-25 22:23:16 +07:00

199 lines
9.4 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

--[[
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