deer-flow/backend/tests/test_extension_execution_fence.py
Xuehao Xu cfe2bec371
feat(workflows): add multi-Gateway coordination and session-authorized failover (#7138)
* feat(workflows): add leased multi-Gateway coordination and fenced dispatch

* test(workflows): validate deployed failover and canonicalize config revisions

* fix(workflows): share namespace across registration and lease fencing

* fix(workflows): recover interrupted cancellation without admission caps

* fix(workflows): retain execution lease through child cleanup

* docs: trim guidance padding to fit merged instruction budget

* fix(workflows): release rejected admission slots and normalize gateway routing

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-23 23:31:43 +08:00

159 lines
8.2 KiB
Python

"""Execution ownership must be checked in the child admission transaction."""
import pytest
import pytest_asyncio
from deerflow_extension_api import ExecutionFence
from sqlalchemy import insert
from test_run_repository import _cleanup, _make_repo
from deerflow.persistence.extension_leases.model import ExtensionExecutionLeaseRow
@pytest_asyncio.fixture
async def repo(tmp_path):
value = await _make_repo(tmp_path)
try:
yield value
finally:
await _cleanup()
@pytest.mark.asyncio
async def test_admission_rejects_stale_extension_epoch(repo):
async with repo._sf() as session:
await session.execute(insert(ExtensionExecutionLeaseRow).values(namespace="test", resource_id="workflow", user_id="alice", holder_id="new", epoch=2, expires_at=9999999999))
await session.commit()
from deerflow.runtime.runs.manager import ConflictError
with pytest.raises(ConflictError, match="execution fence"):
await repo.create_thread_operation_atomic("child", thread_id="thread", owner_worker_id="worker", lease_expires_at=None, user_id="alice", execution_fence=ExecutionFence("test", "workflow", "old", 1))
assert await repo.get("child", user_id="alice") is None
@pytest.mark.asyncio
@pytest.mark.parametrize("changed", ["holder", "epoch", "user", "expired", "missing"])
async def test_fence_rejects_wrong_authority(repo, changed):
from deerflow.runtime.runs.manager import ConflictError
values = dict(namespace="test", resource_id="workflow", user_id="alice", holder_id="owner", epoch=1, expires_at=9999999999)
if changed != "missing":
values.update({"holder_id": "other"} if changed == "holder" else {"epoch": 2} if changed == "epoch" else {"user_id": "bob"} if changed == "user" else {"expires_at": 0})
async with repo._sf() as session:
await session.execute(insert(ExtensionExecutionLeaseRow).values(**values))
await session.commit()
with pytest.raises((ValueError, ConflictError), match="execution fence"):
await repo.create_thread_operation_atomic("child", thread_id="thread", owner_worker_id="worker", lease_expires_at=None, user_id="alice", execution_fence=ExecutionFence("test", "workflow", "owner", 1))
@pytest.mark.asyncio
async def test_fence_allows_live_owner(repo):
async with repo._sf() as session:
await session.execute(insert(ExtensionExecutionLeaseRow).values(namespace="test", resource_id="workflow", user_id="alice", holder_id="owner", epoch=1, expires_at=9999999999))
await session.commit()
row, _ = await repo.create_thread_operation_atomic("child", thread_id="thread", owner_worker_id="worker", lease_expires_at=None, user_id="alice", execution_fence=ExecutionFence("test", "workflow", "owner", 1))
assert row["run_id"] == "child"
@pytest.mark.asyncio
async def test_postgres_takeover_fences_admission_after_lock_wait():
import asyncio
import os
import uuid
import psycopg
from psycopg import sql
from sqlalchemy import update
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from deerflow.persistence.run import RunRepository
from deerflow.persistence.run.model import RunChangeClockRow, RunRow
from deerflow.runtime.runs.manager import ConflictError
dsn = os.getenv("DEERFLOW_TEST_WORKFLOW_POSTGRES")
if not dsn:
pytest.skip("Requires dedicated PostgreSQL")
schema = "fence_test_" + uuid.uuid4().hex
with psycopg.connect(dsn, autocommit=True) as db:
db.execute(sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema)))
engine = create_async_engine(dsn.split("?")[0].replace("postgresql://", "postgresql+asyncpg://"), connect_args={"server_settings": {"search_path": schema}})
sessions = async_sessionmaker(engine, expire_on_commit=False)
try:
async with engine.begin() as connection:
for table in (ExtensionExecutionLeaseRow.__table__, RunChangeClockRow.__table__, RunRow.__table__):
await connection.run_sync(table.create)
async with sessions() as session:
await session.execute(insert(ExtensionExecutionLeaseRow).values(namespace="test", resource_id="workflow", user_id="alice", holder_id="old", epoch=1, expires_at=9999999999))
await session.commit()
repository = RunRepository(sessions)
async with sessions() as takeover:
await takeover.execute(update(ExtensionExecutionLeaseRow).values(holder_id="new", epoch=2))
admission = asyncio.create_task(
repository.create_thread_operation_atomic("child", thread_id="thread", owner_worker_id="worker", lease_expires_at=None, user_id="alice", execution_fence=ExecutionFence("test", "workflow", "old", 1))
)
try:
# Observe an actual database lock waiter before releasing the
# takeover; a sleep-only test could miss the vulnerable window.
from sqlalchemy import text
for _ in range(200):
async with engine.connect() as observer:
waiting = await observer.scalar(text("SELECT count(*) FROM pg_stat_activity WHERE wait_event_type='Lock' AND query LIKE '%extension_execution_leases%'"))
if waiting:
break
await asyncio.sleep(0.01)
assert waiting
await takeover.commit()
with pytest.raises(ConflictError, match="execution fence"):
await admission
finally:
await takeover.rollback()
await asyncio.gather(admission, return_exceptions=True)
assert await repository.get("child", user_id="alice") is None
finally:
await engine.dispose()
with psycopg.connect(dsn, autocommit=True) as db:
db.execute(sql.SQL("DROP SCHEMA {} CASCADE").format(sql.Identifier(schema)))
@pytest.mark.asyncio
async def test_fenced_cancel_cannot_cancel_successor_child(repo):
from deerflow.runtime.runs.manager import ConflictError
async with repo._sf() as session:
await session.execute(insert(ExtensionExecutionLeaseRow).values(namespace="test", resource_id="workflow", user_id="alice", holder_id="new", epoch=2, expires_at=9999999999))
await session.commit()
await repo.create_thread_operation_atomic("child", thread_id="thread", owner_worker_id="worker", lease_expires_at=None, user_id="alice")
with pytest.raises(ConflictError, match="execution fence"):
await repo.request_cancel("child", action="interrupt", execution_fence=ExecutionFence("test", "workflow", "old", 1), user_id="alice")
assert (await repo.get("child", user_id="alice"))["cancel_action"] is None
assert await repo.request_cancel("child", action="interrupt", execution_fence=ExecutionFence("test", "workflow", "new", 2), user_id="alice") == "interrupt"
@pytest.mark.asyncio
async def test_execution_fence_migration_upgrade_and_downgrade(tmp_path):
import asyncio
import sqlalchemy as sa
from alembic import command
from sqlalchemy.ext.asyncio import create_async_engine
from deerflow.persistence import bootstrap
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'fence-migration.db'}")
config = bootstrap._get_alembic_config(engine)
try:
await asyncio.to_thread(bootstrap._upgrade, config, "0047_mcp_oauth_tokens")
async with engine.begin() as connection:
await connection.execute(sa.text("DROP TABLE IF EXISTS extension_execution_leases"))
await asyncio.to_thread(bootstrap._upgrade, config, "0048_extension_execution_leases")
async with engine.connect() as connection:
assert "extension_execution_leases" in await connection.run_sync(lambda c: sa.inspect(c).get_table_names())
await asyncio.to_thread(command.downgrade, config, "0047_mcp_oauth_tokens")
async with engine.connect() as connection:
names = await connection.run_sync(lambda c: sa.inspect(c).get_table_names())
assert "extension_execution_leases" not in names
assert "mcp_oauth_tokens" in names
assert (await connection.execute(sa.text("SELECT version_num FROM alembic_version"))).scalar_one() == "0047_mcp_oauth_tokens"
finally:
await engine.dispose()