mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* fix(gateway): gate declared multi-instance deployments and require the redis stream bridge GATEWAY_WORKERS / WEB_CONCURRENCY only count the uvicorn workers of one process tree, so a Kubernetes Deployment with replicas > 1 (one worker per Pod) passed every multi-worker startup check while each Pod's startup orphan reconciliation wrote the other Pods' lease-less runs off as crashed on every rolling update. Add `deployment.multi_instance` as an explicit declaration that runs the same gate, plus a DEER_FLOW_MULTI_INSTANCE env override so deploy tooling can set the declaration from its replica count without editing config.yaml (wiring the Helm chart to export it is a separate change). Both the worker count and the declaration now also require a Redis stream bridge and refuse an explicit memory sandbox ownership store, since the memory bridge and ownership store are process-local. The agent-storage divergence warning, the inbound webhook dedupe warning and the WeChat QR-login guard honor the same declaration. * docs(changelog): link the multi-instance gate entry to #6328 * fix(ci): bump chart config_version to 53 and keep AGENTS.md under the size limits config.example.yaml moved to config_version 53 for the new deployment section, so the Helm chart's embedded config and its README example follow. The multi-instance notes in backend/AGENTS.md and backend/app/gateway/AGENTS.md shrink to one line each: main already sits within 55 bytes of the per-file hard limit for the gateway guide and within 163 bytes of the chain hard limit under backend/, and the full contract lives in backend/docs/CONFIGURATION.md. * fix(gateway): name every active knob in the multi-process rollback When GATEWAY_WORKERS > 1 and a multi-instance declaration are both active, the refusal offered only "Set GATEWAY_WORKERS=1", so an operator who followed it bounced through a second refusal on DEER_FLOW_MULTI_INSTANCE / deployment.multi_instance at the next start. The rollback now withdraws both knobs, and the browser-tools refusal takes the same path instead of the worker-count-only message. multi_instance_declaration() returns a frozen MultiInstanceDeclaration (source, value) with knob/rollback properties instead of a display string, so deps.py no longer branches on message prose. Tests pin the combined rollback text and that following it actually clears the gate. * docs(gateway): keep the gateway AGENTS.md under the size hard limit after merging main main grew backend/app/gateway/AGENTS.md by 30 bytes, which left it 25 bytes under the 49152-byte hard limit; the one-sentence multi-instance note from this branch pushed the merged file to 49164. Shorten that sentence by 18 bytes so scripts/check_agent_guidance.py passes on the merge result. * ci: re-run checks after test_stop_cancels_a_hung_driver_poll flaked on shard 4 tests/test_mcp_task_service.py::test_stop_cancels_a_hung_driver_poll timed out once on backend-unit-tests (4) for 2c16206ac. The MCP task service and its test are identical to main, main's run for cfe2023df passed, and 15 consecutive local runs pass; the test gives service.stop() a 1s budget while cancellation drain allows 5s, so a loaded runner can exceed it. Fork contributors cannot re-run a job, hence this empty commit. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
343 lines
15 KiB
Python
343 lines
15 KiB
Python
"""Unit tests for the inbound dedupe store (issue #4120).
|
|
|
|
Covers config resolution (memory / postgres / auto), the multi-worker
|
|
misconfiguration WARNING, and the Postgres store's atomic insert / fail-open
|
|
behavior with a mocked session factory.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from app.channels.dedupe_store import (
|
|
MemoryInboundDedupeStore,
|
|
PostgresInboundDedupeStore,
|
|
make_inbound_dedupe_store,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_worker_env(monkeypatch):
|
|
"""Keep config-resolution tests independent of the invoking shell.
|
|
|
|
``WEB_CONCURRENCY`` is a common ambient convention on uvicorn/gunicorn/PaaS hosts,
|
|
and it now decides whether a deployment counts as multi-worker here too. The tests
|
|
that exercise that path set the spelling they mean to exercise.
|
|
"""
|
|
monkeypatch.delenv("GATEWAY_WORKERS", raising=False)
|
|
monkeypatch.delenv("WEB_CONCURRENCY", raising=False)
|
|
monkeypatch.delenv("DEER_FLOW_MULTI_INSTANCE", raising=False)
|
|
|
|
|
|
class _FakeDedupe:
|
|
def __init__(self, backend: str) -> None:
|
|
self.backend = backend # plain string, mimics DedupeStorageBackend.value
|
|
|
|
|
|
class _FakeDb:
|
|
def __init__(self, backend: str) -> None:
|
|
self.backend = backend
|
|
|
|
|
|
class _FakeApp:
|
|
def __init__(self, dedupe: str, db: str) -> None:
|
|
self.dedupe_storage = _FakeDedupe(dedupe)
|
|
self.database = _FakeDb(db)
|
|
|
|
|
|
def test_factory_default_is_memory():
|
|
assert isinstance(make_inbound_dedupe_store(None), MemoryInboundDedupeStore)
|
|
|
|
|
|
def test_factory_explicit_memory_is_memory():
|
|
app = _FakeApp("memory", "sqlite")
|
|
assert isinstance(make_inbound_dedupe_store(app), MemoryInboundDedupeStore)
|
|
|
|
|
|
def test_factory_auto_with_sqlite_resolves_memory():
|
|
app = _FakeApp("auto", "sqlite")
|
|
assert isinstance(make_inbound_dedupe_store(app), MemoryInboundDedupeStore)
|
|
|
|
|
|
def test_factory_auto_with_postgres_and_multi_worker_resolves_postgres_store(monkeypatch):
|
|
# 'auto' shares Postgres state whenever the DB is Postgres, even with a single
|
|
# worker: the common K8s case is N pods x 1 worker sharing one DB, and gating on
|
|
# the in-pod worker count would silently disable cross-pod dedupe there. See the
|
|
# sibling ..._regardless_of_workers test for the documented behavior.
|
|
monkeypatch.setenv("GATEWAY_WORKERS", "2")
|
|
app = _FakeApp("auto", "postgres")
|
|
assert isinstance(make_inbound_dedupe_store(app), PostgresInboundDedupeStore)
|
|
|
|
|
|
def test_factory_auto_with_postgres_resolves_postgres_store_regardless_of_workers():
|
|
# 'auto' shares Postgres dedupe state whenever the DB is Postgres, including
|
|
# the common Kubernetes case of many replicas each running a single worker
|
|
# (GATEWAY_WORKERS=1) that still share one Postgres DB. Cross-pod dedupe must
|
|
# not depend on the in-pod worker count (issue #4120).
|
|
app = _FakeApp("auto", "postgres")
|
|
assert isinstance(make_inbound_dedupe_store(app), PostgresInboundDedupeStore)
|
|
|
|
|
|
def test_factory_explicit_postgres_resolves_postgres_store():
|
|
app = _FakeApp("postgres", "postgres")
|
|
assert isinstance(make_inbound_dedupe_store(app), PostgresInboundDedupeStore)
|
|
|
|
|
|
def test_factory_explicit_postgres_without_postgres_db_falls_back_to_memory_and_warns(monkeypatch, caplog):
|
|
import logging
|
|
|
|
# Explicit 'postgres' but the application DB is not Postgres: must warn and
|
|
# fall back to the in-process store rather than silently disabling dedupe.
|
|
app = _FakeApp("postgres", "sqlite")
|
|
with caplog.at_level(logging.WARNING):
|
|
store = make_inbound_dedupe_store(app)
|
|
assert isinstance(store, MemoryInboundDedupeStore)
|
|
assert any("dedupe_storage=postgres requires database.backend='postgres'" in r.message for r in caplog.records)
|
|
|
|
|
|
def test_factory_warns_when_memory_explicit_under_multi_worker(monkeypatch, caplog):
|
|
import logging
|
|
|
|
monkeypatch.setenv("GATEWAY_WORKERS", "2")
|
|
app = _FakeApp("memory", "sqlite")
|
|
with caplog.at_level(logging.WARNING):
|
|
store = make_inbound_dedupe_store(app)
|
|
assert isinstance(store, MemoryInboundDedupeStore)
|
|
assert any("dedupe_storage=memory with GATEWAY_WORKERS>1" in r.message for r in caplog.records)
|
|
|
|
|
|
def test_factory_warns_when_memory_explicit_under_a_declared_multi_instance_deployment(monkeypatch, caplog):
|
|
"""Kubernetes replicas run one worker each and declare their peers instead of a worker count."""
|
|
import logging
|
|
|
|
monkeypatch.setenv("DEER_FLOW_MULTI_INSTANCE", "1")
|
|
app = _FakeApp("memory", "sqlite")
|
|
with caplog.at_level(logging.WARNING):
|
|
store = make_inbound_dedupe_store(app)
|
|
assert isinstance(store, MemoryInboundDedupeStore)
|
|
assert any("dedupe_storage=memory with DEER_FLOW_MULTI_INSTANCE=1" in r.message for r in caplog.records)
|
|
|
|
|
|
def test_factory_warns_when_auto_resolves_memory_under_a_declared_multi_instance_deployment(monkeypatch, caplog):
|
|
import logging
|
|
|
|
monkeypatch.setenv("DEER_FLOW_MULTI_INSTANCE", "true")
|
|
app = _FakeApp("auto", "sqlite")
|
|
with caplog.at_level(logging.WARNING):
|
|
store = make_inbound_dedupe_store(app)
|
|
assert isinstance(store, MemoryInboundDedupeStore)
|
|
assert any("Multi-worker deployment detected but dedupe_storage=auto" in r.message for r in caplog.records)
|
|
|
|
|
|
def test_factory_warns_when_auto_resolves_memory_under_multi_worker(monkeypatch, caplog):
|
|
import logging
|
|
|
|
monkeypatch.setenv("GATEWAY_WORKERS", "2")
|
|
app = _FakeApp("auto", "sqlite")
|
|
with caplog.at_level(logging.WARNING):
|
|
store = make_inbound_dedupe_store(app)
|
|
assert isinstance(store, MemoryInboundDedupeStore)
|
|
assert any("Multi-worker deployment detected but dedupe_storage=auto" in r.message for r in caplog.records)
|
|
|
|
|
|
def test_factory_warns_under_the_uvicorn_worker_fallback(monkeypatch, caplog):
|
|
import logging
|
|
|
|
# backend/Dockerfile and scripts/serve.sh start uvicorn with no --workers, so
|
|
# WEB_CONCURRENCY is what decides how many processes share no dedupe state.
|
|
monkeypatch.delenv("GATEWAY_WORKERS", raising=False)
|
|
monkeypatch.setenv("WEB_CONCURRENCY", "2")
|
|
app = _FakeApp("memory", "sqlite")
|
|
with caplog.at_level(logging.WARNING):
|
|
store = make_inbound_dedupe_store(app)
|
|
assert isinstance(store, MemoryInboundDedupeStore)
|
|
assert any("dedupe_storage=memory with WEB_CONCURRENCY>1" in r.message for r in caplog.records)
|
|
|
|
|
|
def test_factory_warns_when_an_unparsable_knob_hides_the_fallback_count(monkeypatch, caplog):
|
|
import logging
|
|
|
|
# GATEWAY_WORKERS never reaches uvicorn on the launchers that pass no --workers, so
|
|
# an unusable value there must not report the deployment as single-worker while
|
|
# WEB_CONCURRENCY still starts the processes.
|
|
monkeypatch.setenv("GATEWAY_WORKERS", "abc")
|
|
monkeypatch.setenv("WEB_CONCURRENCY", "4")
|
|
app = _FakeApp("memory", "sqlite")
|
|
with caplog.at_level(logging.WARNING):
|
|
make_inbound_dedupe_store(app)
|
|
assert any("dedupe_storage=memory with WEB_CONCURRENCY>1" in r.message for r in caplog.records)
|
|
|
|
|
|
def test_factory_prefers_the_documented_knob_when_both_are_set(monkeypatch, caplog):
|
|
import logging
|
|
|
|
# docker-compose forwards GATEWAY_WORKERS as --workers, which outranks the
|
|
# WEB_CONCURRENCY uvicorn would otherwise read.
|
|
monkeypatch.setenv("GATEWAY_WORKERS", "1")
|
|
monkeypatch.setenv("WEB_CONCURRENCY", "4")
|
|
app = _FakeApp("memory", "sqlite")
|
|
with caplog.at_level(logging.WARNING):
|
|
make_inbound_dedupe_store(app)
|
|
assert not any("dedupe_storage=memory with" in r.message for r in caplog.records)
|
|
|
|
|
|
def test_factory_treats_a_blank_gateway_workers_as_unset(monkeypatch, caplog):
|
|
import logging
|
|
|
|
# The compose command spells the fallback ${GATEWAY_WORKERS:-1}, so an exported
|
|
# empty value means "unset" rather than "no workers".
|
|
monkeypatch.setenv("GATEWAY_WORKERS", " ")
|
|
monkeypatch.setenv("WEB_CONCURRENCY", "3")
|
|
app = _FakeApp("auto", "sqlite")
|
|
with caplog.at_level(logging.WARNING):
|
|
make_inbound_dedupe_store(app)
|
|
assert any("Multi-worker deployment detected" in r.message for r in caplog.records)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PostgresInboundDedupeStore (unit, mocked session factory)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _fake_session_factory(*, insert_rowcount: int = 1, execute_raises: BaseException | None = None):
|
|
"""Build a fake async session factory whose single session returns the
|
|
given upsert result (fetchone -> a row when admitted, None on live conflict)
|
|
or raises for ``execute``.
|
|
|
|
``try_record`` issues up to two executes: (0) the conditional upsert
|
|
``INSERT ... ON CONFLICT DO UPDATE ... RETURNING`` (admitted -> row returned)
|
|
and, only when admitted, (1) the cross-table lazy cleanup ``DELETE``. On a
|
|
live conflict only the upsert runs (no row returned -> no cleanup).
|
|
"""
|
|
insert_result = MagicMock()
|
|
insert_result.rowcount = insert_rowcount
|
|
# A truthy fetchone means the key was admitted (new delivery or an expired row
|
|
# re-admitted); None means a live conflict (duplicate). Mirrors the RETURNING
|
|
# channel contract.
|
|
insert_result.fetchone.return_value = ("c",)
|
|
session = AsyncMock()
|
|
if execute_raises is not None:
|
|
session.execute = AsyncMock(side_effect=execute_raises)
|
|
else:
|
|
session.execute = AsyncMock(
|
|
side_effect=[
|
|
insert_result, # conditional upsert result (fetchone -> row when admitted)
|
|
MagicMock(), # lazy cleanup DELETE result (only when admitted)
|
|
]
|
|
)
|
|
begin_cm = AsyncMock()
|
|
begin_cm.__aenter__ = AsyncMock(return_value=None)
|
|
begin_cm.__aexit__ = AsyncMock(return_value=False)
|
|
session.begin = MagicMock(return_value=begin_cm)
|
|
|
|
factory_cm = AsyncMock()
|
|
factory_cm.__aenter__ = AsyncMock(return_value=session)
|
|
factory_cm.__aexit__ = AsyncMock(return_value=False)
|
|
factory = MagicMock()
|
|
factory.return_value = factory_cm
|
|
return factory, session
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_postgres_try_record_new_then_conflict():
|
|
factory, session = _fake_session_factory(insert_rowcount=1)
|
|
store = PostgresInboundDedupeStore(session_factory=factory)
|
|
key = ("github", "repo", "repo", "d1:uA:agentX")
|
|
# First delivery is inserted -> proceed (not a duplicate).
|
|
assert await store.try_record(key) is False
|
|
# Redelivery (same key) on a still-live row: ON CONFLICT -> DO UPDATE WHERE
|
|
# fails -> no row returned -> duplicate -> drop. Only the upsert runs.
|
|
session.execute.side_effect = [MagicMock(fetchone=MagicMock(return_value=None))]
|
|
assert await store.try_record(key) is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_postgres_try_record_uses_atomic_on_conflict_and_lazy_cleanup():
|
|
factory, session = _fake_session_factory()
|
|
store = PostgresInboundDedupeStore(session_factory=factory)
|
|
await store.try_record(("slack", "T1", "C1", "123.456"))
|
|
# call 0: conditional upsert (INSERT ... ON CONFLICT DO UPDATE ... WHERE TTL);
|
|
# call 1: lazy cross-table cleanup (only on admit).
|
|
upsert_sql = session.execute.call_args_list[0].args[0].text
|
|
cleanup_sql = session.execute.call_args_list[1].args[0].text
|
|
assert "ON CONFLICT" in upsert_sql
|
|
assert "DO UPDATE SET first_seen = now()" in upsert_sql
|
|
assert "first_seen < now()" in upsert_sql # TTL reclamation condition
|
|
assert "RETURNING" in upsert_sql
|
|
assert "make_interval" in cleanup_sql
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_postgres_try_record_reclaims_expired_unreleased_row():
|
|
# Reproduces the P1 defect: a key's row has passed the TTL but was never
|
|
# released (e.g. a crashed run). With the conditional upsert, the conflict
|
|
# fires DO UPDATE (WHERE first_seen < TTL is true), refreshes first_seen and
|
|
# RETURNS the row, so the redelivery is re-admitted (proceed, not dropped).
|
|
factory, session = _fake_session_factory()
|
|
store = PostgresInboundDedupeStore(session_factory=factory)
|
|
key = ("slack", "T1", "C1", "123.456")
|
|
# The upsert result returns a row, meaning the expired row was re-admitted
|
|
# (proceed, not a duplicate).
|
|
assert await store.try_record(key) is False
|
|
|
|
upsert_sql = session.execute.call_args_list[0].args[0].text
|
|
# The reclaim is part of the atomic upsert: a conditional DO UPDATE gated on
|
|
# the TTL, not a separate PK-scoped DELETE.
|
|
assert "ON CONFLICT" in upsert_sql
|
|
assert "DO UPDATE SET first_seen = now()" in upsert_sql
|
|
assert "first_seen < now() - make_interval" in upsert_sql
|
|
# Exactly two executes: the upsert (admitted/refreshed=True) + lazy cleanup.
|
|
assert len(session.execute.call_args_list) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_postgres_try_record_alive_row_still_deduped_without_cleanup():
|
|
# A still-live (not expired) row for this key must remain a duplicate, and
|
|
# because no row is returned the lazy cleanup is skipped (only 1 execute).
|
|
factory, session = _fake_session_factory()
|
|
store = PostgresInboundDedupeStore(session_factory=factory)
|
|
key = ("slack", "T1", "C1", "123.456")
|
|
session.execute.side_effect = [
|
|
MagicMock(fetchone=MagicMock(return_value=None)), # upsert: live conflict, no row
|
|
]
|
|
assert await store.try_record(key) is True
|
|
assert len(session.execute.call_args_list) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_postgres_try_record_fail_open_on_exception():
|
|
factory, _ = _fake_session_factory(execute_raises=RuntimeError("db down"))
|
|
store = PostgresInboundDedupeStore(session_factory=factory)
|
|
# Storage error must NOT drop the webhook: fail open = proceed (return False).
|
|
assert await store.try_record(("discord", "G1", "C1", "111")) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_postgres_release_deletes_key():
|
|
factory, session = _fake_session_factory()
|
|
store = PostgresInboundDedupeStore(session_factory=factory)
|
|
await store.release(("telegram", "chat1", "chat1", "55"))
|
|
sql = session.execute.call_args_list[0].args[0].text
|
|
assert "DELETE FROM webhook_deliveries WHERE channel = " in sql
|
|
assert "AND message_id = " in sql
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_postgres_try_record_fail_open_when_no_session_factory(monkeypatch):
|
|
# When Postgres is selected but no session factory is available, the store
|
|
# must fail open (allow the message) rather than crash startup/handling.
|
|
import deerflow.persistence.engine as engine_mod
|
|
|
|
monkeypatch.setattr(engine_mod, "get_session_factory", lambda: None)
|
|
store = PostgresInboundDedupeStore() # no injected factory -> resolved lazily
|
|
# No DB available must NOT drop the message: fail open = proceed (return False).
|
|
assert await store.try_record(("discord", "G1", "C1", "111")) is False
|
|
|
|
|
|
def test_factory_resolves_postgres_store_when_db_is_postgres():
|
|
app = _FakeApp("postgres", "postgres")
|
|
store = make_inbound_dedupe_store(app)
|
|
assert isinstance(store, PostgresInboundDedupeStore)
|