homelab-codex-ws/services/kb-query/app/fallback.py

114 lines
5.2 KiB
Python
Raw Normal View History

feat(kb-query): active embed fallback SOLARIA→PIHA (module 5 phase 4, plan §2/§5) Last missing core piece of KB phase 4: kb-query no longer hard-fails /search when Ollama@SOLARIA is unreachable. app/fallback.py implements the plan's circuit-breaker exactly (30s cached health probe, 3s hard embed timeout on SOLARIA, one-shot same-request switch to a new local ollama-piha@PIHA container on timeout/error). sol_status in /healthz and /search now reflects the real breaker state instead of a hardcoded "up". New services/ollama-piha (bge-m3, OLLAMA_KEEP_ALIVE=0, arm64/no-GPU) is the local fallback leg. Live calibration on PIHA (2026-07-27, normal load): embed latency 4.2-5.2s, RAM peak ~983MiB against a 2.5GiB ceiling -- both inside the plan's go-bar, so the fallback is enabled by default rather than gated behind a flag. Calibration also surfaced and disabled (not removed) a previously-undocumented orphaned native ollama.service on PIHA that had been conflicting with the container's port. The embed-model invariant (query embedding == document_chunk.model) still enforces once at startup, since both fallback legs share one EMBED_MODEL constant by construction; a redundant per-request DB check was deliberately skipped and the invariant is instead proven structurally by test. retrieval_eval.py gains --transport http (plan §2 decision 6/§9), previously unimplemented. Verified live: HTTP transport is bit-identical to direct transport against the same live SOLARIA (0 mismatches), and a live sol-down simulation (kb-query's own OLLAMA_URL pointed at a dead address, no other Ollama consumer touched) shows the PIHA fallback answering with the same hit@3 gate outcome and dist within ~3e-4 of the SOLARIA baseline. Zero changes to DB schema or kb_retrieval's retrieval logic -- only the embed + health layer, per task constraints. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 18:57:46 +02:00
"""Active embed fallback state machine -- module 5 phase 4 plan §2 decision 2 / §5
(docs/kb/modules/05-faza4-plan.md). One process-global circuit breaker (`SolCircuitBreaker`,
held in `app.state`, not a module global -- keeps tests isolated) shared by every `/search`
request.
State machine (plan §2 decision 2 table):
1. Cached status still fresh (< CACHE_TTL_S old) -> use it, no network call at all.
2. Cache expired -> probe `GET {solaria}/api/tags` with a short timeout (HEALTH_PROBE_TIMEOUT_S);
cache the result (up or down) for CACHE_TTL_S. Same TTL for both outcomes -- plan explicitly
defers a separate down-backoff until flapping is observed.
3. status == "up" -> embed on SOLARIA with a hard per-call timeout (EMBED_TIMEOUT_S), distinct
from the probe timeout: a slow-but-technically-reachable SOLARIA must not hang the user's
query past this bound.
3a. Success -> return, sol_status="up".
3b. Timeout/error *on the real embed call* (not just the probe) -> flip the breaker to "down"
immediately (one-shot switch) and fall through to step 4 **in the same request** -- the
user never sees an error for this, only the first unlucky hit in a 30s window pays one
extra EMBED_TIMEOUT_S before falling through.
4. status == "down" -> embed locally via `piha_url`, sol_status="down".
Both branches call `embed_chunk(..., model=embed_model)` with the exact same `embed_model`
argument -- there is no separate "PIHA model" and no per-request model choice (plan §2: "nie ma
dziś ... per-request wyboru modelu"), so the hard model invariant (app/startup.py, enforced once
at process startup against document_chunk/document_summary) already covers both paths by
construction. Adding a second, redundant per-request DB check here would guard against a
scenario that cannot occur while `embed_model` stays a single constant threaded through one
function -- instead, `tests/test_fallback.py` proves the invariant holds by asserting both the
failed SOLARIA attempt and the successful PIHA attempt in the same request carry an identical
`model` field.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Callable, Optional
import aiohttp
from kb_retrieval.embed import check_ollama_health, embed_chunk
CACHE_TTL_S = 30.0
HEALTH_PROBE_TIMEOUT_S = 0.5
EMBED_TIMEOUT_S = 3.0
@dataclass
class SolCircuitBreaker:
"""Process-global cached `sol_status` ("up"/"down"), one instance per running app (held in
`app.state.sol_breaker`, created fresh in `lifespan` -- never a module-level singleton, so
tests get a clean breaker per instance). `clock` is injectable (default `time.monotonic`,
never wall-clock/datetime) purely to test TTL expiry without real sleeps."""
cache_ttl_s: float = CACHE_TTL_S
clock: Callable[[], float] = field(default=time.monotonic)
_status: Optional[str] = field(default=None, init=False)
_checked_at: Optional[float] = field(default=None, init=False)
@property
def status(self) -> Optional[str]:
"""Cached status if still within `cache_ttl_s`, else `None` (caller must (re)probe)."""
if self._status is None or self._checked_at is None:
return None
if self.clock() - self._checked_at >= self.cache_ttl_s:
return None
return self._status
def set(self, status: str) -> None:
self._status = status
self._checked_at = self.clock()
async def resolve_sol_status(
breaker: SolCircuitBreaker, session: aiohttp.ClientSession, solaria_url: str
) -> str:
"""Cache-or-probe: returns "up"/"down". Shared by `/search` (embed_with_fallback) and
`/healthz` (plan: "sol_status w stopce UI ... podepnij realny stan") so both endpoints
agree on the same cached view instead of running independent probes."""
cached = breaker.status
if cached is not None:
return cached
up = await check_ollama_health(session, solaria_url, HEALTH_PROBE_TIMEOUT_S)
status = "up" if up else "down"
breaker.set(status)
return status
async def embed_with_fallback(
breaker: SolCircuitBreaker,
session: aiohttp.ClientSession,
solaria_url: str,
piha_url: str,
embed_model: str,
query_text: str,
) -> tuple[list[float], str]:
"""query_text -> (embedding, sol_status_used). `sol_status_used` is "up" (SOLARIA answered
the real embed call) or "down" (PIHA answered -- either the breaker already cached "down",
or SOLARIA just failed mid-request and this call transparently fell through, plan §2 step
3b's one-shot switch)."""
status = await resolve_sol_status(breaker, session, solaria_url)
if status == "up":
try:
embedding, _elapsed = await embed_chunk(
session, solaria_url, embed_model, query_text, timeout_s=EMBED_TIMEOUT_S
)
return embedding, "up"
except (aiohttp.ClientError, TimeoutError):
# One-shot switch (plan §2 step 3b): don't fail this request, fall through to PIHA
# below and remember "down" for the rest of the cache window.
breaker.set("down")
embedding, _elapsed = await embed_chunk(session, piha_url, embed_model, query_text)
return embedding, "down"