Maksim Totmin 110289a093 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

441 lines
14 KiB
Go
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.

// 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
}
}