Files
SQS-service/app/leader/leader.go
T

152 lines
4.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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()
}