ForcePilot/backend/package/yuxi/channels/application/pipeline/base.py

447 lines
19 KiB
Python
Raw Normal View History

"""管道基础设施。
定义应用层管道基类 ``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.2None 时不生成 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 Falseerror 携带失败原因
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 包裹独立 spantracer_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