deer-flow/backend/tests/test_run_producer_cutover.py
spud 21175ba46f
fix(runtime): persistently fence stale run event producers (#7619)
* fix(runtime): fence revoked run event producers

* fix(runtime): reduce off-mode producer guard locking

* docs: keep inherited guidance within size budget

* chore(runtime): remove development phase labels

* fix(persistence): normalize libpq SSL options for asyncpg connections

* docs(runtime): clarify producer fencing behavior in off mode

* test(persistence): align migration TLS probes with channel-binding rejection

* fix(runtime): authorize fenced Gateway history seeding

* fix(scheduler): preserve takeover identity in recovery publication

* fix(scheduler): report unconfirmed recovery publication

* fix(runtime): preserve history messages before the first human turn

* fix(runtime): clarify run creation failure diagnostic

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-02 15:26:42 +08:00

177 lines
8.2 KiB
Python

"""Strict fencing is a durable, executable cutover, not a hot config toggle."""
import os
from uuid import uuid4
import pytest
import pytest_asyncio
from sqlalchemy import text
from sqlalchemy.exc import DBAPIError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from support.postgres import asyncpg_test_url
from deerflow.config.run_events_config import RunEventsConfig
from deerflow.persistence.base import Base
from deerflow.persistence.run.sql import RunRepository
from deerflow.runtime.events.store.db import DbRunEventStore
@pytest_asyncio.fixture
async def cutover_db():
uri = os.environ.get("TEST_POSTGRES_URI")
if not uri:
pytest.skip("requires TEST_POSTGRES_URI")
import deerflow.persistence.models # noqa: F401
schema = "cutover_" + uuid4().hex
engine = create_async_engine(asyncpg_test_url(uri), pool_size=1, max_overflow=0, connect_args={"server_settings": {"search_path": schema, "application_name": schema}})
async with engine.begin() as conn:
await conn.execute(text(f'CREATE SCHEMA "{schema}"'))
await conn.run_sync(Base.metadata.create_all)
try:
yield engine, async_sessionmaker(engine, expire_on_commit=False), schema
finally:
async with engine.begin() as conn:
await conn.execute(text(f'DROP SCHEMA "{schema}" CASCADE'))
await engine.dispose()
def test_strict_requires_db_backend_and_explicit_cutover():
with pytest.raises(ValueError):
RunEventsConfig(backend="memory", producer_fencing="strict", producer_cutover_id="upgrade-1")
with pytest.raises(ValueError):
RunEventsConfig(backend="db", producer_fencing="strict")
@pytest.mark.asyncio
async def test_unaware_sql_writer_is_rejected_after_cutover(cutover_db):
from deerflow.persistence.run.producer import activate_producer_fencing
engine, sf, _ = cutover_db
runs = RunRepository(sf)
await runs.put("run", thread_id="thread", user_id="alice", status="success")
await activate_producer_fencing(sf, cutover_id="upgrade-1")
with pytest.raises(DBAPIError):
async with engine.begin() as conn:
await conn.execute(text("INSERT INTO run_events(thread_id,run_id,user_id,event_type,category,content,event_metadata,seq,created_at) VALUES ('thread','run','alice','middleware:old','trace','','{}',1,clock_timestamp())"))
async with engine.connect() as conn:
assert await conn.scalar(text("SELECT count(*) FROM run_events")) == 0
with pytest.raises(RuntimeError):
await DbRunEventStore(sf).validate_producer_fencing()
with pytest.raises(RuntimeError):
await DbRunEventStore(sf, producer_fencing="strict", producer_cutover_id="wrong").validate_producer_fencing()
await DbRunEventStore(sf, producer_fencing="strict", producer_cutover_id="upgrade-1").validate_producer_fencing()
@pytest.mark.asyncio
async def test_cutover_refuses_live_run(cutover_db):
from deerflow.persistence.run.producer import activate_producer_fencing
_, sf, _ = cutover_db
await RunRepository(sf).put("run", thread_id="thread", user_id="alice", status="running")
with pytest.raises(RuntimeError, match="active"):
await activate_producer_fencing(sf, cutover_id="upgrade-1")
@pytest.mark.asyncio
async def test_cutover_refuses_other_database_sessions(cutover_db):
from deerflow.persistence.run.producer import activate_producer_fencing
_, sf, schema = cutover_db
other = create_async_engine(asyncpg_test_url(os.environ["TEST_POSTGRES_URI"]), connect_args={"server_settings": {"search_path": schema}})
try:
async with other.connect():
with pytest.raises(RuntimeError, match="sessions"):
await activate_producer_fencing(sf, cutover_id="upgrade-1")
finally:
await other.dispose()
@pytest.mark.asyncio
@pytest.mark.parametrize("method", ["put", "put_batch", "put_if_absent"])
async def test_original_apis_reject_missing_authority_and_metadata_forgery(cutover_db, method):
from deerflow.persistence.run.producer import activate_producer_fencing
from deerflow.runtime.events.producer import RunEventProducerRejected
_, sf, _ = cutover_db
runs = RunRepository(sf)
await runs.put("run", thread_id="thread", user_id="alice", status="success")
row = await runs.get("run", user_id="alice")
await activate_producer_fencing(sf, cutover_id="upgrade-1")
store = DbRunEventStore(sf, producer_fencing="strict", producer_cutover_id="upgrade-1")
event = dict(thread_id="thread", run_id="run", event_type="middleware:old", category="trace", metadata={"producer_epoch": row["producer_epoch"], "run_incarnation": row["run_incarnation"], "trusted": True})
with pytest.raises(RunEventProducerRejected):
if method == "put_batch":
await store.put_batch([event])
else:
await getattr(store, method)(**event)
assert await store.list_events("thread", "run", user_id="alice") == []
@pytest.mark.asyncio
async def test_audit_grant_is_named_and_independent_of_closed_producer(cutover_db):
from types import SimpleNamespace
from deerflow.persistence.run.producer import activate_producer_fencing
from deerflow.runtime.events.producer import RunEventProducerRejected, audit_authority, producer_authority, producer_scope
_, sf, _ = cutover_db
runs = RunRepository(sf)
await runs.put("run", thread_id="thread", user_id="alice", status="success")
record = SimpleNamespace(**await runs.get("run", user_id="alice"))
await activate_producer_fencing(sf, cutover_id="upgrade-1")
store = DbRunEventStore(sf, producer_fencing="strict", producer_cutover_id="upgrade-1")
assert await store.close_producer(producer_authority(record))
with pytest.raises(ValueError):
audit_authority(record, event_types=("run.terminal",))
with producer_scope(audit_authority(record, event_types=("audit.review",))):
await store.put(thread_id="thread", run_id="run", event_type="audit.review", category="trace")
with pytest.raises(RunEventProducerRejected):
await store.put(thread_id="thread", run_id="run", event_type="audit.other", category="trace")
await store.delete_by_run("thread", "run", user_id="alice")
with pytest.raises(RunEventProducerRejected):
await store.put(thread_id="thread", run_id="run", event_type="audit.review", category="trace")
@pytest.mark.asyncio
async def test_sqlite_strict_startup_fails(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'strict.db'}")
try:
with pytest.raises(RuntimeError, match="PostgreSQL"):
await DbRunEventStore(async_sessionmaker(engine), producer_fencing="strict", producer_cutover_id="upgrade-1").validate_producer_fencing()
finally:
await engine.dispose()
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ["memory", "jsonl"])
async def test_gateway_rejects_activated_database_with_downgraded_event_backend(cutover_db, monkeypatch, backend):
from contextlib import asynccontextmanager
from types import SimpleNamespace
from fastapi import FastAPI
from app.gateway.deps import langgraph_runtime
from deerflow.persistence.run.producer import activate_producer_fencing
_, sf, _ = cutover_db
await activate_producer_fencing(sf, cutover_id="upgrade-1")
@asynccontextmanager
async def resource(_config):
yield object()
async def noop(*args):
pass
monkeypatch.setattr("deerflow.runtime.checkpointer.async_provider.make_checkpointer", resource)
monkeypatch.setattr("deerflow.runtime.make_stream_bridge", resource)
monkeypatch.setattr("deerflow.runtime.make_store", resource)
monkeypatch.setattr("deerflow.persistence.engine.init_engine_from_config", noop)
monkeypatch.setattr("deerflow.persistence.engine.close_engine", noop)
monkeypatch.setattr("deerflow.persistence.engine.get_session_factory", lambda: sf)
config = SimpleNamespace(database=SimpleNamespace(backend="postgres", checkpoint_channel_mode="full", checkpoint_delta=SimpleNamespace(snapshot_frequency=10)), run_events=RunEventsConfig(backend=backend))
with pytest.raises(RuntimeError, match="unfenced Gateway"):
async with langgraph_runtime(FastAPI(), config):
pytest.fail("An activated SQL database allowed an unfenced event backend")