Files
awoooi/apps/api/src/services/ollama_auto_recovery.py
Your Name 869646459c
Some checks failed
CD Pipeline / tests (push) Failing after 1m48s
CD Pipeline / build-and-deploy (push) Has been skipped
CD Pipeline / post-deploy-checks (push) Has been skipped
Code Review / ai-code-review (push) Successful in 28s
fix(ollama): treat legacy primary as ollama
2026-05-05 13:25:27 +08:00

497 lines
19 KiB
Python
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.
"""
Ollama 自動恢復服務 - P1.1d
============================
背景監控:偵測 111 從 OFFLINE/SLOW/DEGRADED 恢復為 HEALTHY 後,立刻切回 Ollama。
核心設計:
- 30s 輪詢 111 健康狀態
- 防抖:連續 3 次 HEALTHY 才觸發切回30s × 3 = 90s 穩定視窗)
- 中途若又 OFFLINEcounter 歸零,重新計數
- 切回後呼叫 failover_manager.clear_cache(),讓下次路由重新評估
- 預留 telegram_alerter 介面P1.5 Engineer 接入)
- structlog audit service="ollama_auto_recovery"
整合方式FastAPI lifespan
from src.services.ollama_auto_recovery import OllamaAutoRecoveryService
@asynccontextmanager
async def lifespan(app: FastAPI):
recovery_svc = OllamaAutoRecoveryService()
await recovery_svc.start()
yield
await recovery_svc.stop()
版本: v1.0
建立: 2026-04-25 (台北時區)
建立者: Claude Engineer-C (P1.1d)
# 2026-04-25 統帥指令 by Claude Engineer-C — 自動切 Gemini + 自動恢復
"""
from __future__ import annotations
import asyncio
import datetime
from datetime import timezone, timedelta
from typing import Any, Protocol, runtime_checkable
import structlog
# 台北時區 +8標準庫保險絲100% 可用)
# 2026-04-25 critic-fix Part2 B4 by Claude Engineer-C2
# 原 zoneinfo.ZoneInfo("Asia/Taipei") 失敗時 = None → datetime.now(None) 為 UTC
TAIPEI_TZ = timezone(timedelta(hours=8))
from src.core.config import get_settings
from src.services.ollama_health_monitor import (
HealthStatus,
OllamaHealthMonitor,
get_ollama_health_monitor,
)
from src.services.ollama_failover_manager import (
OllamaFailoverManager,
get_ollama_failover_manager,
)
logger = structlog.get_logger(__name__)
# =============================================================================
# 常數
# =============================================================================
RECOVERY_CHECK_INTERVAL_SEC = 30 # 每 30s 檢查一次
STABLE_COUNT_REQUIRED = 3 # 連續 3 次 HEALTHY 才切回(防抖)
# =============================================================================
# Telegram Alerter Protocol預留介面P1.5 Engineer 實作)
# =============================================================================
@runtime_checkable
class TelegramAlerter(Protocol):
"""Telegram 通知介面預留P1.5 Engineer 接入)"""
async def alert_recovery(self, payload: dict[str, Any]) -> None:
"""111 恢復通知"""
...
# =============================================================================
# OllamaAutoRecoveryService
# =============================================================================
class OllamaAutoRecoveryService:
"""
Ollama 111 自動恢復服務
偵測 111 從 OFFLINE/SLOW/DEGRADED 恢復為 HEALTHY 後,立刻切回 Ollama 路由。
防抖邏輯:
- 連續 3 次 HEALTHY90s 穩定視窗)才觸發切回
- 中途任一次非 HEALTHY → counter 歸零
- 已是 ollama primary 時不重複觸發
2026-04-25 統帥指令 by Claude Engineer-C — 自動切 Gemini + 自動恢復
"""
def __init__(
self,
health_monitor: OllamaHealthMonitor | None = None,
failover_manager: OllamaFailoverManager | None = None,
telegram_alerter: TelegramAlerter | None = None,
recovery_check_interval_sec: int = RECOVERY_CHECK_INTERVAL_SEC,
stable_count_required: int = STABLE_COUNT_REQUIRED,
) -> None:
self._monitor = health_monitor or get_ollama_health_monitor()
self._failover_manager = failover_manager or get_ollama_failover_manager()
self._alerter = telegram_alerter
self._recovery_check_interval_sec = recovery_check_interval_sec
self._stable_count_required = stable_count_required
self._settings = get_settings()
# 狀態追蹤
# 2026-05-04 ogt: B5 修復 — 改用與 failover_manager callback 一致的命名
# failover_manager 傳入的是 "ollama_gcp_a"/"ollama_gcp_b"/"ollama_local"/"gemini"
# 原設計用 "ollama" 造成 != "ollama" 永遠成立 → recovery loop 永遠以為在 Gemini
# 修法:判斷改用 _is_ollama_primary(),比對 startswith("ollama_")
self._current_primary: str = "ollama_gcp_a" # fallback_manager 傳入的 provider_name
self._consecutive_healthy: int = 0
self._task: asyncio.Task | None = None
# -------------------------------------------------------------------------
# Lifecycle
# -------------------------------------------------------------------------
async def start(self) -> None:
"""
啟動背景監控任務。
在 FastAPI lifespan 的 startup 階段呼叫:
recovery_svc = OllamaAutoRecoveryService()
await recovery_svc.start()
# 2026-04-25 critic-fix Part2 H5+H6 by Claude Engineer-C2
# 啟動時從 Redis 載入狀態(跨重啟持久化),
# 若 primary != "ollama" 立刻執行一次 check不等 30s
"""
if self._task is not None and not self._task.done():
logger.warning(
"ollama_auto_recovery_already_running",
service="ollama_auto_recovery",
)
return
# 從 Redis 載入持久化狀態(跨重啟恢復)
# 2026-04-25 critic-fix Part2 H5+H6 by Claude Engineer-C2
self._current_primary = await self._load_primary()
logger.info(
"ollama_auto_recovery_bootstrap",
service="ollama_auto_recovery",
current_primary=self._current_primary,
)
# 若 primary 不是 Ollama立刻評估一次不等 30s interval
if not self._is_ollama_primary(self._current_primary):
try:
await self._check_and_recover()
except Exception:
logger.exception(
"ollama_auto_recovery_immediate_check_error",
service="ollama_auto_recovery",
)
self._task = asyncio.create_task(
self._monitor_loop(),
name="ollama_auto_recovery_loop",
)
logger.info(
"ollama_auto_recovery_started",
service="ollama_auto_recovery",
check_interval_sec=self._recovery_check_interval_sec,
stable_count_required=self._stable_count_required,
)
async def stop(self) -> None:
"""
優雅關閉背景任務。
在 FastAPI lifespan 的 shutdown 階段呼叫:
await recovery_svc.stop()
"""
if self._task is None or self._task.done():
return
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
finally:
self._task = None
logger.info(
"ollama_auto_recovery_stopped",
service="ollama_auto_recovery",
)
# -------------------------------------------------------------------------
# 狀態控制(供整合層設定當前 primary例如 failover 觸發時更新)
# -------------------------------------------------------------------------
async def set_current_primary(self, provider: str) -> None:
"""
通知 recovery service 當前路由 primary 是哪個 provider。
OllamaFailoverManager.select_provider() 觸發 failover 後呼叫此方法更新狀態。
# 2026-04-25 critic-fix Part2 H5+H6 by Claude Engineer-C2
# 改為 async切換時同步寫 Redis 持久化(跨重啟恢復)
"""
if self._current_primary != provider:
logger.info(
"ollama_auto_recovery_primary_changed",
service="ollama_auto_recovery",
from_primary=self._current_primary,
to_primary=provider,
)
self._current_primary = provider
# Redis 持久化(跨重啟恢復)
await self._persist_primary(provider)
# 2026-05-04 ogt: B5/B6 修復 — 判斷改用 _is_ollama_primary()
# 原設計provider != "ollama",但 callback 傳 "ollama_gcp_a" → 永遠觸發 tracking
if not self._is_ollama_primary(provider):
# 切換到非 Ollamagemini/nemotron/claude→ 重置 counter開始監控恢復
self._consecutive_healthy = 0
logger.info(
"ollama_auto_recovery_tracking_started",
service="ollama_auto_recovery",
current_primary=provider,
)
# -------------------------------------------------------------------------
# Redis 持久化(跨重啟恢復)
# 2026-04-25 critic-fix Part2 H5+H6 by Claude Engineer-C2
# 2026-05-04 P0-11 Phase A 雙寫遷移ADR-110/ADR-118
# 舊 key "ollama:current_primary" → 新 key "platform:ollama:current_primary"
# Ollama 是 platform_resource非 tenant 資源),加 platform: 前綴明確分類
# Phase A30 天):同時寫入新舊 key讀取以新 key 為主,舊 key 作 fallback
# Phase C~2026-06-04停止寫入舊 key刪除舊 key
# -------------------------------------------------------------------------
_REDIS_PRIMARY_KEY = "platform:ollama:current_primary" # 新 keyPhase A 起生效)
_REDIS_PRIMARY_KEY_LEGACY = "ollama:current_primary" # 舊 keyPhase C 前持續雙寫)
async def _persist_primary(self, primary: str) -> None:
"""持久化 current_primary 到 Redis無 TTL跨重啟恢復
Phase A 雙寫:同時寫入新舊 key確保舊版 Pod 滾動期間不失效。
"""
try:
from src.core.redis_client import get_redis
redis = get_redis()
await redis.set(self._REDIS_PRIMARY_KEY, primary)
await redis.set(self._REDIS_PRIMARY_KEY_LEGACY, primary) # Phase A 雙寫
except Exception as e:
logger.warning(
"ollama_auto_recovery_persist_failed",
service="ollama_auto_recovery",
error=str(e),
)
async def _load_primary(self) -> str:
"""從 Redis 載入 current_primary找不到時預設 "ollama"
Phase A 讀取:優先讀新 key不存在時 fallback 舊 key。
"""
try:
from src.core.redis_client import get_redis
redis = get_redis()
val = await redis.get(self._REDIS_PRIMARY_KEY)
if not val:
# Phase A fallback新 key 尚未有值(舊版 Pod 只寫了舊 key
val = await redis.get(self._REDIS_PRIMARY_KEY_LEGACY)
if val:
logger.info(
"ollama_auto_recovery_loaded_from_legacy_key",
service="ollama_auto_recovery",
note="Phase A fallback預計 2026-06-04 移除舊 key",
)
if val:
decoded = val.decode() if isinstance(val, bytes) else val
logger.info(
"ollama_auto_recovery_loaded_from_redis",
service="ollama_auto_recovery",
current_primary=decoded,
)
return decoded
except Exception as e:
logger.warning(
"ollama_auto_recovery_load_failed",
service="ollama_auto_recovery",
error=str(e),
)
return "ollama" # 預設:正常路由
@property
def current_primary(self) -> str:
return self._current_primary
@property
def consecutive_healthy(self) -> int:
return self._consecutive_healthy
@property
def is_running(self) -> bool:
return self._task is not None and not self._task.done()
@staticmethod
def _is_ollama_primary(provider: str) -> bool:
"""Return true for both legacy "ollama" and ADR-110 provider names."""
return provider == "ollama" or provider.startswith("ollama_")
# -------------------------------------------------------------------------
# 背景監控迴圈
# -------------------------------------------------------------------------
async def _monitor_loop(self) -> None:
"""
每 30s 檢查 111 健康狀態。
連續 3 次 HEALTHY 且 current_primary != "ollama" → 觸發切回。
"""
while True:
try:
await asyncio.sleep(self._recovery_check_interval_sec)
await self._check_and_recover()
except asyncio.CancelledError:
raise # 必須重新 raise讓 stop() 的 await 正常結束
except Exception:
logger.exception(
"ollama_auto_recovery_loop_error",
service="ollama_auto_recovery",
)
# 不 break繼續監控異常不應停止監控迴圈
async def _check_and_recover(self) -> None:
"""
單次健康狀態檢查 + 切回邏輯。
抽取為獨立方法,方便單元測試。
"""
host = self._settings.OLLAMA_URL
try:
health = await self._monitor.check(host)
except Exception:
logger.exception(
"ollama_auto_recovery_check_failed",
service="ollama_auto_recovery",
host=host,
)
self._consecutive_healthy = 0
return
# 2026-05-04 ogt: SLOW 視為可用GCP 高負載 10-30s 仍優於 Gemini quota 限制)
# 原設計只接受 HEALTHYGCP 若因負載偏高落入 SLOW 區會永久卡在 Gemini
is_usable = health.status in (HealthStatus.HEALTHY, HealthStatus.SLOW)
if is_usable:
self._consecutive_healthy += 1
logger.debug(
"ollama_auto_recovery_healthy_tick",
service="ollama_auto_recovery",
consecutive=self._consecutive_healthy,
required=self._stable_count_required,
current_primary=self._current_primary,
actual_status=health.status.value,
)
if (
self._consecutive_healthy >= self._stable_count_required
and not self._is_ollama_primary(self._current_primary)
):
await self._switch_back_to_ollama()
else:
# DEGRADED / OFFLINE → counter 歸零,繼續等
if self._consecutive_healthy > 0:
logger.debug(
"ollama_auto_recovery_counter_reset",
service="ollama_auto_recovery",
previous_count=self._consecutive_healthy,
status=health.status.value,
)
self._consecutive_healthy = 0
# -------------------------------------------------------------------------
# 切回 Ollama 111
# -------------------------------------------------------------------------
async def _switch_back_to_ollama(self) -> None:
"""
切回 Ollama 111
1. 更新 _current_primary
2. 呼叫 failover_manager.clear_cache() 清空路由快取
3. 通知 failover_managernotify_recovery
4. Telegram 通知(若 alerter 已設定)
5. structlog audit
"""
from_provider = self._current_primary
self._current_primary = "ollama"
stable_count = self._consecutive_healthy
# counter 不立即清零:保留供 audit下次 loop 開始後自然計數
self._consecutive_healthy = 0
# 取台北時間(標準庫保證 +8禁 UTC
# 2026-04-25 critic-fix Part2 B4 by Claude Engineer-C2
recovery_time = datetime.datetime.now(TAIPEI_TZ)
# 清空 failover manager 快取(讓下次 select_provider 重新評估)
try:
await self._failover_manager.clear_cache()
except Exception:
logger.exception(
"ollama_auto_recovery_clear_cache_error",
service="ollama_auto_recovery",
)
# 通知 failover_manager
try:
# 2026-05-03 ogt: ADR-110 — provider name 改為 "ollama_gcp_a"GCP-A Primary
self._failover_manager.notify_recovery("ollama_gcp_a")
except Exception:
logger.exception(
"ollama_auto_recovery_notify_error",
service="ollama_auto_recovery",
)
# Telegram 通知P1.5 Engineer-D 接入)
# 2026-04-25 P1.5 by Claude Engineer-D — 接 FailoverAlerterself._alerter 優先,
# 無則 fallback 到 get_failover_alerter() singleton
try:
alerter = self._alerter
if alerter is None:
from src.services.failover_alerter import get_failover_alerter
alerter = get_failover_alerter()
# 2026-05-05 ogt C1 修復:動態判斷 provider label避免 hardcode "GCP-A"
# 依 OLLAMA_URL port 判斷11435=GCP-A、11436=GCP-B、11437/111=Local
_recovered_url = getattr(self._settings, "OLLAMA_URL", "Ollama Primary")
if "11435" in _recovered_url:
_provider_label = "GCP-A"
elif "11436" in _recovered_url:
_provider_label = "GCP-B"
elif "11437" in _recovered_url or "192.168.0.111" in _recovered_url:
_provider_label = "Local"
else:
_provider_label = "Ollama"
await alerter.alert_recovery({
"from": from_provider,
"to": "ollama",
"recovered_host": f"{_provider_label} {_recovered_url}",
"stable_count": stable_count,
"recovery_time": recovery_time.isoformat(),
})
except Exception:
logger.exception(
"ollama_auto_recovery_telegram_alert_error",
service="ollama_auto_recovery",
)
# 2026-04-26 P2.3 by Claude Sonnet 4.6 (tool-expert) — 記錄 recovery Prometheus metric
try:
from src.core.metrics import (
OLLAMA_RECOVERY_TRIGGERED_TOTAL,
OLLAMA_CURRENT_PRIMARY_IS_OLLAMA,
)
OLLAMA_RECOVERY_TRIGGERED_TOTAL.labels(from_provider=from_provider).inc()
OLLAMA_CURRENT_PRIMARY_IS_OLLAMA.set(1)
except Exception as _metric_err:
logger.debug("ollama_recovery_metric_error", error=str(_metric_err))
# structlog audit必須記錄
logger.info(
"ollama_auto_recovery_switched_back",
service="ollama_auto_recovery",
action="switched_back",
from_provider=from_provider,
to_provider="ollama_gcp_a", # 2026-05-03 ogt: ADR-110
stable_count=stable_count,
recovery_time=recovery_time.isoformat(),
)
# =============================================================================
# Singleton
# =============================================================================
_recovery_service: OllamaAutoRecoveryService | None = None
def get_ollama_auto_recovery_service() -> OllamaAutoRecoveryService:
"""取得 OllamaAutoRecoveryService singleton"""
global _recovery_service
if _recovery_service is None:
_recovery_service = OllamaAutoRecoveryService()
return _recovery_service
def reset_ollama_auto_recovery_service() -> None:
"""重置 singleton測試用"""
global _recovery_service
_recovery_service = None