mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 16:08:41 +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.
283 lines
13 KiB
Python
283 lines
13 KiB
Python
"""Readiness probe helpers for the gateway health endpoints.
|
|
|
|
``GET /health`` stays a pure liveness signal: 200 whenever the process is up.
|
|
``GET /health/ready`` additionally probes the persistence the gateway actually
|
|
depends on, so orchestrators (Docker healthchecks, Kubernetes probes) treat the
|
|
gateway as ready only when the databases behind agent runs are reachable. Two
|
|
backends can be configured independently:
|
|
|
|
* the ORM engine behind ``database:`` (application repositories), and
|
|
* the effective LangGraph checkpointer/Store backend - the legacy
|
|
``checkpointer:`` section when present, otherwise derived from ``database:``
|
|
(memory/sqlite/postgres).
|
|
|
|
Both probes run concurrently beneath a single endpoint-wide deadline
|
|
(:data:`_READINESS_DEADLINE_SECONDS`), so a healthy response completes within
|
|
one probe window rather than the sum of both budgets. The checkpointer config
|
|
is resolved once at startup from the same snapshot ``langgraph_runtime`` builds
|
|
its resources from and is stored on ``app.state``; probing a hot-reloaded
|
|
config instead could check a backend the running process is not using. A
|
|
``backend=memory`` deployment has nothing to probe and is always considered
|
|
ready; a startup config that cannot be resolved fails closed as unreachable.
|
|
Connection-opening probes are serialized behind a strict per-process gate: the
|
|
route is public through the ``/health`` auth prefix, so unlimited concurrent
|
|
requests must never translate into unlimited new PostgreSQL connections.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import pathlib
|
|
import urllib.parse
|
|
import weakref
|
|
from typing import TYPE_CHECKING
|
|
|
|
from sqlalchemy import text
|
|
|
|
from deerflow.persistence.engine import get_engine
|
|
|
|
if TYPE_CHECKING:
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.config.checkpointer_config import CheckpointerConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Upper bound for a single probe attempt. The endpoint must never hang behind
|
|
# a dead database (for example a TCP connect timeout to Postgres).
|
|
_PROBE_TIMEOUT_SECONDS = 2.0
|
|
|
|
# Whole-endpoint deadline covering both probes. They run concurrently, so a
|
|
# healthy response completes within a single probe window; the extra margin
|
|
# absorbs scheduling/cancellation overhead without letting the request
|
|
# approach the sum of both probe budgets. Orchestrator timeouts (Helm
|
|
# readinessProbe ``timeoutSeconds``, the docker-compose healthcheck client
|
|
# timeout) must be configured above this bound.
|
|
_READINESS_DEADLINE_SECONDS = 3.0
|
|
|
|
# ``app.state`` attribute under which :func:`app.gateway.deps.langgraph_runtime`
|
|
# records the startup-bound checkpointer/Store config the probe targets.
|
|
READINESS_CHECKPOINTER_CONFIG_ATTR = "checkpointer_config"
|
|
|
|
# One gate per running event loop (one per worker process in production; one
|
|
# per test loop in the suite). ``/health/ready`` is public and unauthenticated,
|
|
# so a thundering herd of probes - or an attacker - must never be able to open
|
|
# an unbounded number of new connections: every connection-opening probe below
|
|
# is serialized through this gate, bounding in-flight probe connections to one
|
|
# per process. Waiting requests are still shed by the endpoint-wide deadline.
|
|
_PROBE_GATES: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = weakref.WeakKeyDictionary()
|
|
|
|
|
|
def _probe_gate() -> asyncio.Lock:
|
|
"""Return the serialization gate bound to the currently running loop."""
|
|
loop = asyncio.get_running_loop()
|
|
gate = _PROBE_GATES.get(loop)
|
|
if gate is None:
|
|
gate = asyncio.Lock()
|
|
_PROBE_GATES[loop] = gate
|
|
return gate
|
|
|
|
|
|
# Result vocabulary for the database probe.
|
|
DATABASE_OK = "ok"
|
|
DATABASE_NOT_CONFIGURED = "not_configured"
|
|
DATABASE_UNREACHABLE = "unreachable"
|
|
|
|
|
|
async def check_database_health() -> str:
|
|
"""Probe the persistence engine; return one of the DATABASE_* values."""
|
|
engine = get_engine()
|
|
if engine is None:
|
|
# backend=memory, or the engine has not been initialized yet: there is
|
|
# no database to probe.
|
|
return DATABASE_NOT_CONFIGURED
|
|
try:
|
|
async with asyncio.timeout(_PROBE_TIMEOUT_SECONDS):
|
|
async with engine.connect() as connection:
|
|
await connection.execute(text("SELECT 1"))
|
|
except Exception:
|
|
logger.warning("Readiness database probe failed", exc_info=True)
|
|
return DATABASE_UNREACHABLE
|
|
return DATABASE_OK
|
|
|
|
|
|
def resolve_checkpointer_config(startup_config: AppConfig) -> CheckpointerConfig | None:
|
|
"""Resolve the checkpointer/Store backend bound to a startup config snapshot.
|
|
|
|
Mirrors the runtime's own selection (the legacy ``checkpointer`` section
|
|
first, otherwise derived from the unified ``database`` section), so the
|
|
probe targets the exact backend ``langgraph_runtime`` built at startup -
|
|
which can differ from the ORM ``database:`` backend and from a later,
|
|
hot-reloaded config. Returns None when the config cannot be resolved;
|
|
callers must treat that as a failure (unreachable), never as
|
|
``not_configured``.
|
|
"""
|
|
from deerflow.runtime.checkpointer.provider import _resolve_checkpointer_config
|
|
|
|
try:
|
|
return _resolve_checkpointer_config(startup_config)
|
|
except Exception:
|
|
logger.warning(
|
|
"Readiness probe: unable to resolve the startup checkpointer config; failing closed",
|
|
exc_info=True,
|
|
)
|
|
return None
|
|
|
|
|
|
def _sqlite_is_in_memory(conn_str: str) -> bool:
|
|
"""Return True when *conn_str* refers to a purely in-memory SQLite database."""
|
|
if conn_str == ":memory:":
|
|
return True
|
|
if not conn_str.startswith("file:"):
|
|
return False
|
|
parts = urllib.parse.urlsplit(conn_str)
|
|
if parts.path in (":memory:", ""):
|
|
return True
|
|
return any(key == "mode" and value == "memory" for key, value in urllib.parse.parse_qsl(parts.query))
|
|
|
|
|
|
def _sqlite_disk_uri(conn_str: str) -> str:
|
|
"""Return a non-creating (``mode=rw``) SQLite URI for a disk-backed database.
|
|
|
|
Opening with ``mode=rw`` refuses to create a missing database file, so a
|
|
readiness probe can never resurrect a checkpointer/Store file that was
|
|
deleted or lost after startup - absence must surface as unreachable. Plain
|
|
filesystem paths (already absolute after
|
|
``deerflow.runtime.store._sqlite_utils.resolve_sqlite_conn_str``) are
|
|
converted with ``Path.as_uri`` for correct percent-encoding; existing
|
|
``file:`` URIs keep their path bytes and get ``mode=rw`` merged into the
|
|
query, replacing any pinned mode.
|
|
"""
|
|
if not conn_str.startswith("file:"):
|
|
return f"{pathlib.Path(conn_str).as_uri()}?mode=rw"
|
|
parts = urllib.parse.urlsplit(conn_str)
|
|
query_pairs = urllib.parse.parse_qsl(parts.query, keep_blank_values=True)
|
|
if not any(key == "mode" for key, _ in query_pairs):
|
|
separator = "&" if parts.query else "?"
|
|
return f"{conn_str}{separator}mode=rw"
|
|
replaced = urllib.parse.urlencode([(key, "rw") if key == "mode" else (key, value) for key, value in query_pairs])
|
|
return urllib.parse.urlunsplit((parts.scheme, parts.netloc, parts.path, replaced, parts.fragment))
|
|
|
|
|
|
async def _probe_sqlite_backend(conn_string: str | None) -> str:
|
|
"""Probe a SQLite checkpointer/Store database with a bounded SELECT 1.
|
|
|
|
Disk-backed databases are opened non-creating (``mode=rw``): a missing
|
|
file stays missing and fails the probe instead of being recreated empty.
|
|
In-memory forms (``:memory:`` and ``file:`` URIs with ``mode=memory``)
|
|
only exist inside the running process, so there is nothing external to
|
|
probe and they report ``not_configured`` like the memory backend.
|
|
"""
|
|
try:
|
|
import aiosqlite
|
|
except ImportError:
|
|
logger.error("Readiness probe: aiosqlite is not installed for the sqlite checkpointer backend")
|
|
return DATABASE_UNREACHABLE
|
|
from deerflow.runtime.store._sqlite_utils import resolve_sqlite_conn_str
|
|
|
|
conn_str = resolve_sqlite_conn_str(conn_string or "store.db")
|
|
if _sqlite_is_in_memory(conn_str):
|
|
return DATABASE_NOT_CONFIGURED
|
|
try:
|
|
async with asyncio.timeout(_PROBE_TIMEOUT_SECONDS):
|
|
connection = await aiosqlite.connect(_sqlite_disk_uri(conn_str), uri=True)
|
|
try:
|
|
await connection.execute("SELECT 1")
|
|
finally:
|
|
await connection.close()
|
|
except Exception:
|
|
logger.warning("Readiness sqlite checkpointer probe failed", exc_info=True)
|
|
return DATABASE_UNREACHABLE
|
|
return DATABASE_OK
|
|
|
|
|
|
async def _probe_postgres_backend(conn_string: str, schema: str) -> str:
|
|
"""Probe a PostgreSQL checkpointer/Store database with a bounded SELECT 1."""
|
|
try:
|
|
from psycopg import AsyncConnection
|
|
except ImportError:
|
|
logger.error("Readiness probe: psycopg is not installed for the postgres checkpointer backend")
|
|
return DATABASE_UNREACHABLE
|
|
try:
|
|
from deerflow.persistence.postgres_schema import dsn_with_search_path, normalize_libpq_dsn
|
|
|
|
dsn = dsn_with_search_path(normalize_libpq_dsn(conn_string), schema)
|
|
async with asyncio.timeout(_PROBE_TIMEOUT_SECONDS):
|
|
connection = await AsyncConnection.connect(dsn, connect_timeout=int(_PROBE_TIMEOUT_SECONDS))
|
|
try:
|
|
async with connection.cursor() as cursor:
|
|
await cursor.execute("SELECT 1")
|
|
finally:
|
|
await connection.close()
|
|
except Exception:
|
|
logger.warning("Readiness postgres checkpointer probe failed", exc_info=True)
|
|
return DATABASE_UNREACHABLE
|
|
return DATABASE_OK
|
|
|
|
|
|
async def _probe_checkpointer_backend(config: CheckpointerConfig) -> str:
|
|
"""Probe the LangGraph checkpointer/Store backend described by *config*.
|
|
|
|
*config* is the startup-bound snapshot (see :func:`resolve_checkpointer_config`);
|
|
an in-process memory backend has nothing external to probe. Probes that
|
|
open a connection (sqlite file, postgres) are serialized so concurrent
|
|
unauthenticated requests cannot exhaust the database's connections.
|
|
"""
|
|
if config.type == "memory":
|
|
# In-process backend: there is nothing external to probe.
|
|
return DATABASE_NOT_CONFIGURED
|
|
if config.type not in ("sqlite", "postgres"):
|
|
logger.warning("Readiness probe: unknown checkpointer backend %r", config.type)
|
|
return DATABASE_UNREACHABLE
|
|
async with _probe_gate():
|
|
if config.type == "sqlite":
|
|
return await _probe_sqlite_backend(config.connection_string)
|
|
if not config.connection_string:
|
|
return DATABASE_UNREACHABLE
|
|
return await _probe_postgres_backend(config.connection_string, config.postgres_schema)
|
|
|
|
|
|
async def readiness_payload(checkpointer_config: CheckpointerConfig | None = None) -> tuple[int, dict[str, str]]:
|
|
"""Return the (status_code, body) pair served by ``GET /health/ready``.
|
|
|
|
Probes both persistence halves the gateway depends on: the ORM engine
|
|
behind ``database:`` (repositories) and the effective LangGraph
|
|
checkpointer/Store backend (the legacy ``checkpointer:`` section, otherwise
|
|
derived from ``database:``). The probes run concurrently beneath one
|
|
endpoint-wide deadline so the request duration is bounded by the slowest
|
|
single probe, not their sum. ``checkpointer_config`` is the startup
|
|
snapshot recorded by ``langgraph_runtime``; None means no snapshot could be
|
|
resolved, which fails closed as an unreachable backend rather than
|
|
reporting ready. Either backend can be configured independently of the
|
|
other, so an unreachable probe on either degrades the endpoint.
|
|
"""
|
|
|
|
async def _probe_engine() -> str:
|
|
return await check_database_health()
|
|
|
|
async def _probe_checkpointer() -> str:
|
|
if checkpointer_config is None:
|
|
# Fail closed: without the startup-bound config we cannot know what
|
|
# backend agent runs use, so readiness must not be claimed.
|
|
logger.error("Readiness probe: no startup checkpointer config snapshot recorded; failing closed")
|
|
return DATABASE_UNREACHABLE
|
|
return await _probe_checkpointer_backend(checkpointer_config)
|
|
|
|
try:
|
|
async with asyncio.timeout(_READINESS_DEADLINE_SECONDS):
|
|
database, checkpointer = await asyncio.gather(_probe_engine(), _probe_checkpointer())
|
|
except TimeoutError:
|
|
logger.error(
|
|
"Readiness probes exceeded the %.1fs endpoint deadline",
|
|
_READINESS_DEADLINE_SECONDS,
|
|
)
|
|
database = checkpointer = DATABASE_UNREACHABLE
|
|
degraded = DATABASE_UNREACHABLE in (database, checkpointer)
|
|
payload = {
|
|
"status": "degraded" if degraded else "ready",
|
|
"service": "deer-flow-gateway",
|
|
"database": database,
|
|
"checkpointer": checkpointer,
|
|
}
|
|
return (503 if degraded else 200, payload)
|