152 lines
4.2 KiB
Go
152 lines
4.2 KiB
Go
// 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
|
||
|
||
// lastRefresh — время последнего успешного refresh/acquire лока.
|
||
// Нужен для сброса лидерства при длительном сбое refresh (split-brain).
|
||
lastRefresh time.Time
|
||
}
|
||
|
||
// 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{}),
|
||
lastRefresh: time.Now(),
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
// Refresh не удаётся: если с последнего успеха прошло > TTL — лок
|
||
// гарантированно истёк, и другой инстанс мог стать лидером (split-brain).
|
||
// Сбрасываем лидерство (fail-safe), а не остаёмся «лидером» без лока.
|
||
if time.Since(e.lastRefreshAt()) > e.ttl {
|
||
e.setLeader(false)
|
||
}
|
||
return
|
||
}
|
||
e.markRefreshed()
|
||
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)
|
||
e.markRefreshed()
|
||
}
|
||
}
|
||
|
||
func (e *Elector) lastRefreshAt() time.Time {
|
||
e.mu.RLock()
|
||
defer e.mu.RUnlock()
|
||
return e.lastRefresh
|
||
}
|
||
|
||
func (e *Elector) markRefreshed() {
|
||
e.mu.Lock()
|
||
e.lastRefresh = time.Now()
|
||
e.mu.Unlock()
|
||
}
|