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:
@@ -0,0 +1,126 @@
|
||||
// app/leader/leader.go
|
||||
// Простейший leader-election для active/passive (Шаг 3, вариант C).
|
||||
//
|
||||
// Лидер держит Redis-лок (ssq:leader) с TTL и продлевает его каждые interval.
|
||||
// Пассивные периодически пытаются взять лок; когда лидер падает, TTL истекает и
|
||||
// один из пассивных становится лидером. Готовность отдаётся через IsLeader().
|
||||
//
|
||||
// Почему так, а не полный вынос состояния (вариант A): дешевле в разы и не требует
|
||||
// переписывания ядра очередей; снимает главную боль «одна ВМ = SPOF». Потолок
|
||||
// (горизонтальное масштабирование) при этом остаётся — см.
|
||||
// doc/thinking/state-extraction-and-ha-2026-10-10.md.
|
||||
//
|
||||
// ВАЖНО: при memory-only (нет Redis) — считаем себя лидером (мы единственные).
|
||||
package leader
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"shared-sqs/app/persistence"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Elector — выборы лидера через Redis-лок.
|
||||
type Elector struct {
|
||||
key string
|
||||
id string
|
||||
ttl time.Duration
|
||||
interval time.Duration
|
||||
|
||||
mu sync.RWMutex
|
||||
isLeader bool
|
||||
stopCh chan struct{}
|
||||
stopped bool
|
||||
}
|
||||
|
||||
// New — создаёт Elector. key — ключ лока, id — уникальный id инстанса.
|
||||
func New(key, id string, ttl, interval time.Duration) *Elector {
|
||||
return &Elector{
|
||||
key: key,
|
||||
id: id,
|
||||
ttl: ttl,
|
||||
interval: interval,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start — запускает цикл выборов в фоне (первая попытка — сразу).
|
||||
func (e *Elector) Start() {
|
||||
log.Infof("leader: старт выборов (key=%s, id=%s, ttl=%s)", e.key, e.id, e.ttl)
|
||||
go e.loop()
|
||||
}
|
||||
|
||||
// IsLeader — текущий статус (готовность обслуживать).
|
||||
func (e *Elector) IsLeader() bool {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return e.isLeader
|
||||
}
|
||||
|
||||
// Stop — останавливает цикл и освобождает лок (если были лидером).
|
||||
func (e *Elector) Stop() {
|
||||
e.mu.Lock()
|
||||
if e.stopped {
|
||||
e.mu.Unlock()
|
||||
return
|
||||
}
|
||||
e.stopped = true
|
||||
close(e.stopCh)
|
||||
was := e.isLeader
|
||||
e.mu.Unlock()
|
||||
if was {
|
||||
persistence.ReleaseLock(e.key, e.id)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Elector) setLeader(v bool) {
|
||||
e.mu.Lock()
|
||||
changed := e.isLeader != v
|
||||
e.isLeader = v
|
||||
e.mu.Unlock()
|
||||
if changed {
|
||||
if v {
|
||||
log.Info("leader: инстанс стал ЛИДЕРОМ (активный)")
|
||||
} else {
|
||||
log.Warn("leader: потерян лидерский лок (пассивный)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Elector) loop() {
|
||||
t := time.NewTicker(e.interval)
|
||||
defer t.Stop()
|
||||
e.step()
|
||||
for {
|
||||
select {
|
||||
case <-e.stopCh:
|
||||
return
|
||||
case <-t.C:
|
||||
e.step()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Elector) step() {
|
||||
if e.IsLeader() {
|
||||
ok, err := persistence.RefreshLock(e.key, e.id, e.ttl)
|
||||
if err != nil {
|
||||
log.Errorf("leader: refresh: %v", err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
e.setLeader(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
ok, err := persistence.TryAcquireLock(e.key, e.id, e.ttl)
|
||||
if err != nil {
|
||||
log.Errorf("leader: acquire: %v", err)
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
e.setLeader(true)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user