deer-flow/backend/tests/test_workflow_cluster.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

506 lines
23 KiB
Python

"""Real PostgreSQL coordination; opt in with DEERFLOW_TEST_WORKFLOW_POSTGRES."""
import asyncio
import os
import uuid
from dataclasses import replace
import pytest
import pytest_asyncio
import test_workflow_plugin as fixtures
from test_workflow_plugin import context
plugin = fixtures.plugin
pytestmark = [pytest.mark.asyncio, pytest.mark.skipif(not os.getenv("DEERFLOW_TEST_WORKFLOW_POSTGRES"), reason="Requires dedicated PostgreSQL")]
@pytest_asyncio.fixture
async def cluster(plugin):
import psycopg
from deerflow_extension_workflows.service import Workflows
from psycopg import sql
from psycopg.conninfo import make_conninfo
dsn = os.environ["DEERFLOW_TEST_WORKFLOW_POSTGRES"]
schema = "workflow_test_" + uuid.uuid4().hex
with psycopg.connect(dsn, autocommit=True) as db:
db.execute(sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema)))
scoped = make_conninfo(dsn, options=f"-csearch_path={schema}")
with psycopg.connect(scoped) as db:
db.execute(
"CREATE TABLE extension_execution_leases(namespace varchar(96),resource_id varchar(128),user_id varchar(64) NOT NULL,holder_id varchar(128) NOT NULL,epoch bigint NOT NULL,expires_at double precision NOT NULL,"
"PRIMARY KEY(namespace,resource_id))"
)
original_start = plugin[3].start
plugin[3].fences = []
async def fenced_start(*, execution_fence, **kwargs):
assert execution_fence.namespace == "community.workflows"
plugin[3].fences.append(execution_fence)
return await original_start(**kwargs)
original_cancel = plugin[3].cancel
async def fenced_cancel(*, execution_fence, **kwargs):
assert execution_fence.namespace == "community.workflows"
return await original_cancel(**kwargs)
plugin[3].cancel = fenced_cancel
plugin[3].start = fenced_start
config = {**plugin[2].config, "storage_url": scoped, "lease_seconds": 10}
a, b = Workflows(config), Workflows(config)
await a.start(None)
await b.start(None)
try:
yield a, b, plugin[3]
finally:
await asyncio.gather(a.stop(), b.stop())
with psycopg.connect(dsn, autocommit=True) as db:
db.execute(sql.SQL("DROP SCHEMA {} CASCADE").format(sql.Identifier(schema)))
async def test_launch_on_a_resume_on_b(cluster):
a, b, runs = cluster
payload = {"request_id": "same", "script": "await pause({stepId:'p'}); return 42;", "args": {}}
first, retry = await asyncio.gather(a.launch(payload, context(runs)), b.launch(payload, context(runs)))
assert first["run_id"] == retry["run_id"]
run_id = first["run_id"]
for _ in range(200):
record = await b.get({"run_id": run_id}, context(runs))
if record["status"] == "paused":
break
await asyncio.sleep(0.02)
assert record["status"] == "paused"
await b.resume(dict(run_id=run_id, fingerprint=record["fingerprint"], step_id="p", attempt=1), context(runs))
await asyncio.wait_for(asyncio.gather(a.wait_idle(run_id), b.wait_idle(run_id)), 10)
assert (await b.get({"run_id": run_id}, context(runs)))["result"] == 42
async def test_expired_owner_cannot_write_after_takeover(cluster):
a, b, runs = cluster
payload = {"request_id": "takeover", "script": "await pause({stepId:'p'}); return 42;", "args": {}}
run_id = (await a.launch(payload, context(runs)))["run_id"]
for _ in range(200):
old = await asyncio.to_thread(a.store.get, "alice", run_id)
if old["status"] == "paused":
break
await asyncio.sleep(0.02)
a.heartbeats[run_id].cancel()
await asyncio.gather(a.heartbeats[run_id], return_exceptions=True)
def expire():
from contextlib import closing
with closing(a.store.connect()) as db, db:
db.execute("UPDATE extension_execution_leases SET expires_at=0 WHERE resource_id=?", (run_id,))
await asyncio.to_thread(expire)
session = replace(context(runs), interaction="session")
await b.recover({"run_id": run_id}, session)
with pytest.raises(ValueError, match="lease lost"):
await asyncio.to_thread(a.store.put, "alice", old)
# Restore the stale owner after takeover; its next renewal must kill only
# its own worker and leave the successor and shared record untouched.
await a.follow_lease(a.entries[run_id])
for _ in range(200):
record = await b.get({"run_id": run_id}, session)
if record["status"] == "paused":
break
await asyncio.sleep(0.02)
await b.resume(dict(run_id=run_id, fingerprint=record["fingerprint"], step_id="p", attempt=1), session)
await asyncio.wait_for(b.wait_idle(run_id), 10)
assert (await b.get({"run_id": run_id}, session))["result"] == 42
async def test_shared_workflow_limit(cluster):
a, b, runs = cluster
a.config["max_workflows"] = b.config["max_workflows"] = 1
await a.launch({"request_id": "first", "script": "await pause({stepId:'p'});", "args": {}}, context(runs))
with pytest.raises(ValueError, match="workflow limit"):
await b.launch({"request_id": "second", "script": "return 1;", "args": {}}, context(runs, owner="bob"))
async def poll(service, runs, run_id, status):
for _ in range(300):
record = await service.get({"run_id": run_id}, context(runs))
if record["status"] == status:
return record
await asyncio.sleep(0.02)
raise AssertionError(record)
async def test_cross_gateway_approval_is_exact_and_idempotent(cluster):
a, b, runs = cluster
run_id = (
await a.launch(
{
"request_id": "approval",
"script": "const a=await agent({stepId:'a',agentRef:'lead_agent',task:1}); await humanCheckpoint({stepId:'c',sourceStepId:'a',artifactVersion:a.artifactVersion}); "
"return await agent({stepId:'b',agentRef:'reviewer',task:2});",
"args": {},
},
context(runs),
)
)["run_id"]
record = await poll(b, runs, run_id, "waiting_approval")
session = replace(context(runs), interaction="session")
payload = dict(run_id=run_id, step_id="c", attempt=1, artifact_version=record["checkpoints"][0]["artifact_version"], decision="approve")
with pytest.raises(ValueError, match="exact checkpoint"):
await b.decide({**payload, "artifact_version": "wrong"}, session)
await asyncio.gather(a.decide(payload, session), b.decide(payload, session))
await asyncio.wait_for(a.wait_idle(run_id), 10)
assert (await b.get({"run_id": run_id}, session))["status"] == "completed"
assert len(runs.starts) == 2
assert len(runs.fences) == 2
async def test_cross_gateway_cancel_and_revocation(cluster):
a, b, runs = cluster
run_id = (await a.launch({"request_id": "cancel", "script": "await pause({stepId:'p'}); return 1;", "args": {}}, context(runs)))["run_id"]
record = await poll(b, runs, run_id, "paused")
runs.denied = True
with pytest.raises(Exception, match="revoked"):
await b.resume(dict(run_id=run_id, fingerprint=record["fingerprint"], step_id="p", attempt=1), context(runs))
runs.denied = False
await b.cancel({"run_id": run_id}, context(runs))
await asyncio.wait_for(a.wait_idle(run_id), 10)
assert (await b.get({"run_id": run_id}, context(runs)))["status"] == "cancelled"
async def test_real_owner_kill_during_review_and_rolling_upgrade(cluster):
import json
import sys
from pathlib import Path
a, b, runs = cluster
root = Path(__file__).resolve().parents[2]
program = r"""
import asyncio,json,sys
from deerflow_extension_workflows.service import Workflows
from test_workflow_plugin import Runs,context
async def main():
service=Workflows(json.loads(sys.argv[1]));await service.start(None)
runs=Runs()
result=await service.launch({'request_id':'killed-owner','script':"await pause({stepId:'p'}); return 7;",'args':{}},context(runs))
while (await service.get({'run_id':result['run_id']},context(runs)))['status']!='paused':
await asyncio.sleep(.01)
print(result['run_id'],flush=True)
await asyncio.Event().wait()
asyncio.run(main())
"""
child = await asyncio.create_subprocess_exec(
sys.executable,
"-c",
program,
json.dumps(a.config),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, "PYTHONPATH": os.pathsep.join([str(root / "examples/deerflow-extension-workflows"), str(root / "backend/tests"), str(root / "backend")])},
)
try:
run_id = (await asyncio.wait_for(child.stdout.readline(), 10)).decode().strip()
assert len(run_id) == 32
finally:
child.kill()
await child.wait()
def expire():
from contextlib import closing
with closing(b.store.connect()) as db, db:
db.execute("UPDATE extension_execution_leases SET expires_at=0 WHERE resource_id=?", (run_id,))
await asyncio.to_thread(expire)
session = replace(context(runs), interaction="session")
version = b.runtime_version
b.runtime_version = "incompatible-upgrade"
with pytest.raises(ValueError, match="runtime version"):
await b.recover({"run_id": run_id}, session)
b.runtime_version = version
runs.denied = True
with pytest.raises(Exception, match="revoked"):
await b.recover({"run_id": run_id}, session)
runs.denied = False
await b.recover({"run_id": run_id}, session)
record = await poll(b, runs, run_id, "paused")
await b.resume(dict(run_id=run_id, fingerprint=record["fingerprint"], step_id="p", attempt=1), session)
await asyncio.wait_for(b.wait_idle(run_id), 10)
assert (await b.get({"run_id": run_id}, session))["result"] == 7
async def test_cancel_keeps_lease_until_child_cleanup_finishes(cluster, monkeypatch):
a, b, runs = cluster
cleaning = asyncio.Event()
finish_cleanup = asyncio.Event()
original_cancel = a.cancel_child
async def held_cancel(*args, **kwargs):
cleaning.set()
await finish_cleanup.wait()
return await original_cancel(*args, **kwargs)
monkeypatch.setattr(a, "cancel_child", held_cancel)
script = "return await agent({stepId:'a',agentRef:'lead_agent',task:'blocked'});"
run_id = (await a.launch({"request_id": "cleanup-lease", "script": script, "args": {}}, context(runs)))["run_id"]
await asyncio.wait_for(runs.entered.wait(), 5)
try:
# Agent admission signals before the supervisor persists its run ID.
# Wait for that commit so remote cancel does not race its revision CAS.
async with asyncio.timeout(5):
while True:
record = await b.get({"run_id": run_id}, context(runs))
if record["steps"][0]["status"] == "running":
break
await asyncio.sleep(0.01)
await b.cancel({"run_id": run_id}, context(runs))
await asyncio.wait_for(cleaning.wait(), 5)
# The supervisor has stopped its worker but is still draining the child.
async with asyncio.timeout(5):
while run_id in a.processes:
await asyncio.sleep(0.01)
finally:
finish_cleanup.set()
await asyncio.wait_for(a.wait_idle(run_id), 10)
record = await b.get({"run_id": run_id}, context(runs))
assert record["status"] == "cancelled"
assert record["steps"][0]["status"] == "cancelled"
assert not a.entries
async def test_shared_child_quota_and_cancel(cluster):
a, b, runs = cluster
a.config["global_concurrency"] = b.config["global_concurrency"] = 1
script = "return await agent({stepId:'a',agentRef:'lead_agent',task:'blocked'});"
first = (await a.launch({"request_id": "slot-a", "script": script, "args": {}}, context(runs)))["run_id"]
await asyncio.wait_for(runs.entered.wait(), 5)
second = (await b.launch({"request_id": "slot-b", "script": script, "args": {}}, context(runs)))["run_id"]
await asyncio.sleep(0.2)
assert len(runs.starts) == 1
await b.cancel({"run_id": first}, context(runs))
await asyncio.wait_for(a.wait_idle(first), 10)
await asyncio.wait_for(b.wait_idle(second), 10)
assert len(runs.starts) == 2
assert runs.peak == 1
async def test_capture_is_fenced_and_immutable(cluster):
a, b, runs = cluster
run_id = (await a.launch({"request_id": "file", "script": "await pause({stepId:'p'});", "args": {}}, context(runs)))["run_id"]
await poll(a, runs, run_id, "paused")
record = await asyncio.to_thread(a.store.get, "alice", run_id)
with pytest.raises(ValueError, match="lease lost"):
await asyncio.to_thread(b.store.capture_artifact, "alice", run_id, "file", "report.txt", b"wrong", execution_record=record)
info = await asyncio.to_thread(a.store.capture_artifact, "alice", run_id, "file", "report.txt", b"original", execution_record=record)
assert (await asyncio.to_thread(b.store.artifact, "alice", run_id, info["version"]))[1] == b"original"
with pytest.raises(ValueError, match="overwritten"):
await asyncio.to_thread(a.store.capture_artifact, "alice", run_id, "file", "report.txt", b"changed", execution_record=record)
@pytest.mark.parametrize("boundary", ["admission", "result", "approval"])
async def test_cross_gateway_recovery_persistence_boundaries(cluster, monkeypatch, boundary):
import copy
import json
from contextlib import closing
a, b, runs = cluster
snapshots = []
original = runs.start if boundary == "admission" else runs.configuration_evidence
async def capture(**kwargs):
value = await original(**kwargs)
entry = next(iter(a.entries.values()))
snapshots.append(await asyncio.to_thread(a.store.get, "alice", entry["record"]["id"]))
return value
if boundary != "approval":
monkeypatch.setattr(runs, "start" if boundary == "admission" else "configuration_evidence", capture)
script = "const a=await agent({stepId:'a',agentRef:'lead_agent',task:1}); await humanCheckpoint({stepId:'c',sourceStepId:'a',artifactVersion:a.artifactVersion}); return await agent({stepId:'b',agentRef:'reviewer',task:2});"
run_id = (await a.launch({"request_id": boundary, "script": script, "args": {}}, context(runs)))["run_id"]
record = await poll(a, runs, run_id, "waiting_approval")
session = replace(context(runs), interaction="session")
if boundary == "approval":
a.heartbeats[run_id].cancel()
await asyncio.gather(a.heartbeats[run_id], return_exceptions=True)
await b.decide(dict(run_id=run_id, step_id="c", attempt=1, artifact_version=record["checkpoints"][0]["artifact_version"], decision="approve"), session)
snapshots.append(await asyncio.to_thread(a.store.get, "alice", run_id))
snapshot = copy.deepcopy(snapshots[0])
if boundary != "approval":
monkeypatch.setattr(runs, "start" if boundary == "admission" else "configuration_evidence", original)
await a.stop()
def install_crash_image():
with closing(b.store.connect()) as db, db:
revision = db.execute("SELECT revision FROM workflows WHERE id=?", (run_id,)).fetchone()[0] + 1
snapshot["revision"] = revision
db.execute("UPDATE workflows SET data=?,status=?,revision=? WHERE id=?", (json.dumps(snapshot), snapshot["status"], revision, run_id))
db.execute("UPDATE extension_execution_leases SET expires_at=0 WHERE resource_id=?", (run_id,))
await asyncio.to_thread(install_crash_image)
restored = await b.recover({"run_id": run_id}, session)
if boundary == "admission":
assert restored["status"] == "needs_reconciliation"
assert len(runs.starts) == 1
else:
if boundary == "result":
record = await poll(b, runs, run_id, "waiting_approval")
await b.decide(dict(run_id=run_id, step_id="c", attempt=1, artifact_version=record["checkpoints"][0]["artifact_version"], decision="approve"), session)
await asyncio.wait_for(b.wait_idle(run_id), 10)
assert (await b.get({"run_id": run_id}, session))["status"] == "completed"
assert len(runs.starts) == 2
async def test_expired_cancel_command_cannot_resume_dispatch(cluster):
from contextlib import closing
a, b, runs = cluster
run_id = (await a.launch({"request_id": "cancel-before-crash", "script": "await pause({stepId:'p'}); return 1;", "args": {}}, context(runs)))["run_id"]
await poll(a, runs, run_id, "paused")
a.heartbeats[run_id].cancel()
await asyncio.gather(a.heartbeats[run_id], return_exceptions=True)
await b.cancel({"run_id": run_id}, context(runs))
def expire():
with closing(b.store.connect()) as db, db:
db.execute("UPDATE extension_execution_leases SET expires_at=0 WHERE resource_id=?", (run_id,))
await asyncio.to_thread(expire)
session = replace(context(runs), interaction="session")
with pytest.raises(ValueError, match="Cancellation pending"):
await b.recover({"run_id": run_id}, session)
await a.follow_lease(a.entries[run_id])
await b.cancel({"run_id": run_id}, session)
assert (await b.get({"run_id": run_id}, session))["status"] == "cancelled"
async def test_cluster_policy_rejects_mismatched_replica(cluster):
from deerflow_extension_workflows.service import Workflows
a, _, _ = cluster
peer = Workflows({**a.config, "global_concurrency": 1})
with pytest.raises(ValueError, match="cluster limits differ"):
await peer.start(None)
async def test_peer_rejects_configuration_change_and_expired_review(cluster):
import json
from contextlib import closing
a, b, runs = cluster
run_id = (await a.launch({"request_id": "config", "script": "await pause({stepId:'p'}); return 1;", "args": {}}, context(runs)))["run_id"]
record = await poll(a, runs, run_id, "paused")
payload = dict(run_id=run_id, fingerprint=record["fingerprint"], step_id="p", attempt=1)
runs.revision = "changed-config"
with pytest.raises(ValueError, match="configuration changed"):
await b.resume(payload, context(runs))
runs.revision = "initial"
a.heartbeats[run_id].cancel()
await asyncio.gather(a.heartbeats[run_id], return_exceptions=True)
def expire_checkpoint():
with closing(b.store.connect()) as db, db:
retained = json.loads(db.execute("SELECT data FROM workflows WHERE id=?", (run_id,)).fetchone()[0])
retained["pause_expires_at"] = 0
db.execute("UPDATE workflows SET data=? WHERE id=?", (json.dumps(retained), run_id))
await asyncio.to_thread(expire_checkpoint)
with pytest.raises(ValueError, match="expired"):
await b.resume(payload, context(runs))
@pytest.mark.parametrize("saturated", [False, True])
async def test_expired_cancelling_can_finish_cleanup_and_delete(cluster, saturated):
import copy
from contextlib import closing
a, b, runs = cluster
run_id = (await a.launch({"request_id": "cancel-recovery", "script": "await pause({stepId:'p'}); return 1;", "args": {}}, context(runs)))["run_id"]
await poll(a, runs, run_id, "paused")
await a.stop()
# Crash image of cancel_recovered after persisting cancelling, before cleanup.
def crash():
with closing(b.store.connect()) as db, db:
import json
record = json.loads(db.execute("SELECT data FROM workflows WHERE id=?", (run_id,)).fetchone()[0])
record["status"] = "cancelling"
db.execute("UPDATE workflows SET status='cancelling',data=? WHERE id=?", (json.dumps(record), run_id))
db.execute("UPDATE extension_execution_leases SET expires_at=0 WHERE resource_id=?", (run_id,))
if saturated:
# Other active rows already consume both budget ceilings.
for i in range(max(b.config["max_workflows"], b.config["max_workflows_per_owner"])):
other = copy.deepcopy(record)
other.update(id=f"occupied-{i}", status="running")
db.execute("INSERT INTO workflows(id,owner,request_id,fingerprint,status,data) VALUES (?,?,?,?,?,?)", (other["id"], "alice", other["id"], "occupied", "running", json.dumps(other)))
await asyncio.to_thread(crash)
session = replace(context(runs), interaction="session")
with pytest.raises(ValueError, match="Cancellation pending"):
await b.recover({"run_id": run_id}, session)
result = await b.cancel({"run_id": run_id}, session)
assert result["status"] == "cancelled"
await b.delete({"run_id": run_id}, session)
with pytest.raises(ValueError, match="unavailable"):
await b.get({"run_id": run_id}, session)
assert not runs.starts
async def test_reads_do_not_wait_for_cluster_budget_lock(cluster):
import psycopg
from deerflow_extension_workflows.postgres import LOCK
a, b, runs = cluster
run_id = (await a.launch({"request_id": "read-lock", "script": "return 1;", "args": {}}, context(runs)))["run_id"]
await poll(a, runs, run_id, "completed")
with psycopg.connect(b.store.dsn) as blocker:
blocker.execute("SELECT pg_advisory_xact_lock(%s)", (LOCK,))
# A real held advisory lock makes the old read path hit lock_timeout.
assert (await asyncio.to_thread(b.store.get, "alice", run_id))["status"] == "completed"
assert [r["id"] for r in await asyncio.to_thread(b.store.list, "alice")] == [run_id]
assert await asyncio.to_thread(b.store.list, "other") == []
@pytest.mark.parametrize("failure", ["permission", "configuration", "uncertain"])
async def test_rejected_admission_slot_accounting(cluster, monkeypatch, failure):
from contextlib import closing
from deerflow_extension_api import AgentRunError
a, b, runs = cluster
a.config["global_concurrency"] = b.config["global_concurrency"] = 1
original_start = runs.start
original_check = a.check_configuration
async def reject_start(**kwargs):
raise AgentRunError(403 if failure == "permission" else 503, "synthetic rejection")
async def reject_step_configuration(entry):
if entry["record"]["steps"]:
raise ValueError("Configuration changed")
await original_check(entry)
if failure == "configuration":
monkeypatch.setattr(a, "check_configuration", reject_step_configuration)
else:
monkeypatch.setattr(runs, "start", reject_start)
script = "return await agent({stepId:'a',agentRef:'lead_agent',task:'hello'});"
run_id = (await a.launch({"request_id": "rejected", "script": script, "args": {}}, context(runs)))["run_id"]
await asyncio.wait_for(a.wait_idle(run_id), 10)
assert not runs.starts
def slots():
with closing(a.store.connect(readonly=True)) as db:
return db.execute("SELECT count(*) FROM workflow_slots").fetchone()[0]
assert await asyncio.to_thread(slots) == (1 if failure == "uncertain" else 0)
if failure == "uncertain":
with pytest.raises(ValueError, match="Reconcile retained"):
await b.delete({"run_id": run_id}, context(runs))
return
await b.delete({"run_id": run_id}, context(runs))
monkeypatch.setattr(runs, "start", original_start)
peer = (await b.launch({"request_id": "peer-after-rejection", "script": script, "args": {}}, context(runs)))["run_id"]
await asyncio.wait_for(b.wait_idle(peer), 10)
assert (await b.get({"run_id": peer}, context(runs)))["status"] == "completed"
assert len(runs.starts) == 1