mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
336 lines
12 KiB
Python
336 lines
12 KiB
Python
"""
|
|
验证构建、图替换、取消和关闭之间的真实资源所有权转换。
|
|
"""
|
|
|
|
import asyncio
|
|
from datetime import datetime
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
import pytest
|
|
|
|
from app.agent.graph import AgentGraphCache, CompiledAgentBundle, GraphResources
|
|
from app.agent.llm.helper import LLMHelper
|
|
|
|
|
|
@pytest.fixture
|
|
def anyio_backend():
|
|
"""
|
|
生命周期屏障使用 asyncio 事件循环。
|
|
"""
|
|
return "asyncio"
|
|
|
|
|
|
def _bundle(resources: GraphResources, revision: int = 0) -> CompiledAgentBundle:
|
|
"""
|
|
以真实资源范围构造不同签名的占位执行图。
|
|
"""
|
|
return CompiledAgentBundle(
|
|
signature=(revision,), agent=object(), streaming=False,
|
|
created_at=datetime.now(), resources=resources,
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("failure", [None, RuntimeError("构建失败"), asyncio.CancelledError()])
|
|
async def test_unpublished_build_closes_consumers_before_models(monkeypatch, failure):
|
|
"""
|
|
正常复核返回、异常和取消均关闭未发布范围,消费者必须先于模型退出。
|
|
"""
|
|
events = []
|
|
cache = AgentGraphCache(models_in_use=lambda: False)
|
|
model = object()
|
|
|
|
async def close_consumer():
|
|
"""
|
|
记录消费者退出,验证其使用模型期间不会提前释放连接。
|
|
"""
|
|
events.append("consumer")
|
|
return True
|
|
|
|
async def close_model(value):
|
|
"""
|
|
只允许关闭本范围接管的模型。
|
|
"""
|
|
assert value is model
|
|
events.append("model")
|
|
return True
|
|
|
|
monkeypatch.setattr(LLMHelper, "close_llm", close_model)
|
|
|
|
async def build():
|
|
"""
|
|
在登记完整资源后模拟三种构图退出路径。
|
|
"""
|
|
async with cache.build() as resources:
|
|
resources.own_model(model)
|
|
resources.own_model(model)
|
|
resources.own_middlewares([SimpleNamespace(close=close_consumer), object()])
|
|
if failure is not None:
|
|
raise failure
|
|
|
|
if failure is None:
|
|
await build()
|
|
else:
|
|
with pytest.raises(type(failure)):
|
|
await build()
|
|
assert events == ["consumer", "model"]
|
|
assert await cache.invalidate() is True
|
|
assert events == ["consumer", "model"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_cancel_during_post_publish_collection_keeps_new_graph_alive(monkeypatch):
|
|
"""
|
|
新图已发布后取消旧图回收,不能把新图误当作未提交的临时资源关闭。
|
|
"""
|
|
cache = AgentGraphCache(models_in_use=lambda: False)
|
|
old_model, new_model = object(), object()
|
|
started, release = asyncio.Event(), asyncio.Event()
|
|
closed = []
|
|
|
|
async def close_model(model):
|
|
"""
|
|
在旧模型关闭前阻塞,使取消准确落在图发布后的回收阶段。
|
|
"""
|
|
if model is old_model:
|
|
started.set()
|
|
await release.wait()
|
|
closed.append(model)
|
|
return True
|
|
|
|
monkeypatch.setattr(LLMHelper, "close_llm", close_model)
|
|
async with cache.build() as resources:
|
|
resources.own_model(old_model)
|
|
cache.publish(_bundle(resources))
|
|
|
|
async def replace():
|
|
"""
|
|
在真实构建范围内同步发布新图,再由退出逻辑回收旧图。
|
|
"""
|
|
async with cache.build() as replacement:
|
|
replacement.own_model(new_model)
|
|
cache.publish(_bundle(replacement, 1))
|
|
|
|
task = asyncio.create_task(replace())
|
|
await started.wait()
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
assert cache.get((1,), False) is cache.current.agent
|
|
assert closed == []
|
|
release.set()
|
|
await cache.collect()
|
|
assert closed == [old_model]
|
|
assert await cache.invalidate() is True
|
|
assert closed == [old_model, new_model]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("first_result", [False, RuntimeError("关闭失败")])
|
|
async def test_pending_consumers_retain_only_their_own_models(monkeypatch, first_result):
|
|
"""
|
|
某代消费者未退出时保留对应模型,但不能阻塞另一独立范围释放。
|
|
"""
|
|
cache = AgentGraphCache(models_in_use=lambda: False)
|
|
close_model = AsyncMock(return_value=True)
|
|
monkeypatch.setattr(LLMHelper, "close_llm", close_model)
|
|
model, independent_model = object(), object()
|
|
consumer = SimpleNamespace(close=AsyncMock(side_effect=[first_result, False, True]))
|
|
async with cache.build() as resources:
|
|
resources.own_model(model)
|
|
resources.own_middlewares([consumer])
|
|
close_model.assert_not_awaited()
|
|
async with cache.build() as independent:
|
|
independent.own_model(independent_model)
|
|
close_model.assert_awaited_once_with(independent_model)
|
|
assert await cache.invalidate() is True
|
|
assert [call.args[0] for call in close_model.await_args_list] == [independent_model, model]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("phase", ["consumer", "model"])
|
|
async def test_interrupted_collection_can_retry_without_losing_resources(monkeypatch, phase):
|
|
"""
|
|
消费者或模型关闭中再次取消,资源必须仍由原 owner 保留并可重试。
|
|
"""
|
|
cache = AgentGraphCache(models_in_use=lambda: False)
|
|
started, release = asyncio.Event(), asyncio.Event()
|
|
model = object()
|
|
|
|
async def blocked_close(*_args):
|
|
"""
|
|
在真实关闭边界等待,用屏障代替不稳定的时间等待。
|
|
"""
|
|
started.set()
|
|
await release.wait()
|
|
return True
|
|
|
|
consumer = SimpleNamespace(close=AsyncMock(return_value=True))
|
|
close_model = AsyncMock(return_value=True)
|
|
if phase == "consumer":
|
|
consumer.close.side_effect = blocked_close
|
|
else:
|
|
close_model.side_effect = blocked_close
|
|
monkeypatch.setattr(LLMHelper, "close_llm", close_model)
|
|
async with cache.build() as resources:
|
|
resources.own_model(model)
|
|
resources.own_middlewares([consumer])
|
|
cache.publish(_bundle(resources))
|
|
task = asyncio.create_task(cache.invalidate())
|
|
await started.wait()
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
assert cache.current is None
|
|
assert not resources.empty
|
|
release.set()
|
|
assert await cache.invalidate() is True
|
|
assert resources.empty
|
|
assert consumer.close.await_count == (2 if phase == "consumer" else 1)
|
|
assert close_model.await_count == (1 if phase == "consumer" else 2)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_shutdown_tracks_inflight_build_and_seals_late_controller(monkeypatch):
|
|
"""
|
|
关停与构建交错时不提前释放在用模型,迟到控制器立即封口,迟到图拒绝发布。
|
|
"""
|
|
cache = AgentGraphCache(models_in_use=lambda: False)
|
|
close_model = AsyncMock(return_value=True)
|
|
monkeypatch.setattr(LLMHelper, "close_llm", close_model)
|
|
model = object()
|
|
consumer = SimpleNamespace(seal=Mock(), close=AsyncMock(return_value=True))
|
|
async with cache.build() as resources:
|
|
resources.own_model(model)
|
|
cache.seal()
|
|
assert await cache.invalidate() is False
|
|
close_model.assert_not_awaited()
|
|
resources.own_middlewares([consumer])
|
|
consumer.seal.assert_called_once()
|
|
with pytest.raises(RuntimeError, match="正在关闭"):
|
|
cache.publish(_bundle(resources))
|
|
close_model.assert_awaited_once_with(model)
|
|
assert await cache.invalidate() is True
|
|
with pytest.raises(RuntimeError, match="正在关闭"):
|
|
async with cache.build():
|
|
pytest.fail("关闭后不得进入构建范围")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_concurrent_collection_does_not_double_close(monkeypatch):
|
|
"""
|
|
两个清理请求并发时只执行一次实际模型关闭。
|
|
"""
|
|
cache = AgentGraphCache(models_in_use=lambda: False)
|
|
started, release = asyncio.Event(), asyncio.Event()
|
|
|
|
async def close_model(_model):
|
|
"""
|
|
保持首次关闭在途,让第二次清理与其真实交错。
|
|
"""
|
|
started.set()
|
|
await release.wait()
|
|
return True
|
|
|
|
close = AsyncMock(side_effect=close_model)
|
|
monkeypatch.setattr(LLMHelper, "close_llm", close)
|
|
async with cache.build() as resources:
|
|
resources.own_model(object())
|
|
cache.publish(_bundle(resources))
|
|
first = asyncio.create_task(cache.invalidate())
|
|
await started.wait()
|
|
second = asyncio.create_task(cache.invalidate())
|
|
release.set()
|
|
assert await asyncio.gather(first, second) == [True, True]
|
|
close.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_retirement_during_collection_keeps_shared_model_for_late_consumer(monkeypatch):
|
|
"""
|
|
清理等待期间另一图退休时,也必须等待其消费者退出后再释放共享模型。
|
|
"""
|
|
cache = AgentGraphCache(models_in_use=lambda: False)
|
|
started, release = asyncio.Event(), asyncio.Event()
|
|
model = object()
|
|
close = AsyncMock(return_value=True)
|
|
monkeypatch.setattr(LLMHelper, "close_llm", close)
|
|
|
|
async def close_old_consumer():
|
|
"""
|
|
阻塞旧消费者退出,为新图退休提供确定的交错点。
|
|
"""
|
|
started.set()
|
|
await release.wait()
|
|
return True
|
|
|
|
async with cache.build() as resources:
|
|
resources.own_model(model)
|
|
resources.own_middlewares([SimpleNamespace(close=close_old_consumer)])
|
|
cache.publish(_bundle(resources))
|
|
cache.retire()
|
|
newer = GraphResources()
|
|
consumer = SimpleNamespace(close=AsyncMock(return_value=True))
|
|
newer.own_model(model)
|
|
newer.own_middlewares([consumer])
|
|
cache.publish(_bundle(newer, 1))
|
|
collecting = asyncio.create_task(cache.collect())
|
|
await started.wait()
|
|
cache.retire()
|
|
release.set()
|
|
await collecting
|
|
close.assert_not_awaited()
|
|
consumer.close.assert_not_awaited()
|
|
assert await cache.invalidate() is True
|
|
consumer.close.assert_awaited_once()
|
|
close.assert_awaited_once_with(model)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_repeated_replacement_preserves_shared_resources_and_releases_once(monkeypatch):
|
|
"""
|
|
多轮替换共享同一模型与控制器时,只有最终失效才释放各一次。
|
|
"""
|
|
cache = AgentGraphCache(models_in_use=lambda: False)
|
|
model = object()
|
|
consumer = SimpleNamespace(close=AsyncMock(return_value=True))
|
|
close = AsyncMock(return_value=True)
|
|
monkeypatch.setattr(LLMHelper, "close_llm", close)
|
|
for revision in range(20):
|
|
async with cache.build() as resources:
|
|
resources.own_model(model)
|
|
resources.own_middlewares([consumer])
|
|
bundle = _bundle(resources, revision)
|
|
cache.publish(bundle)
|
|
cache.publish(bundle)
|
|
assert cache.get((revision,), False) is bundle.agent
|
|
assert cache.get((revision,), True) is None
|
|
close.assert_not_awaited()
|
|
consumer.close.assert_not_awaited()
|
|
assert await cache.invalidate() is True
|
|
close.assert_awaited_once_with(model)
|
|
consumer.close.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_learning_and_close_failure_keep_resource_until_both_converge(monkeypatch):
|
|
"""
|
|
后台复盘退出后才能尝试关闭;关闭失败仍保留资源至后续成功。
|
|
"""
|
|
learning = asyncio.get_running_loop().create_future()
|
|
cache = AgentGraphCache(models_in_use=lambda: not learning.done())
|
|
close = AsyncMock(side_effect=[False, True])
|
|
monkeypatch.setattr(LLMHelper, "close_llm", close)
|
|
async with cache.build() as resources:
|
|
resources.own_model(object())
|
|
cache.publish(_bundle(resources))
|
|
assert await cache.invalidate() is False
|
|
close.assert_not_awaited()
|
|
learning.set_result(None)
|
|
assert await cache.invalidate() is False
|
|
assert not resources.empty
|
|
assert await cache.invalidate() is True
|
|
assert resources.empty
|
|
assert close.await_count == 2
|