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

370 lines
15 KiB
Python
Raw Normal View History

"""微信 wechat_wocWechatOnCloud渠道插件生命周期钩子处理。
实现 ``LifecycleHookHandler`` Protocol 8 个钩子onInit / onStart /
onStop / onPause / onResume / onUnload / onReconfigure / onFail
管理插件级资源httpx 连接池并响应宿主生命周期状态变迁
设计要点
- LifecycleHandler 为单实例 per-account可持有 mutable 状态用于
资源管理与适配器的 INV-5 mutable 状态约束不同
- httpx 连接池在 ``onInit`` 创建并注入 ``WocBridgeClient````onUnload``
解除引用后关闭连接数上限对齐 manifest.json
``resource_quota.max_connections=10``
- 短轮询拉取由 ``StreamWorker`` 通过 ``PullerAdapter`` 统一管理
``LifecycleHandler`` 不持有轮询句柄
- ``onReconfigure`` 校验 ``bridge_url`` 格式必须以 ``http://``
``https://`` 开头非法时抛 ``ConfigRestartRequiredError`` 触发宿主
重启回滚FR-37
- ``onUnload`` 在关闭连接池后清空 CachePort ``wechat_woc:*`` 缓存
避免插件卸载后残留孤儿缓存键FR-32 资源释放约束
- ``onFail`` 不抛异常不静默吞错logger 失败时回退到 ``sys.stderr.write``
不阻断 FR-36 优雅降级流程
依赖方向 import ``yuxi.channels.contract.*`` + 标准库 + httpx
不污染框架层
"""
from __future__ import annotations
import os
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, ValidationError
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 .woc_bridge_client import WocBridgeClient
from ._constants import DEFAULT_MAX_CONNECTIONS, HTTP_TIMEOUT_SECONDS
# httpx 连接池默认配置;最终值由 discover 阶段解析的 manifest.resource_quota
# 注入,本默认值仅用于测试或未声明 resource_quota 的场景F-03 单真相源)。
_HTTP_MAX_KEEPALIVE_CONNECTIONS = 5
# 在途请求 drain 超时onStop/onUnload 等待在途请求完成的最长时间
_DRAIN_TIMEOUT_SECONDS = 30.0
# WOC_HTTP_TIMEOUT_MS 合法范围(毫秒):低于 100ms 易导致误超时,高于
# 120000ms2 分钟)则偏离 bridge HTTP 调用的合理上限,越界值抛 ValidationError
_HTTP_TIMEOUT_MS_MIN = 100
_HTTP_TIMEOUT_MS_MAX = 120_000
# CachePort 失效模式(清空插件所有运行时缓存,避免孤儿键)
_CACHE_INVALIDATE_PATTERN = "wechat_woc:*"
class WeChatWocLifecycleHandler:
"""微信 wechat_woc 渠道插件生命周期钩子处理。
单实例管理插件级资源生命周期``_http_client`` 为共享连接池通过
``attach_http_client`` 注入 ``WocBridgeClient`` 供其复用避免每请求
新建 TCP 连接短轮询连接由 ``StreamWorker`` 管理本处理器不持有
拉取句柄
实现 8 个钩子manifest.lifecycle 声明 init/start/stop/pause/resume/
unload/reconfigure/fail``onPause`` / ``onResume`` 通过
``set_active`` 切换 ``WocBridgeClient`` 请求接收开关语义为"暂停
接收新请求"而非"停止拉取"——拉取 Worker 由宿主 ``StreamWorker``
PAUSED 状态下统一挂起本钩子仅控制 HTTP 客户端可用性
"""
def __init__(
self,
config_port: ConfigPort,
logger_port: LoggerPort,
cache_port: CachePort,
config_schema: tuple[ConfigField, ...],
client: WocBridgeClient | 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 保留用于未来扩展(如派生热更新键集合),当前 onReconfigure
# 仅校验 bridge_url 格式,不依赖 schema 派生键清单。
self._config_schema: tuple[ConfigField, ...] = config_schema
# ------------------------------------------------------------------
# LifecycleHookHandler Protocol 实现8 个钩子)
# ------------------------------------------------------------------
async def onInit(self) -> None:
"""初始化资源:创建 httpx 连接池并注入 WocBridgeClient。
超时 60s由宿主控制超时后标记插件失败
@pre
- 宿主已注入 ConfigPort / LoggerPort / CachePort
- ``client``若提供尚未持有 httpx 连接池
@post
- ``_http_client`` 已创建并注入 ``WocBridgeClient``若提供
- 后续 HTTP 请求复用该连接池
@failure
- httpx.AsyncClient 构造失败由宿主捕获并触发 onFail
@consistency
- 状态机驱动钩子完成后状态机推进至 INITIALIZED
"""
timeout_seconds = _read_http_timeout()
self._http_client = httpx.AsyncClient(
timeout=timeout_seconds,
limits=httpx.Limits(
max_connections=self._max_connections,
max_keepalive_connections=_HTTP_MAX_KEEPALIVE_CONNECTIONS,
),
)
if self._client is not None:
self._client.attach_http_client(self._http_client)
await self._logger.info("WeChat woc plugin initialized")
async def onStart(self) -> None:
"""标记插件就绪:允许 WocBridgeClient 接受新请求。
超时 60s由宿主控制
@post
- ``_started=True``
- ``WocBridgeClient.set_active(True)`` 已调用
@consistency
- 状态机驱动钩子完成后状态机推进至 STARTED
"""
self._started = True
if self._client is not None:
self._client.set_active(True)
await self._logger.info("WeChat woc plugin started")
async def onStop(self) -> None:
"""停止插件:拒绝新请求,等待在途请求完成(默认 30s 超时)。
``set_active(False)`` 阻止新请求进入 ``_execute_http``
``drain(30)`` 等待在途请求完成超时后记录告警但仍继续关闭流程
@post
- ``_started=False``
- ``WocBridgeClient.set_active(False)`` 已调用
- 在途请求已 drain超时则告警不抛异常
@consistency
- 状态机驱动钩子完成后状态机推进至 STOPPED
- 幂等停止已停止的插件安全
"""
await self._logger.info(
"WeChat woc 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 woc plugin drain timed out, in-flight requests may be interrupted",
timeout=_DRAIN_TIMEOUT_SECONDS,
)
async def onPause(self) -> None:
"""暂停插件:停止接收新请求,但在途请求继续完成。
``onStop`` 的区别 drain 在途请求 ``set_active(False)``
阻止新请求进入 ``_execute_http``已接收的请求继续执行至完成
适用于宿主临时挂起场景如配置预校验限流降级恢复后调用
``onResume`` 即可重新接收请求
@post
- ``_started=False``
- ``WocBridgeClient.set_active(False)`` 已调用
@consistency
- 状态机驱动钩子完成后状态机推进至 PAUSED
- 幂等暂停已暂停的插件安全
"""
self._started = False
if self._client is not None:
self._client.set_active(False)
await self._logger.info("WeChat woc plugin paused")
async def onResume(self) -> None:
"""恢复插件:重新接收新请求。
仅在 PAUSED 状态下由宿主调用恢复 ``WocBridgeClient`` 请求接收
开关若插件未启动``_http_client`` None仅标记 ``_started``
等待 ``onStart`` 完成资源装配
@pre
- 插件处于 PAUSED 状态
@post
- ``_started=True``
- ``WocBridgeClient.set_active(True)`` 已调用 client 存在
@consistency
- 状态机驱动钩子完成后状态机推进至 STARTED
- 幂等恢复已启动的插件安全
"""
self._started = True
if self._client is not None:
self._client.set_active(True)
await self._logger.info("WeChat woc plugin resumed")
async def onUnload(self) -> None:
"""释放所有资源drain 在途请求后关闭 httpx 连接池并清空缓存。
超时 30s由宿主控制必须释放所有资源不得留下孤儿资源
FR-32 资源释放约束
步骤
1. ``set_active(False)`` + ``drain(30)`` 等待在途请求完成
2. ``detach_http_client`` 解除 client 引用避免悬挂
3. ``aclose()`` 关闭连接池异常告警不抛
4. ``cache_port.invalidate("wechat_woc:*")`` 清空插件运行时缓存
异常告警不抛遵循 CachePort 写故障降级约定
@post
- ``_http_client=None````_started=False``
- CachePort ``wechat_woc:*`` 模式缓存已失效
@consistency
- 状态机驱动钩子完成后状态机推进至 UNLOADED
- 幂等重复卸载仅清理已释放的资源
"""
# 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 woc 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 woc httpx client close failed during unload",
error=str(exc),
)
self._http_client = None
# 3. 清空 CachePort 中 wechat_woc:* 缓存,避免孤儿键残留
try:
await self._cache.invalidate(pattern=_CACHE_INVALIDATE_PATTERN)
except Exception as exc:
await self._logger.warn(
"WeChat woc cache invalidate failed during unload",
error=str(exc),
)
self._started = False
await self._logger.info("WeChat woc plugin unloaded")
async def onReconfigure(
self,
*,
old_config: dict[str, Any] | None = None,
new_config: dict[str, Any],
) -> None:
"""配置热更新:校验 bridge_url 格式,非法抛 ConfigRestartRequiredError。
``bridge_url`` wechat_woc 唯一不可降级校验的配置项必须以
``http://`` ``https://`` 开头否则视为配置非法
``ConfigRestartRequiredError`` 触发宿主重启回滚FR-37
校验通过后仅记录日志不缓存基线wechat_woc bridge_url 实时
ConfigPort 读取无需本地缓存``old_config`` 由宿主通过关键字
参数传入wechat_woc 仅做格式校验无差异化校验需求故不消费
@pre
- ``new_config`` 为新配置字典 ``bridge_url`` 字段
@post
- 校验通过日志记录配置即时生效bridge_url 实时读取
- 校验失败 ``ConfigRestartRequiredError``宿主回滚
@failure
- ``ConfigRestartRequiredError````bridge_url`` 格式非法
@consistency
- 状态机驱动配置变更即时生效且支持回滚
- 幂等相同配置重复应用产生等价结果
"""
bridge_url = new_config.get("bridge_url")
if not isinstance(bridge_url, str) or not bridge_url:
raise ConfigRestartRequiredError(key="bridge_url") from None
if not (bridge_url.startswith("http://") or bridge_url.startswith("https://")):
raise ConfigRestartRequiredError(key="bridge_url") from None
await self._logger.info("WeChat woc plugin reconfigured")
async def onFail(self, error: str) -> None:
"""失败清理不抛异常不阻断降级流程FR-36
插件初始化/启动/运行时失败时由宿主调用标记 ``_started=False``
并记录错误日志logger 不可用时回退到 ``sys.stderr.write``确保
失败信息不丢失不被静默吞掉同时不向上抛出异常以保证降级流程
继续
@post
- ``_started=False``
- 错误已记录logger stderr 兜底
- 不抛出任何异常
@consistency
- 状态机驱动钩子完成后状态机推进至 FAILED 并触发降级
- 幂等重复通知失败仅记录状态无累积副作用
"""
self._started = False
try:
await self._logger.error("WeChat woc plugin failed", error=error)
except Exception as exc:
sys.stderr.write(f"WeChat woc plugin failed (logger unavailable): {error}\nlogger_error={exc!r}\n")
def _read_http_timeout() -> float:
"""读取 HTTP 客户端超时(秒)。
优先读取环境变量 ``WOC_HTTP_TIMEOUT_MS`` manifest.json env_vars 声明
对齐解析为秒未设置时回退到 ``HTTP_TIMEOUT_SECONDS``
合法范围为 ``_HTTP_TIMEOUT_MS_MIN``~``_HTTP_TIMEOUT_MS_MAX``100ms~120s
越界值抛 ``ValidationError`` 暴露配置错误不再静默替换为默认值
Returns:
HTTP 超时秒数
Raises:
ValidationError: ``WOC_HTTP_TIMEOUT_MS`` 值非整数或越界时抛出
"""
env_value = os.environ.get("WOC_HTTP_TIMEOUT_MS")
if not env_value:
return HTTP_TIMEOUT_SECONDS
try:
ms = int(env_value)
except (ValueError, TypeError) as exc:
raise ValidationError(
field="WOC_HTTP_TIMEOUT_MS",
message=f"invalid_integer: {env_value!r}",
) from exc
if ms < _HTTP_TIMEOUT_MS_MIN or ms > _HTTP_TIMEOUT_MS_MAX:
raise ValidationError(
field="WOC_HTTP_TIMEOUT_MS",
message=(f"out_of_range: {ms}ms, expected [{_HTTP_TIMEOUT_MS_MIN}, {_HTTP_TIMEOUT_MS_MAX}]"),
)
return ms / 1000.0
__all__ = ["WeChatWocLifecycleHandler"]