124 lines
4.6 KiB
Python
124 lines
4.6 KiB
Python
"""backend.py —— 存储后端解析与握手校验(本地磁盘 / 远程 HTTP)。
|
||||
|
|
|
|||
|
|
- parse_backend(spec) → LocalBackend | RemoteBackend
|
|||
|
|
含 :// 且 scheme http/https → RemoteBackend(vault/index 操作转 HTTP 指向远程根);
|
|||
|
|
其余(含 file://)→ LocalBackend(本机磁盘)
|
|||
|
|
- 启动握手校验:/health + 空检索;失败日志告警并抛异常(拒绝启动)
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import logging
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any
|
|||
|
|
from urllib.parse import urlparse
|
|||
|
|
|
|||
|
|
import httpx
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class BackendError(Exception):
|
|||
|
|
"""后端不可用/握手失败(启动拒绝)。"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_scheme(spec: str) -> str:
|
|||
|
|
"""提取 scheme(http/https 才视为远程)。"""
|
|||
|
|
return urlparse(spec).scheme.lower()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def is_remote_spec(spec: str) -> bool:
|
|||
|
|
"""含 :// 且 scheme http/https → 远程。"""
|
|||
|
|
return "://" in spec and _parse_scheme(spec) in ("http", "https")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class LocalBackend:
|
|||
|
|
"""本机磁盘后端:vault/index 均为本地目录。"""
|
|||
|
|
|
|||
|
|
kind = "local"
|
|||
|
|
|
|||
|
|
def __init__(self, vault_path: str, index_path: str) -> None:
|
|||
|
|
self.vault_path = Path(vault_path)
|
|||
|
|
self.index_path = Path(index_path)
|
|||
|
|
|
|||
|
|
def health(self) -> dict[str, Any]:
|
|||
|
|
"""本地健康:目录可写即 ok。"""
|
|||
|
|
try:
|
|||
|
|
self.vault_path.mkdir(parents=True, exist_ok=True)
|
|||
|
|
self.index_path.mkdir(parents=True, exist_ok=True)
|
|||
|
|
return {"ok": True, "backend": self.kind}
|
|||
|
|
except OSError as exc:
|
|||
|
|
raise BackendError(f"本地目录不可写:{exc}") from exc
|
|||
|
|
|
|||
|
|
def search_empty(self) -> dict[str, Any]:
|
|||
|
|
"""空检索握手:本地恒可用。"""
|
|||
|
|
return {"ok": True, "mode": "local", "items": []}
|
|||
|
|
|
|||
|
|
def close(self) -> None: # pragma: no cover - 无资源可释放
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
class RemoteBackend:
|
|||
|
|
"""远程 HTTP 后端:vault/index 操作指向远程 vstore 根。"""
|
|||
|
|
|
|||
|
|
kind = "remote"
|
|||
|
|
|
|||
|
|
def __init__(self, vault_url: str, index_url: str) -> None:
|
|||
|
|
self.vault_url = vault_url.rstrip("/")
|
|||
|
|
self.index_url = index_url.rstrip("/")
|
|||
|
|
|
|||
|
|
def health(self) -> dict[str, Any]:
|
|||
|
|
"""远程健康:GET /health(超时 5s)。"""
|
|||
|
|
try:
|
|||
|
|
resp = httpx.get(f"{self.index_url}/health", timeout=5.0)
|
|||
|
|
resp.raise_for_status()
|
|||
|
|
return resp.json()
|
|||
|
|
except (httpx.HTTPError, ValueError) as exc:
|
|||
|
|
raise BackendError(f"远程 vstore 健康检查失败:{exc}") from exc
|
|||
|
|
|
|||
|
|
def search_empty(self) -> dict[str, Any]:
|
|||
|
|
"""空检索握手:GET /api/vstore/search?q=。"""
|
|||
|
|
try:
|
|||
|
|
resp = httpx.get(
|
|||
|
|
f"{self.index_url}/api/vstore/search", params={"q": "", "top_k": 1}, timeout=5.0
|
|||
|
|
)
|
|||
|
|
resp.raise_for_status()
|
|||
|
|
return resp.json()
|
|||
|
|
except (httpx.HTTPError, ValueError) as exc:
|
|||
|
|
raise BackendError(f"远程 vstore 空检索握手失败:{exc}") from exc
|
|||
|
|
|
|||
|
|
def close(self) -> None: # pragma: no cover - 无连接可释放
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_backend(vault_spec: str, index_spec: str) -> LocalBackend | RemoteBackend:
|
|||
|
|
"""解析存储后端。
|
|||
|
|
|
|||
|
|
- 两个 spec 都是本地(含 file://)→ LocalBackend(file:// 剥掉前缀)
|
|||
|
|
- 两个 spec 都是 http(s):// → RemoteBackend
|
|||
|
|
- 混合形态不支持,抛 BackendError
|
|||
|
|
"""
|
|||
|
|
vault_remote = is_remote_spec(vault_spec)
|
|||
|
|
index_remote = is_remote_spec(index_spec)
|
|||
|
|
if vault_remote != index_remote:
|
|||
|
|
raise BackendError(
|
|||
|
|
f"vault/index 后端形态不一致(vault={'远程' if vault_remote else '本地'},"
|
|||
|
|
f"index={'远程' if index_remote else '本地'}),不支持混合部署"
|
|||
|
|
)
|
|||
|
|
if vault_remote:
|
|||
|
|
return RemoteBackend(vault_url=vault_spec, index_url=index_spec)
|
|||
|
|
# 本地路径(file:// 前缀剥掉)
|
|||
|
|
vault_path = vault_spec[len("file://"):] if vault_spec.startswith("file://") else vault_spec
|
|||
|
|
index_path = index_spec[len("file://"):] if index_spec.startswith("file://") else index_spec
|
|||
|
|
return LocalBackend(vault_path=vault_path, index_path=index_path)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def handshake(backend: LocalBackend | RemoteBackend) -> None:
|
|||
|
|
"""启动握手:health + 空检索。失败日志告警并抛 BackendError(拒绝启动)。"""
|
|||
|
|
health = backend.health()
|
|||
|
|
if not health.get("ok"):
|
|||
|
|
raise BackendError(f"后端健康检查未通过:{health}")
|
|||
|
|
search = backend.search_empty()
|
|||
|
|
if not search.get("ok"):
|
|||
|
|
raise BackendError(f"后端空检索握手未通过:{search}")
|
|||
|
|
logger.info("backend: %s 握手通过(health+空检索)", backend.kind)
|