ForcePilot/backend/package/yuxi/channels/plugins/wechat_ilink/lifecycle.py

219 lines
9.0 KiB
Python
Raw Normal View History

"""微信 iLinkClawBot渠道插件生命周期钩子处理。
实现 ``LifecycleHookHandler`` Protocol管理插件级资源httpx 连接池
响应宿主生命周期状态变迁
设计要点
- LifecycleHandler 为单实例 per-account可持有 mutable 状态用于
资源管理与适配器的 INV-5 mutable 状态约束不同
- httpx 连接池在 ``onInit`` 创建并注入 ``ILinkClient````onUnload`` 解除
引用后关闭连接数上限对齐 manifest.json
``resource_quota.max_connections=10``
- 长轮询拉取由 ``StreamWorker`` 通过 ``PullerAdapter`` 统一管理
``LifecycleHandler`` 不持有长轮询句柄
- ``onReconfigure`` 校验不可热更新字段变更时抛 ``ConfigRestartRequiredError``
校验失败保持原基线以支持宿主回滚
- ``onFail`` 不抛异常不静默吞错logger 失败时回退到 ``sys.stderr.write``
不阻断 FR-36 优雅降级流程
依赖方向 import ``yuxi.channels.contract.*`` + 标准库 + httpx
不污染框架层
"""
from __future__ import annotations
import sys
from typing import TYPE_CHECKING, Any
import httpx
from yuxi.channels.contract.dtos.config import ConfigField
from yuxi.channels.contract.errors import ConfigRestartRequiredError
from yuxi.channels.contract.ports.driven.cache_port import CachePort
from yuxi.channels.contract.ports.driven.config_port import ConfigPort
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
if TYPE_CHECKING:
from .ilink_client import ILinkClient
from ._constants import (
DEFAULT_MAX_CONNECTIONS,
DEFAULT_MAX_KEEPALIVE_CONNECTIONS,
DRAIN_TIMEOUT_SECONDS,
HTTP_TIMEOUT_SECONDS,
)
# CachePort 失效模式(清空插件所有运行时缓存,避免孤儿键残留)
_CACHE_INVALIDATE_PATTERN = "wechat_ilink:*"
class WeChatILinkLifecycleHandler:
"""微信 iLink 渠道插件生命周期钩子处理。
单实例管理插件级资源生命周期``_http_client`` 为共享连接池通过
``attach_http_client`` 注入 ``ILinkClient`` 供其复用避免每请求新建
TCP 连接``onReconfigure`` 通过入参 ``old_config`` 做差异化校验
不再自行维护基线缓存长轮询连接由 ``StreamWorker`` 管理本处理器不持有拉取句柄
"""
def __init__(
self,
config_port: ConfigPort,
logger_port: LoggerPort,
cache_port: CachePort,
config_schema: tuple[ConfigField, ...],
client: ILinkClient | None = None,
max_connections: int = DEFAULT_MAX_CONNECTIONS,
) -> None:
self._config = config_port
self._logger = logger_port
self._cache = cache_port
self._client = client
self._max_connections = max_connections
self._http_client: httpx.AsyncClient | None = None
self._started: bool = False
# 从 config_schema 派生不可热更新键hot_reloadable=False 的字段 key
self._restart_required_keys: tuple[str, ...] = tuple(
field.key for field in config_schema if not field.hot_reloadable
)
# ------------------------------------------------------------------
# LifecycleHookHandler Protocol 实现
# ------------------------------------------------------------------
async def onInit(self) -> None:
"""初始化资源:创建 httpx 连接池并注入 ILinkClient。"""
self._http_client = httpx.AsyncClient(
timeout=HTTP_TIMEOUT_SECONDS,
limits=httpx.Limits(
max_connections=self._max_connections,
max_keepalive_connections=DEFAULT_MAX_KEEPALIVE_CONNECTIONS,
),
)
if self._client is not None:
self._client.attach_http_client(self._http_client)
await self._logger.info("WeChat iLink plugin initialized")
async def onStart(self) -> None:
"""标记插件就绪:允许 ILinkClient 接受新请求。"""
self._started = True
if self._client is not None:
self._client.set_active(True)
await self._logger.info("WeChat iLink plugin started")
async def onStop(self) -> None:
"""停止插件:拒绝新请求,等待在途请求完成(默认 30s 超时)。
``set_active(False)`` 阻止新请求进入 ``_execute_http``
``drain(30)`` 等待在途请求完成超时后记录告警但仍继续关闭流程
"""
await self._logger.info(
"WeChat iLink plugin stopping, waiting for in-flight requests",
)
self._started = False
if self._client is not None:
self._client.set_active(False)
drained = await self._client.drain(timeout=DRAIN_TIMEOUT_SECONDS)
if not drained:
await self._logger.warn(
"WeChat iLink plugin drain timed out, in-flight requests may be interrupted",
timeout=DRAIN_TIMEOUT_SECONDS,
)
async def onPause(self) -> None:
"""暂停插件:停止接收新请求。"""
self._started = False
if self._client is not None:
self._client.set_active(False)
await self._logger.info("WeChat iLink plugin paused")
async def onResume(self) -> None:
"""恢复插件:重新接收请求。"""
self._started = True
if self._client is not None:
self._client.set_active(True)
await self._logger.info("WeChat iLink plugin resumed")
async def onUnload(self) -> None:
"""释放所有资源drain 在途请求后关闭 httpx 连接池并清空缓存。
长轮询连接由 ``StreamWorker`` 在宿主关停时首先停止
本方法先 drain 在途请求默认 30s 超时再解除 client 引用并
关闭连接池最后清空 CachePort ``wechat_ilink:*`` 缓存避免
孤儿键残留FR-32 资源释放约束
"""
# 1. 先 drain 在途请求,避免 aclose 打断正在进行的请求
if self._client is not None:
self._client.set_active(False)
drained = await self._client.drain(timeout=DRAIN_TIMEOUT_SECONDS)
if not drained:
await self._logger.warn(
"WeChat iLink plugin unload drain timed out, in-flight requests may be interrupted",
timeout=DRAIN_TIMEOUT_SECONDS,
)
self._client.detach_http_client()
# 2. 关闭 httpx 连接池(异常告警不抛,避免阻断后续缓存清理)
if self._http_client is not None:
try:
await self._http_client.aclose()
except Exception as exc:
await self._logger.warn(
"WeChat iLink httpx client close failed during unload",
error=str(exc),
)
self._http_client = None
# 3. 清空 CachePort 中 wechat_ilink:* 缓存,避免孤儿键残留
try:
await self._cache.invalidate(pattern=_CACHE_INVALIDATE_PATTERN)
except Exception as exc:
await self._logger.warn(
"WeChat iLink cache invalidate failed during unload",
error=str(exc),
)
self._started = False
await self._logger.info("WeChat iLink plugin unloaded")
async def onReconfigure(
self,
*,
old_config: dict[str, Any] | None = None,
new_config: dict[str, Any],
) -> None:
"""配置热更新:校验不可热更新字段。
- ``_restart_required_keys`` config_schema 派生``hot_reloadable=False``
的字段变更抛 ``ConfigRestartRequiredError``FR-37
- ``old_config`` ``None``首次调用或无基线时仅记录日志不做对比
- 校验失败由宿主回滚本方法无副作用不缓存基线基线由宿主维护
"""
if old_config is None:
await self._logger.info(
"WeChat iLink plugin initial config baseline set",
)
return
for key in self._restart_required_keys:
new_value = new_config.get(key)
old_value = old_config.get(key)
if new_value != old_value:
raise ConfigRestartRequiredError(key=key) from None
await self._logger.info("WeChat iLink plugin reconfigured")
async def onFail(self, error: str) -> None:
"""失败清理不抛异常不阻断降级流程FR-36
logger 不可用时回退到 ``sys.stderr.write``确保失败信息不丢失
不被静默吞掉同时不向上抛出异常以保证降级流程继续
"""
self._started = False
try:
await self._logger.error("WeChat iLink plugin failed", error=error)
except Exception as exc:
sys.stderr.write(f"WeChat iLink plugin failed (logger unavailable): {error}\nlogger_error={exc!r}\n")
__all__ = ["WeChatILinkLifecycleHandler"]