Files
MoviePilot/tests/test_interactive_priority.py

449 lines
18 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.
"""
交互执行隔离、HTTP 传播、有限避让与关闭合同的离线回归。
"""
import asyncio
import threading
import time
from contextvars import copy_context
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from anyio.to_thread import current_default_thread_limiter, run_sync
from app.adapters.web.priority import ExecutionPriorityMiddleware
from app.agent.api import executor as api_module
from app.agent.api.executor import ApiExecutionContext, MoviePilotApiExecutor
from app.agent.middleware.config import RuntimeConfigMiddleware
from app.agent.orchestrator import MoviePilotAgent
from app.agent.terminal.ownership import TerminalScope
from app.agent.tools import base as tool_module
from app.application.messaging.chat import AgentChatPersistenceService
from app.db.worker import DatabaseWorker
from app.runtime import priority
from app.runtime.execution import run_in_threadpool
from app.runtime.thread import ThreadHelper
from app.scheduler import execution as scheduler_execution
from app.scheduler.admission import defer_coroutine, invoke_background
from app.schemas.exception import DatabaseWorkerOverloadedError
@pytest.fixture(autouse=True)
def execution_settings(monkeypatch):
"""
固定本文件的执行策略,避免依赖真实配置或等待生产默认预算。
"""
settings = {"AGENT_INTERACTIVE_PRIORITY": True, "AGENT_BACKGROUND_DEFER_SECONDS": 0.1}
monkeypatch.setattr(priority, "get_runtime_setting", lambda key, default=None: settings.get(key, default))
return settings
async def _entered(event: threading.Event) -> None:
"""
有界等待线程开始;超时明确失败,避免挂住测试进程。
"""
async with asyncio.timeout(2):
while not event.is_set():
await asyncio.sleep(0.001)
def test_scope_restoration_expiry_and_disabled_policy(execution_settings) -> None:
"""
轮次结束撤销复制上下文和 HTTP 凭据,禁用策略后不再取得保留容量。
"""
assert not priority.is_interactive()
with priority.interactive_execution():
key = priority.interactive_key()
copied = copy_context()
with priority.interactive_execution():
assert priority.interactive_key() == key
with priority.interactive_execution(enabled=False):
assert not priority.is_interactive()
with priority.propagated_execution(key):
assert priority.is_interactive()
with priority.propagated_execution("forged"):
assert not priority.is_interactive()
assert priority.background_defer_seconds() == 0
execution_settings["AGENT_INTERACTIVE_PRIORITY"] = False
assert priority.interactive_key() is None
assert priority.background_defer_seconds() == 0
assert copied.run(priority.is_interactive) is False
with priority.propagated_execution(key):
assert not priority.is_interactive()
@pytest.mark.asyncio
async def test_http_only_restores_live_process_registered_key(monkeypatch) -> None:
"""
仅真实宿主轮次可经 API 传播优先级,普通请求、过期和伪造头均无效。
"""
monkeypatch.setattr(api_module, "create_access_token", lambda **_kwargs: "test-auth")
executor = MoviePilotApiExecutor(context=ApiExecutionContext("1", "admin", True))
observed = []
async def app(_scope, _receive, _send):
"""
观察下游请求实际看到的执行归属。
"""
observed.append(priority.is_interactive())
middleware = ExecutionPriorityMiddleware(app)
with priority.interactive_execution():
headers = executor._build_headers()
key = headers[priority.PRIORITY_HEADER]
assert headers["Authorization"] == "Bearer test-auth"
with priority.interactive_execution(enabled=False):
await middleware({"type": "http", "headers": [(b"x-moviepilot-execution", key.encode())]}, None, None)
assert not priority.is_interactive()
assert priority.PRIORITY_HEADER not in executor._build_headers()
for value in (key, "forged", ""):
await middleware({"type": "http", "headers": [(b"x-moviepilot-execution", value.encode())]}, None, None)
await middleware({"type": "lifespan"}, None, None)
assert observed == [True, False, False, False, False]
@pytest.mark.asyncio
async def test_anyio_interactive_and_gateway_capacity_survive_background_saturation() -> None:
"""
普通宿主令牌用尽时框架入口仍执行,两组均忙时交互工具仍可取得保留容量。
"""
limiter = current_default_thread_limiter()
original = limiter.total_tokens
limiter.total_tokens = 1
entered = threading.Event()
framework_entered = threading.Event()
release = threading.Event()
task = asyncio.create_task(run_in_threadpool(lambda: entered.set() or release.wait(3)))
framework = None
try:
await _entered(entered)
# FastAPI 的同步依赖和端点走库默认入口,也不能等待宿主后台调用归还令牌。
assert await asyncio.wait_for(run_sync(lambda: "gateway"), 1) == "gateway"
framework = asyncio.create_task(run_sync(lambda: framework_entered.set() or release.wait(3)))
await _entered(framework_entered)
with priority.interactive_execution():
assert priority.interactive_thread_limiter() is priority.interactive_thread_limiter()
assert await asyncio.wait_for(run_in_threadpool(priority.is_interactive), 1) is True
assert priority.background_thread_limiter() is priority.background_thread_limiter()
assert not task.done()
finally:
release.set()
await task
if framework is not None:
await framework
limiter.total_tokens = original
@pytest.mark.asyncio
async def test_database_reservation_is_bounded_and_cancelled_transaction_keeps_owner() -> None:
"""
普通 DB 队列满时交互仍执行,交互容量也有上限,取消等待真实事务结束。
"""
worker = DatabaseWorker(max_workers=1, capacity=1)
await worker.start()
background_entered = threading.Event()
interactive_entered = threading.Event()
release = threading.Event()
interactive_release = threading.Event()
background = asyncio.create_task(worker.run(lambda: background_entered.set() or release.wait(3)))
tasks = []
try:
await _entered(background_entered)
with pytest.raises(DatabaseWorkerOverloadedError):
await worker.run(lambda: None)
with priority.interactive_execution():
assert await asyncio.wait_for(worker.run(priority.is_interactive), 1) is True
active = asyncio.create_task(worker.run(lambda: interactive_entered.set() or interactive_release.wait(3)))
tasks.append(active)
await _entered(interactive_entered)
for _ in range(priority.INTERACTIVE_DATABASE_CAPACITY - 1):
tasks.append(asyncio.create_task(worker.run(lambda: None)))
await asyncio.sleep(0)
with pytest.raises(DatabaseWorkerOverloadedError):
await worker.run(lambda: None)
active.cancel()
await asyncio.sleep(0)
assert not active.done()
interactive_release.set()
await asyncio.gather(*tasks, return_exceptions=True)
assert await worker.run(lambda: "reused") == "reused"
assert worker.interactive_snapshot().running == 0
assert worker._interactive_worker.interactive_snapshot() is None
assert not background.done()
finally:
release.set()
interactive_release.set()
await asyncio.gather(background, *tasks, return_exceptions=True)
await worker.shutdown()
def test_thread_helper_reservation_shutdown_and_reopen(monkeypatch) -> None:
"""
普通池占满时使用交互池;关闭先共同封口,未收敛时不得创建替代 owner。
"""
from app.runtime import thread as thread_module
monkeypatch.setattr(thread_module, "get_runtime_setting", lambda _key: SimpleNamespace(threadpool=1))
helper = object.__new__(ThreadHelper)
helper.__init__()
entered = threading.Event()
release = threading.Event()
future = helper.submit(lambda: entered.set() or release.wait(3))
try:
assert entered.wait(1)
with priority.interactive_execution():
assert helper.submit(priority.is_interactive).result(1) is True
assert helper.shutdown(timeout=0.01) is False
with pytest.raises(RuntimeError):
helper.submit(lambda: None)
assert helper.reopen() is False
finally:
release.set()
future.result(1)
assert helper.shutdown(timeout=1)
assert helper.reopen()
with priority.interactive_execution():
assert helper.submit(lambda: "restored").result(1) == "restored"
assert helper.shutdown(timeout=1)
@pytest.mark.asyncio
async def test_tool_bucket_background_saturation_does_not_block_interactive() -> None:
"""
自主任务占满某工具桶后,交互工具仍获得独立额度并保留归属。
"""
release = threading.Event()
entered = [threading.Event() for _ in range(tool_module._BLOCKING_BUCKET_LIMITS["web"])]
background = [asyncio.create_task(tool_module.run_agent_blocking("web", lambda e=e: e.set() or release.wait(3))) for e in entered]
try:
for event in entered:
await _entered(event)
with priority.interactive_execution():
assert await asyncio.wait_for(tool_module.run_agent_blocking("web", priority.is_interactive), 1) is True
assert all(not task.done() for task in background)
finally:
release.set()
await asyncio.gather(*background)
assert await tool_module.close_blocking_executors(timeout_seconds=1)
assert tool_module.reopen_blocking_executors()
@pytest.mark.asyncio
async def test_background_defer_resumes_and_does_not_starve(execution_settings) -> None:
"""
活跃轮次使后台等待,交互结束立即恢复;持续对话只等待配置预算。
"""
job = {"owner": "subscription"}
def accepting():
"""
保持调度器运行,用于验证避让期限。
"""
return True
with priority.interactive_execution():
with priority.interactive_execution(enabled=False):
task = asyncio.create_task(defer_coroutine(job, accepting))
await asyncio.sleep(0.02)
assert job["deferred"] and not task.done()
await asyncio.wait_for(task, 1)
assert job["deferred"] is False
execution_settings["AGENT_BACKGROUND_DEFER_SECONDS"] = 0.01
with priority.interactive_execution():
with priority.interactive_execution(enabled=False):
await asyncio.wait_for(defer_coroutine(job, accepting), 1)
priority.wait_for_interactive_idle(0.01, accepting)
for exempt in ({"owner": "outbox"}, {"manual": True}):
await asyncio.wait_for(defer_coroutine(exempt, accepting), 1)
with pytest.raises(asyncio.CancelledError):
await defer_coroutine(job, lambda: False)
assert not job["deferred"]
@pytest.mark.asyncio
async def test_cancelling_deferred_job_clears_state() -> None:
"""
等待交互期间取消任务会复原展示状态,不会继续调用业务函数。
"""
job = {"owner": "plugin:test"}
with priority.interactive_execution():
with priority.interactive_execution(enabled=False):
task = asyncio.create_task(defer_coroutine(job, lambda: True))
await asyncio.sleep(0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert job["deferred"] is False
def test_sync_deferred_job_does_not_start_after_shutdown() -> None:
"""
关闭期间尚未进入业务函数的同步任务不得继续执行。
"""
with pytest.raises(RuntimeError, match="尚未开始"):
invoke_background({}, lambda: False, lambda: pytest.fail("任务不应执行"))
def test_sync_background_wait_is_bounded_and_resets_inherited_priority() -> None:
"""
同步任务按预算避让,并在真正执行时撤销意外继承的交互归属。
"""
job = {"owner": "site"}
with priority.interactive_execution():
assert invoke_background(job, lambda: True, priority.is_interactive) is False
with priority.interactive_execution(enabled=False):
started = time.monotonic()
assert invoke_background(job, lambda: True, lambda: "completed") == "completed"
assert time.monotonic() - started >= 0.05
assert job["deferred"] is False
@pytest.mark.asyncio
async def test_sync_entry_on_loop_does_not_block_for_defer() -> None:
"""
兼容的主循环同步入口不能再同步等待交互结束。
"""
with priority.interactive_execution():
assert invoke_background({}, lambda: True, priority.is_interactive) is False
@pytest.mark.asyncio
@pytest.mark.parametrize("scheduled", [False, True])
async def test_agent_process_binds_interactive_only_for_foreground(monkeypatch, scheduled) -> None:
"""
真实 Agent 入口在交互轮次绑定优先级,定时任务即使带回传渠道也不升级。
"""
agent = MoviePilotAgent("priority-test", user_id="1", channel="Web", source="web-agent")
observed = []
async def process(_message, **_kwargs):
"""
代替模型推理记录实际调用上下文。
"""
observed.append(priority.is_interactive())
return "done"
monkeypatch.setattr(agent, "_process", process)
scope = TerminalScope("1", "run", "scheduled") if scheduled else None
assert await agent.process("message", terminal_scope=scope) == "done"
assert observed == [not scheduled]
assert not priority.is_interactive()
assert await agent.cleanup()
@pytest.mark.asyncio
async def test_model_request_timeout_and_disabled_timeout(monkeypatch) -> None:
"""
模型请求超时包含内部重试,取消真正传入 handler;0 保留无限等待语义。
"""
from app.agent.middleware import config as config_module
middleware = RuntimeConfigMiddleware()
monkeypatch.setattr(middleware, "modify_request", lambda request: request)
monkeypatch.setattr(config_module, "get_runtime_setting", lambda *_args: 0.01)
cancelled = asyncio.Event()
async def handler(_request):
"""
持有模型请求直到超时,并记录取消传播。
"""
try:
await asyncio.Event().wait()
finally:
cancelled.set()
with pytest.raises(TimeoutError):
await middleware.awrap_model_call(None, handler)
assert cancelled.is_set()
monkeypatch.setattr(config_module, "get_runtime_setting", lambda *_args: 0)
assert await middleware.awrap_model_call(None, AsyncMock(return_value="done")) == "done"
def test_timeout_notice_requires_checking_existing_write_status() -> None:
"""
超时提示必须说明操作仍可能收尾,避免用户直接重复执行写入。
"""
message = MoviePilotAgent._friendly_execution_error_message(TimeoutError())
assert "超时" in message
assert "先确认状态" in message
@pytest.mark.asyncio
async def test_chat_admission_keeps_foreground_capacity_when_background_is_full() -> None:
"""
聊天写入的上层准入同样隔离,避免请求到达保留 DB worker 前就被后台挤出。
"""
entered = asyncio.Event()
release = asyncio.Event()
class Executor:
"""
用异步屏障隔离数据库 I/O,观察聊天服务自己的准入。
"""
async def run(self, operation):
"""
后台持有容量,交互直接完成;不访问真实仓储。
"""
if not priority.is_interactive():
entered.set()
await release.wait()
service = AgentChatPersistenceService(
repository=lambda _session: None, async_executor=Executor(),
sync_transaction=lambda operation: None, capacity=1,
)
background = asyncio.create_task(service._run_write("background", lambda _repository: None))
try:
await entered.wait()
with priority.interactive_execution():
await service._run_write("interactive", lambda _repository: None)
assert not background.done()
assert service._pending_writes == 1
assert service._pending_interactive_writes == 0
finally:
release.set()
await background
await service.shutdown()
assert service._pending_writes == 0
@pytest.mark.asyncio
async def test_scheduler_applies_default_timeout_and_finishes_timed_out_job(monkeypatch) -> None:
"""
默认超时只填补协程任务的空声明,超时取消后由原 owner 发布失败终态。
"""
owner = scheduler_execution.SchedulerExecutionOwner()
owner._registry = SimpleNamespace(assign_generation=Mock())
owner._lifecycle_state = "running"
owner._finish_job = AsyncMock()
owner._handle_job_error = Mock()
monkeypatch.setattr(scheduler_execution, "get_runtime_setting", lambda *_args: 7)
cancelled = asyncio.Event()
async def task():
"""
模拟需要收到取消才能结束的自动任务。
"""
assert not priority.is_interactive()
try:
await asyncio.Event().wait()
finally:
cancelled.set()
job = {"func": task, "owner": "site", "timeout_seconds": None}
owner._assign_job_generation("job", job)
assert job["timeout_seconds"] == 7
job["timeout_seconds"] = 0.01
owner._assign_job_generation("job", job)
assert job["timeout_seconds"] == 0.01
with priority.interactive_execution():
await owner._run_coro_job(task, "job", job)
assert priority.is_interactive()
assert cancelled.is_set()
assert owner._finish_job.await_args.kwargs["success"] is False
assert "超时" in owner._finish_job.await_args.kwargs["error"]
monkeypatch.setattr(scheduler_execution, "get_runtime_setting", lambda *_args: 0)
unlimited = {"func": task}
owner._assign_job_generation("unlimited", unlimited)
assert unlimited["timeout_seconds"] is None