deer-flow/backend/tests/test_multi_worker_postgres_gate.py
Nan Gao f39cdd6077
fix(mcp): keep rotating OAuth refresh tokens across restarts and Gateway instances (#7207)
* fix(mcp): keep rotating OAuth refresh tokens across restarts and Gateway instances

MCP servers on the OAuth refresh_token grant lost their rotated refresh
token whenever a new process or manager started (restart, tool-cache
reset for non-task servers, every personal-connection run, a second
Gateway instance) and re-seeded with the consumed configured token,
getting invalid_grant for good.

- New table mcp_oauth_tokens (migration 0047_mcp_oauth_tokens), one row
  per refresh_token connection keyed (scope, owner_user_id, server_key):
  ('deployment', '', name) or ('personal', owner, personal runtime
  name). Access and refresh tokens are DEER_FLOW_CREDENTIALS_KEY
  ciphertext; a config fingerprint (token_url, client_id, grant, scope,
  audience, seed) makes a changed seed win.
- OAuthTokenManager re-reads the row under its process lock, adopts a
  peer's valid token, and otherwise claims the refresh with a lease plus
  a version compare-and-set (database clock), fetches outside any
  transaction, writes under the lease and releases it on failure.
- The Gateway installs the store (sync engine via to_thread) around the
  LangGraph runtime for sqlite/postgres; personal managers pass their
  owner. client_credentials stays in process; an unloadable key falls
  back to process memory; database errors fail the refresh.
- credentials_key_consumers lists refresh_token MCP OAuth servers, so a
  declared multi-instance deployment needs the shared key.

Docs: MCP_SERVER.md, CONFIGURATION.md, Helm README, README, mcp and
migrations AGENTS.md, CHANGELOG (en/zh).

* docs(changelog): link the MCP OAuth token store entry to #7207

* fix(mcp): prune removed connections' OAuth token rows and ask owners to re-authorize on invalid_grant

Review follow-up (willem-bd):
- Both MCP config writers drop mcp_oauth_tokens rows of connections a
  save removes (deployment: _commit_mcp_config_write; personal:
  _mutate, keeping each connection's enabled/disabled runtime-name
  variants so a toggle keeps its chain). Best effort; no startup sweep,
  since a missing config file would look like every server removed.
- invalid_grant on a refresh_token request raises
  McpOAuthReauthorizationRequired: the tool error names the server and
  asks for a new refresh token, plus the shared-key hint when this
  instance could not decrypt the stored chain.
- Annotate the gateway helpers' config as AppConfig.

* fix(mcp): renew the OAuth refresh lease, keep unstored rotations, and key access tokens by request parameters

Review follow-up (willem-bd, second round):
- HTTPX timeouts bound each I/O wait, not a whole response: the refresh
  holder now renews its lease every third of REFRESH_LEASE_SECONDS while
  the token request runs and abandons it after REFRESH_MAX_SECONDS (120 s);
  waiters outlast a live holder. Regression uses a real loopback endpoint
  that streams its body for three lease lengths.
- A refresh whose tokens could not be stored keeps them pending
  (unsaved_base_version): they are written back before an older stored
  token is adopted or refreshed with, retried every few seconds while the
  access token is still valid, and dropped once a peer stored a newer
  chain. A failed save releases the lease.
- New token_request_fingerprint (extra_token_params and response fields):
  a stored access token is reused only under the same request parameters;
  otherwise the stored refresh-token chain is refreshed.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-21 18:17:29 +08:00

1136 lines
52 KiB
Python

"""Tests for the multi-worker Postgres startup gate.
Pins the contract documented in ``docs/multi_worker.md`` work item 1
(issue #3948): when ``GATEWAY_WORKERS > 1`` and the configured
database backend is not Postgres, the Gateway must refuse to start.
The gate runs inside :func:`langgraph_runtime` *before* any
persistence engine is initialised so operators see a clear error
instead of intermittent SQLite ``database is locked`` failures in
production.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from app.gateway.deps import (
_enforce_credentials_key,
_enforce_postgres_for_multi_worker,
_validate_agent_storage,
_validate_login_throttle_storage,
_validate_memory_retrieval_index,
credentials_key_consumers,
langgraph_runtime,
)
from app.gateway.routers.browser import _browser_tools_enabled
from deerflow.config.credentials_key import CREDENTIALS_KEY_ENV_VAR, GENERATE_KEY_COMMAND
from deerflow.config.database_config import DatabaseConfig
from deerflow.config.deployment_config import MULTI_INSTANCE_ENV_VAR, DeploymentConfig, multi_instance_declaration
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.run_ownership_config import RunOwnershipConfig
from deerflow.config.stream_bridge_config import StreamBridgeConfig
def _config_with_backend(
backend: str,
*,
heartbeat_enabled: bool | None = None,
browser_enabled: bool = False,
run_events_backend: str = "db",
scheduler_enabled: bool = False,
scheduler_multi_instance: bool = False,
deployment_multi_instance: bool = False,
stream_bridge_type: str | None = None,
ownership_type: str | None = None,
) -> SimpleNamespace:
run_ownership = RunOwnershipConfig(heartbeat_enabled=heartbeat_enabled) if heartbeat_enabled is not None else None
tools = [SimpleNamespace(name="browser_navigate")] if browser_enabled else []
stream_bridge = StreamBridgeConfig(type=stream_bridge_type) if stream_bridge_type is not None else None
ownership = SimpleNamespace(type=ownership_type) if ownership_type is not None else None
return SimpleNamespace(
database=DatabaseConfig(backend=backend),
run_ownership=run_ownership,
run_events=SimpleNamespace(backend=run_events_backend),
scheduler=SimpleNamespace(enabled=scheduler_enabled, multi_instance=scheduler_multi_instance),
tools=tools,
deployment=DeploymentConfig(multi_instance=deployment_multi_instance),
stream_bridge=stream_bridge,
sandbox=SimpleNamespace(ownership=ownership),
)
def _cluster_ready(**overrides):
"""A Postgres deployment with every multi-instance prerequisite satisfied."""
kwargs = dict(heartbeat_enabled=True, stream_bridge_type="redis")
kwargs.update(overrides)
return _config_with_backend("postgres", **kwargs)
@pytest.fixture(autouse=True)
def isolated_worker_env(monkeypatch):
"""Keep the suite independent of the invoking shell's worker count.
Several tests here assert that the gate stays inert, which only holds when
no worker count is set at all. ``WEB_CONCURRENCY`` is the count uvicorn takes
on the launches that pass no ``--workers``, so an exported value in the
invoking shell would otherwise turn these inert-gate expectations into
refusals.
"""
monkeypatch.delenv("GATEWAY_WORKERS", raising=False)
monkeypatch.delenv("WEB_CONCURRENCY", raising=False)
monkeypatch.delenv(MULTI_INSTANCE_ENV_VAR, raising=False)
monkeypatch.delenv("DEER_FLOW_STREAM_BRIDGE_REDIS_URL", raising=False)
monkeypatch.delenv(CREDENTIALS_KEY_ENV_VAR, raising=False)
# ---------------------------------------------------------------------------
# Unit tests of the gate function itself
# ---------------------------------------------------------------------------
def test_gate_noop_when_gateway_workers_unset(monkeypatch):
"""With GATEWAY_WORKERS unset, every backend must be accepted."""
monkeypatch.delenv("GATEWAY_WORKERS", raising=False)
monkeypatch.delenv("WEB_CONCURRENCY", raising=False)
for backend in ("sqlite", "memory", "postgres"):
_enforce_postgres_for_multi_worker(_config_with_backend(backend))
def test_gate_noop_for_single_worker(monkeypatch):
"""GATEWAY_WORKERS=1 preserves the historical single-worker behavior."""
monkeypatch.setenv("GATEWAY_WORKERS", "1")
for backend in ("sqlite", "memory", "postgres"):
_enforce_postgres_for_multi_worker(_config_with_backend(backend))
# ---------------------------------------------------------------------------
# Uvicorn's WEB_CONCURRENCY fallback also starts several worker processes
# ---------------------------------------------------------------------------
def test_gate_rejects_multi_worker_from_uvicorn_worker_fallback(monkeypatch):
"""WEB_CONCURRENCY=N starts N workers even with GATEWAY_WORKERS unset.
``backend/Dockerfile`` and ``scripts/serve.sh`` launch uvicorn with no
``--workers``, so uvicorn takes the count from ``WEB_CONCURRENCY``.
"""
monkeypatch.delenv("GATEWAY_WORKERS", raising=False)
monkeypatch.setenv("WEB_CONCURRENCY", "2")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite"))
assert "requires database.backend='postgres'" in str(exc_info.value)
def test_gate_rejects_scheduler_duplication_from_uvicorn_worker_fallback(monkeypatch):
"""Each of those workers starts its own scheduler, which the gate exists to refuse."""
monkeypatch.delenv("GATEWAY_WORKERS", raising=False)
monkeypatch.setenv("WEB_CONCURRENCY", "3")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite", scheduler_enabled=True))
assert "each worker starts its own scheduler" in str(exc_info.value)
def test_gate_rejects_process_local_browser_from_uvicorn_worker_fallback(monkeypatch):
monkeypatch.delenv("GATEWAY_WORKERS", raising=False)
monkeypatch.setenv("WEB_CONCURRENCY", "2")
with pytest.raises(SystemExit, match="process-local"):
_enforce_postgres_for_multi_worker(_config_with_backend("postgres", heartbeat_enabled=True, browser_enabled=True))
def test_gate_reads_uvicorn_worker_fallback_when_gateway_workers_is_blank(monkeypatch):
"""A blank GATEWAY_WORKERS means unset, exactly as the compose default treats it."""
monkeypatch.setenv("GATEWAY_WORKERS", "")
monkeypatch.setenv("WEB_CONCURRENCY", "2")
with pytest.raises(SystemExit):
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite"))
def test_unparsable_gateway_workers_does_not_mask_uvicorn_worker_fallback(monkeypatch):
"""A non-numeric documented knob must not report one worker while uvicorn starts four.
The launchers that pass no ``--workers`` (``backend/Dockerfile``, ``scripts/serve.sh``,
``backend/Makefile gateway``) take the count from ``WEB_CONCURRENCY`` alone, so there
``GATEWAY_WORKERS=abc`` never reaches uvicorn at all.
"""
monkeypatch.setenv("GATEWAY_WORKERS", "abc")
monkeypatch.setenv("WEB_CONCURRENCY", "4")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite"))
assert "WEB_CONCURRENCY=4" in str(exc_info.value), "must name the variable that actually set the count"
def test_unparsable_gateway_workers_does_not_mask_the_browser_gate(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "auto")
monkeypatch.setenv("WEB_CONCURRENCY", "2")
config = _config_with_backend("postgres", heartbeat_enabled=True, browser_enabled=True)
with pytest.raises(SystemExit, match="process-local"):
_enforce_postgres_for_multi_worker(config)
def test_agent_storage_warning_names_the_fallback_when_the_knob_is_unparsable(monkeypatch, caplog):
monkeypatch.setenv("GATEWAY_WORKERS", "auto")
monkeypatch.setenv("WEB_CONCURRENCY", "2")
with caplog.at_level("WARNING"):
_validate_agent_storage(_config_with_backend("postgres", heartbeat_enabled=True))
messages = [r.message for r in caplog.records if "not visible across workers" in r.message]
assert messages and "WEB_CONCURRENCY=2" in messages[0]
def test_gate_accepts_single_worker_from_uvicorn_worker_fallback(monkeypatch):
"""WEB_CONCURRENCY=1 is a single worker, so the gate stays inert."""
monkeypatch.delenv("GATEWAY_WORKERS", raising=False)
for value in ("1", "0", ""):
monkeypatch.setenv("WEB_CONCURRENCY", value)
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite", scheduler_enabled=True))
def test_gate_prefers_gateway_workers_over_uvicorn_worker_fallback(monkeypatch):
"""An explicit GATEWAY_WORKERS wins: uvicorn ignores WEB_CONCURRENCY when --workers is passed.
``docker/docker-compose.yaml`` always passes ``--workers ${GATEWAY_WORKERS:-1}``.
"""
monkeypatch.setenv("GATEWAY_WORKERS", "1")
monkeypatch.setenv("WEB_CONCURRENCY", "4")
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite", scheduler_enabled=True))
def test_agent_storage_warning_names_the_variable_that_set_the_count(monkeypatch, caplog):
"""The divergence warning must be actionable for a WEB_CONCURRENCY deployment."""
monkeypatch.delenv("GATEWAY_WORKERS", raising=False)
monkeypatch.setenv("WEB_CONCURRENCY", "2")
with caplog.at_level("WARNING"):
_validate_agent_storage(_config_with_backend("postgres", heartbeat_enabled=True))
messages = [r.message for r in caplog.records if "not visible across workers" in r.message]
assert messages and "WEB_CONCURRENCY=2" in messages[0]
def test_gate_allows_multi_worker_with_postgres_and_heartbeat(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "2")
_enforce_postgres_for_multi_worker(_config_with_backend("postgres", heartbeat_enabled=True, stream_bridge_type="redis"))
def test_gate_rejects_multi_worker_with_scheduler_enabled(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "2")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(
_config_with_backend(
"postgres",
heartbeat_enabled=True,
scheduler_enabled=True,
)
)
msg = str(exc_info.value)
assert "scheduler.multi_instance=true" in msg
assert "GATEWAY_WORKERS=1" in msg
assert "scheduler.enabled=false" in msg
def test_gate_allows_single_worker_with_scheduler_enabled(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "1")
_enforce_postgres_for_multi_worker(
_config_with_backend("sqlite", scheduler_enabled=True),
)
def test_gate_allows_multi_instance_scheduler_with_single_worker(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "1")
_enforce_postgres_for_multi_worker(
_config_with_backend(
"postgres",
heartbeat_enabled=True,
scheduler_enabled=True,
scheduler_multi_instance=True,
)
)
def test_gate_allows_multi_instance_scheduler_with_multiple_workers(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "2")
_enforce_postgres_for_multi_worker(
_config_with_backend(
"postgres",
heartbeat_enabled=True,
scheduler_enabled=True,
scheduler_multi_instance=True,
stream_bridge_type="redis",
)
)
def test_gate_rejects_multi_instance_scheduler_without_postgres(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "1")
with pytest.raises(SystemExit, match="database.backend='postgres'"):
_enforce_postgres_for_multi_worker(
_config_with_backend(
"sqlite",
heartbeat_enabled=True,
scheduler_enabled=True,
scheduler_multi_instance=True,
)
)
def test_gate_rejects_unsafe_multi_instance_config_even_when_scheduler_disabled(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "1")
with pytest.raises(SystemExit, match="database.backend='postgres'"):
_enforce_postgres_for_multi_worker(
_config_with_backend(
"sqlite",
heartbeat_enabled=True,
scheduler_enabled=False,
scheduler_multi_instance=True,
)
)
def test_gate_rejects_multi_instance_scheduler_without_heartbeat(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "1")
with pytest.raises(SystemExit, match="heartbeat_enabled=true"):
_enforce_postgres_for_multi_worker(
_config_with_backend(
"postgres",
heartbeat_enabled=False,
scheduler_enabled=True,
scheduler_multi_instance=True,
)
)
def test_gate_rejects_multi_instance_scheduler_with_process_local_events(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "1")
with pytest.raises(SystemExit, match="run_events.backend='db'"):
_enforce_postgres_for_multi_worker(
_config_with_backend(
"postgres",
heartbeat_enabled=True,
run_events_backend="memory",
scheduler_enabled=True,
scheduler_multi_instance=True,
)
)
@pytest.mark.parametrize("run_events_backend", ["memory", "jsonl"])
def test_gate_rejects_process_local_run_events_with_multi_worker(monkeypatch, run_events_backend):
monkeypatch.setenv("GATEWAY_WORKERS", "2")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(
_config_with_backend(
"postgres",
heartbeat_enabled=True,
run_events_backend=run_events_backend,
)
)
msg = str(exc_info.value)
assert "run_events.backend='db'" in msg
assert run_events_backend in msg
assert "GATEWAY_WORKERS=1" in msg
def test_gate_allows_process_local_run_events_for_single_worker(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "1")
for run_events_backend in ("memory", "jsonl"):
_enforce_postgres_for_multi_worker(
_config_with_backend(
"sqlite",
run_events_backend=run_events_backend,
)
)
def test_gate_rejects_process_local_browser_with_multi_worker(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "2")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(
_config_with_backend("postgres", heartbeat_enabled=True, browser_enabled=True),
)
msg = str(exc_info.value)
assert "process-local" in msg
assert "GATEWAY_WORKERS=1" in msg
def test_runtime_browser_surface_stays_disabled_after_incompatible_hot_reload(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "2")
live_config = SimpleNamespace(tools=[SimpleNamespace(name="browser_navigate", model_extra={})])
with patch("deerflow.config.get_app_config", return_value=live_config):
assert _browser_tools_enabled() is False
def test_gate_rejects_multi_worker_with_sqlite(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "2")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite"))
msg = str(exc_info.value)
assert "GATEWAY_WORKERS=2" in msg
assert "postgres" in msg.lower()
assert "sqlite" in msg.lower()
def test_gate_rejects_multi_worker_with_memory(monkeypatch):
"""The gate is not sqlite-specific: memory is also unsafe across processes."""
monkeypatch.setenv("GATEWAY_WORKERS", "2")
with pytest.raises(SystemExit):
_enforce_postgres_for_multi_worker(_config_with_backend("memory"))
def test_gate_rejects_high_worker_counts(monkeypatch):
"""The threshold is >1, not ==2; prod-scale counts must also be gated."""
monkeypatch.setenv("GATEWAY_WORKERS", "4")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite"))
assert "GATEWAY_WORKERS=4" in str(exc_info.value)
def test_gate_treats_invalid_env_as_single_worker(monkeypatch):
"""Non-integer GATEWAY_WORKERS values must not crash startup.
Uvicorn itself rejects these later; the gate should not preempt
that with its own crash. The count is then taken from the remaining
spellings, and with no other variable set the gate stays inert.
"""
for invalid in ("", "auto", "1.5", "abc", "0x4"):
monkeypatch.setenv("GATEWAY_WORKERS", invalid)
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite"))
def test_gate_treats_zero_and_negatives_as_single_worker(monkeypatch):
"""GATEWAY_WORKERS <= 1 (including 0 and negatives) skips the gate."""
for value in ("0", "-1", "-999"):
monkeypatch.setenv("GATEWAY_WORKERS", value)
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite"))
def test_gate_error_message_lists_both_remediations(monkeypatch):
"""Operators must see both fix options without reading docs."""
monkeypatch.setenv("GATEWAY_WORKERS", "2")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite"))
msg = str(exc_info.value)
assert "GATEWAY_WORKERS=1" in msg, "must mention the rollback knob"
assert "Postgres" in msg, "must mention the alternative backend"
# ---------------------------------------------------------------------------
# Heartbeat enforcement: multi-worker requires heartbeat_enabled=true
# ---------------------------------------------------------------------------
def test_gate_rejects_multi_worker_without_heartbeat(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "2")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_config_with_backend("postgres", heartbeat_enabled=False))
msg = str(exc_info.value)
assert "heartbeat_enabled=true" in msg
def test_gate_rejects_multi_worker_without_run_ownership_config(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "2")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_config_with_backend("postgres", heartbeat_enabled=None))
msg = str(exc_info.value)
assert "heartbeat_enabled=true" in msg
def test_gate_heartbeat_check_not_triggered_for_single_worker(monkeypatch):
"""GATEWAY_WORKERS=1 skips the heartbeat check entirely."""
monkeypatch.setenv("GATEWAY_WORKERS", "1")
_enforce_postgres_for_multi_worker(_config_with_backend("postgres", heartbeat_enabled=False))
def test_gate_heartbeat_check_not_triggered_for_sqlite(monkeypatch):
"""The gate exits on Postgres check before reaching heartbeat check."""
monkeypatch.setenv("GATEWAY_WORKERS", "2")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite", heartbeat_enabled=True))
msg = str(exc_info.value)
assert "postgres" in msg.lower()
# ---------------------------------------------------------------------------
# Integration: the gate is wired into langgraph_runtime before init_engine
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_langgraph_runtime_invokes_gate_before_persistence_setup(monkeypatch):
"""When the gate trips, no persistence / stream-bridge setup may run.
Guards against regressions that reorder the gate behind
``init_engine_from_config`` (or any other expensive startup step).
"""
monkeypatch.setenv("GATEWAY_WORKERS", "2")
init_engine_from_config = AsyncMock(name="init_engine_from_config")
@asynccontextmanager
async def _noop_stream_bridge(_config):
yield MagicMock()
with (
patch(
"deerflow.persistence.engine.init_engine_from_config",
init_engine_from_config,
),
patch("deerflow.runtime.make_stream_bridge", side_effect=_noop_stream_bridge) as make_stream_bridge,
patch("deerflow.runtime.make_store", side_effect=_noop_stream_bridge) as make_store,
):
app = FastAPI()
startup_config = _config_with_backend("sqlite")
with pytest.raises(SystemExit):
async with langgraph_runtime(app, startup_config):
pass
init_engine_from_config.assert_not_called()
make_stream_bridge.assert_not_called()
make_store.assert_not_called()
# ---------------------------------------------------------------------------
# Cross-process stream bridge: multi-worker now needs it too
# ---------------------------------------------------------------------------
def test_multi_worker_requires_a_cross_process_stream_bridge(monkeypatch):
"""GATEWAY_WORKERS > 1 with the memory bridge used to start and then 409 every SSE join on a peer."""
monkeypatch.setenv("GATEWAY_WORKERS", "2")
for bridge in (None, "memory"):
with pytest.raises(SystemExit, match="stream_bridge.type='redis'"):
_enforce_postgres_for_multi_worker(_config_with_backend("postgres", heartbeat_enabled=True, stream_bridge_type=bridge))
def test_multi_worker_accepts_the_env_redis_stream_bridge(monkeypatch):
"""docker-compose and the Helm chart inject DEER_FLOW_STREAM_BRIDGE_REDIS_URL instead of a config section."""
monkeypatch.setenv("GATEWAY_WORKERS", "2")
monkeypatch.setenv("DEER_FLOW_STREAM_BRIDGE_REDIS_URL", "redis://redis:6379/0")
_enforce_postgres_for_multi_worker(_config_with_backend("postgres", heartbeat_enabled=True))
def test_multi_worker_rejects_explicit_memory_sandbox_ownership(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "2")
with pytest.raises(SystemExit, match="sandbox.ownership.type='memory'"):
_enforce_postgres_for_multi_worker(_cluster_ready(ownership_type="memory"))
# ---------------------------------------------------------------------------
# Explicit multi-instance declaration: Kubernetes replicas run one worker per Pod
# ---------------------------------------------------------------------------
def test_declared_multi_instance_is_gated_with_a_single_worker(monkeypatch):
"""replicas > 1 with GATEWAY_WORKERS=1 per Pod must not bypass the gate.
Without the declaration every Pod reports one worker, passes every check,
and its startup orphan reconciliation writes the peers' lease-less runs off
as crashed on every rolling update.
"""
monkeypatch.setenv("GATEWAY_WORKERS", "1")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite", deployment_multi_instance=True))
msg = str(exc_info.value)
assert "deployment.multi_instance=true" in msg
assert "requires database.backend='postgres'" in msg
assert "deployment.multi_instance=false" in msg, "must name the rollback knob"
def test_declared_multi_instance_accepts_a_fully_shared_deployment(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "1")
_enforce_postgres_for_multi_worker(_cluster_ready(deployment_multi_instance=True))
def test_declared_multi_instance_requires_heartbeat():
with pytest.raises(SystemExit, match="heartbeat_enabled=true"):
_enforce_postgres_for_multi_worker(_cluster_ready(deployment_multi_instance=True, heartbeat_enabled=False))
@pytest.mark.parametrize("run_events_backend", ["memory", "jsonl"])
def test_declared_multi_instance_requires_db_run_events(run_events_backend):
with pytest.raises(SystemExit, match="run_events.backend='db'"):
_enforce_postgres_for_multi_worker(_cluster_ready(deployment_multi_instance=True, run_events_backend=run_events_backend))
def test_declared_multi_instance_requires_a_redis_stream_bridge():
"""The memory bridge is process-local, so a peer never sees the owner's SSE events."""
for bridge in (None, "memory"):
with pytest.raises(SystemExit, match="stream_bridge.type='redis'"):
_enforce_postgres_for_multi_worker(_cluster_ready(deployment_multi_instance=True, stream_bridge_type=bridge))
def test_declared_multi_instance_accepts_the_env_redis_stream_bridge(monkeypatch):
monkeypatch.setenv("DEER_FLOW_STREAM_BRIDGE_REDIS_URL", "redis://redis:6379/0")
_enforce_postgres_for_multi_worker(_cluster_ready(deployment_multi_instance=True, stream_bridge_type=None))
def test_declared_multi_instance_rejects_explicit_memory_sandbox_ownership():
with pytest.raises(SystemExit, match="sandbox.ownership.type='memory'"):
_enforce_postgres_for_multi_worker(_cluster_ready(deployment_multi_instance=True, ownership_type="memory"))
def test_declared_multi_instance_accepts_redis_or_inferred_sandbox_ownership():
"""An omitted ownership section is inferred from the redis stream bridge (ownership/factory.py)."""
for ownership in (None, "redis"):
_enforce_postgres_for_multi_worker(_cluster_ready(deployment_multi_instance=True, ownership_type=ownership))
def test_declared_multi_instance_rejects_single_instance_scheduler():
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_cluster_ready(deployment_multi_instance=True, scheduler_enabled=True))
msg = str(exc_info.value)
assert "each worker starts its own scheduler" in msg
assert "scheduler.multi_instance=true" in msg
assert "deployment.multi_instance=false" in msg
def test_declared_multi_instance_accepts_multi_instance_scheduler():
_enforce_postgres_for_multi_worker(_cluster_ready(deployment_multi_instance=True, scheduler_enabled=True, scheduler_multi_instance=True))
def test_declared_multi_instance_rejects_process_local_browser():
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_cluster_ready(deployment_multi_instance=True, browser_enabled=True))
msg = str(exc_info.value)
assert "process-local" in msg
assert "deployment.multi_instance=false" in msg
@pytest.mark.parametrize("value", ["1", "true", "True", "yes", "on", "replicas"])
def test_env_declaration_is_gated(monkeypatch, value):
"""DEER_FLOW_MULTI_INSTANCE is what deploy tooling sets from the replica count.
Any spelling that is not an explicit "off" counts: a typo must fail closed
(run the gate) rather than silently disable it.
"""
monkeypatch.setenv(MULTI_INSTANCE_ENV_VAR, value)
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite"))
msg = str(exc_info.value)
assert f"{MULTI_INSTANCE_ENV_VAR}={value}" in msg
assert f"Unset {MULTI_INSTANCE_ENV_VAR}" in msg, "must name the rollback knob"
@pytest.mark.parametrize("value", ["", "0", "false", "False", "no", "off", " "])
def test_falsy_env_declaration_stays_inert(monkeypatch, value):
"""A templated DEER_FLOW_MULTI_INSTANCE=false (one replica) must not trip the gate."""
monkeypatch.setenv(MULTI_INSTANCE_ENV_VAR, value)
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite", scheduler_enabled=True))
def test_env_declaration_accepts_a_fully_shared_deployment(monkeypatch):
monkeypatch.setenv(MULTI_INSTANCE_ENV_VAR, "1")
_enforce_postgres_for_multi_worker(_cluster_ready())
def test_worker_count_is_reported_over_the_declaration(monkeypatch):
"""When both apply, the refusal names the worker count: that is the knob uvicorn acts on."""
monkeypatch.setenv("GATEWAY_WORKERS", "2")
monkeypatch.setenv(MULTI_INSTANCE_ENV_VAR, "1")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite"))
assert "GATEWAY_WORKERS=2" in str(exc_info.value)
@pytest.mark.parametrize(
("declared_by", "expected_rollback"),
[
("env", f"Set GATEWAY_WORKERS=1 and unset {MULTI_INSTANCE_ENV_VAR}"),
("config", "Set GATEWAY_WORKERS=1 and set deployment.multi_instance=false"),
],
)
def test_worker_count_rollback_also_withdraws_the_declaration(monkeypatch, declared_by, expected_rollback):
"""With both knobs active, a rollback that only resets the worker count is not enough.
Following ``Set GATEWAY_WORKERS=1`` alone leaves the declaration tripping the
gate at the next start, so the operator would bounce through a second
refusal before learning about the other knob. The message names both.
"""
monkeypatch.setenv("GATEWAY_WORKERS", "2")
if declared_by == "env":
monkeypatch.setenv(MULTI_INSTANCE_ENV_VAR, "1")
config = _config_with_backend("sqlite", deployment_multi_instance=declared_by == "config")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(config)
assert expected_rollback in str(exc_info.value)
def test_offered_env_rollback_clears_the_gate(monkeypatch):
"""The remediation a refusal offers must make the next start succeed."""
monkeypatch.setenv("GATEWAY_WORKERS", "2")
monkeypatch.setenv(MULTI_INSTANCE_ENV_VAR, "1")
sqlite = _config_with_backend("sqlite")
with pytest.raises(SystemExit):
_enforce_postgres_for_multi_worker(sqlite)
# Resetting only the worker count is the half-step the message must not suggest.
monkeypatch.setenv("GATEWAY_WORKERS", "1")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(sqlite)
assert f"{MULTI_INSTANCE_ENV_VAR}=1" in str(exc_info.value)
# The full rollback the message offered clears the gate.
monkeypatch.delenv(MULTI_INSTANCE_ENV_VAR)
_enforce_postgres_for_multi_worker(sqlite)
def test_offered_config_rollback_clears_the_gate(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "2")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite", deployment_multi_instance=True))
assert "Set GATEWAY_WORKERS=1 and set deployment.multi_instance=false" in str(exc_info.value)
monkeypatch.setenv("GATEWAY_WORKERS", "1")
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite", deployment_multi_instance=False))
def test_browser_refusal_names_the_declaration_when_both_knobs_are_active(monkeypatch):
"""The browser refusal must not send the operator through the same double bounce."""
monkeypatch.setenv("GATEWAY_WORKERS", "2")
monkeypatch.setenv(MULTI_INSTANCE_ENV_VAR, "1")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_cluster_ready(browser_enabled=True))
msg = str(exc_info.value)
assert "browser" in msg
assert f"Set GATEWAY_WORKERS=1 and unset {MULTI_INSTANCE_ENV_VAR}" in msg
def test_agent_storage_warning_fires_for_a_declared_multi_instance_deployment(caplog):
with caplog.at_level("WARNING"):
_validate_agent_storage(_cluster_ready(deployment_multi_instance=True))
messages = [r.message for r in caplog.records if "not visible across workers" in r.message]
assert messages and "deployment.multi_instance=true" in messages[0]
def test_deployment_declaration_helpers(monkeypatch):
assert DeploymentConfig().multi_instance is False
assert multi_instance_declaration(None) is None
by_config = multi_instance_declaration(SimpleNamespace(deployment=DeploymentConfig(multi_instance=True)))
assert by_config is not None
assert (by_config.source, by_config.knob, by_config.rollback) == ("config", "deployment.multi_instance=true", "set deployment.multi_instance=false")
assert str(by_config) == by_config.knob
# The environment wins over config.yaml and keeps the operator's spelling.
monkeypatch.setenv(MULTI_INSTANCE_ENV_VAR, "replicas")
by_env = multi_instance_declaration(SimpleNamespace(deployment=DeploymentConfig(multi_instance=True)))
assert by_env is not None
assert (by_env.source, by_env.knob, by_env.rollback) == ("env", f"{MULTI_INSTANCE_ENV_VAR}=replicas", f"unset {MULTI_INSTANCE_ENV_VAR}")
# ---------------------------------------------------------------------------
# Login throttle storage warning (auth.local.throttle_storage)
# ---------------------------------------------------------------------------
def _with_throttle_storage(config, selector):
config.auth = SimpleNamespace(local=SimpleNamespace(throttle_storage=selector))
return config
def _throttle_warnings(caplog):
return [r.message for r in caplog.records if "auth.local.throttle_storage" in r.message]
def test_login_throttle_warning_fires_for_a_declared_multi_instance_deployment_on_memory(caplog):
"""Explicit memory counters under N replicas hand an attacker N x max_login_attempts guesses."""
from deerflow.config.auth_config import LoginThrottleStorage
with caplog.at_level("WARNING"):
_validate_login_throttle_storage(_with_throttle_storage(_cluster_ready(deployment_multi_instance=True), LoginThrottleStorage.MEMORY))
messages = _throttle_warnings(caplog)
assert messages and "deployment.multi_instance=true" in messages[0]
assert "max_login_attempts" in messages[0]
# The selector is a StrEnum; the warning must render its value, not "LoginThrottleStorage.MEMORY".
assert "auth.local.throttle_storage=memory:" in messages[0]
assert "LoginThrottleStorage" not in messages[0]
def test_login_throttle_warning_names_the_worker_variable(monkeypatch, caplog):
monkeypatch.setenv("WEB_CONCURRENCY", "2")
with caplog.at_level("WARNING"):
_validate_login_throttle_storage(_with_throttle_storage(_cluster_ready(), "memory"))
messages = _throttle_warnings(caplog)
assert messages and "WEB_CONCURRENCY=2" in messages[0]
def test_login_throttle_auto_resolves_to_the_database_under_multi_instance_without_warning(caplog):
with caplog.at_level("WARNING"):
_validate_login_throttle_storage(_with_throttle_storage(_cluster_ready(deployment_multi_instance=True), "auto"))
_validate_login_throttle_storage(_with_throttle_storage(_cluster_ready(deployment_multi_instance=True), "db"))
assert _throttle_warnings(caplog) == []
def test_login_throttle_memory_is_silent_for_a_single_instance(caplog):
with caplog.at_level("WARNING"):
_validate_login_throttle_storage(_with_throttle_storage(_config_with_backend("sqlite"), "memory"))
assert _throttle_warnings(caplog) == []
def test_login_throttle_gate_tolerates_a_config_without_an_auth_section(caplog):
with caplog.at_level("WARNING"):
_validate_login_throttle_storage(_cluster_ready(deployment_multi_instance=True))
assert _throttle_warnings(caplog) == []
# DeerMem retrieval index: a declared multi-instance deployment must keep the
# derived SQLite index off the shared memory volume.
# ---------------------------------------------------------------------------
def _memory_config(*, enabled: bool = True, manager_class: str = "deermem", **backend_config):
return SimpleNamespace(enabled=enabled, manager_class=manager_class, backend_config=backend_config)
def _retrieval_index_warnings(caplog) -> list[str]:
return [r.message for r in caplog.records if "retrieval_index_path" in r.message]
def test_declared_multi_instance_warns_when_the_retrieval_index_shares_the_memory_root(monkeypatch, caplog, tmp_path):
monkeypatch.setenv(MULTI_INSTANCE_ENV_VAR, "true")
config = _cluster_ready()
config.memory = _memory_config(storage_path=str(tmp_path))
with caplog.at_level("WARNING"):
_validate_memory_retrieval_index(config)
messages = _retrieval_index_warnings(caplog)
assert messages, "the default index location is inside storage_path and therefore on the shared volume"
assert f"{MULTI_INSTANCE_ENV_VAR}=true" in messages[0], "must name the knob that declared the topology"
assert str(tmp_path / ".retrieval") in messages[0]
assert str(tmp_path) in messages[0]
def test_declared_multi_instance_warns_when_a_relative_index_path_stays_below_storage_path(caplog, tmp_path):
config = _cluster_ready(deployment_multi_instance=True)
config.memory = _memory_config(storage_path=str(tmp_path), retrieval_index_path="pod-index")
with caplog.at_level("WARNING"):
_validate_memory_retrieval_index(config)
messages = _retrieval_index_warnings(caplog)
assert messages and "deployment.multi_instance=true" in messages[0]
assert str(tmp_path / "pod-index") in messages[0]
def test_declared_multi_instance_defaults_the_memory_root_to_runtime_home(monkeypatch, caplog, tmp_path):
"""An empty storage_path means the host injects runtime_home(), which is the shared home volume."""
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
config = _cluster_ready(deployment_multi_instance=True)
config.memory = _memory_config()
with caplog.at_level("WARNING"):
_validate_memory_retrieval_index(config)
messages = _retrieval_index_warnings(caplog)
assert messages and str(tmp_path.resolve() / ".retrieval") in messages[0]
def test_declared_multi_instance_accepts_an_instance_local_retrieval_index(caplog, tmp_path):
config = _cluster_ready(deployment_multi_instance=True)
config.memory = _memory_config(storage_path=str(tmp_path / "home"), retrieval_index_path=str(tmp_path / "pod-local"))
with caplog.at_level("WARNING"):
_validate_memory_retrieval_index(config)
assert _retrieval_index_warnings(caplog) == []
def test_retrieval_index_warning_requires_the_multi_instance_declaration(monkeypatch, caplog, tmp_path):
"""Workers of one process tree share local disk, where a shared WAL index is supported."""
monkeypatch.setenv("GATEWAY_WORKERS", "2")
config = _cluster_ready()
config.memory = _memory_config(storage_path=str(tmp_path))
with caplog.at_level("WARNING"):
_validate_memory_retrieval_index(config)
assert _retrieval_index_warnings(caplog) == []
@pytest.mark.parametrize(
"memory",
[
None,
_memory_config(enabled=False, storage_path="/shared/home"),
_memory_config(manager_class="mem0", storage_path="/shared/home"),
_memory_config(storage_path="/shared/home", retrieval_adapter=""),
_memory_config(storage_path="/shared/home", retrieval_adapter="my_pkg.retrieval:create"),
],
)
def test_retrieval_index_warning_only_covers_the_bundled_fts5_index(caplog, memory):
config = _cluster_ready(deployment_multi_instance=True)
if memory is not None:
config.memory = memory
with caplog.at_level("WARNING"):
_validate_memory_retrieval_index(config)
assert _retrieval_index_warnings(caplog) == []
# ---------------------------------------------------------------------------
# At-rest credentials key (DEER_FLOW_CREDENTIALS_KEY)
# ---------------------------------------------------------------------------
_VALID_KEY = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE="
def _with_channel_connections(config, *, enabled: bool = True):
config.channel_connections = SimpleNamespace(enabled=enabled)
return config
def test_credentials_key_consumers_name_the_enabled_features():
assert credentials_key_consumers(_cluster_ready()) == [], "a config without the section uses no key"
assert credentials_key_consumers(_with_channel_connections(_cluster_ready(), enabled=False)) == []
assert credentials_key_consumers(_with_channel_connections(_cluster_ready())) == ["channel_connections.enabled=true"]
def _with_mcp_oauth(config, *, grant_type: str = "refresh_token", oauth_enabled: bool = True, server_enabled: bool = True):
oauth = {"enabled": oauth_enabled, "token_url": "https://auth.example.invalid/token", "grant_type": grant_type, "refresh_token": "seed", "client_id": "client", "client_secret": "secret"}
config.extensions = ExtensionsConfig.model_validate({"mcpServers": {"reports": {"enabled": server_enabled, "type": "http", "url": "https://mcp.example.invalid/mcp", "oauth": oauth}}})
return config
def test_rotating_mcp_oauth_servers_store_tokens_under_the_credentials_key():
"""mcp_oauth_tokens keeps refresh_token connections' tokens encrypted; client_credentials stay in process."""
assert credentials_key_consumers(_with_mcp_oauth(_cluster_ready())) == ["mcpServers.reports.oauth.enabled=true"]
assert credentials_key_consumers(_with_mcp_oauth(_cluster_ready(), grant_type="client_credentials")) == []
assert credentials_key_consumers(_with_mcp_oauth(_cluster_ready(), oauth_enabled=False)) == []
assert credentials_key_consumers(_with_mcp_oauth(_cluster_ready(), server_enabled=False)) == []
def test_declared_multi_instance_with_rotating_mcp_oauth_requires_the_credentials_key(monkeypatch):
"""A peer that cannot decrypt the stored chain falls back to the consumed seed and gets invalid_grant."""
with pytest.raises(SystemExit) as exc_info:
_enforce_credentials_key(_with_mcp_oauth(_cluster_ready(deployment_multi_instance=True)))
msg = str(exc_info.value)
assert CREDENTIALS_KEY_ENV_VAR in msg
assert "mcpServers.reports.oauth.enabled=true" in msg
assert "or set mcpServers.reports.oauth.enabled=false" in msg
monkeypatch.setenv(CREDENTIALS_KEY_ENV_VAR, _VALID_KEY)
_enforce_credentials_key(_with_mcp_oauth(_cluster_ready(deployment_multi_instance=True)))
def test_declared_multi_instance_with_channel_connections_requires_the_credentials_key():
"""Each instance would otherwise generate its own key file and could not read its peers' credentials."""
with pytest.raises(SystemExit) as exc_info:
_enforce_credentials_key(_with_channel_connections(_cluster_ready(deployment_multi_instance=True)))
msg = str(exc_info.value)
assert CREDENTIALS_KEY_ENV_VAR in msg
assert "deployment.multi_instance=true" in msg
assert "channel_connections.enabled=true" in msg
assert GENERATE_KEY_COMMAND in msg, "the refusal must say how to generate a key"
assert "Helm" in msg
assert "deployment.multi_instance=false" in msg, "must name the rollback knob"
assert "or set channel_connections.enabled=false" in msg, "must name the setting that turns the consumer off"
def test_env_declared_multi_instance_names_the_env_knob(monkeypatch):
monkeypatch.setenv(MULTI_INSTANCE_ENV_VAR, "1")
with pytest.raises(SystemExit) as exc_info:
_enforce_credentials_key(_with_channel_connections(_cluster_ready()))
msg = str(exc_info.value)
assert f"{MULTI_INSTANCE_ENV_VAR}=1" in msg
assert f"unset {MULTI_INSTANCE_ENV_VAR}" in msg
def test_declared_multi_instance_accepts_a_shared_credentials_key(monkeypatch):
monkeypatch.setenv(CREDENTIALS_KEY_ENV_VAR, _VALID_KEY)
_enforce_credentials_key(_with_channel_connections(_cluster_ready(deployment_multi_instance=True)))
@pytest.mark.parametrize("channel_connections", [None, False], ids=["section-absent", "disabled"])
def test_declared_multi_instance_without_key_consumers_keeps_booting(channel_connections):
"""Existing multi-instance deployments that store no credentials must not need the key."""
config = _cluster_ready(deployment_multi_instance=True)
if channel_connections is not None:
_with_channel_connections(config, enabled=channel_connections)
_enforce_credentials_key(config)
def test_multi_worker_without_a_declaration_shares_the_generated_key_file(monkeypatch):
"""Workers of one process tree share base_dir, where the exclusive-create key file converges."""
monkeypatch.setenv("GATEWAY_WORKERS", "4")
_enforce_credentials_key(_with_channel_connections(_cluster_ready()))
def test_single_instance_without_a_credentials_key_keeps_booting():
_enforce_credentials_key(_with_channel_connections(_config_with_backend("sqlite")))
@pytest.mark.parametrize("declared", [False, True], ids=["single-instance", "multi-instance"])
@pytest.mark.parametrize("value", ["not-a-fernet-key", f"{_VALID_KEY},second-entry-is-garbage"], ids=["garbage", "bad-rotation-entry"])
def test_a_malformed_credentials_key_is_refused_without_echoing_it(monkeypatch, declared, value):
monkeypatch.setenv(CREDENTIALS_KEY_ENV_VAR, value)
with pytest.raises(SystemExit) as exc_info:
_enforce_credentials_key(_with_channel_connections(_cluster_ready(deployment_multi_instance=declared)))
msg = str(exc_info.value)
assert CREDENTIALS_KEY_ENV_VAR in msg
assert "not-a-fernet-key" not in msg and "second-entry-is-garbage" not in msg and _VALID_KEY not in msg
@pytest.mark.asyncio
async def test_langgraph_runtime_enforces_the_credentials_key_before_persistence_setup():
init_engine_from_config = AsyncMock(name="init_engine_from_config")
@asynccontextmanager
async def _noop_stream_bridge(_config):
yield MagicMock()
with (
patch("deerflow.persistence.engine.init_engine_from_config", init_engine_from_config),
patch("deerflow.runtime.make_stream_bridge", side_effect=_noop_stream_bridge) as make_stream_bridge,
):
startup_config = _with_channel_connections(_cluster_ready(deployment_multi_instance=True))
with pytest.raises(SystemExit, match=CREDENTIALS_KEY_ENV_VAR):
async with langgraph_runtime(FastAPI(), startup_config):
pass
init_engine_from_config.assert_not_called()
make_stream_bridge.assert_not_called()
# ---------------------------------------------------------------------------
# IM channel leader lease clause (channels.leader_lease)
# ---------------------------------------------------------------------------
def _channels_cluster(*, leader_lease=None, channels=None, connections=None, **overrides):
"""A fully shared Postgres deployment plus the channel-facing config a gate double needs.
``channels`` is the raw ``channels`` mapping that lives in ``AppConfig.model_extra``;
``connections`` is the ``channel_connections`` section.
"""
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
config = _cluster_ready(**overrides)
section = dict(channels or {})
if leader_lease is not None:
section["leader_lease"] = leader_lease
config.model_extra = {"channels": section} if section else {}
config.channel_connections = ChannelConnectionsConfig.model_validate(connections) if connections is not None else ChannelConnectionsConfig()
return config
def test_multi_worker_rejects_disabled_leader_lease_with_an_enabled_channel(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "2")
config = _channels_cluster(leader_lease={"enabled": False}, channels={"telegram": {"enabled": True, "bot_token": "x"}, "discord": {"enabled": True}, "slack": {"enabled": False}})
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(config)
msg = str(exc_info.value)
assert "GATEWAY_WORKERS=2" in msg
assert "channels.leader_lease.enabled=false" in msg
assert "discord, telegram" in msg # sorted, only the enabled ones
assert "slack" not in msg
assert "409" in msg
assert "set GATEWAY_WORKERS=1" in msg
_assert_lease_is_offered_as_the_remedy(msg)
@pytest.mark.parametrize("leader_lease", [None, {}, {"enabled": "auto"}, {"enabled": True}, {"enabled": True, "lease_seconds": 15, "grace_seconds": 5}])
def test_multi_worker_accepts_auto_or_enabled_leader_lease_with_channels(monkeypatch, leader_lease):
monkeypatch.setenv("GATEWAY_WORKERS", "2")
_enforce_postgres_for_multi_worker(_channels_cluster(leader_lease=leader_lease, channels={"telegram": {"enabled": True}}))
def test_multi_worker_accepts_disabled_leader_lease_without_enabled_channels(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "2")
_enforce_postgres_for_multi_worker(_channels_cluster(leader_lease={"enabled": False}))
_enforce_postgres_for_multi_worker(_channels_cluster(leader_lease={"enabled": False}, channels={"telegram": {"enabled": False, "bot_token": "x"}, "langgraph_url": "http://gw:8001/api", "session": {"assistant_id": "lead_agent"}}))
def test_github_is_webhook_driven_and_needs_no_leader_lease(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "2")
_enforce_postgres_for_multi_worker(_channels_cluster(leader_lease={"enabled": False}, channels={"github": {"enabled": True}}))
def test_declared_multi_instance_rejects_disabled_leader_lease_for_ui_connectable_providers():
"""channel_connections providers get credentials at runtime, so they count even without a channels block."""
config = _channels_cluster(
deployment_multi_instance=True,
leader_lease={"enabled": False},
connections={"enabled": True, "telegram": {"enabled": True, "bot_username": "bot"}, "slack": {"enabled": False}},
)
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(config)
msg = str(exc_info.value)
assert "deployment.multi_instance=true" in msg
assert "IM channels telegram are enabled" in msg
assert "deployment.multi_instance=false and run a single Gateway instance" in msg
assert "channel_connections.<provider>.enabled=false" in msg
_assert_lease_is_offered_as_the_remedy(msg)
def _assert_lease_is_offered_as_the_remedy(msg: str) -> None:
"""The leader lease now arbitrates each provider's transport, so it is the first remedy.
While the arbitration had not shipped, ``auto``/``true`` only bypassed the
refusal and the message had to say so (review on #6746). With
``channel_leases`` in place the refusal leads with the setting that keeps
every instance running, then the single-instance and channel-disable fallbacks.
"""
remedies = msg.split("diverges). ", 1)[1]
assert remedies.startswith("Set channels.leader_lease.enabled to auto (the default) or true so one instance holds each provider's connection"), remedies
assert "reserved" not in msg and "not a remedy" not in msg
assert "alternatively " in remedies and "or disable those channels" in remedies
def test_disabled_channel_connections_master_switch_does_not_count_providers():
config = _channels_cluster(deployment_multi_instance=True, leader_lease={"enabled": False}, connections={"enabled": False, "telegram": {"enabled": True}})
_enforce_postgres_for_multi_worker(config)
def test_single_instance_ignores_a_disabled_leader_lease():
"""Without a multi-process signal the clause is inert, like every other clause of the gate."""
_enforce_postgres_for_multi_worker(_channels_cluster(leader_lease={"enabled": False}, channels={"telegram": {"enabled": True}}))
def test_single_instance_explicit_leader_lease_needs_a_sql_database():
"""``enabled: true`` turns the lease on without peers; its table needs sqlite or postgres."""
config = _channels_cluster(leader_lease={"enabled": True}, channels={"telegram": {"enabled": True}})
config.database = DatabaseConfig(backend="memory")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(config)
msg = str(exc_info.value)
assert "channels.leader_lease.enabled=true" in msg
assert "database.backend is 'memory'" in msg
assert "IM channels telegram are enabled" in msg
@pytest.mark.parametrize("backend", ["sqlite", "postgres"])
def test_single_instance_explicit_leader_lease_runs_on_a_sql_database(backend):
config = _channels_cluster(leader_lease={"enabled": True}, channels={"telegram": {"enabled": True}})
config.database = DatabaseConfig(backend=backend)
_enforce_postgres_for_multi_worker(config)
@pytest.mark.parametrize(
("leader_lease", "channels"),
[
({"enabled": "auto"}, {"telegram": {"enabled": True}}), # auto stays off on a single instance
({"enabled": True}, {"telegram": {"enabled": False}}), # nothing for the lease to arbitrate
({"enabled": True}, {"github": {"enabled": True}}), # webhooks are never arbitrated
],
)
def test_single_instance_memory_database_passes_when_the_lease_has_nothing_to_hold(leader_lease, channels):
config = _channels_cluster(leader_lease=leader_lease, channels=channels)
config.database = DatabaseConfig(backend="memory")
_enforce_postgres_for_multi_worker(config)
@pytest.mark.parametrize("block", [{"enabled": "maybe"}, {"lease_seconds": 0}, {"grace_seconds": -1}, {"enable": False}, "auto"])
def test_malformed_leader_lease_is_refused_on_every_topology(block):
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_channels_cluster(leader_lease=block))
assert "channels.leader_lease" in str(exc_info.value)
def test_leader_lease_clause_runs_after_the_postgres_clause(monkeypatch):
"""The lease needs the shared database; a SQLite deployment is told about Postgres first."""
monkeypatch.setenv("GATEWAY_WORKERS", "2")
config = _channels_cluster(leader_lease={"enabled": False}, channels={"telegram": {"enabled": True}})
config.database = DatabaseConfig(backend="sqlite")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(config)
msg = str(exc_info.value)
assert "postgres" in msg.lower()
assert "leader_lease" not in msg
def test_gate_doubles_without_channel_attributes_still_pass(monkeypatch):
"""The existing SimpleNamespace doubles carry neither model_extra nor channel_connections."""
monkeypatch.setenv("GATEWAY_WORKERS", "2")
_enforce_postgres_for_multi_worker(_cluster_ready())