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