Files
zuevav da12a9ec1c feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР
- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk)
- трансферные таблицы FTV_M2M: создание при установке, события, справочник участников
- сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO
- боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК
- онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML)
- модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 19:41:12 +03:00

291 lines
11 KiB
Go

package lkgateway
import (
"context"
"fmt"
"log"
"net"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// health.go — стабильность и самоконтроль сервиса.
//
// Три независимых слоя защиты, чтобы сбой не остался незамеченным:
// 1. superviseLoop — оборачивает каждую фоновую горутину: recover() ловит
// панику, пишет в лог и в ленту, и перезапускает горутину. Паника одного
// поллера больше не роняет весь процесс.
// 2. heartbeat — каждый поллер «отмечается» на каждом тике. Монитор следит за
// свежестью отметок; если компонент завис (нет отметок > 3 интервалов) —
// поднимает алерт в ленту «Новости» (оператор увидит).
// 3. sd_notify watchdog — пока все компоненты живы, процесс «пинает» systemd
// (WATCHDOG=1). Если внутренний цикл завис намертво (дедлок) и отметки
// прекратились — systemd по таймауту сам перезапустит весь сервис.
// compHealth — состояние одного наблюдаемого компонента.
type compHealth struct {
Name string
Interval time.Duration // ожидаемый интервал между отметками
LastBeat time.Time
LastErr string
alerted bool // алерт «завис» уже поднят — не дублируем
}
var (
healthMu sync.Mutex
healthReg = map[string]*compHealth{}
)
// healthRegister объявляет компонент и его ожидаемый интервал отметок.
func healthRegister(name string, interval time.Duration) {
healthMu.Lock()
defer healthMu.Unlock()
if _, ok := healthReg[name]; !ok {
healthReg[name] = &compHealth{Name: name, Interval: interval, LastBeat: time.Now()}
}
}
// healthBeat — компонент жив: обновляет отметку и снимает флаг алерта.
func healthBeat(name string) {
healthMu.Lock()
defer healthMu.Unlock()
c := healthReg[name]
if c == nil {
c = &compHealth{Name: name, Interval: time.Minute}
healthReg[name] = c
}
c.LastBeat = time.Now()
c.alerted = false
}
// healthError фиксирует последнюю ошибку компонента (для UI; на watchdog не
// влияет — временная сетевая ошибка не должна перезапускать сервис).
func healthError(name, msg string) {
healthMu.Lock()
defer healthMu.Unlock()
if c := healthReg[name]; c != nil {
c.LastErr = msg
}
}
// healthComponent возвращает зарегистрированный компонент по имени (или nil).
func healthComponent(name string) *compHealth {
healthMu.Lock()
defer healthMu.Unlock()
return healthReg[name]
}
// HealthStatus — снимок состояния компонента для панели администратора.
type HealthStatus struct {
Name string
Healthy bool
Age time.Duration
LastErr string
LastBeat time.Time
}
// HealthSnapshot возвращает состояние всех компонентов, отсортированное по имени.
// Healthy = свежая отметка (моложе 3 интервалов). LastErr на Healthy не влияет.
func HealthSnapshot() []HealthStatus {
healthMu.Lock()
defer healthMu.Unlock()
out := make([]HealthStatus, 0, len(healthReg))
now := time.Now()
for _, c := range healthReg {
age := now.Sub(c.LastBeat)
out = append(out, HealthStatus{
Name: c.Name,
Healthy: age < 3*c.Interval,
Age: age,
LastErr: c.LastErr,
LastBeat: c.LastBeat,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// superviseLoop запускает body в бесконечном цикле с recover(): паника или
// неожиданный выход body → лог + алерт + перезапуск через 5 с. Возвращается
// только когда ctx отменён (штатная остановка).
func superviseLoop(ctx context.Context, name string, rc *RuntimeConfig, body func(ctx context.Context)) {
for {
if ctx.Err() != nil {
return
}
crashed := runGuarded(name, rc, func() { body(ctx) })
if ctx.Err() != nil {
return
}
if crashed {
healthError(name, "перезапуск после сбоя")
}
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
}
}
}
// runGuarded выполняет fn под recover(); true, если была паника.
func runGuarded(name string, rc *RuntimeConfig, fn func()) (crashed bool) {
defer func() {
if r := recover(); r != nil {
crashed = true
msg := fmt.Sprintf("%v", r)
log.Printf("lk-gateway: КОМПОНЕНТ %s УПАЛ (паника): %s — перезапуск через 5с", name, msg)
healthError(name, "паника: "+msg)
raiseSystemAlert(rc, "Сбой компонента: "+name,
fmt.Sprintf("Компонент «%s» аварийно завершился (%s) и автоматически перезапускается. Проверьте лог bj-server.", name, msg))
}
}()
fn()
return false
}
// superviseHealth — монитор здоровья + sd_notify watchdog. Раз в 15 с проверяет
// свежесть отметок всех компонентов: зависшие → алерт в ленту; если все живы —
// «пинает» systemd (при Type=notify + WatchdogSec). Запускается напрямую (сам
// себя не роняет — тело тривиально и без паник).
func (s *Server) superviseHealth(ctx context.Context) {
sdNotify("READY=1")
wd := watchdogTimeout()
if wd > 0 {
log.Printf("lk-gateway: systemd watchdog активен (таймаут %s)", wd)
}
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
allLive := true
for _, h := range HealthSnapshot() {
// Затяжное зависание (>6 интервалов): восстановить горутину не
// удалось (recover бы уже перезапустил). Сообщаем и перезапускаем
// весь процесс — systemd (Restart=always) поднимет за 5 с.
if c := healthComponent(h.Name); c != nil && h.Age > 6*c.Interval {
log.Printf("lk-gateway: КОМПОНЕНТ %s ЗАВИС НАМЕРТВО (%s без отметок) — перезапуск процесса", h.Name, h.Age.Round(time.Second))
raiseSystemAlertSync(s.rc, "Аварийный перезапуск сервиса",
fmt.Sprintf("Компонент «%s» завис (%s без признаков работы) и не восстановился. Сервис перезапускается автоматически.", h.Name, h.Age.Round(time.Second)))
time.Sleep(300 * time.Millisecond)
os.Exit(1)
}
if h.Healthy {
continue
}
allLive = false
s.alertStale(h)
}
// Пинаем watchdog только когда все компоненты живы (если включён
// Type=notify + WatchdogSec — дополнительный внешний контроль).
if allLive {
sdNotify("WATCHDOG=1")
}
}
}
}
// alertStale поднимает алерт «компонент завис» единожды (до восстановления).
func (s *Server) alertStale(h HealthStatus) {
healthMu.Lock()
c := healthReg[h.Name]
if c == nil || c.alerted {
healthMu.Unlock()
return
}
c.alerted = true
healthMu.Unlock()
log.Printf("lk-gateway: КОМПОНЕНТ %s НЕ ОТВЕЧАЕТ (нет отметок %s)", h.Name, h.Age.Round(time.Second))
raiseSystemAlert(s.rc, "Компонент не отвечает: "+h.Name,
fmt.Sprintf("Фоновый компонент «%s» не подавал признаков работы %s. Сервис пытается восстановиться автоматически; если сообщение повторяется — проверьте bj-server.", h.Name, h.Age.Round(time.Second)))
}
// raiseSystemAlert добавляет системное событие в ленту «Новости» (дедуп по
// заголовку в пределах 10 минут, чтобы не засорять при флапе).
func raiseSystemAlert(rc *RuntimeConfig, title, body string) {
if rc == nil {
return
}
id := "sys-" + strconv.FormatInt(time.Now().Unix()/600, 10) + "-" + slugify(title)
_ = rc.AddNews(NewsItem{
ID: id,
At: time.Now(),
Kind: "system",
Title: title,
Body: body,
})
// Внешняя доставка (SMTP/Zabbix) — асинхронно, чтобы не блокировать монитор.
go dispatchAlert(rc, title, body)
}
// raiseSystemAlertSync — как raiseSystemAlert, но с синхронной внешней доставкой:
// нужен на пути аварийного os.Exit, где горутины не успеют отработать.
func raiseSystemAlertSync(rc *RuntimeConfig, title, body string) {
if rc == nil {
return
}
id := "sys-" + strconv.FormatInt(time.Now().Unix()/600, 10) + "-" + slugify(title)
_ = rc.AddNews(NewsItem{ID: id, At: time.Now(), Kind: "system", Title: title, Body: body})
dispatchAlert(rc, title, body)
}
func slugify(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var b strings.Builder
for _, r := range s {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
b.WriteRune(r)
} else if r == ' ' || r == '-' || r == ':' {
b.WriteByte('-')
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "alert"
}
if len(out) > 40 {
out = out[:40]
}
return out
}
// --- sd_notify (systemd) без внешних зависимостей ---
// sdNotify шлёт сообщение состояния в systemd через NOTIFY_SOCKET.
// No-op, если сокет не задан (запуск вне systemd).
func sdNotify(state string) {
sock := os.Getenv("NOTIFY_SOCKET")
if sock == "" {
return
}
if strings.HasPrefix(sock, "@") { // абстрактный сокет
sock = "\x00" + sock[1:]
}
conn, err := net.DialUnix("unixgram", nil, &net.UnixAddr{Name: sock, Net: "unixgram"})
if err != nil {
return
}
defer conn.Close()
_, _ = conn.Write([]byte(state))
}
// watchdogTimeout возвращает интервал watchdog из WATCHDOG_USEC (0 — выключен).
func watchdogTimeout() time.Duration {
v := os.Getenv("WATCHDOG_USEC")
if v == "" {
return 0
}
usec, err := strconv.ParseInt(v, 10, 64)
if err != nil || usec <= 0 {
return 0
}
return time.Duration(usec) * time.Microsecond
}