Files
MoviePilot/tests/test_workflow_safety.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

460 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
工作流准入、真实线程生命周期、截止时间和图定义的行为回归。
"""
import asyncio
import json
import threading
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from app import workflow as workflow_package
from app.application.workflow import WorkflowDefinitionCommand, WorkflowMutationCommand
from app.chain import workflow as workflow_chain
from app.schemas.workflow import Action, ActionResult
def _workflow(workflow_id=1, *, actions=None, flows=None, current_action=None):
"""
构造包含串行两个动作的脱离数据库工作流快照。
"""
return SimpleNamespace(
id=workflow_id, name=f"workflow-{workflow_id}", current_action=current_action,
actions=actions if actions is not None else [
{"id": "A", "name": "A", "type": "ObservedAction"},
{"id": "B", "name": "B", "type": "ObservedAction"},
],
flows=flows if flows is not None else [{"id": "AB", "source": "A", "target": "B"}],
context={}, execution_config={}, execution_state={},
)
def _manager(action_type):
"""
提供真实准入锁和动作调度能力,不启动应用或加载外部插件。
"""
manager = object.__new__(workflow_package.WorkflowManager)
manager._lock = threading.RLock()
manager._actions = {"ObservedAction": action_type}
manager._event_workflows = {}
manager._accepting_executions = True
manager._executions = {}
return manager
def _install_runtime(monkeypatch, manager, workflows):
"""
把真实执行链绑定到可观察的内存查询和事务端口。
"""
port = SimpleNamespace(reset=Mock(return_value=True), start=Mock(return_value=True),
step=Mock(return_value=True), success=Mock(return_value=True), fail=Mock(return_value=True))
monkeypatch.setattr(workflow_chain, "get_workflow_manager", lambda: manager)
monkeypatch.setattr(workflow_chain, "get_configured_workflow_execution", lambda: port)
monkeypatch.setattr(workflow_chain, "get_configured_workflow_query",
lambda: SimpleNamespace(get_sync=workflows.get))
monkeypatch.setattr(workflow_package.runtime_stop_state, "is_workflow_stopped", lambda _wid: False)
return port
def test_same_workflow_from_begin_cannot_reset_active_execution(monkeypatch):
"""
真实节点线程阻塞时,同 ID 重跑必须在重置和写运行中状态之前被拒绝。
"""
entered, release = threading.Event(), threading.Event()
calls = []
class ObservedAction:
"""
记录动作并把第一个节点阻塞到竞争请求结束。
"""
def __init__(self, action_id):
"""
保存当前节点 ID。
"""
self.action_id = action_id
def execute_with_inputs(self, workflow_id, params, inputs, runtime, context):
"""
在真实池线程内阻塞节点 A,其他节点直接成功。
"""
_ = workflow_id, params, inputs, runtime
calls.append(self.action_id)
if self.action_id == "A":
entered.set()
assert release.wait(2)
return ActionResult(success=True, message="", context=context)
manager = _manager(ObservedAction)
port = _install_runtime(monkeypatch, manager, {1: _workflow(current_action="A,B")})
results = []
thread = threading.Thread(target=lambda: results.append(workflow_chain.WorkflowChain.process(1)), daemon=True)
thread.start()
try:
assert entered.wait(1), "from_begin 应清除旧节点状态并真正执行 A"
refused = workflow_chain.WorkflowChain.process(1, from_begin=True)
assert refused[0] is False
port.reset.assert_called_once_with(1)
port.start.assert_called_once_with(1)
assert calls == ["A"]
finally:
release.set()
thread.join(2)
assert not thread.is_alive()
assert results == [(True, "")]
assert calls == ["A", "B"]
assert manager._executions == {}
@pytest.mark.parametrize("succeeded", [True, False])
def test_workflow_retains_owner_until_terminal_transaction_commits(monkeypatch, succeeded):
"""
成功和失败终态提交阻塞时均保留同 ID 所有权,防止覆盖后续运行。
"""
entered, release = threading.Event(), threading.Event()
class ObservedAction:
"""
以预定结果结束动作,令执行进入终态提交。
"""
def __init__(self, action_id):
"""
接收动作标识并保持无副作用。
"""
_ = action_id
def execute_with_inputs(self, workflow_id, params, inputs, runtime, context):
"""
直接结束,成功和失败走各自真实写入分支。
"""
_ = workflow_id, params, inputs, runtime
return ActionResult(success=succeeded, message="failed", context=context)
manager = _manager(ObservedAction)
port = _install_runtime(monkeypatch, manager, {1: _workflow()})
def persist_terminal(*_args, **_kwargs):
"""
让终态事务在尚未完成提交时保持阻塞。
"""
entered.set()
assert release.wait(2)
return True
terminal = port.success if succeeded else port.fail
terminal.side_effect = persist_terminal
results = []
thread = threading.Thread(target=lambda: results.append(workflow_chain.WorkflowChain.process(1)), daemon=True)
thread.start()
try:
assert entered.wait(1)
assert manager._executions
assert workflow_chain.WorkflowChain.process(1)[0] is False
owner = next(iter(manager._executions.values()))
assert owner.wait_stopped(timeout=0.01) is False
port.reset.assert_called_once()
port.start.assert_called_once()
finally:
release.set()
thread.join(2)
assert not thread.is_alive()
assert results[0][0] is succeeded
assert manager._executions == {}
def test_different_workflows_can_execute_concurrently(monkeypatch):
"""
同 ID 互斥不应把不同工作流串行化。
"""
entered = {1: threading.Event(), 2: threading.Event()}
release = threading.Event()
class ObservedAction:
"""
在两个工作流的首节点上记录并发进入。
"""
def __init__(self, action_id):
"""
保存节点 ID。
"""
self.action_id = action_id
def execute_with_inputs(self, workflow_id, params, inputs, runtime, context):
"""
首节点保持阻塞,以证明两个不同 ID 同时被准入。
"""
_ = params, inputs, runtime
if self.action_id == "A":
entered[workflow_id].set()
assert release.wait(2)
return ActionResult(success=True, message="", context=context)
manager = _manager(ObservedAction)
_install_runtime(monkeypatch, manager, {wid: _workflow(wid) for wid in entered})
results = []
threads = [threading.Thread(target=lambda wid=wid: results.append(workflow_chain.WorkflowChain.process(wid)),
daemon=True) for wid in entered]
for thread in threads:
thread.start()
try:
assert all(event.wait(1) for event in entered.values())
assert len(manager._executions) == 2
finally:
release.set()
for thread in threads:
thread.join(2)
assert results == [(True, ""), (True, "")]
@pytest.mark.parametrize("rejected_operation", ["reset", "start"])
def test_failed_start_transaction_releases_unstarted_owner(monkeypatch, rejected_operation):
"""
重置或启动事务返回 False 时,不得执行节点或提交成功终态。
"""
manager = _manager(Mock())
port = _install_runtime(monkeypatch, manager, {1: _workflow()})
getattr(port, rejected_operation).return_value = False
success, message = workflow_chain.WorkflowChain.process(1)
assert success is False
assert "失败" in message
assert manager._executions == {}
manager._actions["ObservedAction"].assert_not_called()
port.success.assert_not_called()
port.fail.assert_not_called()
if rejected_operation == "reset":
port.start.assert_not_called()
@pytest.mark.parametrize("commit_failure", [False, RuntimeError("terminal failed")])
def test_terminal_commit_failure_retains_owner_for_retry(monkeypatch, commit_failure):
"""
终态未提交时不能报告成功或释放 owner,收口重试成功后才允许新执行。
"""
action_type = Mock()
action_type.get_contract.return_value = {}
action_type.return_value.execute_with_inputs.return_value = ActionResult(success=True, message="")
manager = _manager(action_type)
port = _install_runtime(monkeypatch, manager, {1: _workflow()})
port.success.side_effect = [commit_failure, True]
with pytest.raises(RuntimeError):
workflow_chain.WorkflowChain.process(1)
assert manager._executions
assert workflow_chain.WorkflowChain.process(1)[0] is False
port.start.assert_called_once()
owner = next(iter(manager._executions.values()))
assert owner.wait_stopped(timeout=1) is True
assert port.success.call_count == 2
assert manager._executions == {}
@pytest.mark.parametrize("loop", [False, True])
def test_action_timeout_checks_result_and_deadline_after_sleep(monkeypatch, loop):
"""
同步返回和循环等待跨过截止时间时均失败,等待后不能再次调用动作。
"""
clock = [0.0]
calls, sleeps = [], []
class ObservedAction:
"""
用离线时钟模拟同步阻塞或未完成的循环动作。
"""
done = False
def __init__(self, action_id):
"""
保留构造兼容,不引入外部能力。
"""
_ = action_id
def execute_with_inputs(self, workflow_id, params, inputs, runtime, context):
"""
非循环跨过截止时间,循环首轮保持未完成。
"""
_ = workflow_id, params, inputs, runtime
calls.append("execute")
if not loop:
clock[0] = 2
return ActionResult(success=True, message="", context=context)
def simulated_sleep(_wid, seconds, _token):
"""
推进测试时钟并记录调度器裁剪后的等待时间。
"""
sleeps.append(seconds)
clock[0] += seconds
manager = _manager(ObservedAction)
monkeypatch.setattr(workflow_package, "monotonic", lambda: clock[0])
monkeypatch.setattr(manager, "_sleep_with_cancel", simulated_sleep)
monkeypatch.setattr(workflow_package.runtime_stop_state, "is_workflow_stopped", lambda _wid: False)
result = manager.execute(1, Action(id="A", type="ObservedAction", timeout=1,
data={"loop": loop, "loop_interval": 10}))
assert result.success is False
assert "超时" in result.message
assert calls == ["execute"]
assert sleeps == ([1] if loop else [])
def test_cooperative_action_can_observe_deadline_cancel_token(monkeypatch):
"""
支持取消的动作通过运行期令牌看到本次尝试的截止时间。
"""
clock = [0.0]
class ObservedAction:
"""
在动作内部协作检查截止时间,而非让管理器分离后台线程。
"""
def __init__(self, action_id):
"""
接收动作标识。
"""
_ = action_id
def execute_with_inputs(self, workflow_id, params, inputs, runtime, context):
"""
直到统一令牌报告取消才退出,并验证传入截止时间。
"""
_ = workflow_id, params, inputs
token = runtime["cancel_token"]
assert runtime["deadline"] == 1
while not token.is_cancelled():
clock[0] += 0.25
return ActionResult(success=False, message="cancelled", context=context)
manager = _manager(ObservedAction)
monkeypatch.setattr(workflow_package, "monotonic", lambda: clock[0])
monkeypatch.setattr(workflow_package.runtime_stop_state, "is_workflow_stopped", lambda _wid: False)
result = manager.execute(1, Action(id="A", type="ObservedAction", timeout=1))
assert result.success is False
assert "超时" in result.message
assert clock[0] == 1
def test_noncooperative_timeout_keeps_owner_until_thread_really_returns(monkeypatch):
"""
到达截止时间也不能分离阻塞线程或提前放行同 ID 的下一次执行。
"""
entered, release, finished = threading.Event(), threading.Event(), threading.Event()
clock = [0.0]
class ObservedAction:
"""
模拟不读取协作令牌的第三方同步动作。
"""
def __init__(self, action_id):
"""
接收动作标识。
"""
_ = action_id
def execute_with_inputs(self, workflow_id, params, inputs, runtime, context):
"""
忽略令牌,直到测试显式释放才真实返回。
"""
_ = workflow_id, params, inputs, runtime
entered.set()
assert release.wait(2)
return ActionResult(success=True, message="", context=context)
manager = _manager(ObservedAction)
definition = _workflow(actions=[{"id": "A", "name": "A", "type": "ObservedAction", "timeout": 1}], flows=[])
port = _install_runtime(monkeypatch, manager, {1: definition})
monkeypatch.setattr(workflow_package, "monotonic", lambda: clock[0])
results = []
def run_workflow():
"""
在独立宿主线程内运行,只有完整 process 返回才设置完成标志。
"""
try:
results.append(workflow_chain.WorkflowChain.process(1))
finally:
finished.set()
thread = threading.Thread(target=run_workflow, daemon=True)
thread.start()
try:
assert entered.wait(1)
clock[0] = 2
assert finished.wait(0.05) is False
assert manager._executions
assert workflow_chain.WorkflowChain.process(1)[0] is False
port.reset.assert_called_once()
port.fail.assert_not_called()
port.success.assert_not_called()
finally:
release.set()
thread.join(2)
assert results[0][0] is False
assert "超时" in port.step.call_args.kwargs["execution_state"]["nodes"]["A"]["message"]
port.fail.assert_called_once()
assert manager._executions == {}
_INVALID_GRAPHS = [
([{"id": "A"}, {"id": "A"}], [], "重复"),
([{"id": "A"}], [{"source": "A", "target": "missing"}], "不存在"),
([{"id": "A"}], [{"source": "A", "target": ""}], "端点"),
([{"id": "A"}, {"id": "B"}],
[{"source": "A", "target": "B"}, {"source": "B", "target": "A"}], "循环"),
([{"id": "A"}, {"id": "B"}, {"id": "C"}],
[{"source": "B", "target": "C"}, {"source": "C", "target": "B"}], "循环"),
([{"id": "A"}, {"id": "B"}],
[{"id": "same", "source": "A", "target": "B"}, {"id": "same", "source": "A", "target": "B"}], "重复"),
]
@pytest.mark.parametrize("actions,flows,error", _INVALID_GRAPHS)
def test_persisted_invalid_graph_cannot_report_runtime_success(monkeypatch, actions, flows, error):
"""
即使坏定义已持久化,运行入口也应拒绝且不写成功或启动状态。
"""
manager = _manager(object)
port = _install_runtime(monkeypatch, manager, {1: _workflow(actions=actions, flows=flows)})
success, message = workflow_chain.WorkflowChain.process(1)
assert success is False
assert error in message
port.start.assert_not_called()
port.success.assert_not_called()
@pytest.mark.parametrize("actions,flows,error", _INVALID_GRAPHS)
@pytest.mark.parametrize("operation", ["create", "update", "fork"])
def test_invalid_graph_is_rejected_before_definition_write(actions, flows, error, operation):
"""
创建、更新和复用共享同一图校验,错误不触发任何数据库写入。
"""
repository = SimpleNamespace(async_get_by_name=AsyncMock(return_value=None),
stage_create=AsyncMock(), stage_update=Mock(),
get=Mock(return_value=SimpleNamespace(trigger_type="manual", actions=[], flows=[])))
if operation == "update":
unit = SimpleNamespace(commit=Mock(), rollback=Mock())
command = WorkflowMutationCommand(
repository=repository, unit_of_work=unit, add_timer=Mock(), remove_timer=Mock(),
load_event=Mock(), remove_event=Mock(), refresh_event=Mock(), stop_running=Mock(), delete_cache=Mock(),
)
result = command.update({"id": 1, "actions": actions, "flows": flows})
repository.stage_update.assert_not_called()
unit.commit.assert_not_called()
else:
unit = SimpleNamespace(commit=AsyncMock(), rollback=AsyncMock())
command = WorkflowDefinitionCommand(repository=repository, unit_of_work=unit,
stop_running=Mock(), async_delete_cache=AsyncMock())
payload = {"name": "invalid", "actions": actions, "flows": flows}
if operation == "fork":
payload.update(actions=json.dumps(actions), flows=json.dumps(flows))
result = asyncio.run(getattr(command, operation)(payload))
repository.stage_create.assert_not_awaited()
unit.commit.assert_not_awaited()
assert result.success is False
assert error in result.message