ForcePilot/backend/package/yuxi/channels/application/transport/manager.py
Kris bb1934023e refactor: 完成会话持久化与事务机制重构,清理冗余代码
本次提交是一次大型架构重构,核心变更包括:
1. 调整持久化适配器为无状态实现,通过session_factory按需获取会话
2. 重构事务共享与透传机制,统一使用_session_scope管理会话生命周期
3. 移除OutboxEntry聚合根内的版本自增逻辑,由持久化层统一管理
4. 优化微信插件会话类型枚举与配置对齐
5. 简化出站管道阶段依赖注入与流程逻辑
6. 删除健康检查自动释放会话的冗余代码
7. 重构多个定时任务处理器,移除显式会话工厂创建逻辑
8. 修复会话延迟加载异常问题,新增时区转换工具函数
2026-07-10 04:10:33 +08:00

980 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""入站传输管理器。
统一管理 PullerWorker 和 StreamWorker 的生命周期,订阅账号上下线事件,
动态启动/停止账号的传输任务。
"""
from __future__ import annotations
import asyncio
import dataclasses
import uuid
from collections.abc import Awaitable, Callable
from typing import Any
from yuxi.channels.application.transport.base_worker import (
TransportConfig,
)
from yuxi.channels.application.transport.puller_worker import PullerWorker
from yuxi.channels.application.transport.stream_worker import StreamWorker
from yuxi.channels.contract.dtos.channel import AccountFilter, AccountStatus, ChannelType
from yuxi.channels.contract.dtos.health import TransportHealthSnapshot
from yuxi.channels.contract.dtos.plugin import DomainEvent, EventHandler
from yuxi.channels.contract.plugin.extension_point import EventSubscription
from yuxi.channels.contract.plugin.manifest import FailurePolicy
from yuxi.channels.contract.ports.driven import (
ConfigPort,
LoggerPort,
PersistencePort,
)
from yuxi.channels.core.registry import PluginRegistry
__all__ = ["TransportManager"]
# 管理循环的轮询间隔(秒):仅用于等待取消信号,不参与业务逻辑
_MANAGER_LOOP_INTERVAL_S: float = 1.0
class TransportManager:
"""入站传输全局管理器。
单例模式管理 PullerWorker 和 StreamWorker订阅 ChannelAccountOnline/Offline
事件,根据 transport_mode 与适配器能力动态启动/停止对应账号的传输任务
Task 11pull 仅 Puller、stream 仅 Stream、both 优先 Stream 降级 Puller
职责:
- 从 PluginRegistry 动态构建 puller/stream 适配器注册表
- 创建并管理 PullerWorker、StreamWorker 单例
- 订阅账号上下线事件,驱动 Worker 启动/停止账号任务
- 提供全局健康状态查询
- 优雅关停所有传输任务
"""
def __init__(
self,
*,
plugin_registry: PluginRegistry,
persistence_port: PersistencePort,
config_port: ConfigPort,
event_bus: Any,
circuit_breaker: Any,
logger: LoggerPort,
message_deliverer: Callable[[Any], Awaitable[Any]],
transport_config: TransportConfig | None = None,
) -> None:
"""初始化 TransportManager。
参数:
plugin_registry: 插件注册表,用于动态获取渠道适配器。
persistence_port: 持久化端口,用于游标持久化等。
config_port: 配置端口。
event_bus: 事件总线,用于订阅/发布事件。
circuit_breaker: 熔断器实例。
logger: 日志端口。
message_deliverer: 入站消息投递回调。
transport_config: 传输配置,为 None 时使用默认配置。
"""
self._plugin_registry = plugin_registry
self._persistence_port = persistence_port
self._config_port = config_port
self._event_bus = event_bus
self._circuit_breaker = circuit_breaker
self._logger = logger
self._message_deliverer = message_deliverer
self._config = transport_config or TransportConfig()
self._puller_registry: dict[ChannelType, Any] = {}
self._stream_connector_registry: dict[ChannelType, Any] = {}
self._puller_worker: PullerWorker | None = None
self._stream_worker: StreamWorker | None = None
self._manager_task: asyncio.Task[None] | None = None
self._running: bool = False
self._plugin_id = "transport-manager"
# H-14恢复扫描与配置热更新互斥锁进程内防同进程竞态
self._restore_lock = asyncio.Lock()
# P0-1已降级账号集合避免 StreamWorker 反复重启时反复降级。
# key 为 ``{channel_type}:{account_id}``,账号重新上线时清除。
self._degraded_accounts: set[str] = set()
async def start(self) -> None:
"""启动传输管理器。
- 构建 puller_registry 和 stream_connector_registry
- 创建 PullerWorker 和 StreamWorker 实例
- 启动 Worker
- 订阅账号上下线事件
- 启动管理循环任务
"""
if self._running:
return
trace_id = str(uuid.uuid4())
puller_registry: dict[ChannelType, Any] = {}
stream_connector_registry: dict[ChannelType, Any] = {}
for channel_type, plugin_da in self._plugin_registry.listPluginAdapters():
if plugin_da.puller_adapters:
puller_registry[channel_type] = plugin_da.puller_adapters[0]
if plugin_da.stream_connector_adapters:
stream_connector_registry[channel_type] = plugin_da.stream_connector_adapters[0]
self._puller_registry = puller_registry
self._stream_connector_registry = stream_connector_registry
# P0-1 SubTask 4.7:读取 manifest 声明的 max_restart_attempts 作为
# TransportConfig 初始默认值ConfigPort 的 transport.max_restart_attempts
# 可运行时覆盖)。仅当 transport_config 未显式指定时生效。
if self._config.max_restart_attempts is None:
for ct in list(puller_registry.keys()) + list(stream_connector_registry.keys()):
plugin = self._plugin_registry.getPluginByChannelType(ct)
if plugin is not None and plugin.manifest.max_restart_attempts is not None:
self._config = dataclasses.replace(
self._config,
max_restart_attempts=plugin.manifest.max_restart_attempts,
)
break
self._puller_worker = PullerWorker(
message_deliverer=self._message_deliverer,
logger=self._logger,
config_port=self._config_port,
event_publisher=self._event_bus,
circuit_breaker=self._circuit_breaker,
persistence_port=self._persistence_port,
config=self._config,
)
self._stream_worker = StreamWorker(
message_deliverer=self._message_deliverer,
logger=self._logger,
config_port=self._config_port,
event_publisher=self._event_bus,
circuit_breaker=self._circuit_breaker,
persistence_port=self._persistence_port,
config=self._config,
)
await self._puller_worker.start()
await self._stream_worker.start()
self._registerEventHandlers()
self._running = True
self._manager_task = asyncio.create_task(
self._manager_loop(),
name="transport-manager-loop",
)
await self._logger.info(
"transport manager started",
trace_id=trace_id,
puller_channels=[ct for ct in puller_registry.keys()],
stream_channels=[ct for ct in stream_connector_registry.keys()],
)
# 重启恢复:扫描 DB 中 ACTIVE 状态账号,为每个账号启动传输任务。
# 系统重启后不会收到 ChannelAccountOnline 事件,必须主动恢复,
# 否则已上线账号的传输任务SSE/短轮询)不会启动。
await self._restoreOnlineAccounts(trace_id)
async def stop(self, timeout: float = 5.0) -> None:
"""停止传输管理器。
- 取消事件订阅
- 取消管理循环任务
- 停止所有 Worker
- 等待所有任务完成
参数:
timeout: 等待 Worker 停止的超时时间(秒)。
"""
if not self._running:
return
trace_id = str(uuid.uuid4())
self._running = False
self._unregisterEventHandlers()
if self._manager_task is not None and not self._manager_task.done():
self._manager_task.cancel()
try:
await self._manager_task
except asyncio.CancelledError:
pass
self._manager_task = None
if self._puller_worker is not None:
await self._puller_worker.stop(timeout)
self._puller_worker = None
if self._stream_worker is not None:
await self._stream_worker.stop(timeout)
self._stream_worker = None
await self._logger.info(
"transport manager stopped",
trace_id=trace_id,
)
def getHealth(self) -> TransportHealthSnapshot:
"""返回传输引擎健康状态(同步,向后兼容)。
返回:
``TransportHealthSnapshot``,包含 running 状态和各 Worker 健康
子状态(``WorkerHealthSnapshot``)。
"""
return TransportHealthSnapshot(
running=self._running,
puller=self._puller_worker.getHealth() if self._puller_worker else None,
stream=self._stream_worker.getHealth() if self._stream_worker else None,
)
async def getTransportHealth(self) -> TransportHealthSnapshot:
"""返回传输引擎健康状态(实现 TransportHealthPortFR-18
供 ``HealthAggregator`` 通过 ``TransportHealthPort`` 聚合,暴露每个
账号的运行状态running/backoff/stopped/error。实现幂等、无副作用
不抛异常阻塞调用方。
返回:
``TransportHealthSnapshot``,结构见 ``TransportHealthPort`` 协议
文档。异常时降级返回 ``puller`` / ``stream`` 为 ``None`` 的快照。
"""
try:
return self.getHealth()
except Exception as exc:
# 健康检查不得抛异常阻塞调用方(与 HealthAggregator 超时降级策略一致)
await self._logger.warn(
"transport health query failed, returning empty state",
error=str(exc),
)
return TransportHealthSnapshot(
running=self._running,
puller=None,
stream=None,
)
async def _manager_loop(self) -> None:
"""管理循环,等待取消信号。"""
try:
while self._running:
await asyncio.sleep(_MANAGER_LOOP_INTERVAL_S)
except asyncio.CancelledError:
pass
def _registerEventHandlers(self) -> None:
"""注册事件处理器。"""
self._event_bus.register(
self._plugin_id,
EventSubscription(
event_type="ChannelAccountOnline",
handler=_AccountEventHandler(self._on_account_online),
priority=100,
failure_policy=FailurePolicy.DEGRADE,
),
)
self._event_bus.register(
self._plugin_id,
EventSubscription(
event_type="ChannelAccountOffline",
handler=_AccountEventHandler(self._on_account_offline),
priority=100,
failure_policy=FailurePolicy.DEGRADE,
),
)
self._event_bus.register(
self._plugin_id,
EventSubscription(
event_type="ChannelDegraded",
handler=_AccountEventHandler(self._on_channel_degraded),
priority=100,
failure_policy=FailurePolicy.DEGRADE,
),
)
self._event_bus.register(
self._plugin_id,
EventSubscription(
event_type="ChannelRecovered",
handler=_AccountEventHandler(self._on_channel_recovered),
priority=100,
failure_policy=FailurePolicy.DEGRADE,
),
)
self._event_bus.register(
self._plugin_id,
EventSubscription(
event_type="TransportErrorOccurred",
handler=_AccountEventHandler(self._on_transport_error),
priority=100,
failure_policy=FailurePolicy.DEGRADE,
),
)
# P1-4/P1-5账号配置变更后重建 Workerstop + start
self._event_bus.register(
self._plugin_id,
EventSubscription(
event_type="ChannelAccountConfigChanged",
handler=_AccountEventHandler(self._on_account_config_changed),
priority=100,
failure_policy=FailurePolicy.DEGRADE,
),
)
# 配置热更新订阅FR-04transport.* 配置变更时应用 hot/restart 策略
for config_event in ("ConfigChanged", "ConfigRollback"):
self._event_bus.register(
self._plugin_id,
EventSubscription(
event_type=config_event,
handler=_AccountEventHandler(self._on_config_changed),
priority=100,
failure_policy=FailurePolicy.DEGRADE,
),
)
def _unregisterEventHandlers(self) -> None:
"""取消事件订阅。"""
self._event_bus.unregister(self._plugin_id)
@staticmethod
def _make_account_key(channel_type: ChannelType, account_id: str) -> str:
"""构造账号唯一键(与 BaseTransportWorker 一致)。"""
return f"{channel_type}:{account_id}"
async def _on_account_online(self, event: DomainEvent) -> None:
"""处理账号上线事件。
从事件 payload 获取 channel_type 和 account_id根据 transport_mode
与适配器能力选择启动 Puller 或 Stream 任务Task 11.1 + 11.2
- ``pull``: 仅启动 Puller
- ``stream``: 仅启动 Stream
- ``both``: 优先 StreamPuller 作为降级Stream 适配器不可用时启动)
参数:
event: ChannelAccountOnline 事件。
"""
trace_id = event.trace_id or str(uuid.uuid4())
channel_type_raw = event.payload.get("channel_type")
account_id = event.payload.get("account_id")
if channel_type_raw is None or account_id is None:
await self._logger.warn(
"account online event missing required fields, skip",
trace_id=trace_id,
channel_type=channel_type_raw,
account_id=account_id,
)
return
channel_type = ChannelType(channel_type_raw)
await self._logger.info(
"received account online event",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
)
# P0-1 回切探测事件驱动账号重新上线bridge 版本升级/配置变更/
# 重新登录触发 ChannelAccountOnline清除降级标记并停止降级启动
# 的 PullerWorker由 _startTransportForAccount 按 transport_mode
# 重新选择both 模式恢复 StreamWorkerSSE 仍不可用时再次降级)。
account_key = self._make_account_key(channel_type, account_id)
if account_key in self._degraded_accounts:
self._degraded_accounts.discard(account_key)
if self._puller_worker is not None:
await self._puller_worker.stop_account(
channel_type, account_id, reason="recovered"
)
await self._logger.info(
"channel transport recovered from degraded mode",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
)
await self._startTransportForAccount(channel_type, account_id, trace_id, source="event")
async def _startTransportForAccount(
self,
channel_type: ChannelType,
account_id: str,
trace_id: str,
source: str = "event",
force_mode: str | None = None,
) -> None:
"""为单个账号启动传输任务(事件驱动与重启恢复共用)。
根据 transport_mode 与适配器能力选择启动 Puller 或 Stream 任务:
- ``pull``: 仅启动 Puller
- ``stream``: 仅启动 Stream
- ``both``: 优先 StreamPuller 作为降级Stream 适配器不可用时启动)
参数:
channel_type: 渠道类型。
account_id: 账号 ID。
trace_id: 链路追踪 ID。
source: 启动来源(``event`` 事件驱动 / ``restore`` 重启恢复 /
``degraded`` 降级启动),仅用于日志区分。
force_mode: 强制传输模式(``pull`` / ``stream``),非 None 时
覆盖 ``_resolveTransportMode`` 的解析结果。供 P0-1 降级机制
强制以 pull 模式启动 PullerWorker 使用。
"""
transport_mode = (
force_mode
if force_mode is not None
else await self._resolveTransportMode(channel_type, account_id)
)
puller_adapter = self._puller_registry.get(channel_type)
stream_adapter = self._stream_connector_registry.get(channel_type)
await self._logger.info(
"starting transport for account",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
transport_mode=transport_mode,
has_puller_adapter=puller_adapter is not None,
has_stream_adapter=stream_adapter is not None,
source=source,
)
# 先持久化插件运行态为 runningFR-32 诊断字段best-effort 写入),
# 必须在 start_account 之前调用start_account 内部通过 asyncio.create_task
# 调度的 _runAccountLoop 后台任务会使用同一共享 AsyncSession 调用
# getChannelAccount若 updatePluginStatus 与 task 并发执行会触发
# SQLAlchemy AsyncSession 并发访问异常(该异常在 channel_persistence_adapter
# 的 except SQLAlchemyError 分支被静默翻译为 DependencyError无原始异常日志
# 提前完成 updatePluginStatus 的 commit 可确保 task 启动时 session 处于干净状态。
# plugin_status 为诊断字段,即使后续 start_account 因 circuit breaker open
# 等原因未真正启动 task状态轻微不一致可接受下次状态变化时纠正
await self._touchPluginStatus(channel_type, account_id, "running", trace_id)
if transport_mode == "pull":
if puller_adapter is not None and self._puller_worker is not None:
await self._puller_worker.start_account(channel_type, account_id, puller_adapter)
elif transport_mode == "stream":
if stream_adapter is not None and self._stream_worker is not None:
await self._stream_worker.start_account(channel_type, account_id, stream_adapter)
else:
# Task 11.2: both 模式优先 StreamPuller 作为降级。
# Stream 适配器可用时仅启动 StreamStream 健康时不 poll
# Stream 适配器不可用时降级启动 Puller。
if stream_adapter is not None and self._stream_worker is not None:
await self._stream_worker.start_account(channel_type, account_id, stream_adapter)
elif puller_adapter is not None and self._puller_worker is not None:
await self._puller_worker.start_account(channel_type, account_id, puller_adapter)
async def _restoreOnlineAccounts(self, trace_id: str) -> None:
"""重启恢复:扫描 DB 中 ACTIVE 状态账号,启动传输任务。
系统重启后 TransportManager 不会收到 ChannelAccountOnline 事件,
本方法在 ``start()`` 中被调用,为所有已注册渠道的 ACTIVE 状态账号
启动传输任务SSE 或短轮询),实现重启后自动恢复。
错误隔离:单个账号查询/启动失败不阻塞其他账号,仅记录 warn 日志。
仅恢复注册表中有 puller 或 stream 适配器的渠道(未注册适配器的
渠道跳过)。
参数:
trace_id: 启动链路追踪 ID。
"""
# H-14持有恢复锁与 _on_config_changed 的 reloadConfig 互斥,
# 防止恢复扫描与配置热更新竞态。异常时由 async with 保证锁释放。
async with self._restore_lock:
restored = 0
failed = 0
# 合并 puller 和 stream 注册表的渠道类型,避免遗漏
channel_types = set(self._puller_registry.keys()) | set(self._stream_connector_registry.keys())
for channel_type in channel_types:
try:
accounts = await self._persistence_port.findAccountsByFilter(
AccountFilter(channel_type=channel_type, status=AccountStatus.ACTIVE)
)
except Exception as exc:
await self._logger.warn(
"transport restore: failed to query accounts for channel",
trace_id=trace_id,
channel_type=channel_type,
error=str(exc),
)
continue
for account in accounts:
try:
await self._startTransportForAccount(
channel_type=channel_type,
account_id=account.account_id,
trace_id=trace_id,
source="restore",
)
restored += 1
except Exception as exc:
failed += 1
await self._logger.warn(
"transport restore: failed to start account",
trace_id=trace_id,
channel_type=channel_type,
account_id=account.account_id,
error=str(exc),
)
await self._logger.info(
"transport restore completed",
trace_id=trace_id,
restored_accounts=restored,
failed_accounts=failed,
channel_count=len(channel_types),
)
async def _resolveTransportMode(
self,
channel_type: ChannelType,
account_id: str,
) -> str:
"""解析账号的传输模式Task 11.1)。
优先使用账号级 ``transport_mode````ChannelAccount.transport_mode``
账号不存在或读取失败时回退到 manifest 声明的渠道级 ``transport_mode``
均不可用时回退到 ``both``。
参数:
channel_type: 渠道类型。
account_id: 渠道账号ID。
返回:
传输模式字符串(``pull`` / ``stream`` / ``both``)。
"""
try:
account = await self._persistence_port.getChannelAccount(channel_type, account_id)
except Exception as exc:
await self._logger.warn(
"failed to get channel account for transport_mode resolution, fallback to manifest default",
channel_type=channel_type,
account_id=account_id,
error=str(exc),
)
account = None
if account is not None:
return account.transport_mode
plugin = self._plugin_registry.getPluginByChannelType(channel_type)
if plugin is not None:
return plugin.manifest.transport_mode
return "both"
async def _on_account_offline(self, event: DomainEvent) -> None:
"""处理账号下线事件。
停止对应账号的所有传输任务。
参数:
event: ChannelAccountOffline 事件。
"""
trace_id = event.trace_id or str(uuid.uuid4())
channel_type_raw = event.payload.get("channel_type")
account_id = event.payload.get("account_id")
if channel_type_raw is None or account_id is None:
await self._logger.warn(
"account offline event missing required fields, skip",
trace_id=trace_id,
channel_type=channel_type_raw,
account_id=account_id,
)
return
channel_type = ChannelType(channel_type_raw)
reason = event.payload.get("reason", "offline")
await self._logger.info(
"received account offline event, stopping transport",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
reason=reason,
)
if self._puller_worker is not None:
await self._puller_worker.stop_account(channel_type, account_id, reason)
if self._stream_worker is not None:
await self._stream_worker.stop_account(channel_type, account_id, reason)
# 传输任务停止后持久化插件运行态FR-32
await self._touchPluginStatus(channel_type, account_id, "stopped", trace_id)
async def _on_channel_degraded(self, event: DomainEvent) -> None:
"""处理渠道降级事件P0-1
StreamWorker permanent 失败时(如 bridge < 1.5.0 无 SSE 端点),
降级到 PullerWorker停止 StreamWorker 账号任务,以 pull 模式启动
PullerWorker。通过 ``_degraded_accounts`` 去重,避免 StreamWorker
反复重启时反复降级。
事件由 ``base_worker._handleTransportError`` permanent 分支发布
``ChannelDegraded``manager 已在 ``_registerEventHandlers`` 订阅。
死锁规避:降级执行(``stop_account`` 会 await 当前 StreamWorker
任务)通过 ``asyncio.create_task`` 调度到下一 tick 执行,与
``auth_expired`` 的 ``_schedule_stop`` 同模式,避免在事件发布栈
内同步 ``await stop_account`` 形成反馈环路。
参数:
event: ChannelDegraded 事件payload 含 channel_type/account_id/reason。
"""
trace_id = event.trace_id or str(uuid.uuid4())
channel_type_raw = event.payload.get("channel_type")
account_id = event.payload.get("account_id")
if channel_type_raw is None or account_id is None:
await self._logger.warn(
"channel degraded event missing required fields, skip",
trace_id=trace_id,
channel_type=channel_type_raw,
account_id=account_id,
)
return
channel_type = ChannelType(channel_type_raw)
account_key = self._make_account_key(channel_type, account_id)
if account_key in self._degraded_accounts:
return
self._degraded_accounts.add(account_key)
reason = event.payload.get("reason", "permanent_failure")
await self._logger.info(
"received channel degraded event, scheduling degradation to puller",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
reason=reason,
)
try:
asyncio.create_task(
self._degradeToPuller(channel_type, account_id, reason, trace_id),
name=f"transport-degrade-{channel_type}-{account_id}",
)
except RuntimeError as exc:
self._degraded_accounts.discard(account_key)
await self._logger.error(
"schedule degrade failed, no running event loop",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
error=str(exc),
)
async def _degradeToPuller(
self,
channel_type: ChannelType,
account_id: str,
reason: str,
trace_id: str,
) -> None:
"""异步执行降级:停止 StreamWorker + 以 pull 模式启动 PullerWorker。
在 ``_on_channel_degraded`` 通过 ``asyncio.create_task`` 调度的独立
任务中执行,不在事件发布栈内同步调用 ``stop_account``,避免与当前
StreamWorker 任务栈形成反馈环路死锁。
降级失败时清除 ``_degraded_accounts`` 标记,允许下次事件重试。
"""
account_key = self._make_account_key(channel_type, account_id)
try:
if self._stream_worker is not None:
await self._stream_worker.stop_account(
channel_type, account_id, reason="degraded"
)
await self._startTransportForAccount(
channel_type=channel_type,
account_id=account_id,
trace_id=trace_id,
source="degraded",
force_mode="pull",
)
await self._logger.warn(
"channel transport degraded to puller mode",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
reason=reason,
)
except Exception as exc:
self._degraded_accounts.discard(account_key)
await self._logger.error(
"failed to degrade transport to puller mode",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
reason=reason,
error=str(exc),
error_type=type(exc).__name__,
)
async def _on_channel_recovered(self, event: DomainEvent) -> None:
"""处理渠道恢复事件。
P0 阶段留空P1 阶段实现恢复逻辑。
参数:
event: ChannelRecovered 事件。
"""
pass
async def _on_transport_error(self, event: DomainEvent) -> None:
"""处理传输错误事件。
记录日志并将账户插件运行态置为 ``error``FR-32。P1 阶段可用于
监控告警。
参数:
event: TransportErrorOccurred 事件。
"""
trace_id = event.trace_id or str(uuid.uuid4())
channel_type_raw = event.payload.get("channel_type")
account_id = event.payload.get("account_id")
await self._logger.warn(
"transport error occurred",
trace_id=trace_id,
channel_type=channel_type_raw,
account_id=account_id,
error_category=event.payload.get("error_category"),
error_code=event.payload.get("error_code"),
)
# 持久化插件运行态为 errorFR-32。仅当事件携带账户信息时写入。
if channel_type_raw is not None and account_id is not None:
await self._touchPluginStatus(ChannelType(channel_type_raw), account_id, "error", trace_id)
async def _on_account_config_changed(self, event: DomainEvent) -> None:
"""处理账号配置变更事件P1-4/P1-5
账号配置bridge_url / bridge_token 等)变更后,重建对应账号的
Workerstop + start确保使用最新配置连接 bridge。典型场景
bridge_url 变更后 StreamWorker 需重建 SSE 连接PullerWorker 需
使用新 bridge_url 轮询。
仅对 ACTIVE 状态账号执行重建:非 ACTIVE 账号无运行中的 Worker
stop 为 no-opstart 被跳过(账号上线时由 ``ChannelAccountOnline``
事件启动 Worker自然使用最新配置
清除降级标记:若账号此前因 StreamWorker permanent 失败降级到
PullerWorker配置变更后清除降级标记给新配置一个重新尝试
Stream 的机会(``_startTransportForAccount`` 按 transport_mode
重新选择both 模式优先 Stream
参数:
event: ChannelAccountConfigChanged 事件payload 含
channel_type / account_id。
"""
trace_id = event.trace_id or str(uuid.uuid4())
channel_type_raw = event.payload.get("channel_type")
account_id = event.payload.get("account_id")
if channel_type_raw is None or account_id is None:
await self._logger.warn(
"account config changed event missing required fields, skip",
trace_id=trace_id,
channel_type=channel_type_raw,
account_id=account_id,
)
return
channel_type = ChannelType(channel_type_raw)
# 仅对 ACTIVE 状态账号执行 Worker 重建
try:
account = await self._persistence_port.getChannelAccount(channel_type, account_id)
except Exception as exc:
await self._logger.warn(
"failed to get channel account for config change, skip worker rebuild",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
error=str(exc),
)
return
if account is None or account.status != AccountStatus.ACTIVE:
await self._logger.info(
"account config changed but account not active, skip worker rebuild",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
status=account.status if account else None,
)
return
await self._logger.info(
"received account config changed event, rebuilding worker",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
)
# 清除降级标记,给新配置一个重新尝试 Stream 的机会
account_key = self._make_account_key(channel_type, account_id)
self._degraded_accounts.discard(account_key)
# 停止现有 Workerno-op if not running
if self._puller_worker is not None:
await self._puller_worker.stop_account(
channel_type, account_id, reason="config_changed"
)
if self._stream_worker is not None:
await self._stream_worker.stop_account(
channel_type, account_id, reason="config_changed"
)
# 启动新 Worker使用最新配置
await self._startTransportForAccount(
channel_type, account_id, trace_id, source="config_changed"
)
async def _touchPluginStatus(
self,
channel_type: ChannelType,
account_id: str,
plugin_status: str,
trace_id: str,
) -> None:
"""更新账户插件运行态FR-32
传输任务 start/stop/error 后调用 ``PersistencePort.updatePluginStatus``
持久化插件运行态running / stopped / error。诊断字段best-effort
写入:失败时仅记录告警,不中止传输主流程(与 ``last_health_check_at``
一致)。
"""
try:
await self._persistence_port.updatePluginStatus(channel_type, account_id, plugin_status)
except Exception as exc:
await self._logger.warn(
"failed to update plugin_status",
trace_id=trace_id,
channel_type=channel_type,
account_id=account_id,
plugin_status=plugin_status,
error=str(exc),
)
async def _on_config_changed(self, event: DomainEvent) -> None:
"""处理配置变更事件FR-04 配置热更新)。
监听 ``transport.*`` 前缀的全局配置变更,按 hot/restart 模式应用:
- hot 模式(``stall_timeout_ms`` / ``backoff_schedule`` /
``backoff_jitter``):重新加载 TransportConfig 并更新 Worker 配置,
当前轮次结束后生效。
- restart 模式(``max_restart_attempts`` /
``graceful_shutdown_timeout_s``):记录警告日志,需手动重启
TransportManager 生效P1 阶段不实现自动重启,避免账号连接中断)。
参数:
event: ConfigChanged / ConfigRollback 事件payload 包含 key。
"""
key = event.payload.get("key", "")
if not key.startswith("transport."):
return
# 仅处理全局配置target 为 None 或空),忽略账户级配置
target = event.payload.get("target")
if target:
return
trace_id = event.trace_id or str(uuid.uuid4())
if key in _RESTART_REQUIRED_CONFIG_KEYS:
await self._logger.warn(
"transport config change requires restart to take effect",
trace_id=trace_id,
key=key,
event_type=event.event_type,
)
return
# 仅对 hot-reloadable 键触发重新加载,忽略未知 transport.* 键
if key not in _RELOADABLE_CONFIG_KEYS:
return
# hot 模式:重新加载配置并更新 Worker
await self._logger.info(
"transport config hot reload triggered",
trace_id=trace_id,
key=key,
event_type=event.event_type,
)
# H-14与 _restoreOnlineAccounts 互斥,等待恢复扫描完成后再重载配置
async with self._restore_lock:
await self.reloadConfig()
async def reloadConfig(self) -> None:
"""重新加载传输配置并更新 WorkerFR-04 hot 模式)。
从 ``ConfigPort`` 读取最新 ``transport.*`` 配置,更新
``TransportManager._config`` 及两个 Worker 的 ``_config``。
Worker 在下一轮循环(退避等待、轮询间隔、心跳间隔)时自动应用新配置。
幂等:多次调用安全,仅更新配置不重启任务。
"""
trace_id = str(uuid.uuid4())
try:
new_config = await self._loadTransportConfig()
except Exception as exc:
await self._logger.error(
"transport config reload failed, keeping old config",
trace_id=trace_id,
error=str(exc),
)
return
self._config = new_config
if self._puller_worker is not None:
self._puller_worker._config = new_config
if self._stream_worker is not None:
self._stream_worker._config = new_config
await self._logger.info(
"transport config reloaded",
trace_id=trace_id,
stall_timeout_ms=new_config.stall_timeout_ms,
backoff_schedule=list(new_config.backoff_schedule),
backoff_jitter=new_config.backoff_jitter,
)
async def _loadTransportConfig(self) -> TransportConfig:
"""从 ConfigPort 加载 TransportConfig。
复用 BaseTransportWorker._loadConfig 的读取逻辑,集中配置加载
避免逻辑重复。
"""
# 委托任一 Worker 的 _loadConfig两者配置相同无 Worker 时
# 直接构造默认配置
if self._puller_worker is not None:
return await self._puller_worker._loadConfig()
if self._stream_worker is not None:
return await self._stream_worker._loadConfig()
return TransportConfig()
# transport.* 配置键的热更新模式分类FR-04
_RELOADABLE_CONFIG_KEYS: frozenset[str] = frozenset(
{
"transport.stall_timeout_ms",
"transport.backoff_schedule",
"transport.backoff_jitter",
"transport.stream_reconnect_backoff_ms",
"transport.stream_reconnect_max_backoff_ms",
}
)
_RESTART_REQUIRED_CONFIG_KEYS: frozenset[str] = frozenset(
{
"transport.max_restart_attempts",
"transport.graceful_shutdown_timeout_s",
}
)
class _AccountEventHandler(EventHandler):
"""账号事件处理器适配器。
将 EventHandler 接口适配到 TransportManager 的异步处理方法。
"""
def __init__(self, handler: Callable[[DomainEvent], Awaitable[None]]) -> None:
self._handler = handler
async def handle(self, event: DomainEvent) -> None:
await self._handler(event)