本次提交包含多项代码改进与功能增强: 1. 新增服务账号禁用错误类型与状态映射 2. 优化出站管道阶段顺序与上下文字段 3. 完善会话与事务管理逻辑,修复并发冲突处理 4. 调整钉钉与微信插件的适配逻辑 5. 优化内容校验与限流降级策略 6. 新增渠道降级投递事件与围栏回滚机制 7. 修正配置schema与权限校验逻辑 8. 完善审计与监控相关的日志与钩子处理
447 lines
19 KiB
Python
447 lines
19 KiB
Python
"""管道基础设施。
|
||
|
||
定义应用层管道基类 ``Pipeline`` 与阶段协议 ``AppStage``,提供阶段顺序执行、
|
||
条件跳过、失败策略处理与补偿执行能力。``FailureStrategy`` 从契约层复用,
|
||
不重新定义,保证失败语义在全链路一致。
|
||
|
||
可观测性(§11.2 / INV-10):``Pipeline.run()`` 在每个阶段执行后输出结构化
|
||
日志,必填字段 ``trace_id`` / ``stage_id`` / ``duration_ms`` / ``status`` /
|
||
``error_type``(失败时),支持阶段延迟分布与成功率度量。
|
||
|
||
协程取消清理(C-O2):``Pipeline.run()`` 在阶段循环外包裹 ``try/finally``,
|
||
finally 块执行带 ``cleanup=True`` 标记的阶段,确保协程被取消时流式会话与
|
||
输入指示资源被释放。``asyncio.CancelledError`` 不被 ``except Exception``
|
||
捕获(Python 3.8+ 继承 ``BaseException``),finally 是唯一可靠的清理点。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import sys
|
||
import time
|
||
from collections.abc import Callable
|
||
from typing import Any, Protocol, runtime_checkable
|
||
|
||
from yuxi.channels.contract.dtos.trace import Span
|
||
from yuxi.channels.contract.errors import Error, InternalError, PipelineConfigError
|
||
from yuxi.channels.contract.plugin.extension_point import FailureStrategy
|
||
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
||
from yuxi.channels.contract.ports.driven.tracer_port import TracerPort
|
||
|
||
|
||
@runtime_checkable
|
||
class AppStage(Protocol):
|
||
"""应用层管道阶段协议。
|
||
|
||
对齐契约层 ``Stage`` Protocol 并扩展:
|
||
- ``compensate`` 由 ``bool`` 调整为 ``str | None``,携带补偿阶段名称
|
||
(如 "outbox-rollback"),None 表示无补偿;
|
||
- 新增 ``condition`` 字段,支持基于上下文的阶段执行条件,None 表示
|
||
无条件执行;
|
||
- 新增 ``cleanup`` 字段(C-O2),标记为清理阶段的阶段会在 ``Pipeline.run``
|
||
的 finally 块中被重新执行,用于协程取消时释放资源(如流式会话)。
|
||
默认 False,未标记的阶段不参与清理。
|
||
|
||
阶段处理方法 ``process`` 返回 ``bool``:True 表示成功,False 或抛出
|
||
异常表示失败。
|
||
"""
|
||
|
||
id: str
|
||
reads: tuple[str, ...]
|
||
writes: tuple[str, ...]
|
||
idempotent: bool
|
||
thread_safe: bool
|
||
failure: FailureStrategy
|
||
compensate: str | None
|
||
cleanup: bool
|
||
condition: Callable[[Any], bool] | None
|
||
|
||
async def process(self, context: Any) -> bool:
|
||
"""处理管道阶段。
|
||
|
||
参数:
|
||
context: 管道上下文。
|
||
|
||
返回:
|
||
True 表示成功,False 或抛出异常表示失败。
|
||
"""
|
||
...
|
||
|
||
|
||
class Pipeline:
|
||
"""管道基类。
|
||
|
||
顺序执行阶段,按 ``AppStage.failure`` 策略处理失败。支持条件跳过、
|
||
异常翻译与补偿执行。
|
||
|
||
失败策略处理:
|
||
- TERMINATE:终止管道,返回失败;
|
||
- SKIP:跳过当前阶段,继续下一阶段;
|
||
- COMPENSATE:执行补偿阶段后返回失败;
|
||
- DEGRADE:标记降级并继续下一阶段。
|
||
|
||
命名等价说明(6-P2-01):上下文 ``trace_id`` 与 Agent 层 ``turn_id``
|
||
为同一概念的不同命名,均标识一次请求/会话回合的链路追踪 ID。
|
||
``_logStageExecution`` 输出的 ``trace_id`` 字段即等价于 ``turn_id``。
|
||
"""
|
||
|
||
name: str
|
||
stages: list[AppStage]
|
||
logger: LoggerPort | None
|
||
tracer_port: TracerPort | None
|
||
|
||
def __init__(
|
||
self,
|
||
name: str,
|
||
stages: list[AppStage],
|
||
logger: LoggerPort | None = None,
|
||
tracer_port: TracerPort | None = None,
|
||
) -> None:
|
||
"""初始化管道。
|
||
|
||
参数:
|
||
name: 管道名称(如 "inbound" / "outbound" / "control-plane")。
|
||
stages: 阶段列表,按列表顺序执行。
|
||
logger: 可选日志端口,用于记录补偿阶段异常,None 时不记录。
|
||
tracer_port: 可选追踪端口,用于为每个 stage 包裹独立 span
|
||
(H-4 / §11.2),None 时不生成 span(向后兼容)。
|
||
|
||
Raises:
|
||
PipelineConfigError: 任一阶段的 ``compensate`` 字段指向不存在
|
||
的阶段 ID。构造期 fail-fast,避免运行期 ``_runCompensate``
|
||
按名称查找静默失败(§3.1 补偿执行约束)。
|
||
"""
|
||
self.name = name
|
||
self.stages = stages
|
||
self.logger = logger
|
||
self.tracer_port = tracer_port
|
||
self._validateCompensateReferences()
|
||
|
||
def _validateCompensateReferences(self) -> None:
|
||
"""校验所有阶段的 ``compensate`` 引用指向已注册的阶段 ID。
|
||
|
||
``compensate`` 字段为字符串约定,``_runCompensate`` 按名称查找
|
||
阶段,未找到时**静默返回原错误**(不报错)。若因 typo 导致引用
|
||
不存在,补偿逻辑永远不会执行且无任何告警。
|
||
|
||
本方法在构造期校验所有 ``compensate`` 引用,发现不存在的引用
|
||
立即抛出 ``PipelineConfigError``(fail-fast),将运行期静默失败
|
||
转换为构造期显式失败。
|
||
"""
|
||
stage_ids = {stage.id for stage in self.stages}
|
||
for stage in self.stages:
|
||
if stage.compensate is not None and stage.compensate not in stage_ids:
|
||
raise PipelineConfigError(
|
||
pipeline=self.name,
|
||
stage_id=stage.id,
|
||
compensate=stage.compensate,
|
||
)
|
||
|
||
async def run(self, ctx: Any) -> tuple[bool, Error | None]:
|
||
"""执行管道。
|
||
|
||
按 ``stages`` 列表顺序执行每个阶段。阶段返回 True 表示成功,继续
|
||
下一阶段;返回 False 或抛出异常表示失败,按 ``failure`` 策略处理。
|
||
若 ``condition`` 返回 False 则跳过该阶段。
|
||
|
||
异常翻译:``Error`` 子类直接处理,原生 ``Exception`` 翻译为
|
||
``InternalError``(携带 ``trace_id`` 与异常信息),不得让原生异常
|
||
穿透管道边界(INV-7 / FF-ERR-01)。
|
||
|
||
可观测性(§11.2 / INV-10):每个阶段执行后输出结构化日志,必填
|
||
字段 ``trace_id`` / ``stage_id`` / ``duration_ms`` / ``status`` /
|
||
``error_type``(失败时),日志失败不影响管道执行。
|
||
|
||
协程取消清理(C-O2):阶段循环包裹在 ``try/finally`` 中。
|
||
``asyncio.CancelledError`` 继承 ``BaseException``(Python 3.8+),
|
||
不被 ``except Error`` / ``except Exception`` 捕获,直接触发 finally。
|
||
finally 块执行带 ``cleanup=True`` 标记的阶段(如 typing-stop),
|
||
释放流式会话与输入指示资源。清理阶段异常仅 warn 日志,不掩盖原始
|
||
``CancelledError``;清理过程中再次被取消时捕获并继续剩余清理,
|
||
最终重新抛出 ``CancelledError`` 以保留取消语义。
|
||
|
||
参数:
|
||
ctx: 管道上下文。
|
||
|
||
返回:
|
||
元组 (success, error)。全部阶段成功时 success 为 True、
|
||
error 为 None;否则 success 为 False、error 携带失败原因。
|
||
|
||
Raises:
|
||
asyncio.CancelledError: 协程被取消时,清理完成后重新抛出。
|
||
"""
|
||
try:
|
||
for stage in self.stages:
|
||
if stage.condition is not None and not stage.condition(ctx):
|
||
continue
|
||
|
||
ok = False
|
||
err: Error | None = None
|
||
trace_id = getattr(ctx, "trace_id", None)
|
||
start = time.monotonic()
|
||
# H-4 / §11.2:为每个 stage 包裹独立 span,tracer_port 为 None 时
|
||
# 跳过(向后兼容)。span 名格式 {pipeline_name}.{stage_id},使运维
|
||
# 可定位慢调用发生在哪个 stage。
|
||
span: Span | None = None
|
||
if self.tracer_port is not None:
|
||
# M-11: tracer 失败不得中断管道主链路,降级为无 span 执行
|
||
try:
|
||
span = await self.tracer_port.startSpan(f"{self.name}.{stage.id}", trace_id=trace_id)
|
||
except Exception as exc:
|
||
span = None
|
||
try:
|
||
if self.logger is not None:
|
||
await self.logger.warn(
|
||
f"tracer startSpan failed: {stage.id}",
|
||
trace_id=trace_id,
|
||
stage_id=stage.id,
|
||
error=str(exc),
|
||
)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
ok = await stage.process(ctx)
|
||
except Error as e:
|
||
err = e
|
||
except Exception as e:
|
||
err = InternalError(
|
||
trace_id=trace_id,
|
||
message=f"stage '{stage.id}' raised native exception: {type(e).__name__}: {e}",
|
||
cause=e,
|
||
)
|
||
finally:
|
||
if span is not None:
|
||
# M-11: endSpan 失败不得中断管道主链路,降级记录日志
|
||
try:
|
||
await self.tracer_port.endSpan(span, status="ok" if ok else "error")
|
||
except Exception as exc:
|
||
try:
|
||
if self.logger is not None:
|
||
await self.logger.warn(
|
||
f"tracer endSpan failed: {stage.id}",
|
||
trace_id=trace_id,
|
||
stage_id=stage.id,
|
||
error=str(exc),
|
||
)
|
||
except Exception:
|
||
pass
|
||
duration_ms = (time.monotonic() - start) * 1000
|
||
|
||
# 结构化可观测性日志(§11.2 / INV-10)
|
||
await self._logStageExecution(ctx, stage.id, duration_ms, ok, err)
|
||
|
||
if ok:
|
||
continue
|
||
|
||
match stage.failure:
|
||
case FailureStrategy.TERMINATE:
|
||
return False, err
|
||
case FailureStrategy.SKIP:
|
||
continue
|
||
case FailureStrategy.COMPENSATE:
|
||
return False, await self._runCompensate(ctx, stage.compensate, err)
|
||
case FailureStrategy.DEGRADE:
|
||
ctx.degraded = True
|
||
ctx.degraded_reason = err
|
||
continue
|
||
return True, None
|
||
finally:
|
||
# C-O2:协程取消清理。CancelledError 不被上方 except 捕获,
|
||
# 直接触发本 finally。仅当流式资源已启动时执行清理阶段。
|
||
await self._runCleanup(ctx)
|
||
|
||
async def _runCleanup(self, ctx: Any) -> None:
|
||
"""执行清理阶段(C-O2)。
|
||
|
||
在 ``Pipeline.run`` 的 finally 块中调用,查找并执行带
|
||
``cleanup=True`` 标记的阶段(如 typing-stop),释放流式会话与
|
||
输入指示资源。
|
||
|
||
清理策略:
|
||
- 仅当上下文 ``streaming_started`` 或 ``typing_started`` 为 True
|
||
时执行(无流式资源启动时跳过,避免无谓工作);
|
||
- 清理阶段异常仅 warn 日志,不掩盖原始异常(CancelledError 或
|
||
管道返回的错误);
|
||
- 清理过程中若再次被取消(``CancelledError``),捕获并继续剩余
|
||
清理阶段,最终重新抛出 ``CancelledError`` 以保留取消语义。
|
||
|
||
参数:
|
||
ctx: 管道上下文。
|
||
"""
|
||
# 仅当流式资源已启动时执行清理,避免无谓工作
|
||
streaming_started = getattr(ctx, "streaming_started", False)
|
||
typing_started = getattr(ctx, "typing_started", False)
|
||
if not (streaming_started or typing_started):
|
||
return
|
||
|
||
trace_id = getattr(ctx, "trace_id", None)
|
||
cleanup_cancelled = False
|
||
for stage in self.stages:
|
||
# 严格匹配 True,避免 MagicMock 桩的 truthy 属性误判为清理阶段
|
||
if getattr(stage, "cleanup", False) is not True:
|
||
continue
|
||
if stage.condition is not None and not stage.condition(ctx):
|
||
continue
|
||
try:
|
||
await stage.process(ctx)
|
||
except asyncio.CancelledError:
|
||
# 清理过程中被取消:记录并继续剩余清理阶段,最终重新抛出
|
||
cleanup_cancelled = True
|
||
except Exception as exc:
|
||
# 清理阶段异常仅 warn 日志,不掩盖原始异常
|
||
if self.logger is not None:
|
||
try:
|
||
await self.logger.warn(
|
||
f"cleanup stage {stage.id} failed: {exc}",
|
||
trace_id=trace_id,
|
||
stage_id=stage.id,
|
||
)
|
||
except Exception:
|
||
pass
|
||
if cleanup_cancelled:
|
||
raise asyncio.CancelledError()
|
||
|
||
async def _logStageExecution(
|
||
self,
|
||
ctx: Any,
|
||
stage_id: str,
|
||
duration_ms: float,
|
||
ok: bool,
|
||
err: Error | None,
|
||
) -> None:
|
||
"""输出阶段执行结构化日志(§11.2 / INV-10)。
|
||
|
||
必填字段:``trace_id`` / ``stage_id`` / ``duration_ms`` / ``status`` /
|
||
``error_type``(失败时)。日志端口为 None 或日志写入失败时不影响
|
||
管道执行(降级至 stderr)。
|
||
|
||
渠道语义字段(6-P0-04 / 6-P1-07 / 6-P2-02):从 ctx 读取并输出
|
||
``channel_type`` / ``account_id`` / ``agent_run_id`` / ``plugin_id`` /
|
||
``pairing_id`` / ``dm_decision`` / ``channel_format_spec`` /
|
||
``channel_context_note``(仅当值非 None 时输出)。当 ``channel_type``
|
||
与 ``account_id`` 均非 None 时派生 ``channel_id`` 并输出,
|
||
``channel_id`` 等价于 ``${channel_type}:${account_id}``。
|
||
|
||
参数:
|
||
ctx: 管道上下文(用于提取 trace_id 与渠道语义字段)。
|
||
stage_id: 阶段唯一标识。
|
||
duration_ms: 阶段执行耗时(毫秒)。
|
||
ok: 阶段是否成功。
|
||
err: 失败时的错误对象,成功时为 None。
|
||
"""
|
||
if self.logger is None:
|
||
return
|
||
trace_id = getattr(ctx, "trace_id", None)
|
||
# 收集必填字段
|
||
fields: dict[str, Any] = {
|
||
"trace_id": trace_id,
|
||
"pipeline": self.name,
|
||
"stage_id": stage_id,
|
||
"duration_ms": duration_ms,
|
||
"status": "ok" if ok else "failed",
|
||
}
|
||
# 收集渠道语义字段(仅当值非 None 时输出,6-P0-04 / 6-P1-07 / 6-P2-02)
|
||
channel_type = getattr(ctx, "channel_type", None)
|
||
account_id = getattr(ctx, "account_id", None)
|
||
agent_run_id = getattr(ctx, "agent_run_id", None)
|
||
plugin_id = getattr(ctx, "plugin_id", None)
|
||
pairing_id = getattr(ctx, "pairing_id", None)
|
||
dm_decision = getattr(ctx, "dm_decision", None)
|
||
channel_format_spec = getattr(ctx, "channel_format_spec", None)
|
||
channel_context_note = getattr(ctx, "channel_context_note", None)
|
||
if channel_type is not None:
|
||
fields["channel_type"] = channel_type
|
||
if account_id is not None:
|
||
fields["account_id"] = account_id
|
||
if agent_run_id is not None:
|
||
fields["agent_run_id"] = agent_run_id
|
||
if plugin_id is not None:
|
||
fields["plugin_id"] = plugin_id
|
||
if pairing_id is not None:
|
||
fields["pairing_id"] = pairing_id
|
||
if dm_decision is not None:
|
||
fields["dm_decision"] = dm_decision.value
|
||
if channel_format_spec is not None:
|
||
fields["channel_format_spec"] = channel_format_spec
|
||
if channel_context_note is not None:
|
||
fields["channel_context_note"] = channel_context_note
|
||
# 派生 channel_id,等价于 ${channel_type}:${account_id}
|
||
if channel_type is not None and account_id is not None:
|
||
fields["channel_id"] = f"{channel_type}:{account_id}"
|
||
if not ok:
|
||
fields["error_type"] = type(err).__name__ if err else None
|
||
# 记录异常 message 与 details,便于排查 stage 失败根因。
|
||
# 此前仅记录 error_type(类名),导致日志中看不到具体异常原因。
|
||
if err is not None:
|
||
fields["error_message"] = err.message
|
||
if err.details:
|
||
fields["error_details"] = err.details
|
||
try:
|
||
if ok:
|
||
await self.logger.info(
|
||
f"stage executed: {stage_id}",
|
||
**fields,
|
||
)
|
||
else:
|
||
await self.logger.warn(
|
||
f"stage failed: {stage_id}",
|
||
**fields,
|
||
)
|
||
except Exception as e:
|
||
sys.stderr.write(f"[trace_id={trace_id}] stage logging failed: {stage_id}: {e}\n")
|
||
|
||
async def _runCompensate(
|
||
self,
|
||
ctx: Any,
|
||
compensate_name: str | None,
|
||
original_error: Error | None,
|
||
) -> Error | None:
|
||
"""执行补偿阶段。
|
||
|
||
查找补偿阶段并执行。补偿阶段自身的异常 **不** 覆盖原错误,仅通过
|
||
日志端口记录(若注入),保留原始失败原因(§3.1 补偿执行约束)。
|
||
|
||
参数:
|
||
ctx: 管道上下文。
|
||
compensate_name: 补偿阶段名称,None 表示无补偿。
|
||
original_error: 原始失败错误。
|
||
|
||
返回:
|
||
原始失败错误(补偿不改变管道失败状态)。
|
||
"""
|
||
if compensate_name is None:
|
||
return original_error
|
||
compensate_stage = self._findStage(compensate_name)
|
||
if compensate_stage is None:
|
||
return original_error
|
||
try:
|
||
await compensate_stage.process(ctx)
|
||
except Exception as e:
|
||
trace_id = getattr(ctx, "trace_id", None)
|
||
if self.logger is not None:
|
||
try:
|
||
await self.logger.error(
|
||
f"compensate stage {compensate_name} failed: {e}",
|
||
trace_id=trace_id,
|
||
compensate_stage=compensate_name,
|
||
)
|
||
except Exception:
|
||
sys.stderr.write(f"[trace_id={trace_id}] compensate stage {compensate_name} failed: {e}\n")
|
||
else:
|
||
sys.stderr.write(f"[trace_id={trace_id}] compensate stage {compensate_name} failed: {e}\n")
|
||
return original_error
|
||
|
||
def _findStage(self, stage_id: str) -> AppStage | None:
|
||
"""按 id 查找阶段。
|
||
|
||
参数:
|
||
stage_id: 阶段唯一标识。
|
||
|
||
返回:
|
||
匹配的阶段实例,未找到时返回 None。
|
||
"""
|
||
for stage in self.stages:
|
||
if stage.id == stage_id:
|
||
return stage
|
||
return None
|