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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user