修复非模块组件配置变更未触发热重载 (#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:
InfinityPacer
2026-09-04 03:43:49 +08:00
committed by GitHub
parent 43af5b5282
commit b8fd1b9958
9 changed files with 511 additions and 9 deletions
+9 -2
View File
@@ -16,6 +16,7 @@ from typing import (
Tuple,
Type,
Union,
cast,
)
from watchfiles import watch
@@ -167,7 +168,13 @@ def _resolve_plugin_handler_instance(
owner_class: Type[Any],
) -> 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")
@@ -224,7 +231,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
"""为插件声明的事件方法解析当前运行实例。"""
plugin_id = owner_class.__name__
# 旧测试与部分扩展会替换私有映射来构造隔离运行态,解析器继续尊重该接缝。
if plugin_id not in self._plugins:
if self._plugins.get(plugin_id) is not owner_class:
return None
plugin = self._running_plugins.get(plugin_id)
owner_name = plugin_id
+5
View File
@@ -383,6 +383,11 @@ def configure_doh_composition() -> None:
DohHelper()
def get_existing_doh_composition() -> object | None:
"""返回已由组合根物化的 DoH Adapter,不触发资源创建。"""
return cast(Optional[DohHelper], DohHelper.get_existing_instance())
def stop_doh_composition() -> bool:
"""关闭已存在的 DoH Adapter,停机阶段不得反向物化实例。"""
helper = DohHelper.get_existing_instance()
+70
View File
@@ -96,6 +96,7 @@ from app.startup.composition.enrichment import (
from app.startup.composition.network import (
configure_application_network_ports,
configure_doh_composition,
get_existing_doh_composition,
reset_application_network_ports,
stop_doh_composition,
)
@@ -199,6 +200,7 @@ def reset_event_services() -> None:
dispatch_web_agent_message_event,
)
event_manager.unregister_handler_instance_resolver("host")
event_manager.unregister_handler_instance_resolver("config_reload")
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:
"""显式登记宿主内置类处理器,禁止事件总线按类名临时构造未知对象。"""
factories = get_host_event_handler_factories()
@@ -665,6 +733,8 @@ async def _initialize_modules() -> HostRuntime:
)
# 宿主类处理器在启动层显式登记,事件总线不再兜底 owner_class()。
configure_host_event_handler_resolver()
# 配置 owner 按各自生命周期读取当前实例,惰性资源不会因事件而物化。
configure_config_reload_event_handler_resolver()
# 加载模块
ModuleManager()
# 启动事件消费
+1 -1
View File
@@ -755,7 +755,7 @@ flowchart LR
| 指标 | 当前值 |
|---|---:|
| Python 模块 | 968 |
| 内部导入边 | 8,127 |
| 内部导入边 | 8,132 |
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
| Direct egress | 53(债务已清零,53 条精确 containment |
+1 -1
View File
@@ -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 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
+7 -2
View File
@@ -1089,8 +1089,8 @@
"runtime_only": true
}
},
"edge_count": 8127,
"edge_sha256": "b355003c1ca55cd75ed454e9fe986dcf37b37078e6583563b41038c62a05c57f",
"edge_count": 8132,
"edge_sha256": "33505d1acbf26b53533027ed633ec23f3b4c4ee24c242a02372c05493c2919ab",
"edges": [
"app -> app.foundation",
"app -> app.foundation.environment",
@@ -8831,10 +8831,14 @@
"app.startup.initializers.modules -> app.chain.site",
"app.startup.initializers.modules -> app.chain.subscribe",
"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.command",
"app.startup.initializers.modules -> app.db",
"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.config",
"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.log",
"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.thread",
"app.startup.initializers.modules -> app.scheduler",
+287 -1
View File
@@ -5,13 +5,28 @@ ConfigReloadMixin 动态生成的事件处理器,必须能被事件总线解
"""
import inspect
from unittest.mock import AsyncMock, Mock
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.extensions.plugin.manager import PluginManager
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.startup.initializers.modules import (
configure_config_reload_event_handler_resolver,
configure_host_event_handler_resolver,
get_config_reload_handler_providers,
)
class _ReloadRecorder(ConfigReloadMixin):
@@ -67,6 +82,12 @@ def _build_event(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):
"""按处理器类型走事件总线的同步或异步调用路径。"""
handler = instance.__class__.handle_config_changed
@@ -146,3 +167,268 @@ def test_externally_managed_reload_class_does_not_register_listener(monkeypatch)
assert registrations == []
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()
+76 -1
View File
@@ -5,14 +5,20 @@ import threading
import types
from unittest.mock import Mock
from app.foundation.singleton import Singleton
from app.runtime.event.binding import (
EventBindingResolver,
EventHandlerBinding,
)
from app.runtime.event.errors import EventErrorPolicy
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.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:
@@ -28,6 +34,15 @@ class _UnmanagedHandler:
"""提供可解析的实例方法声明。"""
class _PluginHandler:
"""代表插件运行时登记的事件 owner。"""
@staticmethod
def get_name() -> str:
"""返回测试插件名称。"""
return "测试插件"
def _free_function_handler(_event: Event) -> None:
"""模块级自由函数处理器,用于验证直调路径保持不变。"""
@@ -155,6 +170,41 @@ def test_binding_uses_explicit_resolver_instance() -> None:
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:
"""SystemError 处理器自身失败时只能通知和日志降级,不能再次发送事件。"""
notifier = Mock()
@@ -213,3 +263,28 @@ def test_all_decorated_host_handler_classes_have_explicit_factories() -> None:
"SubscribeChain",
"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",
}
+55 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import Iterator
from types import SimpleNamespace
from typing import Any
from unittest.mock import Mock
import pytest
@@ -19,15 +20,19 @@ from app.application.database import (
get_database_governance,
reset_database_governance,
)
from app.foundation.singleton import Singleton
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 (
configure_managed_resource_runtime,
reset_managed_resource_runtime,
)
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.startup.composition import resource as managed_resource_composition
from app.startup.initializers.modules import reset_event_services
_MISSING = object()
@@ -180,6 +185,55 @@ def test_event_manager_host_binding_reset_contract() -> None:
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(
monkeypatch: pytest.MonkeyPatch,
) -> None: