mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 09:26:55 +08:00
修复非模块组件配置变更未触发热重载 (#6569)
* fix(event): bind config reload lifecycle owners * chore(architecture): update config reload dependency baseline * fix(event): preserve config resolver type contracts
This commit is contained in:
@@ -16,6 +16,7 @@ from typing import (
|
|||||||
Tuple,
|
Tuple,
|
||||||
Type,
|
Type,
|
||||||
Union,
|
Union,
|
||||||
|
cast,
|
||||||
)
|
)
|
||||||
|
|
||||||
from watchfiles import watch
|
from watchfiles import watch
|
||||||
@@ -167,7 +168,13 @@ def _resolve_plugin_handler_instance(
|
|||||||
owner_class: Type[Any],
|
owner_class: Type[Any],
|
||||||
) -> Optional[EventHandlerBinding]:
|
) -> Optional[EventHandlerBinding]:
|
||||||
"""通过当前插件管理器单例解析实例,避免事件总线持有过期对象。"""
|
"""通过当前插件管理器单例解析实例,避免事件总线持有过期对象。"""
|
||||||
return PluginManager().resolve_event_handler_instance(owner_class)
|
manager = cast(
|
||||||
|
Optional["PluginManager"],
|
||||||
|
PluginManager.get_existing_instance(),
|
||||||
|
)
|
||||||
|
if manager is None:
|
||||||
|
return None
|
||||||
|
return manager.resolve_event_handler_instance(owner_class)
|
||||||
|
|
||||||
|
|
||||||
@observe_compat_facade("PluginManager")
|
@observe_compat_facade("PluginManager")
|
||||||
@@ -224,7 +231,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
|||||||
"""为插件声明的事件方法解析当前运行实例。"""
|
"""为插件声明的事件方法解析当前运行实例。"""
|
||||||
plugin_id = owner_class.__name__
|
plugin_id = owner_class.__name__
|
||||||
# 旧测试与部分扩展会替换私有映射来构造隔离运行态,解析器继续尊重该接缝。
|
# 旧测试与部分扩展会替换私有映射来构造隔离运行态,解析器继续尊重该接缝。
|
||||||
if plugin_id not in self._plugins:
|
if self._plugins.get(plugin_id) is not owner_class:
|
||||||
return None
|
return None
|
||||||
plugin = self._running_plugins.get(plugin_id)
|
plugin = self._running_plugins.get(plugin_id)
|
||||||
owner_name = plugin_id
|
owner_name = plugin_id
|
||||||
|
|||||||
@@ -383,6 +383,11 @@ def configure_doh_composition() -> None:
|
|||||||
DohHelper()
|
DohHelper()
|
||||||
|
|
||||||
|
|
||||||
|
def get_existing_doh_composition() -> object | None:
|
||||||
|
"""返回已由组合根物化的 DoH Adapter,不触发资源创建。"""
|
||||||
|
return cast(Optional[DohHelper], DohHelper.get_existing_instance())
|
||||||
|
|
||||||
|
|
||||||
def stop_doh_composition() -> bool:
|
def stop_doh_composition() -> bool:
|
||||||
"""关闭已存在的 DoH Adapter,停机阶段不得反向物化实例。"""
|
"""关闭已存在的 DoH Adapter,停机阶段不得反向物化实例。"""
|
||||||
helper = DohHelper.get_existing_instance()
|
helper = DohHelper.get_existing_instance()
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ from app.startup.composition.enrichment import (
|
|||||||
from app.startup.composition.network import (
|
from app.startup.composition.network import (
|
||||||
configure_application_network_ports,
|
configure_application_network_ports,
|
||||||
configure_doh_composition,
|
configure_doh_composition,
|
||||||
|
get_existing_doh_composition,
|
||||||
reset_application_network_ports,
|
reset_application_network_ports,
|
||||||
stop_doh_composition,
|
stop_doh_composition,
|
||||||
)
|
)
|
||||||
@@ -199,6 +200,7 @@ def reset_event_services() -> None:
|
|||||||
dispatch_web_agent_message_event,
|
dispatch_web_agent_message_event,
|
||||||
)
|
)
|
||||||
event_manager.unregister_handler_instance_resolver("host")
|
event_manager.unregister_handler_instance_resolver("host")
|
||||||
|
event_manager.unregister_handler_instance_resolver("config_reload")
|
||||||
event_manager.reset_error_notifier()
|
event_manager.reset_error_notifier()
|
||||||
|
|
||||||
|
|
||||||
@@ -296,6 +298,72 @@ def get_host_event_handler_factories() -> dict[type, Callable[[], object]]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_config_reload_handler_providers(
|
||||||
|
) -> dict[type, Callable[[], object | None]]:
|
||||||
|
"""返回未由专属运行时接管的配置重载 owner Provider。"""
|
||||||
|
from app.chain.transfer.facade import TransferChain
|
||||||
|
from app.monitor.monitor import Monitor
|
||||||
|
from app.runtime.state import SystemHelper
|
||||||
|
|
||||||
|
# SystemHelper 不持有外部资源;闭包为当前 lifespan 保留稳定身份。
|
||||||
|
system_helper = SystemHelper()
|
||||||
|
providers: dict[type, Callable[[], object | None]] = {
|
||||||
|
SystemHelper: lambda: system_helper,
|
||||||
|
RedisHelper: RedisHelper.get_existing_instance,
|
||||||
|
AsyncRedisHelper: AsyncRedisHelper.get_existing_instance,
|
||||||
|
TransferChain: TransferChain.get_existing_instance,
|
||||||
|
Monitor: Monitor.get_existing_instance,
|
||||||
|
}
|
||||||
|
doh_helper = get_existing_doh_composition()
|
||||||
|
if doh_helper is not None:
|
||||||
|
providers[type(doh_helper)] = get_existing_doh_composition
|
||||||
|
plugin_manager = get_existing_plugin_manager()
|
||||||
|
if plugin_manager is not None:
|
||||||
|
providers[type(plugin_manager)] = get_existing_plugin_manager
|
||||||
|
return providers
|
||||||
|
|
||||||
|
|
||||||
|
def configure_config_reload_event_handler_resolver() -> None:
|
||||||
|
"""绑定当前生命周期已拥有的配置重载实例,惰性 owner 未创建时跳过。"""
|
||||||
|
providers = get_config_reload_handler_providers()
|
||||||
|
runtime_providers = (
|
||||||
|
get_existing_doh_composition,
|
||||||
|
get_existing_plugin_manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve(owner_class: type) -> EventHandlerBinding | None:
|
||||||
|
"""按类型身份读取当前 owner,不在事件分发路径构造资源。"""
|
||||||
|
provider = providers.get(owner_class)
|
||||||
|
if provider is not None:
|
||||||
|
instance = provider()
|
||||||
|
if instance is not None and type(instance) is not owner_class:
|
||||||
|
return None
|
||||||
|
return EventHandlerBinding(
|
||||||
|
instance=instance,
|
||||||
|
owner_name=owner_class.__name__,
|
||||||
|
)
|
||||||
|
instance = next(
|
||||||
|
(
|
||||||
|
current
|
||||||
|
for runtime_provider in runtime_providers
|
||||||
|
if (current := runtime_provider()) is not None
|
||||||
|
and type(current) is owner_class
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if instance is None:
|
||||||
|
return None
|
||||||
|
return EventHandlerBinding(
|
||||||
|
instance=instance,
|
||||||
|
owner_name=owner_class.__name__,
|
||||||
|
)
|
||||||
|
|
||||||
|
EventManager().register_handler_instance_resolver( # type: ignore[no-untyped-call]
|
||||||
|
"config_reload",
|
||||||
|
resolve,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def configure_host_event_handler_resolver() -> None:
|
def configure_host_event_handler_resolver() -> None:
|
||||||
"""显式登记宿主内置类处理器,禁止事件总线按类名临时构造未知对象。"""
|
"""显式登记宿主内置类处理器,禁止事件总线按类名临时构造未知对象。"""
|
||||||
factories = get_host_event_handler_factories()
|
factories = get_host_event_handler_factories()
|
||||||
@@ -665,6 +733,8 @@ async def _initialize_modules() -> HostRuntime:
|
|||||||
)
|
)
|
||||||
# 宿主类处理器在启动层显式登记,事件总线不再兜底 owner_class()。
|
# 宿主类处理器在启动层显式登记,事件总线不再兜底 owner_class()。
|
||||||
configure_host_event_handler_resolver()
|
configure_host_event_handler_resolver()
|
||||||
|
# 配置 owner 按各自生命周期读取当前实例,惰性资源不会因事件而物化。
|
||||||
|
configure_config_reload_event_handler_resolver()
|
||||||
# 加载模块
|
# 加载模块
|
||||||
ModuleManager()
|
ModuleManager()
|
||||||
# 启动事件消费
|
# 启动事件消费
|
||||||
|
|||||||
@@ -755,7 +755,7 @@ flowchart LR
|
|||||||
| 指标 | 当前值 |
|
| 指标 | 当前值 |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
| Python 模块 | 968 |
|
| Python 模块 | 968 |
|
||||||
| 内部导入边 | 8,127 |
|
| 内部导入边 | 8,132 |
|
||||||
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
|
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
|
||||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||||
| Direct egress | 53(债务已清零,53 条精确 containment) |
|
| Direct egress | 53(债务已清零,53 条精确 containment) |
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
|||||||
|
|
||||||
| 指标 | 当前值 | 解释 |
|
| 指标 | 当前值 | 解释 |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| 宿主 Python 模块 / 内部依赖边 | 968 / 8,127 | `dependency-baseline.json` 当前快照 |
|
| 宿主 Python 模块 / 内部依赖边 | 968 / 8,132 | `dependency-baseline.json` 当前快照 |
|
||||||
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
|
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
|
||||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||||
|
|||||||
+7
-2
@@ -1089,8 +1089,8 @@
|
|||||||
"runtime_only": true
|
"runtime_only": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"edge_count": 8127,
|
"edge_count": 8132,
|
||||||
"edge_sha256": "b355003c1ca55cd75ed454e9fe986dcf37b37078e6583563b41038c62a05c57f",
|
"edge_sha256": "33505d1acbf26b53533027ed633ec23f3b4c4ee24c242a02372c05493c2919ab",
|
||||||
"edges": [
|
"edges": [
|
||||||
"app -> app.foundation",
|
"app -> app.foundation",
|
||||||
"app -> app.foundation.environment",
|
"app -> app.foundation.environment",
|
||||||
@@ -8831,10 +8831,14 @@
|
|||||||
"app.startup.initializers.modules -> app.chain.site",
|
"app.startup.initializers.modules -> app.chain.site",
|
||||||
"app.startup.initializers.modules -> app.chain.subscribe",
|
"app.startup.initializers.modules -> app.chain.subscribe",
|
||||||
"app.startup.initializers.modules -> app.chain.subscribe.facade",
|
"app.startup.initializers.modules -> app.chain.subscribe.facade",
|
||||||
|
"app.startup.initializers.modules -> app.chain.transfer",
|
||||||
|
"app.startup.initializers.modules -> app.chain.transfer.facade",
|
||||||
"app.startup.initializers.modules -> app.chain.workflow",
|
"app.startup.initializers.modules -> app.chain.workflow",
|
||||||
"app.startup.initializers.modules -> app.command",
|
"app.startup.initializers.modules -> app.command",
|
||||||
"app.startup.initializers.modules -> app.db",
|
"app.startup.initializers.modules -> app.db",
|
||||||
"app.startup.initializers.modules -> app.db.session",
|
"app.startup.initializers.modules -> app.db.session",
|
||||||
|
"app.startup.initializers.modules -> app.monitor",
|
||||||
|
"app.startup.initializers.modules -> app.monitor.monitor",
|
||||||
"app.startup.initializers.modules -> app.runtime",
|
"app.startup.initializers.modules -> app.runtime",
|
||||||
"app.startup.initializers.modules -> app.runtime.config",
|
"app.startup.initializers.modules -> app.runtime.config",
|
||||||
"app.startup.initializers.modules -> app.runtime.events",
|
"app.startup.initializers.modules -> app.runtime.events",
|
||||||
@@ -8845,6 +8849,7 @@
|
|||||||
"app.startup.initializers.modules -> app.runtime.extensions.service",
|
"app.startup.initializers.modules -> app.runtime.extensions.service",
|
||||||
"app.startup.initializers.modules -> app.runtime.log",
|
"app.startup.initializers.modules -> app.runtime.log",
|
||||||
"app.startup.initializers.modules -> app.runtime.settings",
|
"app.startup.initializers.modules -> app.runtime.settings",
|
||||||
|
"app.startup.initializers.modules -> app.runtime.state",
|
||||||
"app.startup.initializers.modules -> app.runtime.tasks",
|
"app.startup.initializers.modules -> app.runtime.tasks",
|
||||||
"app.startup.initializers.modules -> app.runtime.thread",
|
"app.startup.initializers.modules -> app.runtime.thread",
|
||||||
"app.startup.initializers.modules -> app.scheduler",
|
"app.startup.initializers.modules -> app.scheduler",
|
||||||
|
|||||||
@@ -5,13 +5,28 @@ ConfigReloadMixin 动态生成的事件处理器,必须能被事件总线解
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.cache.redis import AsyncRedisHelper, RedisHelper
|
||||||
|
from app.adapters.network.doh import DohHelper
|
||||||
|
from app.chain.scraping import ScrapingChain
|
||||||
|
from app.chain.transfer.facade import TransferChain
|
||||||
|
from app.foundation.singleton import Singleton, SingletonClass
|
||||||
|
from app.monitor.monitor import Monitor
|
||||||
from app.runtime.events import Event, EventHandlerBinding, eventmanager
|
from app.runtime.events import Event, EventHandlerBinding, eventmanager
|
||||||
|
from app.runtime.extensions.plugin.manager import PluginManager
|
||||||
from app.runtime.reload import ConfigReloadMixin
|
from app.runtime.reload import ConfigReloadMixin
|
||||||
from app.schemas import ConfigChangeEventData
|
from app.runtime.state import SystemHelper
|
||||||
|
from app.scheduler.facade import Scheduler
|
||||||
|
from app.schemas.event import ConfigChangeEventData
|
||||||
from app.schemas.types import EventType
|
from app.schemas.types import EventType
|
||||||
|
from app.startup.initializers.modules import (
|
||||||
|
configure_config_reload_event_handler_resolver,
|
||||||
|
configure_host_event_handler_resolver,
|
||||||
|
get_config_reload_handler_providers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _ReloadRecorder(ConfigReloadMixin):
|
class _ReloadRecorder(ConfigReloadMixin):
|
||||||
@@ -67,6 +82,12 @@ def _build_event(keys):
|
|||||||
return Event(EventType.ConfigChanged, ConfigChangeEventData(key=set(keys)))
|
return Event(EventType.ConfigChanged, ConfigChangeEventData(key=set(keys)))
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_lifespan_resolvers() -> None:
|
||||||
|
"""按启动组合顺序登记宿主和配置 owner resolver。"""
|
||||||
|
configure_host_event_handler_resolver()
|
||||||
|
configure_config_reload_event_handler_resolver()
|
||||||
|
|
||||||
|
|
||||||
async def _dispatch(instance, event):
|
async def _dispatch(instance, event):
|
||||||
"""按处理器类型走事件总线的同步或异步调用路径。"""
|
"""按处理器类型走事件总线的同步或异步调用路径。"""
|
||||||
handler = instance.__class__.handle_config_changed
|
handler = instance.__class__.handle_config_changed
|
||||||
@@ -146,3 +167,268 @@ def test_externally_managed_reload_class_does_not_register_listener(monkeypatch)
|
|||||||
|
|
||||||
assert registrations == []
|
assert registrations == []
|
||||||
assert "handle_config_changed" not in _ExternallyManagedReloadRecorder.__dict__
|
assert "handle_config_changed" not in _ExternallyManagedReloadRecorder.__dict__
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_reload_owner_is_skipped_without_lifecycle_resolver(monkeypatch):
|
||||||
|
"""非模块配置 owner 缺少 resolver 时不得被事件总线临时构造。"""
|
||||||
|
helper = object.__new__(DohHelper)
|
||||||
|
reload_config = Mock()
|
||||||
|
helper.on_config_changed = reload_config
|
||||||
|
monkeypatch.setattr(
|
||||||
|
eventmanager,
|
||||||
|
"_EventManager__handler_instance_resolvers",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
eventmanager._EventManager__invoke_handler_by_type_sync(
|
||||||
|
DohHelper.handle_config_changed,
|
||||||
|
_build_event({"DOH_ENABLE"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
reload_config.assert_not_called()
|
||||||
|
assert (
|
||||||
|
"app.adapters.network.doh.DohHelper.handle_config_changed"
|
||||||
|
in eventmanager.unresolved_handler_bindings()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_config_reload_resolver_binds_current_doh_owner(monkeypatch):
|
||||||
|
"""DoH 配置事件必须绑定到已由组合根物化的当前 Adapter。"""
|
||||||
|
helper = object.__new__(DohHelper)
|
||||||
|
reload_config = Mock()
|
||||||
|
helper.on_config_changed = reload_config
|
||||||
|
singleton_key = (DohHelper, (), frozenset())
|
||||||
|
monkeypatch.setitem(Singleton._instances, singleton_key, helper)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
eventmanager,
|
||||||
|
"_EventManager__handler_instance_resolvers",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
_configure_lifespan_resolvers()
|
||||||
|
|
||||||
|
eventmanager._EventManager__invoke_handler_by_type_sync(
|
||||||
|
DohHelper.handle_config_changed,
|
||||||
|
_build_event({"DOH_ENABLE"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
reload_config.assert_called_once_with()
|
||||||
|
|
||||||
|
eventmanager._EventManager__invoke_handler_by_type_sync(
|
||||||
|
DohHelper.handle_config_changed,
|
||||||
|
_build_event({"OTHER_KEY"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
reload_config.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_resolver_binds_current_scraping_owner(monkeypatch):
|
||||||
|
"""宿主 resolver 必须把同步重载处理器绑定到当前 Chain 单例。"""
|
||||||
|
chain = object.__new__(ScrapingChain)
|
||||||
|
reload_config = Mock()
|
||||||
|
chain.on_config_changed = reload_config
|
||||||
|
singleton_key = (ScrapingChain, (), frozenset())
|
||||||
|
monkeypatch.setitem(Singleton._instances, singleton_key, chain)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
eventmanager,
|
||||||
|
"_EventManager__handler_instance_resolvers",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
configure_host_event_handler_resolver()
|
||||||
|
|
||||||
|
eventmanager._EventManager__invoke_handler_by_type_sync(
|
||||||
|
ScrapingChain.handle_config_changed,
|
||||||
|
_build_event({"ScrapingSwitchs"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
reload_config.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_host_resolver_binds_current_scheduler_owner(monkeypatch):
|
||||||
|
"""宿主 resolver 必须把异步重载处理器绑定到当前 Scheduler 单例。"""
|
||||||
|
scheduler = object.__new__(Scheduler)
|
||||||
|
reload_config = AsyncMock()
|
||||||
|
scheduler.on_config_changed = reload_config
|
||||||
|
monkeypatch.setitem(SingletonClass._instances, Scheduler, scheduler)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
eventmanager,
|
||||||
|
"_EventManager__handler_instance_resolvers",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
configure_host_event_handler_resolver()
|
||||||
|
|
||||||
|
await eventmanager._EventManager__invoke_handler_by_type_async(
|
||||||
|
Scheduler.handle_config_changed,
|
||||||
|
_build_event({"DEV"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
reload_config.assert_awaited_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_reload_resolver_reads_latest_singleton_owner(monkeypatch):
|
||||||
|
"""resolver 每次读取当前单例,不能保留已经换代的 Redis 实例。"""
|
||||||
|
first = object.__new__(RedisHelper)
|
||||||
|
first_reload = Mock()
|
||||||
|
first.on_config_changed = first_reload
|
||||||
|
second = object.__new__(RedisHelper)
|
||||||
|
second_reload = Mock()
|
||||||
|
second.on_config_changed = second_reload
|
||||||
|
singleton_key = (RedisHelper, (), frozenset())
|
||||||
|
monkeypatch.setitem(Singleton._instances, singleton_key, first)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
eventmanager,
|
||||||
|
"_EventManager__handler_instance_resolvers",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
_configure_lifespan_resolvers()
|
||||||
|
|
||||||
|
eventmanager._EventManager__invoke_handler_by_type_sync(
|
||||||
|
RedisHelper.handle_config_changed,
|
||||||
|
_build_event({"CACHE_BACKEND_URL"}),
|
||||||
|
)
|
||||||
|
monkeypatch.setitem(Singleton._instances, singleton_key, second)
|
||||||
|
eventmanager._EventManager__invoke_handler_by_type_sync(
|
||||||
|
RedisHelper.handle_config_changed,
|
||||||
|
_build_event({"CACHE_BACKEND_URL"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
first_reload.assert_called_once_with()
|
||||||
|
second_reload.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_config_reload_resolver_invokes_current_async_redis_owner(monkeypatch):
|
||||||
|
"""异步配置处理器必须绑定当前 Redis owner,并保留键筛选。"""
|
||||||
|
helper = object.__new__(AsyncRedisHelper)
|
||||||
|
reload_config = AsyncMock()
|
||||||
|
helper.on_config_changed = reload_config
|
||||||
|
singleton_key = (AsyncRedisHelper, (), frozenset())
|
||||||
|
monkeypatch.setitem(Singleton._instances, singleton_key, helper)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
eventmanager,
|
||||||
|
"_EventManager__handler_instance_resolvers",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
_configure_lifespan_resolvers()
|
||||||
|
|
||||||
|
await eventmanager._EventManager__invoke_handler_by_type_async(
|
||||||
|
AsyncRedisHelper.handle_config_changed,
|
||||||
|
_build_event({"CACHE_REDIS_MAX_CONNECTIONS"}),
|
||||||
|
)
|
||||||
|
await eventmanager._EventManager__invoke_handler_by_type_async(
|
||||||
|
AsyncRedisHelper.handle_config_changed,
|
||||||
|
_build_event({"OTHER_KEY"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
reload_config.assert_awaited_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_reload_resolver_does_not_materialize_lazy_transfer_owner(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""尚未创建的整理链只声明跳过,配置事件不得启动线程资源。"""
|
||||||
|
singleton_key = (TransferChain, (), frozenset())
|
||||||
|
monkeypatch.delitem(Singleton._instances, singleton_key, raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
eventmanager,
|
||||||
|
"_EventManager__handler_instance_resolvers",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
_configure_lifespan_resolvers()
|
||||||
|
|
||||||
|
eventmanager._EventManager__invoke_handler_by_type_sync(
|
||||||
|
TransferChain.handle_config_changed,
|
||||||
|
_build_event({"TRANSFER_THREADS"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert TransferChain.get_existing_instance() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_manager_falls_through_plugin_resolver_to_current_owner(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""resolver 先注册时也应在插件 Runtime 创建后接管当前管理器。"""
|
||||||
|
singleton_key = (PluginManager, (), frozenset())
|
||||||
|
monkeypatch.delitem(Singleton._instances, singleton_key, raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
eventmanager,
|
||||||
|
"_EventManager__handler_instance_resolvers",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
configure_config_reload_event_handler_resolver()
|
||||||
|
|
||||||
|
manager = object.__new__(PluginManager)
|
||||||
|
reload_config = Mock()
|
||||||
|
manager.on_config_changed = reload_config
|
||||||
|
monkeypatch.setitem(Singleton._instances, singleton_key, manager)
|
||||||
|
plugin_resolver = Mock(return_value=None)
|
||||||
|
eventmanager.register_handler_instance_resolver(
|
||||||
|
"plugins",
|
||||||
|
plugin_resolver,
|
||||||
|
)
|
||||||
|
|
||||||
|
eventmanager._EventManager__invoke_handler_by_type_sync(
|
||||||
|
PluginManager.handle_config_changed,
|
||||||
|
_build_event({"PLUGIN_AUTO_RELOAD"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
plugin_resolver.assert_not_called()
|
||||||
|
reload_config.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_reload_resolver_registration_replaces_same_name(monkeypatch):
|
||||||
|
"""重复装配只替换当前 lifespan resolver,不累积并行绑定。"""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
eventmanager,
|
||||||
|
"_EventManager__handler_instance_resolvers",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
configure_config_reload_event_handler_resolver()
|
||||||
|
first = eventmanager._EventManager__handler_instance_resolvers[
|
||||||
|
"config_reload"
|
||||||
|
]
|
||||||
|
configure_config_reload_event_handler_resolver()
|
||||||
|
|
||||||
|
assert set(eventmanager._EventManager__handler_instance_resolvers) == {
|
||||||
|
"config_reload"
|
||||||
|
}
|
||||||
|
assert (
|
||||||
|
eventmanager._EventManager__handler_instance_resolvers[
|
||||||
|
"config_reload"
|
||||||
|
]
|
||||||
|
is not first
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("owner_class", "registry", "singleton_key"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
PluginManager,
|
||||||
|
Singleton._instances,
|
||||||
|
(PluginManager, (), frozenset()),
|
||||||
|
),
|
||||||
|
(Monitor, SingletonClass._instances, Monitor),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_config_reload_provider_returns_lifecycle_owned_instance(
|
||||||
|
monkeypatch,
|
||||||
|
owner_class,
|
||||||
|
registry,
|
||||||
|
singleton_key,
|
||||||
|
):
|
||||||
|
"""管理器和监控器必须从各自生命周期注册表读取当前实例。"""
|
||||||
|
instance = object.__new__(owner_class)
|
||||||
|
monkeypatch.setitem(registry, singleton_key, instance)
|
||||||
|
|
||||||
|
providers = get_config_reload_handler_providers()
|
||||||
|
|
||||||
|
assert providers[owner_class]() is instance
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_reload_provider_keeps_one_lifespan_instance() -> None:
|
||||||
|
"""无资源的系统配置处理器也由 resolver 闭包保持稳定 owner 身份。"""
|
||||||
|
provider = get_config_reload_handler_providers()[SystemHelper]
|
||||||
|
|
||||||
|
assert provider() is provider()
|
||||||
|
|||||||
@@ -5,14 +5,20 @@ import threading
|
|||||||
import types
|
import types
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from app.foundation.singleton import Singleton
|
||||||
from app.runtime.event.binding import (
|
from app.runtime.event.binding import (
|
||||||
EventBindingResolver,
|
EventBindingResolver,
|
||||||
EventHandlerBinding,
|
EventHandlerBinding,
|
||||||
)
|
)
|
||||||
from app.runtime.event.errors import EventErrorPolicy
|
from app.runtime.event.errors import EventErrorPolicy
|
||||||
from app.runtime.events import Event
|
from app.runtime.events import Event
|
||||||
|
from app.runtime.extensions.plugin import manager as plugin_manager_module
|
||||||
|
from app.runtime.extensions.plugin.manager import PluginManager
|
||||||
from app.schemas.types import EventType
|
from app.schemas.types import EventType
|
||||||
from app.startup.initializers.modules import get_host_event_handler_factories
|
from app.startup.initializers.modules import (
|
||||||
|
get_config_reload_handler_providers,
|
||||||
|
get_host_event_handler_factories,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _UnmanagedHandler:
|
class _UnmanagedHandler:
|
||||||
@@ -28,6 +34,15 @@ class _UnmanagedHandler:
|
|||||||
"""提供可解析的实例方法声明。"""
|
"""提供可解析的实例方法声明。"""
|
||||||
|
|
||||||
|
|
||||||
|
class _PluginHandler:
|
||||||
|
"""代表插件运行时登记的事件 owner。"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_name() -> str:
|
||||||
|
"""返回测试插件名称。"""
|
||||||
|
return "测试插件"
|
||||||
|
|
||||||
|
|
||||||
def _free_function_handler(_event: Event) -> None:
|
def _free_function_handler(_event: Event) -> None:
|
||||||
"""模块级自由函数处理器,用于验证直调路径保持不变。"""
|
"""模块级自由函数处理器,用于验证直调路径保持不变。"""
|
||||||
|
|
||||||
@@ -155,6 +170,41 @@ def test_binding_uses_explicit_resolver_instance() -> None:
|
|||||||
assert method_name == "handle"
|
assert method_name == "handle"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_resolver_requires_exact_registered_class() -> None:
|
||||||
|
"""同名宿主类不能被 plugins resolver 误识别为插件 owner。"""
|
||||||
|
manager = object.__new__(PluginManager)
|
||||||
|
plugin = _PluginHandler()
|
||||||
|
manager._plugins = {_PluginHandler.__name__: _PluginHandler}
|
||||||
|
manager._running_plugins = {_PluginHandler.__name__: plugin}
|
||||||
|
impostor = type(_PluginHandler.__name__, (), {})
|
||||||
|
|
||||||
|
assert manager.resolve_event_handler_instance(
|
||||||
|
_PluginHandler
|
||||||
|
) == EventHandlerBinding(
|
||||||
|
instance=plugin,
|
||||||
|
owner_name="测试插件",
|
||||||
|
run_sync_in_threadpool=True,
|
||||||
|
)
|
||||||
|
assert manager.resolve_event_handler_instance(impostor) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_resolver_does_not_materialize_manager(monkeypatch) -> None:
|
||||||
|
"""残留 resolver 在管理器不存在时必须跳过,不能构造插件 Runtime。"""
|
||||||
|
singleton_key = (PluginManager, (), frozenset())
|
||||||
|
monkeypatch.delitem(Singleton._instances, singleton_key, raising=False)
|
||||||
|
runtime_factory = Mock(side_effect=AssertionError("不得构造 PluginManager"))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
plugin_manager_module,
|
||||||
|
"_plugin_runtime_factory",
|
||||||
|
runtime_factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert plugin_manager_module._resolve_plugin_handler_instance(
|
||||||
|
_PluginHandler
|
||||||
|
) is None
|
||||||
|
runtime_factory.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_system_error_failure_does_not_rebroadcast() -> None:
|
def test_system_error_failure_does_not_rebroadcast() -> None:
|
||||||
"""SystemError 处理器自身失败时只能通知和日志降级,不能再次发送事件。"""
|
"""SystemError 处理器自身失败时只能通知和日志降级,不能再次发送事件。"""
|
||||||
notifier = Mock()
|
notifier = Mock()
|
||||||
@@ -213,3 +263,28 @@ def test_all_decorated_host_handler_classes_have_explicit_factories() -> None:
|
|||||||
"SubscribeChain",
|
"SubscribeChain",
|
||||||
"WorkflowChain",
|
"WorkflowChain",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_unmanaged_config_reload_classes_have_explicit_providers(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
"""专属 resolver 未覆盖的配置 owner 必须全部声明生命周期 Provider。"""
|
||||||
|
manager = object.__new__(PluginManager)
|
||||||
|
plugin_singleton_key = (PluginManager, (), frozenset())
|
||||||
|
monkeypatch.setitem(Singleton._instances, plugin_singleton_key, manager)
|
||||||
|
doh_helper = object.__new__(
|
||||||
|
__import__("app.adapters.network.doh", fromlist=["DohHelper"]).DohHelper
|
||||||
|
)
|
||||||
|
doh_singleton_key = (type(doh_helper), (), frozenset())
|
||||||
|
monkeypatch.setitem(Singleton._instances, doh_singleton_key, doh_helper)
|
||||||
|
providers = get_config_reload_handler_providers()
|
||||||
|
|
||||||
|
assert {owner.__name__ for owner in providers} == {
|
||||||
|
"AsyncRedisHelper",
|
||||||
|
"DohHelper",
|
||||||
|
"Monitor",
|
||||||
|
"PluginManager",
|
||||||
|
"RedisHelper",
|
||||||
|
"SystemHelper",
|
||||||
|
"TransferChain",
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -19,15 +20,19 @@ from app.application.database import (
|
|||||||
get_database_governance,
|
get_database_governance,
|
||||||
reset_database_governance,
|
reset_database_governance,
|
||||||
)
|
)
|
||||||
|
from app.foundation.singleton import Singleton
|
||||||
from app.runtime import resources as managed_resource_facade
|
from app.runtime import resources as managed_resource_facade
|
||||||
from app.runtime.events import EventManager
|
from app.runtime.events import Event, EventHandlerBinding, EventManager
|
||||||
from app.runtime.resources import (
|
from app.runtime.resources import (
|
||||||
configure_managed_resource_runtime,
|
configure_managed_resource_runtime,
|
||||||
reset_managed_resource_runtime,
|
reset_managed_resource_runtime,
|
||||||
)
|
)
|
||||||
|
from app.runtime.state import SystemHelper
|
||||||
from app.scheduler.facade import Scheduler
|
from app.scheduler.facade import Scheduler
|
||||||
|
from app.schemas.event import ConfigChangeEventData
|
||||||
from app.schemas.types import EventType
|
from app.schemas.types import EventType
|
||||||
from app.startup.composition import resource as managed_resource_composition
|
from app.startup.composition import resource as managed_resource_composition
|
||||||
|
from app.startup.initializers.modules import reset_event_services
|
||||||
|
|
||||||
_MISSING = object()
|
_MISSING = object()
|
||||||
|
|
||||||
@@ -180,6 +185,55 @@ def test_event_manager_host_binding_reset_contract() -> None:
|
|||||||
assert second_resolver is not first_resolver
|
assert second_resolver is not first_resolver
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_event_services_unregisters_lifespan_resolvers(monkeypatch) -> None:
|
||||||
|
"""模块 lifespan 结束时应撤销宿主与配置 owner resolver。"""
|
||||||
|
manager = object.__new__(EventManager)
|
||||||
|
EventManager.__init__(manager)
|
||||||
|
singleton_key = (EventManager, (), frozenset())
|
||||||
|
monkeypatch.setitem(Singleton._instances, singleton_key, manager)
|
||||||
|
owner = object.__new__(SystemHelper)
|
||||||
|
reload_config = Mock()
|
||||||
|
owner.on_config_changed = reload_config
|
||||||
|
|
||||||
|
def plugin_resolver(_owner: type) -> None:
|
||||||
|
"""代表插件运行时独立拥有的 resolver。"""
|
||||||
|
|
||||||
|
def config_resolver(owner_class: type) -> EventHandlerBinding | None:
|
||||||
|
"""代表当前 lifespan 持有的配置 owner resolver。"""
|
||||||
|
if owner_class is SystemHelper:
|
||||||
|
return EventHandlerBinding(
|
||||||
|
instance=owner,
|
||||||
|
owner_name=owner_class.__name__,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
manager.register_handler_instance_resolver("plugins", plugin_resolver)
|
||||||
|
manager.register_handler_instance_resolver("host", lambda _owner: None)
|
||||||
|
manager.register_handler_instance_resolver(
|
||||||
|
"config_reload",
|
||||||
|
config_resolver,
|
||||||
|
)
|
||||||
|
event = Event(
|
||||||
|
EventType.ConfigChanged,
|
||||||
|
ConfigChangeEventData(key={"DEBUG"}),
|
||||||
|
)
|
||||||
|
manager._EventManager__invoke_handler_by_type_sync(
|
||||||
|
SystemHelper.handle_config_changed,
|
||||||
|
event,
|
||||||
|
)
|
||||||
|
|
||||||
|
reset_event_services()
|
||||||
|
manager._EventManager__invoke_handler_by_type_sync(
|
||||||
|
SystemHelper.handle_config_changed,
|
||||||
|
event,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert manager._EventManager__handler_instance_resolvers == {
|
||||||
|
"plugins": plugin_resolver,
|
||||||
|
}
|
||||||
|
reload_config.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
def test_managed_resource_owner_reset_releases_only_closed_runtime(
|
def test_managed_resource_owner_reset_releases_only_closed_runtime(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user