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
441 lines
14 KiB
Go
441 lines
14 KiB
Go
// Package engine — routing rules sub-engine.
|
||
//
|
||
// Персистентные правила маршрутизации (CID-based, destination, ingress-trunk)
|
||
// с priority-based first-match разрешением. Все match-поля — regex.
|
||
// Time-based match (дни недели, временной диапазон) применяется к текущему
|
||
// локальному времени сервера.
|
||
//
|
||
// Потокобезопасность: Router.mu защищает routing rules независимо от Engine.mu.
|
||
// PickNodeForCall не держит два лока одновременно.
|
||
package engine
|
||
|
||
import (
|
||
"fmt"
|
||
"math/rand/v2"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
"github.com/pulse-lets-go/internal/models"
|
||
)
|
||
|
||
// CompiledRule — правило маршрутизации с прекомпилированными regex.
|
||
// hits — атомарный счётчик срабатываний (debug/мониторинг).
|
||
type CompiledRule struct {
|
||
models.RouteRule // экспортируемые JSON-поля
|
||
hits int64 // атомарный счётчик
|
||
callerRe *regexp.Regexp
|
||
destRe *regexp.Regexp
|
||
ingressRe *regexp.Regexp
|
||
}
|
||
|
||
// Hit атомарно инкрементирует счётчик срабатываний правила.
|
||
func (cr *CompiledRule) Hit() {
|
||
atomic.AddInt64(&cr.hits, 1)
|
||
}
|
||
|
||
// GetHits возвращает текущее количество срабатываний.
|
||
func (cr *CompiledRule) GetHits() int64 {
|
||
return atomic.LoadInt64(&cr.hits)
|
||
}
|
||
|
||
// Router хранит routing rules в порядке приоритета.
|
||
// Собственный sync.RWMutex изолирует contention между админом (редактирует правила) и трафиком (роутинг звонков).
|
||
type Router struct {
|
||
mu sync.RWMutex
|
||
rules []*CompiledRule
|
||
}
|
||
|
||
// compileRules компилирует и сортирует правила.
|
||
// Возвращает ошибку если любой regex невалиден.
|
||
func compileRules(rules []models.RouteRule) ([]*CompiledRule, error) {
|
||
compiled := make([]*CompiledRule, 0, len(rules))
|
||
for _, r := range rules {
|
||
cr := &CompiledRule{RouteRule: r}
|
||
if r.MatchCallerID != "" {
|
||
re, err := regexp.Compile(r.MatchCallerID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("rule %s (%s): invalid match_caller_id regex: %w", r.ID, r.Name, err)
|
||
}
|
||
cr.callerRe = re
|
||
}
|
||
if r.MatchDestination != "" {
|
||
re, err := regexp.Compile(r.MatchDestination)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("rule %s (%s): invalid match_destination regex: %w", r.ID, r.Name, err)
|
||
}
|
||
cr.destRe = re
|
||
}
|
||
if r.MatchIngressTrunk != "" {
|
||
re, err := regexp.Compile(r.MatchIngressTrunk)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("rule %s (%s): invalid match_ingress_trunk regex: %w", r.ID, r.Name, err)
|
||
}
|
||
cr.ingressRe = re
|
||
}
|
||
compiled = append(compiled, cr)
|
||
}
|
||
sort.Slice(compiled, func(i, j int) bool {
|
||
return compiled[i].Priority < compiled[j].Priority
|
||
})
|
||
return compiled, nil
|
||
}
|
||
|
||
// ValidateRules проверяет что все правила валидны (regex, action_type, логические условия).
|
||
// Не изменяет состояние Engine. Используется для validate-before-save.
|
||
func (e *Engine) ValidateRules(rules []models.RouteRule) error {
|
||
for _, r := range rules {
|
||
if err := validateRule(&r); err != nil {
|
||
return fmt.Errorf("rule %s (%s): %w", r.ID, r.Name, err)
|
||
}
|
||
// Проверяем что regex компилябельны
|
||
if r.MatchCallerID != "" {
|
||
if _, err := regexp.Compile(r.MatchCallerID); err != nil {
|
||
return fmt.Errorf("rule %s (%s): invalid match_caller_id: %w", r.ID, r.Name, err)
|
||
}
|
||
}
|
||
if r.MatchDestination != "" {
|
||
if _, err := regexp.Compile(r.MatchDestination); err != nil {
|
||
return fmt.Errorf("rule %s (%s): invalid match_destination: %w", r.ID, r.Name, err)
|
||
}
|
||
}
|
||
if r.MatchIngressTrunk != "" {
|
||
if _, err := regexp.Compile(r.MatchIngressTrunk); err != nil {
|
||
return fmt.Errorf("rule %s (%s): invalid match_ingress_trunk: %w", r.ID, r.Name, err)
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateRule(r *models.RouteRule) error {
|
||
switch r.ActionType {
|
||
case "node":
|
||
if r.ActionNodeID == "" {
|
||
return fmt.Errorf("action_type 'node' requires action_node_id")
|
||
}
|
||
case "pool":
|
||
if len(r.ActionPoolIDs) == 0 {
|
||
return fmt.Errorf("action_type 'pool' requires action_pool_ids")
|
||
}
|
||
case "auto":
|
||
// no extra requirements
|
||
default:
|
||
return fmt.Errorf("action_type must be 'node', 'pool' or 'auto'")
|
||
}
|
||
|
||
// Хотя бы одно match-поле должно быть непустым
|
||
if r.MatchCallerID == "" && r.MatchDestination == "" &&
|
||
r.MatchIngressTrunk == "" && r.MatchDaysOfWeek == "" &&
|
||
r.MatchTimeStart == "" && r.MatchTimeEnd == "" {
|
||
return fmt.Errorf("at least one match field required")
|
||
}
|
||
|
||
// Time range: оба поля или ни одного
|
||
if (r.MatchTimeStart != "" && r.MatchTimeEnd == "") ||
|
||
(r.MatchTimeStart == "" && r.MatchTimeEnd != "") {
|
||
return fmt.Errorf("both match_time_start and match_time_end required together")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// LoadRules компилирует правила из моделей и загружает в in-memory Router.
|
||
// Используется при запуске: битые правила логируются и скипаются,
|
||
// валидные загружаются.
|
||
func (e *Engine) LoadRules(rules []models.RouteRule) {
|
||
compiled, err := compileRules(rules)
|
||
if err != nil {
|
||
// Логируем ошибку, но не прерываем — загружаем что смогли
|
||
_ = err // caller logs
|
||
}
|
||
e.router.mu.Lock()
|
||
e.router.rules = compiled
|
||
e.router.mu.Unlock()
|
||
}
|
||
|
||
// SetRules загружает ПРОВЕРЕННЫЕ правила в in-memory Router.
|
||
// Вызывается после ValidateRules + сохранения на диск.
|
||
// Правила считаются уже валидными.
|
||
func (e *Engine) SetRules(rules []models.RouteRule) {
|
||
compiled, err := compileRules(rules)
|
||
if err != nil {
|
||
// Не должно случиться (rules уже прошли ValidateRules)
|
||
return
|
||
}
|
||
e.router.mu.Lock()
|
||
e.router.rules = compiled
|
||
e.router.mu.Unlock()
|
||
}
|
||
|
||
// GetCompiledRules возвращает текущий срез скомпилированных правил.
|
||
func (e *Engine) GetCompiledRules() []*CompiledRule {
|
||
e.router.mu.RLock()
|
||
defer e.router.mu.RUnlock()
|
||
rules := make([]*CompiledRule, len(e.router.rules))
|
||
copy(rules, e.router.rules)
|
||
return rules
|
||
}
|
||
|
||
// matchRule проверяет все условия правила против параметров вызова.
|
||
// Возвращает true если правило применимо (все непустые поля совпали).
|
||
// Вызывающий НЕ держит router-блокировку (она должна быть внешней).
|
||
func matchRule(cr *CompiledRule, callerID, dest, ingress string) bool {
|
||
if !cr.Enabled {
|
||
return false
|
||
}
|
||
if cr.callerRe != nil && !cr.callerRe.MatchString(callerID) {
|
||
return false
|
||
}
|
||
if cr.destRe != nil && !cr.destRe.MatchString(dest) {
|
||
return false
|
||
}
|
||
if cr.ingressRe != nil && !cr.ingressRe.MatchString(ingress) {
|
||
return false
|
||
}
|
||
return matchTime(cr.MatchDaysOfWeek, cr.MatchTimeStart, cr.MatchTimeEnd)
|
||
}
|
||
|
||
// matchTime проверяет time-based условия правила.
|
||
// Использует локальное время сервера.
|
||
func matchTime(daysOfWeek, timeStart, timeEnd string) bool {
|
||
if daysOfWeek == "" && timeStart == "" && timeEnd == "" {
|
||
return true
|
||
}
|
||
|
||
now := time.Now()
|
||
|
||
// Проверка дня недели
|
||
if daysOfWeek != "" {
|
||
if !matchDayOfWeek(now, daysOfWeek) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
// Проверка временного диапазона
|
||
if timeStart != "" && timeEnd != "" {
|
||
if !matchTimeRange(now, timeStart, timeEnd) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
// matchDayOfWeek проверяет что текущий день недели входит в заданный диапазон/список.
|
||
// Формат: "1-5" (пн-пт), "1,3,5" (пн,ср,пт), "6" (сб).
|
||
// Воскресенье = 0 или 7. Понедельник = 1.
|
||
func matchDayOfWeek(now time.Time, spec string) bool {
|
||
wd := now.Weekday()
|
||
if wd == time.Sunday {
|
||
// Приводим воскресенье к 7 для совместимости с "1-7"
|
||
wd = 7
|
||
}
|
||
dayNum := int(wd)
|
||
|
||
for _, part := range strings.Split(spec, ",") {
|
||
part = strings.TrimSpace(part)
|
||
if part == "" {
|
||
continue
|
||
}
|
||
if strings.Contains(part, "-") {
|
||
parts := strings.SplitN(part, "-", 2)
|
||
start, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||
end, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||
if err1 == nil && err2 == nil && dayNum >= start && dayNum <= end {
|
||
return true
|
||
}
|
||
} else {
|
||
n, err := strconv.Atoi(part)
|
||
if err == nil && dayNum == n {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// matchTimeRange проверяет что текущее время входит в [timeStart, timeEnd].
|
||
// Формат: "HH:MM".
|
||
func matchTimeRange(now time.Time, timeStart, timeEnd string) bool {
|
||
startH, startM, ok1 := parseHHMM(timeStart)
|
||
endH, endM, ok2 := parseHHMM(timeEnd)
|
||
if !ok1 || !ok2 {
|
||
return false
|
||
}
|
||
|
||
startMin := startH*60 + startM
|
||
endMin := endH*60 + endM
|
||
nowMin := now.Hour()*60 + now.Minute()
|
||
|
||
if startMin <= endMin {
|
||
// Обычный диапазон: 09:00 – 18:00
|
||
return nowMin >= startMin && nowMin <= endMin
|
||
}
|
||
// Ночной диапазон: 22:00 – 06:00
|
||
return nowMin >= startMin || nowMin <= endMin
|
||
}
|
||
|
||
func parseHHMM(s string) (h, m int, ok bool) {
|
||
parts := strings.SplitN(s, ":", 2)
|
||
if len(parts) != 2 {
|
||
return 0, 0, false
|
||
}
|
||
hour, err1 := strconv.Atoi(parts[0])
|
||
minute, err2 := strconv.Atoi(parts[1])
|
||
if err1 != nil || err2 != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 {
|
||
return 0, 0, false
|
||
}
|
||
return hour, minute, true
|
||
}
|
||
|
||
// PickNodeForCall — единый фасад маршрутизации вызова с учётом routing rules.
|
||
//
|
||
// Алгоритм:
|
||
// 1. Проверяем routing rules в порядке Priority (first-match-wins)
|
||
// 2. Для правила с action="node" — проверяем что нода жива; если мертва — fallthrough
|
||
// 3. Для правила с action="pool" — scoring только по нодам из пула; пустой пул → fallthrough
|
||
// 4. Для правила с action="auto" — fallthrough к глобальному PickNode()
|
||
// 5. Нет совпадений / fallthrough — стандартный PickNode() (глобальный scoring)
|
||
//
|
||
// matchedRule возвращает имя сработавшего правила (или "none").
|
||
func (e *Engine) PickNodeForCall(callerID, dest, ingress string) (nodeID string, score float64, fallback bool, matchedRule string) {
|
||
e.router.mu.RLock()
|
||
defer e.router.mu.RUnlock()
|
||
|
||
for _, cr := range e.router.rules {
|
||
if !matchRule(cr, callerID, dest, ingress) {
|
||
continue
|
||
}
|
||
cr.Hit()
|
||
|
||
resolved := false
|
||
switch cr.ActionType {
|
||
case "node":
|
||
e.mu.RLock()
|
||
ns, ok := e.nodes[cr.ActionNodeID]
|
||
now := time.Now().Unix()
|
||
staleThreshold := int64(e.cfg.StaleThresholdSec)
|
||
e.mu.RUnlock()
|
||
if ok && ns.Score >= 0 && now-ns.TS <= staleThreshold {
|
||
nodeID, score, matchedRule = cr.ActionNodeID, 100, cr.Name
|
||
resolved = true
|
||
}
|
||
case "pool":
|
||
nid, sc, fb := e.pickNodeFromPoolLocked(cr.ActionPoolIDs)
|
||
if !fb {
|
||
nodeID, score, matchedRule = nid, sc, cr.Name
|
||
resolved = true
|
||
}
|
||
case "auto":
|
||
// Fallthrough к глобальному scoring
|
||
nid, sc, fb := e.pickNodeLocked()
|
||
return nid, sc, fb, cr.Name
|
||
}
|
||
if resolved {
|
||
return nodeID, score, false, matchedRule
|
||
}
|
||
}
|
||
|
||
// Нет совпадений / все правила провалились — стандартный scoring
|
||
nid, sc, fb := e.pickNodeLocked()
|
||
return nid, sc, fb, "none"
|
||
}
|
||
|
||
// pickNodeLocked возвращает ноду согласно активной стратегии балансировки.
|
||
// Вызывающий ДОЛЖЕН держать e.router.mu.RLock (не engine.mu).
|
||
func (e *Engine) pickNodeLocked() (nodeID string, score float64, fallback bool) {
|
||
e.mu.RLock()
|
||
defer e.mu.RUnlock()
|
||
|
||
switch e.balanceMode {
|
||
case "weighted_random":
|
||
return e.pickWeightedRandomLocked()
|
||
default:
|
||
return e.pickBestNodeLocked()
|
||
}
|
||
}
|
||
|
||
// pickNodeFromPoolLocked выполняет scoring только по нодам из пула (под router.RLock).
|
||
func (e *Engine) pickNodeFromPoolLocked(poolIDs []string) (nodeID string, score float64, fallback bool) {
|
||
e.mu.RLock()
|
||
defer e.mu.RUnlock()
|
||
return e.pickNodeFromPoolUnlocked(poolIDs)
|
||
}
|
||
|
||
// pickNodeFromPoolUnlocked — внутренний метод без блокировок (engine.mu уже держится).
|
||
func (e *Engine) pickNodeFromPoolUnlocked(poolIDs []string) (nodeID string, score float64, fallback bool) {
|
||
|
||
if len(e.nodes) == 0 {
|
||
return "", 0, true
|
||
}
|
||
|
||
poolSet := make(map[string]bool, len(poolIDs))
|
||
for _, id := range poolIDs {
|
||
poolSet[id] = true
|
||
}
|
||
|
||
now := time.Now().Unix()
|
||
staleThreshold := int64(e.cfg.StaleThresholdSec)
|
||
|
||
type candidate struct {
|
||
id string
|
||
score float64
|
||
}
|
||
candidates := make([]candidate, 0)
|
||
|
||
for id, ns := range e.nodes {
|
||
if !poolSet[id] {
|
||
continue
|
||
}
|
||
if ns.Disabled {
|
||
continue
|
||
}
|
||
if ns.Score < 0 {
|
||
continue
|
||
}
|
||
if now-ns.TS > staleThreshold {
|
||
continue
|
||
}
|
||
candidates = append(candidates, candidate{id: id, score: ns.Score})
|
||
}
|
||
|
||
if len(candidates) == 0 {
|
||
return "", 0, true
|
||
}
|
||
|
||
switch e.balanceMode {
|
||
case "best":
|
||
// Найти ноду с максимальным score
|
||
best := candidates[0]
|
||
for _, c := range candidates[1:] {
|
||
if c.score > best.score {
|
||
best = c
|
||
}
|
||
}
|
||
return best.id, best.score, false
|
||
default:
|
||
// Weighted random
|
||
var totalScore float64
|
||
for _, c := range candidates {
|
||
totalScore += c.score
|
||
}
|
||
if totalScore <= 0 {
|
||
idx := rand.IntN(len(candidates))
|
||
return candidates[idx].id, 0, false
|
||
}
|
||
pick := rand.Float64() * totalScore
|
||
for _, c := range candidates {
|
||
pick -= c.score
|
||
if pick <= 0 {
|
||
return c.id, c.score, false
|
||
}
|
||
}
|
||
return candidates[len(candidates)-1].id, candidates[len(candidates)-1].score, false
|
||
}
|
||
}
|