Files
awoooi/apps/api/src/services/ollama_failover_manager.py

676 lines
28 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.1b
============================
依 OllamaHealthMonitor 健康狀態決定 Ollama 路由方案。
路由邏輯global_product_governance_v2
selected primary 永遠是 GCP-Aexecutable chain 永遠是
GCP-A → GCP-B → Local 111 → Gemini。健康探測只會產生
observed_fallback不得將 Gemini 呈現為 selected primary。
設計說明:
- GCP-A 主機34.143.170.20SSD9x 載速 + 2x 推理)
- GCP-B 備援34.21.145.224SSD9x 載速 + 2x 推理)
- Local 最後防線192.168.0.111M1 Pro, Metal 加速HDD
- 不直接依賴 AIProviderEnumP1.2 Engineer-A 整合時再對齊)
- 返回輕量 OllamaRoutingResult含主 endpoint + fallback 清單
- 並行檢查三台 Ollama 主機健康狀態
- 切換觸發時寫 audit_logs service="ollama_failover"
- clear_cache() 方法供 OllamaAutoRecoveryService 切回後清空路由快取
版本: v3.0
建立: 2026-04-25 (台北時區)
建立者: Claude Engineer-C (P1.1b)
更新: 2026-05-03 ogt — GCP 三層容災ADR-110GCP-A → GCP-B → Local → Gemini
# Created 2026-04-25 P1.1 by Claude Engineer-C
# 2026-04-25 統帥指令 by Claude Engineer-C — 自動切 Gemini + 自動恢復
# 2026-05-03 ogt: GCP 三層容災ADR-110GCP-A → GCP-B → Local → Gemini
"""
from __future__ import annotations
import asyncio
import datetime
from dataclasses import dataclass
from datetime import timedelta, timezone
import structlog
from src.core.config import get_settings
from src.services.ollama_endpoint_resolver import resolve_ollama_order
from src.services.ollama_health_monitor import (
HealthReport,
HealthStatus,
OllamaHealthMonitor,
get_ollama_health_monitor,
)
logger = structlog.get_logger(__name__)
# 台北時區 +8標準庫保險絲100% 可用)
# 2026-04-25 critic-fix Part2 B4 by Claude Engineer-C2
# 用標準庫 timezone(timedelta(hours=8)) 取代 zoneinfo保證一定有 +8 時區
# 原 zoneinfo.ZoneInfo("Asia/Taipei") 失敗時 = None → datetime.now(None) 為 UTC
TAIPEI_TZ = timezone(timedelta(hours=8))
# =============================================================================
# 路由結果模型輕量P1.2 整合時轉換為 RoutingDecision
# =============================================================================
@dataclass
class OllamaEndpoint:
"""Ollama 端點描述"""
url: str
provider_name: str # 給 AIRouterExecutor 用的 provider 名稱
model: str
def to_dict(self) -> dict:
return {"url": self.url, "provider_name": self.provider_name, "model": self.model}
@dataclass
class OllamaRoutingResult:
"""
Ollama 容災路由結果2026-05-03 ogt: 更新為三層 GCP 容災ADR-110
P1.2 Engineer-A 整合時,將此結果轉換為 ai_router.RoutingDecision
- selected_provider = AIProviderEnum[result.primary.provider_name.upper()]
- selected_model = result.primary.model
- fallback_chain = [(AIProviderEnum[p.provider_name.upper()], p.model) for p in result.fallback_chain]
"""
primary: OllamaEndpoint
fallback_chain: list[OllamaEndpoint]
routing_reason: str
health_gcp_a: HealthReport # GCP-A 健康狀態(原 health_111
health_gcp_b: HealthReport | None = None # GCP-B 健康狀態
health_local: HealthReport | None = None # Local(111) 健康狀態
observed_fallback: OllamaEndpoint | None = None
selection_mode: str = "fixed_production_order"
@property
def health_111(self) -> HealthReport:
"""向後相容屬性(舊測試 / log 使用)"""
return self.health_gcp_a
def all_endpoints_in_order(self) -> list[OllamaEndpoint]:
"""返回完整 executable chain不受健康 observation 重排。"""
return [self.primary, *self.fallback_chain]
def to_dict(self) -> dict:
selected_primary = {
"url": self.primary.url,
"provider": self.primary.provider_name,
"model": self.primary.model,
}
observed_fallback = (
{
"url": self.observed_fallback.url,
"provider": self.observed_fallback.provider_name,
"model": self.observed_fallback.model,
"observation_only": True,
}
if self.observed_fallback
else None
)
return {
# ``primary`` remains as a compatibility alias, but it now always
# means selected execution primary rather than observed health lane.
"primary": selected_primary,
"selected_primary": selected_primary,
"observed_fallback": observed_fallback,
"selection_mode": self.selection_mode,
"provider_order": [
endpoint.provider_name for endpoint in self.all_endpoints_in_order()
],
"fallback_chain": [
{"url": e.url, "provider": e.provider_name, "model": e.model} # noqa: E501
for e in self.fallback_chain
],
"routing_reason": self.routing_reason,
"health_gcp_a": self.health_gcp_a.to_dict(),
"health_gcp_b": self.health_gcp_b.to_dict() if self.health_gcp_b else None,
"health_local": self.health_local.to_dict() if self.health_local else None,
}
# =============================================================================
# Final paid fallback endpoint
# =============================================================================
# 以 provider_name 對應 ai_router.AIProviderEnum 的 value
_GEMINI_ENDPOINT = OllamaEndpoint(
url="",
provider_name="gemini",
model="gemini-2.5-flash-lite",
)
_PAID_FALLBACK_BLOCKED_ENDPOINT = OllamaEndpoint(
url="",
provider_name="paid_fallback_blocked",
model="",
)
# =============================================================================
# OllamaFailoverManager
# =============================================================================
class OllamaFailoverManager:
"""
Ollama 自動容災管理器
並行檢查 111 + 188依健康狀態選擇最佳路由。
使用方式:
manager = OllamaFailoverManager()
result = await manager.select_provider()
# result.primary.url → 使用的 Ollama URL
# result.fallback_chain → 依序 fallback
2026-04-25 Claude Engineer-C (P1.1b)
"""
def __init__(
self,
health_monitor: OllamaHealthMonitor | None = None,
recovery_callback=None,
) -> None:
self._monitor = health_monitor or get_ollama_health_monitor()
self._settings = get_settings()
# 2026-04-25 critic-fix Part2 H5+H6 by Claude Engineer-C2
# recovery_callback: async callable(provider_name: str) → None
# OllamaAutoRecoveryService.set_current_primary 在 failover 時被通知,
# 避免重啟後 _current_primary 停留在 "ollama" 而永不啟動恢復監控
self._recovery_callback = recovery_callback
# -------------------------------------------------------------------------
# Public API
# -------------------------------------------------------------------------
async def select_provider(
self,
task_type: str = "",
context: dict | None = None,
) -> OllamaRoutingResult:
"""
三層 Ollama 容災路由ADR-110 修正版 2026-05-04。所有工作固定
GCP-A → GCP-B → 111Gemini 由實際 generation call 的原子費控
決定是否允許。
Production URL slots are GCP-A primary, GCP-B secondary, and 111 local
fallback; the ordered provider contract does not change by task type.
Args:
task_type: 任務類型(僅供 audit不改變 provider 順序)
context: 額外上下文(預留)
Returns:
OllamaRoutingResult
"""
# 2026-05-04 ogt: 改用語意中性名稱 primary/secondary/tertiary
# 避免 gcp_a/gcp_b/local 與實際 URL 脫鉤造成 log 誤導
url_primary = self._settings.OLLAMA_URL # 110:11435 → GCP-A (nginx proxy)
url_secondary = self._settings.OLLAMA_SECONDARY_URL # 110:11436 → GCP-B (nginx proxy)
url_tertiary = self._settings.OLLAMA_FALLBACK_URL # 110:11437 → Local 111 (nginx proxy)
def _to_health(r, label: str) -> HealthReport:
if isinstance(r, Exception):
return HealthReport(status=HealthStatus.OFFLINE, reason=f"{label} check error: {r}")
return r
def _short(url: str) -> str:
from urllib.parse import urlparse
return urlparse(url).hostname or url
# 2026-05-19 Codex: alert-fast path must not wait for the slow local lane
# when GCP-A is already healthy. The old gather(GCP-A/GCP-B/111) path made
# 111's 45s health timeout dominate every routing decision.
try:
primary_raw = await self._monitor.check(url_primary)
except Exception as exc:
primary_raw = exc
health_gcp_a = _to_health(primary_raw, f"primary({url_primary})")
health_gcp_b: HealthReport | None = None
health_local: HealthReport | None = None
if health_gcp_a.status == HealthStatus.HEALTHY:
model = self._settings.OLLAMA_HEALTH_CHECK_MODEL
fallback_chain = [
OllamaEndpoint(url=url_secondary, provider_name="ollama_gcp_b", model=model),
OllamaEndpoint(url=url_tertiary, provider_name="ollama_local", model=model),
_GEMINI_ENDPOINT,
]
result = OllamaRoutingResult(
primary=OllamaEndpoint(url=url_primary, provider_name="ollama_gcp_a", model=model),
fallback_chain=fallback_chain,
routing_reason=f"primary({_short(url_primary)}) HEALTHY",
health_gcp_a=health_gcp_a,
health_gcp_b=None,
health_local=None,
observed_fallback=None,
)
else:
# Primary 不健康時才並行檢查後兩層,保留 GCP-B/Local 容災。
results_raw = await asyncio.gather(
self._monitor.check(url_secondary),
self._monitor.check(url_tertiary),
return_exceptions=True,
)
health_gcp_b = _to_health(results_raw[0], f"secondary({url_secondary})")
health_local = _to_health(results_raw[1], f"tertiary({url_tertiary})")
result = self._decide_route(
health_gcp_a=health_gcp_a,
health_gcp_b=health_gcp_b,
health_local=health_local,
url_gcp_a=url_primary,
url_gcp_b=url_secondary,
url_local=url_tertiary,
task_type=task_type,
)
# Provider selection never consumes paid quota. GeminiProvider performs
# the only atomic requests/tokens/cost reservation immediately before
# generateContent and fails closed when the guard cannot write a receipt.
# 寫入 audit_logbest-effort
await self._write_failover_audit(result)
def _status(report: HealthReport | None) -> str:
return report.status.value if report else "not_checked"
logger.info(
"ollama_failover_decision",
selected_primary=result.primary.provider_name,
selected_primary_url=result.primary.url,
observed_fallback=(
result.observed_fallback.provider_name
if result.observed_fallback
else None
),
reason=result.routing_reason,
fallback_count=len(result.fallback_chain),
health_gcp_a=health_gcp_a.status.value,
health_gcp_b=_status(health_gcp_b),
health_local=_status(health_local),
)
# Recovery tracks the first health-observed fallback candidate, while
# the selected execution primary remains fixed at GCP-A.
# 2026-04-25 critic-fix Part2 H5+H6 by Claude Engineer-C2
if self._recovery_callback is not None:
try:
recovery_observation = (
result.observed_fallback.provider_name
if result.observed_fallback
else result.primary.provider_name
)
await self._recovery_callback(recovery_observation)
except Exception as e:
logger.warning(
"ollama_failover_recovery_callback_failed",
error=str(e),
)
return result
# -------------------------------------------------------------------------
# 路由決策邏輯
# -------------------------------------------------------------------------
def _decide_route(
self,
health_gcp_a: HealthReport,
health_gcp_b: HealthReport,
health_local: HealthReport,
url_gcp_a: str,
url_gcp_b: str,
url_local: str,
task_type: str = "",
) -> OllamaRoutingResult:
"""Build fixed execution order plus a separate health observation."""
model = self._settings.OLLAMA_HEALTH_CHECK_MODEL
ep_gcp_a = OllamaEndpoint(url=url_gcp_a, provider_name="ollama_gcp_a", model=model)
ep_gcp_b = OllamaEndpoint(url=url_gcp_b, provider_name="ollama_gcp_b", model=model)
ep_local = OllamaEndpoint(url=url_local, provider_name="ollama_local", model=model)
now_ts = datetime.datetime.now(TAIPEI_TZ).isoformat()
# 用實際 URL 取最後一段作為 log 標識IP 或 hostname
def _short(url: str) -> str:
from urllib.parse import urlparse
return urlparse(url).hostname or url
lbl_p = _short(url_gcp_a) # primary label
lbl_s = _short(url_gcp_b) # secondary label
lbl_t = _short(url_local) # tertiary label
_ = task_type
fixed_fallback_chain = [ep_gcp_b, ep_local, _GEMINI_ENDPOINT]
def _result(
*,
observed_fallback: OllamaEndpoint | None,
reason: str,
) -> OllamaRoutingResult:
return OllamaRoutingResult(
primary=ep_gcp_a,
fallback_chain=fixed_fallback_chain.copy(),
routing_reason=reason,
health_gcp_a=health_gcp_a,
health_gcp_b=health_gcp_b,
health_local=health_local,
observed_fallback=observed_fallback,
)
# Primary HEALTHY → no fallback observation.
if health_gcp_a.status == HealthStatus.HEALTHY:
return _result(
observed_fallback=None,
reason=f"selected_primary({lbl_p}) HEALTHY; fixed production order",
)
# Health probes identify the first likely fallback without changing
# the selected primary or truncating the executable chain.
if health_gcp_b.status == HealthStatus.HEALTHY:
return _result(
observed_fallback=ep_gcp_b,
reason=(
f"selected_primary({lbl_p}) remains fixed; "
f"observed_fallback=secondary({lbl_s}) HEALTHY after "
f"{health_gcp_a.status.value} at {now_ts}"
),
)
if health_local.status == HealthStatus.HEALTHY:
return _result(
observed_fallback=ep_local,
reason=(
f"selected_primary({lbl_p}) remains fixed; "
f"observed_fallback=tertiary({lbl_t}) HEALTHY after "
f"secondary({lbl_s}) {health_gcp_b.status.value} at {now_ts}"
),
)
# 2026-05-04 ogt: SLOW 容災備援外網同時抖動時SLOW Ollama 仍優於 Gemini quota 耗盡)
if health_gcp_a.status == HealthStatus.SLOW:
return _result(
observed_fallback=None,
reason=(
f"selected_primary({lbl_p}) SLOW but remains first "
f"execution hop at {now_ts}"
),
)
if health_gcp_b.status == HealthStatus.SLOW:
return _result(
observed_fallback=ep_gcp_b,
reason=(
f"selected_primary({lbl_p}) remains fixed; "
f"observed_fallback=secondary({lbl_s}) SLOW at {now_ts}"
),
)
if health_local.status == HealthStatus.SLOW:
return _result(
observed_fallback=ep_local,
reason=(
f"selected_primary({lbl_p}) remains fixed; "
f"observed_fallback=tertiary({lbl_t}) SLOW at {now_ts}"
),
)
# Gemini is only an observed final policy candidate here. Actual
# selection occurs after all Ollama attempts and its own durable cost
# reservation; this readback must never label Gemini as primary.
return _result(
observed_fallback=_GEMINI_ENDPOINT,
reason=(
f"selected_primary({lbl_p}) remains fixed; all Ollama health "
f"observations unavailable ({health_gcp_a.status.value}, "
f"{health_gcp_b.status.value}, {health_local.status.value}); "
f"observed_fallback=gemini policy candidate at {now_ts}"
),
)
# -------------------------------------------------------------------------
# Gemini 帳單熔斷quota gate
# 2026-04-25 critic-fix Part2 H7 by Claude Engineer-C2
# -------------------------------------------------------------------------
async def _check_gemini_quota(self) -> bool:
"""
Read-only compatibility check against the unified Gemini guard.
Provider selection must not consume quota. The atomic reservation is
owned by GeminiProvider immediately before generateContent.
"""
try:
from src.services.ai_rate_limiter import get_ai_rate_limiter
stats = await get_ai_rate_limiter().get_usage_stats("gemini")
request_gate = stats["daily_requests"]["current"] < stats["daily_requests"]["limit"]
token_gate = (
stats["daily_tokens"]["current"] + stats["daily_tokens"].get("reserved", 0)
< stats["daily_tokens"]["limit"]
)
daily_cost = stats.get("daily_cost_usd") or {}
daily_cost_gate = (
float(daily_cost.get("current", 0.0))
+ float(daily_cost.get("reserved", 0.0))
< float(daily_cost.get("limit", 0.0))
)
return bool(request_gate and token_gate and daily_cost_gate and not stats["cost_exceeded"])
except Exception as e:
logger.exception(
"gemini_quota_check_failed_failing_closed",
error=str(e),
security_note="Redis 異常時拒絕 Gemini不插入其他付費 provider",
)
return False
def _build_quota_exceeded_route(
self,
health_gcp_a: HealthReport,
) -> OllamaRoutingResult:
"""
Legacy compatibility route for a blocked paid fallback.
It deliberately has no executable provider or fallback: cost exhaustion
must not insert Nemo/NVIDIA/Claude or make another paid API call.
"""
model = self._settings.OLLAMA_HEALTH_CHECK_MODEL
resolved = {
endpoint.provider_name: endpoint
for endpoint in resolve_ollama_order(
"interactive",
config=self._settings,
)
}
def _endpoint(provider_name: str) -> OllamaEndpoint:
selection = resolved.get(provider_name)
return OllamaEndpoint(
url=selection.url if selection else "",
provider_name=provider_name,
model=model,
)
return OllamaRoutingResult(
primary=_endpoint("ollama_gcp_a"),
fallback_chain=[
_endpoint("ollama_gcp_b"),
_endpoint("ollama_local"),
_GEMINI_ENDPOINT,
],
routing_reason=(
"selected primary remains GCP-A; Gemini cost guard observed blocked"
),
health_gcp_a=health_gcp_a,
observed_fallback=_PAID_FALLBACK_BLOCKED_ENDPOINT,
)
# -------------------------------------------------------------------------
# Recovery API供 OllamaAutoRecoveryService 呼叫)
# -------------------------------------------------------------------------
def set_recovery_callback(self, callback) -> None:
"""
設定 recovery callback供 lifespan wiring 使用)。
callback signature: async (provider_name: str) -> None
# 2026-04-25 P1.2 by Claude Engineer-A2 — failover 整合到 ai_router + lifespan
"""
self._recovery_callback = callback
async def clear_cache(self) -> None:
"""
清空路由決策快取,讓下次 select_provider 重新評估健康狀態。
OllamaAutoRecoveryService 在偵測 111 恢復後呼叫此方法。
2026-04-25 統帥指令 by Claude Engineer-C — 自動切 Gemini + 自動恢復
# 2026-04-25 P1.2 by Claude Engineer-A2 — 改用 make_cache_key 動態組 key消除硬編碼 IP
"""
try:
from src.core.redis_client import get_redis
from src.services.ollama_health_monitor import make_cache_key
redis = get_redis()
if redis is None:
return
# 動態由 settings URL 組 cache key避免硬編碼 IP
# 2026-05-03 ogt: 新增 OLLAMA_SECONDARY_URLADR-110 GCP-B
keys = [
make_cache_key(self._settings.OLLAMA_URL),
make_cache_key(self._settings.OLLAMA_SECONDARY_URL or ""),
make_cache_key(self._settings.OLLAMA_FALLBACK_URL or ""),
]
for k in keys:
if k and k != "ollama_health:": # 空 URL 會產生無意義的 key跳過
await redis.delete(k)
logger.info(
"ollama_failover_cache_cleared",
service="ollama_failover",
reason="recovery_triggered",
)
except Exception as e:
logger.debug("ollama_failover_clear_cache_failed", error=str(e))
def notify_recovery(self, provider: str) -> None:
"""
預留P1.5 Engineer 接入 Telegram alerter 時使用。
目前僅寫 structlog audit。
2026-04-25 統帥指令 by Claude Engineer-C — 自動切 Gemini + 自動恢復
"""
logger.info(
"ollama_recovery_notified",
service="ollama_failover",
provider=provider,
action="recovery_received",
)
# -------------------------------------------------------------------------
# Audit Log
# -------------------------------------------------------------------------
async def _write_failover_audit(self, result: OllamaRoutingResult) -> None:
"""
切換觸發時寫 structlog auditbest-effort+ Telegram 告警
# 2026-04-25 critic-fix Part2 B1 by Claude Engineer-C2
# 原 AuditLog DB 寫入使用不存在的欄位service/action/target/status/metadata
# → SQLAlchemy crash → except 吃掉 → 零稽核
# 修法:刪除 DB 寫入路徑,改用 structlog onlyaudit 不依賴 DB schema
# 2026-04-25 P1.5 by Claude Engineer-D — 新增 Telegram 告警dedup 10min
service="ollama_failover"per 任務規格)
僅在 primary 非 111 時記錄(真正發生切換)
"""
observed = result.observed_fallback
if observed is None:
return
# 2026-04-26 P2.3 by Claude Sonnet 4.6 (tool-expert) — 記錄 failover Prometheus metric
try:
from src.core.metrics import (
OLLAMA_CURRENT_PRIMARY_IS_OLLAMA,
OLLAMA_FAILOVER_TRIGGERED_TOTAL,
)
OLLAMA_FAILOVER_TRIGGERED_TOTAL.labels(
from_provider=result.primary.provider_name,
to_provider=observed.provider_name,
).inc()
OLLAMA_CURRENT_PRIMARY_IS_OLLAMA.set(
1 if observed.provider_name.startswith("ollama") else 0
)
except Exception as _metric_err:
logger.debug("ollama_failover_metric_error", error=str(_metric_err))
logger.info(
"ollama_failover_triggered",
service="ollama_failover",
action="failover_triggered",
selected_primary=result.primary.provider_name,
observed_fallback=observed.provider_name,
reason=result.routing_reason,
selected_primary_url=result.primary.url or result.primary.provider_name,
observed_fallback_url=observed.url or observed.provider_name,
health_gcp_a=result.health_gcp_a.status.value,
health_gcp_b=result.health_gcp_b.status.value if result.health_gcp_b else "not_configured",
health_local=result.health_local.status.value if result.health_local else "not_configured",
)
# Telegram 告警首次切換才通知dedup 10min 內建)
# 2026-04-25 P1.5 by Claude Engineer-D — 告警失敗不阻斷主路由邏輯
# 2026-05-03 ogt: ADR-110 — failed_host 動態計算,顯示哪台 GCP/Local 故障
try:
from src.services.failover_alerter import get_failover_alerter
from src.services.ollama_health_monitor import HealthStatus
fallback_chain_str = "".join(
p.provider_name for p in result.fallback_chain
)
# 計算故障主機描述(哪層 Ollama 不健康,用實際 URL 不用硬編碼標籤)
_failed = []
if result.health_gcp_a.status != HealthStatus.HEALTHY:
_failed.append(self._settings.OLLAMA_URL)
if result.health_gcp_b and result.health_gcp_b.status != HealthStatus.HEALTHY:
_failed.append(self._settings.OLLAMA_SECONDARY_URL or "secondary")
if result.health_local and result.health_local.status != HealthStatus.HEALTHY:
_failed.append(self._settings.OLLAMA_FALLBACK_URL or "tertiary")
failed_host = " + ".join(_failed) if _failed else "Ollama"
alerter = get_failover_alerter()
await alerter.alert_failover({
"to_provider": observed.provider_name,
"selected_primary": result.primary.provider_name,
"observation_only": True,
"model": observed.model,
"reason": result.routing_reason,
"timestamp": datetime.datetime.now(TAIPEI_TZ).isoformat(),
"fallback_chain_str": fallback_chain_str,
"failed_host": failed_host,
})
except Exception as e:
logger.warning("failover_alert_failed", error=str(e))
# =============================================================================
# Singleton
# =============================================================================
_failover_manager: OllamaFailoverManager | None = None
def get_ollama_failover_manager() -> OllamaFailoverManager:
"""取得 OllamaFailoverManager singleton"""
global _failover_manager
if _failover_manager is None:
_failover_manager = OllamaFailoverManager()
return _failover_manager
def reset_ollama_failover_manager() -> None:
"""重置 singleton測試用"""
global _failover_manager
_failover_manager = None