274 lines
11 KiB
Go
274 lines
11 KiB
Go
// app/cmd/goaws.go
|
||
// Entry point — shared-sqs server
|
||
// Updated: 2026-04-10 — добавлена Redis persistence (write-through cache)
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"flag"
|
||
"fmt"
|
||
"net/http"
|
||
"os"
|
||
"os/signal"
|
||
"syscall"
|
||
"time"
|
||
|
||
"shared-sqs/app/billing"
|
||
"shared-sqs/app/conf"
|
||
"shared-sqs/app/gosqs"
|
||
"shared-sqs/app/leader"
|
||
"shared-sqs/app/metrics"
|
||
"shared-sqs/app/models"
|
||
"shared-sqs/app/persistence"
|
||
"shared-sqs/app/router"
|
||
"shared-sqs/app/tenant"
|
||
|
||
log "github.com/sirupsen/logrus"
|
||
)
|
||
|
||
func main() {
|
||
var configFile string
|
||
var adminToken string
|
||
var port string
|
||
var controlPort string
|
||
var debug bool
|
||
var loglevel string
|
||
var leaderElection bool
|
||
var leaderTTL time.Duration
|
||
var leaderInterval time.Duration
|
||
|
||
flag.StringVar(&configFile, "config", "", "config file location")
|
||
flag.StringVar(&adminToken, "admin-token", "", "admin API bearer token")
|
||
flag.StringVar(&port, "port", "4100", "data-plane listen port")
|
||
flag.StringVar(&controlPort, "control-port", "4101", "control-plane listen port (localhost only: admin/metrics/ui)")
|
||
flag.BoolVar(&debug, "debug", false, "set debug log level")
|
||
flag.StringVar(&loglevel, "loglevel", "info", "log level (info, debug, warn, error)")
|
||
// Leader-election (active/passive HA): выключено по умолчанию — поведение
|
||
// одиночного инстанса не меняется. Подробно — doc/thinking/state-extraction-and-ha-2026-10-10.md
|
||
flag.BoolVar(&leaderElection, "leader-election", false, "enable leader election via Redis lock (active/passive HA); off = always ready")
|
||
flag.DurationVar(&leaderTTL, "leader-ttl", 10*time.Second, "leader lock TTL (should be > 3x leader-interval)")
|
||
flag.DurationVar(&leaderInterval, "leader-interval", 3*time.Second, "leader lock refresh/acquire interval")
|
||
flag.Parse()
|
||
|
||
log.SetFormatter(&log.JSONFormatter{})
|
||
log.SetOutput(os.Stdout)
|
||
|
||
if debug {
|
||
log.SetLevel(log.DebugLevel)
|
||
} else {
|
||
level, err := log.ParseLevel(loglevel)
|
||
if err != nil {
|
||
log.SetLevel(log.InfoLevel)
|
||
log.Warnf("Failed to parse loglevel %v, defaulting to info", loglevel)
|
||
} else {
|
||
log.SetLevel(level)
|
||
}
|
||
}
|
||
|
||
// Admin token: flag > env SHARED_SQS_ADMIN_TOKEN > fatal (Trap #13)
|
||
if adminToken == "" {
|
||
adminToken = os.Getenv("SHARED_SQS_ADMIN_TOKEN")
|
||
}
|
||
if adminToken == "" {
|
||
log.Fatal("admin token required: use --admin-token flag or SHARED_SQS_ADMIN_TOKEN env var")
|
||
}
|
||
|
||
// Загрузить конфиг (очереди, env — без SNS)
|
||
env := "Local"
|
||
if flag.NArg() > 0 {
|
||
env = flag.Arg(0)
|
||
}
|
||
conf.LoadYamlConfig(configFile, env)
|
||
|
||
if models.CurrentEnvironment.LogToFile {
|
||
filename := models.CurrentEnvironment.LogFile
|
||
file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||
if err == nil {
|
||
log.SetOutput(file)
|
||
} else {
|
||
log.Infof("Failed to log to file: %s, using default stdout", filename)
|
||
}
|
||
}
|
||
|
||
// Инициализация in-memory TenantStore
|
||
tenantStore := tenant.NewTenantStore()
|
||
|
||
// Подключение к Redis (если задан REDIS_ADDR)
|
||
// При ошибке — предупреждение, но продолжаем в memory-only режиме
|
||
redisAddr := os.Getenv("REDIS_ADDR")
|
||
redisUser := os.Getenv("REDIS_USER")
|
||
redisPass := os.Getenv("REDIS_PASSWORD")
|
||
if redisAddr != "" {
|
||
if err := persistence.Connect(redisAddr, redisUser, redisPass); err != nil {
|
||
// Fail-fast: при явно заданном REDIS_ADDR, но недоступном Redis,
|
||
// продолжать опасно — SaveQueue перезапишет Redis пустотой, read-through
|
||
// и failover сломаются. Падаем, чтобы systemd перезапустил.
|
||
log.Fatalf("Не удалось подключиться к Redis (%s): %v", redisAddr, err)
|
||
}
|
||
}
|
||
|
||
// Восстановление состояния из Redis (тенанты + очереди)
|
||
if persistence.Client != nil {
|
||
// Загружаем тенантов
|
||
tenantsRaw, err := persistence.LoadAllTenantsRaw()
|
||
if err != nil {
|
||
log.Warnf("Ошибка загрузки тенантов из Redis: %v", err)
|
||
} else {
|
||
for _, jsonBytes := range tenantsRaw {
|
||
var t tenant.Tenant
|
||
if err := json.Unmarshal(jsonBytes, &t); err != nil {
|
||
log.Errorf("Ошибка десериализации тенанта: %v", err)
|
||
continue
|
||
}
|
||
tenantStore.LoadTenant(&t)
|
||
}
|
||
}
|
||
// Загружаем очереди
|
||
queues, err := persistence.LoadAllQueues()
|
||
if err != nil {
|
||
log.Warnf("Ошибка загрузки очередей из Redis: %v", err)
|
||
} else {
|
||
models.SyncQueues.Lock()
|
||
for k, q := range queues {
|
||
models.SyncQueues.AddQueue(k, q)
|
||
}
|
||
models.SyncQueues.Unlock()
|
||
}
|
||
}
|
||
|
||
// Бэкфилл индекса accessKey→tenant для read-through (идемпотентно; нужен для
|
||
// тенантов, созданных до появления индекса, и для multi-instance сценария).
|
||
if persistence.Client != nil {
|
||
for _, t := range tenantStore.List() {
|
||
if data, err := json.Marshal(t); err == nil {
|
||
persistence.SaveTenantRaw(t.ID, data)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Автосид демо-данных при SHARED_SQS_SEED_DEMO=true
|
||
if os.Getenv("SHARED_SQS_SEED_DEMO") == "true" {
|
||
seedDemoData(tenantStore)
|
||
}
|
||
|
||
// Billing: подключение к PostgreSQL для учёта использования.
|
||
// Если BILLING_PG_HOST не задан — billing отключён, SQS работает без него.
|
||
billing.Init()
|
||
// Разбиение data-plane/control-plane (Шаг 2):
|
||
// data-plane — публичный SQS API; control-plane — админка/метрики/UI (только localhost).
|
||
dataRouter := router.NewDataPlane(tenantStore)
|
||
controlRouter := router.NewControlPlane(tenantStore, adminToken)
|
||
|
||
// Leader-election (active/passive, Шаг 3 — вариант C).
|
||
// Включается флагом --leader-election или SHARED_SQS_LEADER_ELECTION=true.
|
||
// Только лидер отдаёт /ready → 200; пассивный — 503 → LB/failover держит
|
||
// трафик на активном. Без флага ReadyFunc = nil (всегда готов) — одиночный
|
||
// инстанс работает ровно как раньше.
|
||
//
|
||
// Ограничение (осознанное, см. док): in-memory состояние НЕ шарится. Пока
|
||
// второй инстанс запущен «горячим», очереди живут в Redis, но изменения
|
||
// второго (пассивного) инстанса не увидит активный до рестарта. Поэтому
|
||
// на фазе C пассивный НЕ должен принимать SQS-трафик: nginx/LB обязан
|
||
// проверять /ready и не отправлять API-запросы на 503.
|
||
if !leaderElection && os.Getenv("SHARED_SQS_LEADER_ELECTION") == "true" {
|
||
leaderElection = true
|
||
}
|
||
var elector *leader.Elector
|
||
if leaderElection {
|
||
hostname, _ := os.Hostname()
|
||
if hostname == "" {
|
||
hostname = "unknown"
|
||
}
|
||
instanceID := fmt.Sprintf("%s-%d", hostname, os.Getpid())
|
||
elector = leader.New("ssq:leader", instanceID, leaderTTL, leaderInterval)
|
||
elector.Start()
|
||
router.ReadyFunc = elector.IsLeader
|
||
log.Infof("leader-election включён (id=%s, ttl=%s, interval=%s)", instanceID, leaderTTL, leaderInterval)
|
||
} else {
|
||
log.Info("leader-election выключен — инстанс всегда ready (одиночный режим)")
|
||
}
|
||
|
||
// PeriodicTasks — visibility timeout, DLQ, deduplication
|
||
quit := make(chan bool)
|
||
go gosqs.PeriodicTasks(1*time.Second, quit)
|
||
|
||
// Metrics: gauge updater — пересчёт очередей/сообщений per tenant каждые 15 секунд
|
||
metrics.StartGaugeUpdater(15*time.Second, quit)
|
||
|
||
// HTTP сервер с таймаутами (рекомендации ревью Соннета, план v0.1.35):
|
||
// - ReadHeaderTimeout 10с — лимит ТОЛЬКО на чтение заголовков (slowloris).
|
||
// ReadTimeout НЕ ставим: он обрывал бы запросы с медленной/большой
|
||
// загрузкой тела (в т.ч. большие SendMessage) и ломал бы long-poll.
|
||
// - WriteTimeout 35с — ответ; чуть больше max WaitTimeSeconds (20с long-poll).
|
||
// - IdleTimeout 90с — keep-alive; больше интервала ретраев клиентских SDK.
|
||
// - MaxHeaderBytes 8192 — защита от гигантских заголовков
|
||
// (платформа режет >16KB, наш лимит строже).
|
||
dataSrv := &http.Server{
|
||
Addr: "0.0.0.0:" + port,
|
||
Handler: dataRouter,
|
||
ReadHeaderTimeout: 10 * time.Second,
|
||
WriteTimeout: 35 * time.Second,
|
||
IdleTimeout: 90 * time.Second,
|
||
MaxHeaderBytes: 8192,
|
||
}
|
||
// control-plane слушает ТОЛЬКО localhost — админка/метрики/UI не должны быть публичными.
|
||
controlSrv := &http.Server{
|
||
Addr: "127.0.0.1:" + controlPort,
|
||
Handler: controlRouter,
|
||
ReadHeaderTimeout: 10 * time.Second,
|
||
WriteTimeout: 35 * time.Second,
|
||
IdleTimeout: 90 * time.Second,
|
||
MaxHeaderBytes: 8192,
|
||
}
|
||
|
||
// Запуск в горутинах для graceful shutdown
|
||
serverErr := make(chan error, 2)
|
||
go func() {
|
||
log.Infof("shared-sqs data-plane listening on 0.0.0.0:%s", port)
|
||
if err := dataSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||
serverErr <- err
|
||
}
|
||
}()
|
||
go func() {
|
||
log.Infof("shared-sqs control-plane listening on 127.0.0.1:%s", controlPort)
|
||
if err := controlSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||
serverErr <- err
|
||
}
|
||
}()
|
||
|
||
// Graceful shutdown по SIGTERM/SIGINT (Trap #13)
|
||
sigCh := make(chan os.Signal, 1)
|
||
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
||
|
||
select {
|
||
case sig := <-sigCh:
|
||
log.Infof("Received signal %s, shutting down", sig)
|
||
case err := <-serverErr:
|
||
log.Fatalf("Server error: %v", err)
|
||
}
|
||
|
||
// Остановить PeriodicTasks
|
||
close(quit)
|
||
|
||
// Отпустить лидерский лок (если были лидером) до закрытия слушателей,
|
||
// чтобы пассивный взял его быстрее, чем через TTL.
|
||
if elector != nil {
|
||
elector.Stop()
|
||
}
|
||
|
||
// Закрыть billing (если был подключён)
|
||
billing.Close()
|
||
|
||
// Дать 10 секунд на завершение текущих HTTP запросов
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
if err := dataSrv.Shutdown(ctx); err != nil {
|
||
log.Errorf("Data-plane shutdown error: %v", err)
|
||
}
|
||
if err := controlSrv.Shutdown(ctx); err != nil {
|
||
log.Errorf("Control-plane shutdown error: %v", err)
|
||
}
|
||
log.Info("shared-sqs stopped")
|
||
}
|