mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* fix(deploy): wire the multi-replica prerequisites into the Helm chart and compose The chart shipped gateway.replicas: 1 with a README citing the long-closed issue #3948, while its rendered config lacked run_ownership heartbeats and db run events: raising replicas made every starting Pod write its peers' lease-less runs off as orphans, and the Gateway's startup gate could not notice because its worker count only sees one Pod. Enable heartbeats and db run events in the default config, export DEER_FLOW_MULTI_INSTANCE from the replica count for the startup gate, generate and inject a shared AUTH_JWT_SECRET (per-Pod .jwt_secret files logged users out across replicas), declare a surge-then-drain rollout strategy, render a PodDisruptionBudget when replicas > 1, and bound uvicorn's graceful shutdown inside a 90s termination grace period. docker-compose gets the same uvicorn bound and a 60s stop_grace_period so the 30s memory flush is no longer SIGKILLed. Docs and comments now list what actually remains single-instance: IM channels, WeChat QR login and browser tools. * fix(deploy): honor existingAppSecret, declare multi-instance explicitly, cover the dev stack's shutdown Follow-up to the multi-replica prerequisites wiring, addressing review: - `deer-flow.appSecret` returns `existingAppSecret` when it is set. The gateway and frontend kept referencing the generated `<release>-app` Secret, which the chart stopped rendering, so every Pod sat in CreateContainerConfigError. The value is now declared in values.yaml. - `gateway.multiInstance` (boolean, default null) overrides the replica-derived DEER_FLOW_MULTI_INSTANCE, PodDisruptionBudget and AUTH_JWT_SECRET requirement for deployments scaled with kubectl or an HPA, which change the replica count without a Helm upgrade. - AUTH_JWT_SECRET is required, not optional, whenever the gateway is multi-instance: concurrently booting Pods otherwise race to write different `.jwt_secret` files on the home volume and sign sessions with different keys. - `gateway.uvicornGracefulShutdownSeconds: 0` is honored instead of being swallowed by `default 10`. - docker-compose-dev.yaml and dev-entrypoint.sh get the same uvicorn graceful-shutdown bound and `stop_grace_period: 60s`. - The chart README documents the one-time sign-out when upgrading a release that predates AUTH_JWT_SECRET and how to seed the previous key into the Secret first. - Tests read the shutdown budget from the Gateway through a shared helper instead of hard-coding 5/5/30, the raw-template string matches give way to `helm template` renders, and new renders cover multiInstance, existingAppSecret, a disabled PDB and a zero graceful-shutdown bound. * docs(changelog): link the multi-replica deploy entry to #6347 * fix(deploy): budget every bounded teardown hook and flag the multiInstance PDB window Review follow-ups on #6347: - The shutdown budget modeled only channel stop + run drain + memory flush (40s). The lifespan also runs the startup trash sweep, the notification delivery worker stop, browser-session close and the MCP session-pool close, each bounded at _SHUTDOWN_HOOK_TIMEOUT_SECONDS, plus a 1s retrieval-warm wait: about 61s worst case. _gateway_shutdown_budget.py now reads all of them from the Gateway, a drift guard pins the hook count against app.py, and both compose files move stop_grace_period from 60s to 90s (Helm's 90s already had room: 5 + 10 + 61). Comments and the chart README cite the full arithmetic. - gateway.multiInstance: true renders the PodDisruptionBudget immediately, which blocks drains of the single Pod until the scale-up happens; the README, values comment and both deployment guides say so and name podDisruptionBudget.enabled: false as the interim escape hatch.
93 lines
4.5 KiB
Python
93 lines
4.5 KiB
Python
"""Regression test for the Docker Compose default Gateway worker count.
|
|
|
|
The Gateway holds run state (RunManager and the stream bridge) in process, so
|
|
the default deployment must run a single Uvicorn worker. Running more than one
|
|
worker without a shared cross-worker stream bridge breaks run cancellation, SSE
|
|
reconnects, request de-duplication, and IM channels (nginx has no sticky
|
|
sessions, so requests scatter across workers that each keep their own run
|
|
state). This test pins the safe default so it cannot silently regress to a
|
|
multi-worker default, while still allowing operators to override it once a
|
|
shared stream bridge exists.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
from _gateway_shutdown_budget import HOOKS_BOUNDED_BY_SHUTDOWN_HOOK_TIMEOUT, lifespan_shutdown_seconds
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
COMPOSE_PATH = REPO_ROOT / "docker" / "docker-compose.yaml"
|
|
DEV_COMPOSE_PATH = REPO_ROOT / "docker" / "docker-compose-dev.yaml"
|
|
DEV_ENTRYPOINT_PATH = REPO_ROOT / "docker" / "dev-entrypoint.sh"
|
|
GATEWAY_APP_PATH = REPO_ROOT / "backend" / "app" / "gateway" / "app.py"
|
|
|
|
|
|
def _gateway_command() -> str:
|
|
"""Return the gateway service command as a single string."""
|
|
compose = yaml.safe_load(COMPOSE_PATH.read_text(encoding="utf-8"))
|
|
command = compose["services"]["gateway"]["command"]
|
|
# ``command`` may load as a scalar string or a list depending on YAML style.
|
|
if isinstance(command, list):
|
|
command = " ".join(str(part) for part in command)
|
|
return command
|
|
|
|
|
|
def test_gateway_defaults_to_single_worker():
|
|
"""With GATEWAY_WORKERS unset, the worker count must default to 1."""
|
|
command = _gateway_command()
|
|
match = re.search(r"GATEWAY_WORKERS:-(\d+)", command)
|
|
assert match is not None, f"gateway command must set a GATEWAY_WORKERS default; got: {command}"
|
|
assert match.group(1) == "1", f"default Gateway worker count must be 1, got {match.group(1)}"
|
|
|
|
|
|
def test_gateway_worker_count_remains_overridable():
|
|
"""The worker count must stay configurable, not hard-coded to 1."""
|
|
command = _gateway_command()
|
|
assert "${GATEWAY_WORKERS:-1}" in command, f"worker count must use ${{GATEWAY_WORKERS:-1}} so operators can override it; got: {command}"
|
|
|
|
|
|
def _graceful_shutdown_bound(launch: str) -> int:
|
|
"""Return uvicorn's ``--timeout-graceful-shutdown`` bound from a launch command or script."""
|
|
match = re.search(r"--timeout-graceful-shutdown (\d+)", launch)
|
|
assert match is not None, f"uvicorn launch must bound its graceful shutdown; got: {launch}"
|
|
return int(match.group(1))
|
|
|
|
|
|
def _stop_grace_seconds(compose_path: Path) -> int:
|
|
compose = yaml.safe_load(compose_path.read_text(encoding="utf-8"))
|
|
grace = compose["services"]["gateway"]["stop_grace_period"]
|
|
match = re.fullmatch(r"(\d+)s", str(grace))
|
|
assert match is not None, f"stop_grace_period must be expressed in seconds, got {grace!r}"
|
|
return int(match.group(1))
|
|
|
|
|
|
def test_gateway_bounds_uvicorn_graceful_shutdown():
|
|
"""Open SSE connections must not hold lifespan shutdown (bounded hooks, run drain, memory flush) past the stop grace period."""
|
|
assert _graceful_shutdown_bound(_gateway_command()) <= 30
|
|
|
|
|
|
def test_gateway_stop_grace_period_covers_the_shutdown_work():
|
|
"""Docker's 10s default SIGKILLed the 30s memory queue flush on every restart."""
|
|
assert _stop_grace_seconds(COMPOSE_PATH) >= _graceful_shutdown_bound(_gateway_command()) + lifespan_shutdown_seconds()
|
|
|
|
|
|
def test_dev_gateway_bounds_uvicorn_graceful_shutdown_and_covers_it():
|
|
"""The dev stack launches uvicorn from dev-entrypoint.sh and is restarted far more often."""
|
|
bound = _graceful_shutdown_bound(DEV_ENTRYPOINT_PATH.read_text(encoding="utf-8"))
|
|
assert bound <= 30
|
|
assert _stop_grace_seconds(DEV_COMPOSE_PATH) >= bound + lifespan_shutdown_seconds()
|
|
|
|
|
|
def test_shutdown_budget_models_every_bounded_lifespan_hook():
|
|
"""Each ``wait_for(..., timeout=_SHUTDOWN_HOOK_TIMEOUT_SECONDS)`` in the lifespan is a sequential 5s worst case the grace periods must cover."""
|
|
source = GATEWAY_APP_PATH.read_text(encoding="utf-8")
|
|
bounded_sites = source.count("timeout=_SHUTDOWN_HOOK_TIMEOUT_SECONDS")
|
|
assert bounded_sites == len(HOOKS_BOUNDED_BY_SHUTDOWN_HOOK_TIMEOUT), (
|
|
f"app.gateway.app bounds {bounded_sites} teardown hook(s) with _SHUTDOWN_HOOK_TIMEOUT_SECONDS but "
|
|
f"_gateway_shutdown_budget models {len(HOOKS_BOUNDED_BY_SHUTDOWN_HOOK_TIMEOUT)}; update HOOKS_BOUNDED_BY_SHUTDOWN_HOOK_TIMEOUT "
|
|
"so the chart and compose grace periods keep covering the worst case"
|
|
)
|