feat(ha): leader-election via Redis lock + /ready endpoint (active/passive, off by default)

- app/leader: Elector (SETNX lock + PEXPIRE heartbeat via Lua, IsLeader)
- app/persistence: TryAcquireLock/RefreshLock/ReleaseLock (no-op when Redis absent)
- app/router: /ready -> 200 if ReadyFunc nil/true, else 503 (both planes)
- app/cmd: --leader-election / SHARED_SQS_LEADER_ELECTION, --leader-ttl, --leader-interval;
  ReadyFunc wired only when enabled, lock released on graceful shutdown
- default OFF: single-instance behaviour unchanged
This commit is contained in:
Repinoid
2026-10-10 16:30:15 +03:00
parent 607aebb8ef
commit d7b3c653e0
4 changed files with 253 additions and 0 deletions
+45
View File
@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
@@ -16,6 +17,7 @@ import (
"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"
@@ -32,6 +34,9 @@ func main() {
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")
@@ -39,6 +44,11 @@ func main() {
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{})
@@ -147,6 +157,35 @@ func main() {
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)
@@ -209,6 +248,12 @@ func main() {
// Остановить PeriodicTasks
close(quit)
// Отпустить лидерский лок (если были лидером) до закрытия слушателей,
// чтобы пассивный взял его быстрее, чем через TTL.
if elector != nil {
elector.Stop()
}
// Закрыть billing (если был подключён)
billing.Close()