mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-11 14:38:38 +00:00
* feat(gateway): add /health/ready readiness probe backed by the database
## Why
GET /health only proves the process is up: it returns 200 even when the persistence engine cannot reach the database. Orchestrators already treat it as a readiness gate (docker-compose.yaml marks the gateway service healthy and nginx depends_on service_healthy), so a DB outage or a still-migrating Postgres leaves the stack 'healthy' while every request fails.
## What changed
- New GET /health/ready endpoint: bounded SELECT 1 against the existing persistence engine (deerflow.persistence.engine.get_engine) with a 2s timeout.
- Response is 200 {'status': 'ready', 'database': 'ok'} when reachable, 503 {'status': 'degraded', 'database': 'unreachable'} when the probe fails, and 200 ready with database=not_configured for backend=memory (nothing to probe).
- GET /health is unchanged (pure liveness), and /health/ready is public through the existing /health auth whitelist.
- docker-compose.yaml gateway healthcheck now polls /health/ready so service_healthy reflects database reachability.
- Documented both endpoints in backend/app/gateway/AGENTS.md.
## Surface area
- [x] Backend API - new GET /health/ready endpoint under backend/app/gateway
- [x] Sandbox / Docker - gateway healthcheck in docker/docker-compose.yaml now gates on readiness
- [ ] Frontend UI / Agents / Skills / Dependencies
- [x] Default behavior change - existing /health unchanged; the prod compose healthcheck is stricter (503 while the database is unreachable)
## Validation
- New unit tests in backend/tests/test_gateway_health.py cover ok / unreachable / not_configured probe results and the 200/503 payload mapping (6 passed).
- app.gateway.app imports cleanly and registers both /health and /health/ready.
- ruff check + ruff format clean.
## AI assistance
**Tool(s) used:** Codex (coding agent)
**How you used it:** design, implementation, and unit tests produced with AI assistance; reviewed before commit.
- [ ] I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.
* fix(helm): point the gateway readiness probe at /health/ready
## Why
Review on #5166 (willem-bd, P1): the chart still probed /health for readiness,
so Kubernetes marked the pod ready and routed traffic while the database was
unreachable - exactly the failure mode /health/ready was added to catch.
## What changed
- deploy/helm/deer-flow/templates/gateway-deployment.yaml: readinessProbe
httpGet.path now hits /health/ready (DB-backed, 503 while the database is
unreachable). The liveness probe stays on /health.
## Verification
- One-line path change inside the existing readinessProbe block; git diff
confirms only the readiness path changed (liveness untouched).
## AI assistance
**Tool(s) used:** Codex (coding agent)
**How you used it:** implemented the reviewer-requested probe path change; reviewed before commit.
- [ ] I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.
* fix(gateway): readiness probe also checks the effective checkpointer/Store backend
## Why
Follow-up review on #5166 (willem-bd, P1): get_engine() only represents the
ORM backend selected by `database:`. The legacy `checkpointer:` section takes
precedence for the LangGraph checkpointer and Store, so a split configuration
(a local SQLite/memory `database:` with `checkpointer.type: postgres`) could
report 200 while the PostgreSQL backend agent runs depend on was down.
## What changed
- GET /health/ready now probes both persistence halves: the ORM engine behind
`database:` (unchanged) and the effective LangGraph checkpointer/Store
backend resolved with the runtime's own rule (legacy `checkpointer:` config,
otherwise derived from `database:`), for memory/sqlite/postgres backends.
- The payload gains a `checkpointer` field with the same
ok / not_configured / unreachable vocabulary as `database`; 503 degraded is
returned when either probe is unreachable.
- Probes are bounded by the existing 2s timeout: sqlite via aiosqlite SELECT 1
on the resolved path, postgres via a bounded psycopg AsyncConnection SELECT 1
on the DSN with the configured search_path. A missing driver for a configured
backend degrades readiness (the runtime could not run either).
- Documented the two-probe semantics in the endpoint docstring and
backend/app/gateway/AGENTS.md.
## Verification
- New tests: healthy ORM engine + unreachable legacy checkpointer backend ->
503 degraded with database: ok / checkpointer: unreachable; checkpointer
probe mapping for memory/sqlite(postgres missing-driver) backends; existing
payload tests now pin the checkpointer field.
- cd backend && python -m pytest tests/test_gateway_health.py: 11 passed.
- app.gateway.app imports cleanly; ruff check + ruff format clean.
## AI assistance
**Tool(s) used:** Codex (coding agent)
**How you used it:** design, implementation, and unit tests produced with AI assistance; reviewed before commit.
- [ ] I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.
* fix(gateway): bound /health/ready to one deadline and probe the startup checkpointer snapshot
## Why
Second round of review on #5166 (zhfeng P1/P2, willem-bd P1/P1/P2). Three
correctness issues remained in the readiness endpoint:
- The database and checkpointer probes ran sequentially, each allowed 2s, so a
healthy response could take almost 4s - past Kubernetes' 1s default
readinessProbe timeout and inside Docker's 3s client timeout. A slow but
healthy backend could make every replica unready.
- The checkpointer probe re-resolved process-wide, hot-reloaded configuration
per request, while app.state.checkpointer/store are built once from the
startup_config snapshot in langgraph_runtime(). After a live config edit the
endpoint could probe a backend the running gateway does not use, and a
resolution failure was swallowed into None -> not_configured -> 200.
- The SQLite probe opened the path with aiosqlite.connect(), which creates the
file when missing: a deleted checkpoint database was silently resurrected as
an empty file and reported ok instead of surfacing the outage.
## What changed
- backend/app/gateway/health.py: the two probes now run concurrently beneath a
single endpoint-wide deadline (_READINESS_DEADLINE_SECONDS=3.0) so a healthy
response completes within one probe window (~2s), never the sum of both.
A probe that overruns the deadline degrades the endpoint instead of hanging.
- langgraph_runtime() now records the checkpointer/Store config resolved from
the same startup_config snapshot its checkpointer/store singletons are built
from (app.state.checkpointer_config); /health/ready probes that snapshot and
never re-resolves hot-reloaded config. resolve_checkpointer_config() returns
None on resolution failure and the endpoint fails closed (503, checkpointer:
unreachable) instead of reporting not_configured.
- The SQLite probe opens disk-backed paths with the non-creating mode=rw URI
flag, so a missing database file stays missing and yields unreachable;
in-memory forms (:memory:, file:...mode=memory) have nothing external to
probe and report not_configured like the memory backend.
- Orchestrator timeouts now sit above the endpoint bound: Helm readinessProbe
gains timeoutSeconds: 5 (Kubernetes default is 1s) and the docker-compose
gateway healthcheck client timeout moves from 3s to 5s.
## Verification
- New regression tests: concurrent probes keep total elapsed time within one
probe window; a probe ignoring its budget trips the endpoint deadline to 503;
missing SQLite file stays absent and yields unreachable; in-memory SQLite
forms map to not_configured; missing startup snapshot / config resolution
failure fail closed to 503; resolve_checkpointer_config() raising is covered.
- cd backend && python -m pytest tests/test_gateway_health.py: 21 passed;
tests/test_gateway_docs_toggle.py and lifespan/shutdown gateway suites pass.
- ruff check + ruff format clean on all changed files.
## AI assistance
**Tool(s) used:** Codex (coding agent)
**How you used it:** implemented the reviewer-requested concurrency/deadline, startup-snapshot probing, fail-closed resolution, and non-creating SQLite probe; reviewed before commit.
- [ ] I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.
* fix(gateway): serialize connection-opening readiness probes behind a strict gate
## Why
Review on #5166 (willem-bd, P1): every request to /health/ready opened a new
PostgreSQL connection in _probe_postgres_backend, outside both the ORM pool and
the runtime checkpointer pool. The route is public through the /health auth
prefix and nginx proxies /health/*, so concurrent unauthenticated requests
could create an unbounded number of connections (each held for up to two
seconds), exhaust PostgreSQL max_connections, and take down both normal
traffic and the readiness probe itself.
## What changed
- backend/app/gateway/health.py: connection-opening checkpointer probes
(sqlite connect, postgres AsyncConnection.connect) now run inside a strict
per-process gate - an asyncio.Lock cached per running event loop - so at
most one probe connection can be in flight per worker process. Requests
that queue behind the gate are still shed by the existing endpoint-wide
deadline, so a flood cannot pile up new connections or open files.
- Memory and unknown-backend decisions stay outside the gate; payload and
probe semantics are unchanged. The serialization is documented in the
module docstring and backend/app/gateway/AGENTS.md.
## Verification
- New regression test: 8 concurrent readiness_payload() requests against an
instrumented sqlite probe assert the maximum number of in-flight probe
connections is 1 while every request still returns 200.
- cd backend && python -m pytest tests/test_gateway_health.py: 22 passed;
tests/test_gateway_docs_toggle.py and tests/test_gateway_lifespan_shutdown.py
also pass on the merged main head.
- ruff check + ruff format clean on all changed files.
## AI assistance
**Tool(s) used:** Codex (coding agent)
**How you used it:** implemented the reviewer-requested strict concurrency bound for the public readiness probe; reviewed before commit.
- [ ] I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.
304 lines
10 KiB
Python
304 lines
10 KiB
Python
"""Unit tests for the gateway readiness probe (app.gateway.health)."""
|
|
|
|
import asyncio
|
|
import pathlib
|
|
import sqlite3
|
|
import sys
|
|
import time
|
|
from contextlib import asynccontextmanager
|
|
|
|
import pytest
|
|
|
|
import app.gateway.health as health_module
|
|
from app.gateway.health import (
|
|
DATABASE_NOT_CONFIGURED,
|
|
DATABASE_OK,
|
|
DATABASE_UNREACHABLE,
|
|
_probe_checkpointer_backend,
|
|
check_database_health,
|
|
readiness_payload,
|
|
resolve_checkpointer_config,
|
|
)
|
|
from deerflow.config.checkpointer_config import CheckpointerConfig
|
|
|
|
|
|
class _FakeConnection:
|
|
async def execute(self, *args, **kwargs):
|
|
return None
|
|
|
|
|
|
class _FakeEngine:
|
|
def __init__(self, *, unreachable: bool = False):
|
|
self._unreachable = unreachable
|
|
|
|
def connect(self):
|
|
@asynccontextmanager
|
|
async def _connect():
|
|
if self._unreachable:
|
|
raise RuntimeError("database is down")
|
|
yield _FakeConnection()
|
|
|
|
return _connect()
|
|
|
|
|
|
def _create_sqlite_file(path: pathlib.Path) -> None:
|
|
"""Create a valid (empty) SQLite database file at *path*."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
sqlite3.connect(str(path)).close()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_check_database_health_without_engine(monkeypatch):
|
|
"""backend=memory (no engine) must report not_configured, never unreachable."""
|
|
monkeypatch.setattr("app.gateway.health.get_engine", lambda: None)
|
|
|
|
assert await check_database_health() == DATABASE_NOT_CONFIGURED
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_check_database_health_reachable(monkeypatch):
|
|
monkeypatch.setattr("app.gateway.health.get_engine", lambda: _FakeEngine())
|
|
|
|
assert await check_database_health() == DATABASE_OK
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_check_database_health_unreachable(monkeypatch):
|
|
monkeypatch.setattr("app.gateway.health.get_engine", lambda: _FakeEngine(unreachable=True))
|
|
|
|
assert await check_database_health() == DATABASE_UNREACHABLE
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_readiness_payload_ready_when_database_ok_and_memory_backend(monkeypatch):
|
|
monkeypatch.setattr("app.gateway.health.get_engine", lambda: _FakeEngine())
|
|
|
|
status_code, payload = await readiness_payload(CheckpointerConfig(type="memory"))
|
|
|
|
assert status_code == 200
|
|
assert payload["status"] == "ready"
|
|
assert payload["database"] == DATABASE_OK
|
|
assert payload["checkpointer"] == DATABASE_NOT_CONFIGURED
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_readiness_payload_degraded_when_database_unreachable(monkeypatch):
|
|
monkeypatch.setattr("app.gateway.health.get_engine", lambda: _FakeEngine(unreachable=True))
|
|
|
|
status_code, payload = await readiness_payload(CheckpointerConfig(type="memory"))
|
|
|
|
assert status_code == 503
|
|
assert payload["status"] == "degraded"
|
|
assert payload["database"] == DATABASE_UNREACHABLE
|
|
assert payload["checkpointer"] == DATABASE_NOT_CONFIGURED
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_readiness_payload_ready_when_nothing_configured(monkeypatch):
|
|
"""backend=memory end to end must stay ready with not_configured results."""
|
|
monkeypatch.setattr("app.gateway.health.get_engine", lambda: None)
|
|
|
|
status_code, payload = await readiness_payload(CheckpointerConfig(type="memory"))
|
|
|
|
assert status_code == 200
|
|
assert payload["status"] == "ready"
|
|
assert payload["database"] == DATABASE_NOT_CONFIGURED
|
|
assert payload["checkpointer"] == DATABASE_NOT_CONFIGURED
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_readiness_payload_degraded_when_checkpointer_unreachable_but_database_ok(tmp_path, monkeypatch):
|
|
"""A healthy ORM engine must not mask an unreachable legacy checkpointer backend."""
|
|
monkeypatch.setattr("app.gateway.health.get_engine", lambda: _FakeEngine())
|
|
config = CheckpointerConfig(type="sqlite", connection_string=str(tmp_path / "missing" / "checkpoints.db"))
|
|
|
|
status_code, payload = await readiness_payload(config)
|
|
|
|
assert status_code == 503
|
|
assert payload["status"] == "degraded"
|
|
assert payload["database"] == DATABASE_OK
|
|
assert payload["checkpointer"] == DATABASE_UNREACHABLE
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_readiness_payload_fails_closed_without_startup_snapshot(monkeypatch):
|
|
"""No startup config snapshot must degrade readiness, never report ready."""
|
|
monkeypatch.setattr("app.gateway.health.get_engine", lambda: _FakeEngine())
|
|
|
|
status_code, payload = await readiness_payload(None)
|
|
|
|
assert status_code == 503
|
|
assert payload["status"] == "degraded"
|
|
assert payload["database"] == DATABASE_OK
|
|
assert payload["checkpointer"] == DATABASE_UNREACHABLE
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_readiness_probes_run_concurrently(monkeypatch):
|
|
"""Slow-but-healthy probes must not add their budgets together."""
|
|
|
|
async def _slow_ok(*args) -> str:
|
|
await asyncio.sleep(0.35)
|
|
return DATABASE_OK
|
|
|
|
monkeypatch.setattr(health_module, "check_database_health", _slow_ok)
|
|
monkeypatch.setattr(health_module, "_probe_checkpointer_backend", _slow_ok)
|
|
|
|
started = time.perf_counter()
|
|
status_code, payload = await readiness_payload(CheckpointerConfig(type="memory"))
|
|
elapsed = time.perf_counter() - started
|
|
|
|
assert status_code == 200
|
|
assert payload["database"] == DATABASE_OK
|
|
assert payload["checkpointer"] == DATABASE_OK
|
|
# Two sequential 0.35s probes would take ~0.7s; concurrent ones finish
|
|
# within a single probe window.
|
|
assert elapsed < 0.6
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_readiness_payload_enforces_endpoint_deadline(monkeypatch):
|
|
"""A probe ignoring its own budget must trip the endpoint-wide deadline."""
|
|
|
|
async def _hanging(*args) -> str:
|
|
await asyncio.sleep(30)
|
|
return DATABASE_OK
|
|
|
|
monkeypatch.setattr(health_module, "check_database_health", _hanging)
|
|
monkeypatch.setattr(health_module, "_probe_checkpointer_backend", _hanging)
|
|
monkeypatch.setattr(health_module, "_READINESS_DEADLINE_SECONDS", 0.05)
|
|
|
|
status_code, payload = await readiness_payload(CheckpointerConfig(type="memory"))
|
|
|
|
assert status_code == 503
|
|
assert payload["status"] == "degraded"
|
|
assert payload["database"] == DATABASE_UNREACHABLE
|
|
assert payload["checkpointer"] == DATABASE_UNREACHABLE
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_concurrent_readiness_requests_do_not_open_concurrent_probe_connections(monkeypatch):
|
|
"""Public /health/ready must serialize connection-opening probes.
|
|
|
|
An unauthenticated thundering herd must never translate into an unbounded
|
|
number of new database connections (e.g. past PostgreSQL
|
|
max_connections): at most one probe connection may be in flight at a time
|
|
per process.
|
|
"""
|
|
active = 0
|
|
max_active = 0
|
|
|
|
async def _tracked_probe(conn_string: str | None) -> str:
|
|
nonlocal active, max_active
|
|
active += 1
|
|
max_active = max(max_active, active)
|
|
try:
|
|
await asyncio.sleep(0.05)
|
|
return DATABASE_OK
|
|
finally:
|
|
active -= 1
|
|
|
|
monkeypatch.setattr(health_module, "_probe_sqlite_backend", _tracked_probe)
|
|
monkeypatch.setattr("app.gateway.health.get_engine", lambda: None)
|
|
config = CheckpointerConfig(type="sqlite", connection_string=":memory:")
|
|
|
|
results = await asyncio.gather(*(readiness_payload(config) for _ in range(8)))
|
|
|
|
assert [status_code for status_code, _ in results] == [200] * 8
|
|
assert max_active == 1
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_probe_checkpointer_memory_reports_not_configured():
|
|
assert await _probe_checkpointer_backend(CheckpointerConfig(type="memory")) == DATABASE_NOT_CONFIGURED
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_probe_checkpointer_sqlite_reachable(tmp_path):
|
|
db_path = tmp_path / "checkpoints.db"
|
|
_create_sqlite_file(db_path)
|
|
|
|
result = await _probe_checkpointer_backend(CheckpointerConfig(type="sqlite", connection_string=str(db_path)))
|
|
|
|
assert result == DATABASE_OK
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_probe_checkpointer_sqlite_file_uri_reachable(tmp_path):
|
|
db_path = tmp_path / "checkpoints.db"
|
|
_create_sqlite_file(db_path)
|
|
|
|
result = await _probe_checkpointer_backend(CheckpointerConfig(type="sqlite", connection_string=pathlib.Path(db_path).as_uri()))
|
|
|
|
assert result == DATABASE_OK
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_probe_checkpointer_sqlite_missing_file_stays_missing_and_unreachable(tmp_path):
|
|
"""The probe must never create a missing SQLite file (regression)."""
|
|
missing = tmp_path / "checkpoints.db"
|
|
|
|
result = await _probe_checkpointer_backend(CheckpointerConfig(type="sqlite", connection_string=str(missing)))
|
|
|
|
assert result == DATABASE_UNREACHABLE
|
|
assert not missing.exists()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_probe_checkpointer_sqlite_unreachable_when_parent_missing(tmp_path):
|
|
missing_parent = tmp_path / "does-not-exist" / "checkpoints.db"
|
|
|
|
result = await _probe_checkpointer_backend(CheckpointerConfig(type="sqlite", connection_string=str(missing_parent)))
|
|
|
|
assert result == DATABASE_UNREACHABLE
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize(
|
|
"conn_string",
|
|
[":memory:", "file:memdb1?mode=memory&cache=shared", "file::memory:?cache=shared"],
|
|
)
|
|
async def test_probe_checkpointer_sqlite_in_memory_is_not_configured(conn_string):
|
|
"""In-memory SQLite has no external state, mirroring the memory backend."""
|
|
result = await _probe_checkpointer_backend(CheckpointerConfig(type="sqlite", connection_string=conn_string))
|
|
|
|
assert result == DATABASE_NOT_CONFIGURED
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_probe_checkpointer_postgres_without_psycopg_is_unreachable(monkeypatch):
|
|
monkeypatch.setitem(sys.modules, "psycopg", None)
|
|
|
|
result = await _probe_checkpointer_backend(
|
|
CheckpointerConfig(
|
|
type="postgres",
|
|
connection_string="postgresql://user:pass@localhost:5432/deerflow",
|
|
)
|
|
)
|
|
|
|
assert result == DATABASE_UNREACHABLE
|
|
|
|
|
|
def test_resolve_checkpointer_config_passes_through_resolution(monkeypatch):
|
|
resolved = CheckpointerConfig(type="memory")
|
|
monkeypatch.setattr(
|
|
"deerflow.runtime.checkpointer.provider._resolve_checkpointer_config",
|
|
lambda app_config: resolved,
|
|
)
|
|
|
|
assert resolve_checkpointer_config(object()) is resolved
|
|
|
|
|
|
def test_resolve_checkpointer_config_failure_fails_closed(monkeypatch):
|
|
"""A resolution failure must surface as None, never as a memory default."""
|
|
|
|
def _raise(app_config):
|
|
raise RuntimeError("broken checkpointer config")
|
|
|
|
monkeypatch.setattr(
|
|
"deerflow.runtime.checkpointer.provider._resolve_checkpointer_config",
|
|
_raise,
|
|
)
|
|
|
|
assert resolve_checkpointer_config(object()) is None
|