Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,17 @@ async def health_ready():
用于"服务是否可以接收流量"的判断(部署/路由层)。
"""
diagnostics = database_diagnostics(database_profile)

# OLTP 连通性必须真实探测:diagnostics 是纯元数据 dict,不能作为就绪依据(#71)。
oltp_error: str | None = None
try:
async with async_session() as db:
from sqlalchemy import text

await db.execute(text("SELECT 1"))
except Exception as exc:
oltp_error = redact_database_secrets(type(exc).__name__, database_profile)

try:
from app.services.duckdb_service import get_analytics, run_query

Expand All @@ -600,17 +611,20 @@ async def health_ready():
except Exception:
scheduler_running = False

# 判定:DB OK 即 ready(DuckDB 有 fallback,scheduler 可能被配置禁用)
db_ok = diagnostics.get("oltp") is not None
overall = "ready" if db_ok else "not_ready"

return {
# 判定:OLTP 可达即 ready(DuckDB 有 fallback,scheduler 可能被配置禁用,
# 两者是受支持部署形态,见 DEPLOYMENT.md §1.4);OLTP 不可达 → not_ready。
overall = "ready" if oltp_error is None else "not_ready"
payload = {
"status": overall,
"service": "topiceye-backend",
"database": {
"backend": database_profile.backend,
**diagnostics,
**({"oltp_error": oltp_error} if oltp_error else {}),
"duckdb": duckdb_status,
},
"scheduler": {"running": scheduler_running},
}
if oltp_error is not None:
return JSONResponse(status_code=503, content=payload)
return payload
159 changes: 159 additions & 0 deletions backend/tests/test_health_endpoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""路由级健康端点语义回归(#71 / D-8)。

钉死 /health/live 与 /health/ready 的四态契约(DEPLOYMENT.md §3.1 / §1.4):
- 正常:ready 200;
- 调度器禁用:仍 ready(scheduler.running=false 是受支持形态);
- DuckDB 降级:仍 ready(duckdb.available=false 是受支持形态);
- OLTP 不可达:not_ready + 503。
"""

from __future__ import annotations

from types import SimpleNamespace

import httpx
import pytest

import app.main as app_main
from app.services import duckdb_service

# 健康端点直连 app 级 async_session(全局引擎连接池);pytest-asyncio 默认每测试
# 换事件循环会让池内连接跨循环复用而炸(InterfaceError),本文件共用一个 loop。
pytestmark = pytest.mark.asyncio(loop_scope="module")


def _client() -> httpx.AsyncClient:
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app_main.app),
base_url="http://testserver",
)


def _duckdb_ok(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(duckdb_service, "get_analytics", lambda: SimpleNamespace(status="fake"))

async def _run_query(_):
return {"status": "ok", "available": True}

monkeypatch.setattr(duckdb_service, "run_query", _run_query)


def _duckdb_down(monkeypatch: pytest.MonkeyPatch) -> None:
def _raise():
raise RuntimeError("duckdb attach failed")

monkeypatch.setattr(duckdb_service, "get_analytics", _raise)


def _oltp_ok(monkeypatch: pytest.MonkeyPatch) -> None:
"""stub OLTP 探测为成功。

conftest 的 autouse clean_tables 每测试前 pg_terminate_backend 会杀掉
app 级连接池里的存量连接,同模块多个用例连续使用真实池必然随机踩到
被杀连接(InterfaceError → 503)。探测的异常路径由
test_ready_oltp_unreachable_returns_503 覆盖,真实池路径由首位的
test_live_ok 覆盖,故此处 stub。
"""

class _ProbeSession:
async def __aenter__(self):
return self

async def __aexit__(self, *args):
return False

async def execute(self, _stmt):
return None

monkeypatch.setattr(app_main, "async_session", lambda: _ProbeSession())


def _oltp_down(monkeypatch: pytest.MonkeyPatch) -> None:
class _BrokenSessionFactory:
def __call__(self):
return self

async def __aenter__(self):
raise ConnectionError("oltp unreachable")

async def __aexit__(self, *args):
return False

monkeypatch.setattr(app_main, "async_session", _BrokenSessionFactory())


async def test_live_ok_returns_alive():
async with _client() as client:
resp = await client.get("/health/live")
assert resp.status_code == 200
assert resp.json()["status"] == "alive"


async def test_live_unhealthy_when_oltp_down(monkeypatch):
_oltp_down(monkeypatch)
async with _client() as client:
resp = await client.get("/health/live")
assert resp.status_code == 503
assert resp.json()["status"] == "unhealthy"


async def test_ready_ok(monkeypatch):
_duckdb_ok(monkeypatch)
_oltp_ok(monkeypatch)
async with _client() as client:
resp = await client.get("/health/ready")
assert resp.status_code == 200
payload = resp.json()
assert payload["status"] == "ready"
assert payload["database"]["duckdb"]["available"] is True
assert "scheduler" in payload


async def test_ready_scheduler_disabled_still_ready(monkeypatch):
"""SCHEDULER_ENABLED=false 是受支持形态:调度器不在跑不影响就绪。

测试进程从不 start_scheduler,scheduler.running 天然为 False——
该属性是只读 property,无需(也不能)monkeypatch。
"""
_duckdb_ok(monkeypatch)
_oltp_ok(monkeypatch)
async with _client() as client:
resp = await client.get("/health/ready")
assert resp.status_code == 200
payload = resp.json()
assert payload["status"] == "ready"
assert payload["scheduler"]["running"] is False


async def test_ready_duckdb_degraded_still_ready(monkeypatch):
"""DuckDB 降级(生产默认形态)不影响就绪,但必须如实上报不可用。"""
_duckdb_down(monkeypatch)
_oltp_ok(monkeypatch)
async with _client() as client:
resp = await client.get("/health/ready")
assert resp.status_code == 200
payload = resp.json()
assert payload["status"] == "ready"
assert payload["database"]["duckdb"]["available"] is False
assert payload["database"]["duckdb"]["status"] == "error"


async def test_ready_oltp_unreachable_returns_503(monkeypatch):
"""核心回归(#71):OLTP 不可达必须 not_ready + 503,而非恒 ready。"""
_duckdb_ok(monkeypatch)
_oltp_down(monkeypatch)
async with _client() as client:
resp = await client.get("/health/ready")
assert resp.status_code == 503
payload = resp.json()
assert payload["status"] == "not_ready"
assert payload["database"]["oltp_error"] == "ConnectionError"


async def test_health_alias_matches_ready(monkeypatch):
_duckdb_ok(monkeypatch)
_oltp_ok(monkeypatch)
async with _client() as client:
resp = await client.get("/health")
assert resp.status_code == 200
assert resp.json()["status"] == "ready"
14 changes: 14 additions & 0 deletions backend/tests/test_settings_notifications_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,20 @@ def fail_get_analytics():
monkeypatch.setattr(app_main, "database_profile", profile)
monkeypatch.setattr(duckdb_service, "get_analytics", fail_get_analytics)

# 本测试关注 DuckDB 报错脱敏;stub 掉 OLTP 探测,避免 app 级全局连接池
# 在 function-scoped 事件循环下跨循环复用连接(见 test_health_endpoints.py)。
class _ProbeSession:
async def __aenter__(self):
return self

async def __aexit__(self, *args):
return False

async def execute(self, _stmt):
return None

monkeypatch.setattr(app_main, "async_session", lambda: _ProbeSession())

payload = await app_main.health_check()

error = payload["database"]["duckdb"]["error"]
Expand Down
Loading