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
+60
View File
@@ -653,3 +653,63 @@ func LoadAllTenantsRaw() (map[string][]byte, error) {
log.Infof("persistence: загружено %d тенантов из Redis", len(result))
return result, nil
}
// ---- Leader election (active/passive) ----
//
// Простейший HA-механизм (вариант C): один инстанс — активный (держит лок),
// остальные — пассивные. При падении активного лок истекает по TTL и пассивный
// его перехватывает (см. app/leader). Инстанс сообщает готовность через /ready.
//
// ВАЖНО: при Client == nil (memory-only) — считаем себя лидером (мы единственные).
// TryAcquireLock — пытается взять лок (SETNX с TTL). true — лок наш.
func TryAcquireLock(key, value string, ttl time.Duration) (bool, error) {
if Client == nil {
return true, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return Client.SetNX(ctx, key, value, ttl).Result()
}
// refreshLockScript — продлить TTL, ТОЛЬКО если лок всё ещё наш (атомарно).
var refreshLockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
return 0
end`)
// releaseLockScript — удалить лок, ТОЛЬКО если он наш.
var releaseLockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end`)
// RefreshLock — продлевает TTL лока, если он всё ещё наш. false — лок потерян.
func RefreshLock(key, value string, ttl time.Duration) (bool, error) {
if Client == nil {
return true, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
n, err := refreshLockScript.Run(ctx, Client, []string{key}, value, ttl.Milliseconds()).Int()
if err != nil {
return false, err
}
return n == 1, nil
}
// ReleaseLock — освобождает лок (если он наш). Вызывается при graceful shutdown.
func ReleaseLock(key, value string) {
if Client == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if _, err := releaseLockScript.Run(ctx, Client, []string{key}, value).Int(); err != nil {
log.Errorf("persistence: release lock %q: %v", key, err)
}
}