montana/Русский/Сеть/failover_chain.py

307 lines
11 KiB
Python
Raw Permalink 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.

#!/usr/bin/env python3
"""
failover_chain.py — Цепочка отказоустойчивости Montana
Книга Монтана, Глава 08:
> "Failover цепь: Amsterdam → Almaty → SPB → Novosibirsk"
> "Если Amsterdam падает — Almaty подхватывает автоматически."
> "Пользователь отправил сообщение в Амстердам — получил ответ из Алматы.
> Разница — пять секунд."
Механика:
1. Primary узел падает
2. Standby ждёт 5 секунд (может быть глитч)
3. Проверяет ещё раз
4. Если всё ещё молчит — подхватывает
"""
from dataclasses import dataclass, field
from datetime import datetime, timezone, timedelta
from typing import Dict, List, Optional, Callable
from enum import Enum
import time
import threading
class FailoverEvent(Enum):
"""Типы событий failover."""
NODE_TIMEOUT = "timeout"
NODE_RECOVERED = "recovered"
TAKEOVER_STARTED = "takeover_started"
TAKEOVER_COMPLETE = "takeover_complete"
CHAIN_BROKEN = "chain_broken"
@dataclass
class HealthCheck:
"""Результат проверки здоровья узла."""
node_id: str
is_healthy: bool
latency_ms: float
checked_at: str
error: Optional[str] = None
@dataclass
class FailoverRecord:
"""Запись о событии failover."""
event_id: str
event_type: FailoverEvent
from_node: str
to_node: Optional[str]
timestamp: str
detection_time_sec: float
takeover_time_sec: float = 0.0
class FailoverChain:
"""
Цепочка отказоустойчивости.
Книга Монтана:
> "Almaty видит это. Ждёт пять секунд — может, просто глитч.
> Проверяет ещё раз. Amsterdam молчит."
> "Almaty подхватывает. Бот продолжает работать."
"""
# Константы из книги
DETECTION_TIMEOUT_SEC = 5.0 # Ожидание перед takeover
HEALTH_CHECK_INTERVAL_SEC = 1.0 # Интервал проверки
MAX_RETRIES = 2 # Количество повторных проверок
def __init__(self):
# Цепочка узлов (из книги)
self.bot_chain = ["amsterdam", "almaty", "spb", "novosibirsk"]
self.brain_chain = ["moscow", "almaty", "spb", "novosibirsk"]
# Текущие активные узлы
self.active_bot: str = self.bot_chain[0]
self.active_brain: str = self.brain_chain[0]
# Состояние узлов
self.node_health: Dict[str, bool] = {
node: True for node in set(self.bot_chain + self.brain_chain)
}
# История событий
self.events: List[FailoverRecord] = []
# Callbacks
self.on_failover: Optional[Callable[[FailoverRecord], None]] = None
def check_node(self, node_id: str) -> HealthCheck:
"""
Проверить здоровье узла.
Args:
node_id: ID узла
Returns:
HealthCheck
"""
start = time.time()
is_healthy = self.node_health.get(node_id, False)
latency = (time.time() - start) * 1000
return HealthCheck(
node_id=node_id,
is_healthy=is_healthy,
latency_ms=latency,
checked_at=datetime.now(timezone.utc).isoformat(),
error=None if is_healthy else "Node not responding"
)
def simulate_failure(self, node_id: str) -> FailoverRecord:
"""
Симулировать падение узла.
Args:
node_id: ID узла
Returns:
FailoverRecord с результатом
"""
if node_id not in self.node_health:
raise ValueError(f"Unknown node: {node_id}")
# Узел падает
self.node_health[node_id] = False
detection_start = time.time()
# Определяем какую роль занимал узел
is_bot = node_id == self.active_bot
is_brain = node_id == self.active_brain
chain = self.bot_chain if is_bot else self.brain_chain
# Ждём DETECTION_TIMEOUT (как в книге — 5 секунд)
# В симуляции просто записываем
detection_time = self.DETECTION_TIMEOUT_SEC
# Находим следующий в цепи
current_idx = chain.index(node_id) if node_id in chain else -1
next_node = None
for i in range(current_idx + 1, len(chain)):
candidate = chain[i]
if self.node_health.get(candidate, False):
next_node = candidate
break
# Takeover
takeover_start = time.time()
if next_node:
if is_bot:
self.active_bot = next_node
elif is_brain:
self.active_brain = next_node
takeover_time = time.time() - takeover_start
# Создаём запись
record = FailoverRecord(
event_id=f"fo_{int(time.time()*1000)}",
event_type=FailoverEvent.TAKEOVER_COMPLETE if next_node else FailoverEvent.CHAIN_BROKEN,
from_node=node_id,
to_node=next_node,
timestamp=datetime.now(timezone.utc).isoformat(),
detection_time_sec=detection_time,
takeover_time_sec=takeover_time
)
self.events.append(record)
if self.on_failover:
self.on_failover(record)
return record
def recover_node(self, node_id: str) -> None:
"""
Восстановить узел (не возвращает роль автоматически).
Args:
node_id: ID узла
"""
self.node_health[node_id] = True
record = FailoverRecord(
event_id=f"rec_{int(time.time()*1000)}",
event_type=FailoverEvent.NODE_RECOVERED,
from_node=node_id,
to_node=None,
timestamp=datetime.now(timezone.utc).isoformat(),
detection_time_sec=0,
takeover_time_sec=0
)
self.events.append(record)
def get_chain_status(self) -> Dict:
"""Статус цепочки failover."""
healthy_bots = [n for n in self.bot_chain if self.node_health.get(n, False)]
healthy_brains = [n for n in self.brain_chain if self.node_health.get(n, False)]
return {
"bot_chain": self.bot_chain,
"brain_chain": self.brain_chain,
"active_bot": self.active_bot,
"active_brain": self.active_brain,
"healthy_nodes": [n for n, h in self.node_health.items() if h],
"failed_nodes": [n for n, h in self.node_health.items() if not h],
"bot_chain_depth": len(healthy_bots),
"brain_chain_depth": len(healthy_brains),
"detection_timeout_sec": self.DETECTION_TIMEOUT_SEC,
"total_failovers": len([e for e in self.events
if e.event_type == FailoverEvent.TAKEOVER_COMPLETE])
}
def get_next_in_chain(self, role: str) -> Optional[str]:
"""
Получить следующий узел в цепи.
Args:
role: "bot" или "brain"
Returns:
ID следующего узла или None
"""
if role == "bot":
chain = self.bot_chain
current = self.active_bot
else:
chain = self.brain_chain
current = self.active_brain
current_idx = chain.index(current) if current in chain else -1
for i in range(current_idx + 1, len(chain)):
candidate = chain[i]
if self.node_health.get(candidate, False):
return candidate
return None
# ═══════════════════════════════════════════════════════════════════════════════
# DEMO
# ═══════════════════════════════════════════════════════════════════════════════
if __name__ == "__main__":
chain = FailoverChain()
print("=" * 60)
print("FAILOVER CHAIN — Цепочка отказоустойчивости")
print("=" * 60)
print("\n'Если Amsterdam падает — Almaty подхватывает автоматически.'")
# Статус цепи
print("\n--- НАЧАЛЬНЫЙ СТАТУС ---")
status = chain.get_chain_status()
print(f"BOT цепь: {''.join(status['bot_chain'])}")
print(f"BRAIN цепь: {''.join(status['brain_chain'])}")
print(f"Активный BOT: {status['active_bot']}")
print(f"Активный BRAIN: {status['active_brain']}")
print(f"Timeout детекции: {status['detection_timeout_sec']} сек")
# Симуляция падения Amsterdam
print("\n--- ПАДЕНИЕ AMSTERDAM ---")
print("[ERROR] Amsterdam: connection timeout")
print(f"[INFO] Ожидание {chain.DETECTION_TIMEOUT_SEC} секунд...")
record = chain.simulate_failure("amsterdam")
print(f"[INFO] {record.to_node}: taking over bot")
print(f"\nСобытие: {record.event_type.value}")
print(f"От узла: {record.from_node}")
print(f"К узлу: {record.to_node}")
print(f"Время детекции: {record.detection_time_sec} сек")
# Новый статус
print("\n--- НОВЫЙ СТАТУС ---")
status = chain.get_chain_status()
print(f"Активный BOT: {status['active_bot']}")
print(f"Здоровые узлы: {', '.join(status['healthy_nodes'])}")
print(f"Упавшие узлы: {', '.join(status['failed_nodes'])}")
# Следующий в цепи
print("\n--- СЛЕДУЮЩИЙ В ЦЕПИ ---")
next_bot = chain.get_next_in_chain("bot")
print(f"Если {status['active_bot']} упадёт, следующий: {next_bot}")
# Каскадное падение
print("\n--- КАСКАДНОЕ ПАДЕНИЕ ---")
for node in ["almaty", "spb"]:
record = chain.simulate_failure(node)
print(f"{node} упал → замена: {record.to_node or 'НЕТ'}")
# Финальный статус
print("\n--- ФИНАЛЬНЫЙ СТАТУС ---")
status = chain.get_chain_status()
print(f"Активный BOT: {status['active_bot']}")
print(f"Глубина BOT цепи: {status['bot_chain_depth']}")
print(f"Всего failover: {status['total_failovers']}")
print("\n" + "=" * 60)
print("'Пользователь не замечает падения. Разница — 5 секунд.'")
print("=" * 60)