Compare commits
10 Commits
36d0add930
...
f9f08221fc
| Author | SHA1 | Date | |
|---|---|---|---|
| f9f08221fc | |||
| 002e6a356b | |||
| 41bd0a618c | |||
| b10366e898 | |||
| 44dcb028b9 | |||
| 56022a9199 | |||
| 80296b7db1 | |||
| 1bbfdb56c3 | |||
| 0239dea9ee | |||
| 140b13f1d9 |
@ -7,7 +7,7 @@ from yuxi.utils.paths import (
|
||||
)
|
||||
|
||||
PROMPT = f"""
|
||||
你是一个交互式智能体“语析“。
|
||||
你是一个交互式智能体“Kris“。
|
||||
|
||||
专门用来回答用户的问题。请根据用户提供的信息,尽可能详细地回答问题。
|
||||
如果你不确定答案,可以说你不知道,但请尽量提供相关的信息或建议。请保持礼貌和专业。
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
# buildin 工具包
|
||||
from .install_skill import install_skill
|
||||
from .tools import ask_user_question, present_artifacts
|
||||
# Web 搜索与抓取工具(不依赖 LITE 模式,始终注册)
|
||||
from . import search # noqa: F401
|
||||
from . import crawl # noqa: F401
|
||||
from . import (
|
||||
crawl, # noqa: F401
|
||||
search, # noqa: F401
|
||||
)
|
||||
from .install_skill import install_skill
|
||||
|
||||
# 会话通信工具仅在非 LITE 模式下注册
|
||||
from .session_tools import _LITE_MODE as _session_lite_mode
|
||||
from .tools import ask_user_question, present_artifacts
|
||||
|
||||
if not _session_lite_mode:
|
||||
from .session_tools import get_agent_progress, get_session_history, list_sessions, send_to_session
|
||||
@ -18,9 +20,11 @@ __all__ = [
|
||||
]
|
||||
|
||||
if not _session_lite_mode:
|
||||
__all__.extend([
|
||||
"get_agent_progress",
|
||||
"get_session_history",
|
||||
"list_sessions",
|
||||
"send_to_session",
|
||||
])
|
||||
__all__.extend(
|
||||
[
|
||||
"get_agent_progress",
|
||||
"get_session_history",
|
||||
"list_sessions",
|
||||
"send_to_session",
|
||||
]
|
||||
)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"""web_search 与 tavily_search 工具注册。"""
|
||||
|
||||
import json
|
||||
|
||||
from langgraph.prebuilt.tool_node import ToolRuntime
|
||||
@ -9,7 +10,6 @@ from .chain import build_provider_chain
|
||||
from .models import SearchResult
|
||||
from .rate_limiter import rate_limiter
|
||||
|
||||
|
||||
WEB_SEARCH_DESCRIPTION = """
|
||||
搜索互联网获取实时信息。
|
||||
|
||||
@ -104,7 +104,7 @@ async def _execute_web_search(query: str, max_results: int, runtime: ToolRuntime
|
||||
async def web_search(
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
runtime: ToolRuntime,
|
||||
runtime: ToolRuntime = None,
|
||||
) -> str:
|
||||
"""搜索互联网,返回结构化结果列表。"""
|
||||
return await _execute_web_search(query, max_results, runtime)
|
||||
@ -119,7 +119,7 @@ async def web_search(
|
||||
async def tavily_search(
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
runtime: ToolRuntime,
|
||||
runtime: ToolRuntime = None,
|
||||
) -> str:
|
||||
"""Tavily 网页搜索(兼容入口,内部委托 _execute_web_search)。"""
|
||||
return await _execute_web_search(query, max_results, runtime)
|
||||
|
||||
@ -19,16 +19,35 @@ def _extract_tool_info(tool_obj) -> dict:
|
||||
|
||||
if hasattr(tool_obj, "args_schema") and tool_obj.args_schema:
|
||||
schema = tool_obj.args_schema
|
||||
if hasattr(schema, "schema"):
|
||||
schema = schema.schema()
|
||||
for arg_name, arg_info in schema.get("properties", {}).items():
|
||||
info["args"].append(
|
||||
{
|
||||
"name": arg_name,
|
||||
"type": arg_info.get("type", ""),
|
||||
"description": arg_info.get("description", ""),
|
||||
}
|
||||
)
|
||||
# 优先从 pydantic v2 ``model_fields`` 提取参数信息,避免对
|
||||
# ``ToolRuntime`` 等含 callable 字段(如 ``stream_writer``)的注入式
|
||||
# 参数生成完整 JSON schema 时抛出 ``PydanticInvalidForJsonSchema``。
|
||||
# ``runtime`` 字段由 langgraph 自动注入,不属于用户输入参数,跳过展示。
|
||||
model_fields = getattr(schema, "model_fields", None)
|
||||
if model_fields:
|
||||
for arg_name, field_info in model_fields.items():
|
||||
if arg_name == "runtime":
|
||||
continue
|
||||
annotation = field_info.annotation
|
||||
info["args"].append(
|
||||
{
|
||||
"name": arg_name,
|
||||
"type": getattr(annotation, "__name__", str(annotation)),
|
||||
"description": field_info.description or "",
|
||||
}
|
||||
)
|
||||
elif hasattr(schema, "schema"):
|
||||
schema_dict = schema.schema()
|
||||
for arg_name, arg_info in schema_dict.get("properties", {}).items():
|
||||
if arg_name == "runtime":
|
||||
continue
|
||||
info["args"].append(
|
||||
{
|
||||
"name": arg_name,
|
||||
"type": arg_info.get("type", ""),
|
||||
"description": arg_info.get("description", ""),
|
||||
}
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
@ -146,15 +165,21 @@ async def resolve_configured_runtime_tools(context) -> list[Any]:
|
||||
if external_tool_names:
|
||||
async with pg_manager.get_async_session_context() as db:
|
||||
use_cases = create_use_cases_from_db(db)
|
||||
# 透传 agent 上下文(uid 作为 caller_id,run_id 作为 correlation_id),
|
||||
# 使 agent 调用外部工具时写入完整的可观测性字段
|
||||
thread_id = getattr(context, "thread_id", None)
|
||||
output = await use_cases.tool_service.build_runtime_tools(
|
||||
BuildRuntimeToolsInput(slugs=external_tool_names),
|
||||
BuildRuntimeToolsInput(
|
||||
slugs=external_tool_names,
|
||||
caller_id=getattr(context, "uid", None),
|
||||
correlation_id=getattr(context, "run_id", None),
|
||||
tags={"thread_id": thread_id} if thread_id else {},
|
||||
),
|
||||
)
|
||||
selected_tools.extend(output.items)
|
||||
selected_tool_names.update(tool.name for tool in output.items)
|
||||
# 构建失败的工具 slug 必须显式记录,避免用户配置的工具被静默丢失
|
||||
if output.failed_slugs:
|
||||
logger.warning(
|
||||
f"Failed to build external runtime tools, skipped: {output.failed_slugs}"
|
||||
)
|
||||
logger.warning(f"Failed to build external runtime tools, skipped: {output.failed_slugs}")
|
||||
|
||||
return selected_tools
|
||||
|
||||
@ -42,6 +42,7 @@ from yuxi.channels.contract.ports.driven.agent_run_execution_port import (
|
||||
AgentRunExecutionPort,
|
||||
)
|
||||
from yuxi.channels.contract.ports.driven.aggregate import DrivenAdapters
|
||||
from yuxi.storage.transactions import SqlAlchemyTransactionAdapter
|
||||
|
||||
# 被驱动适配器(请求级 / 无状态,14 个,经 create_driven_adapters 工厂装配)
|
||||
from .agent_access_config_adapter import AgentAccessConfigAdapter
|
||||
@ -63,7 +64,6 @@ from .redis_cache_adapter import RedisCacheAdapter
|
||||
from .redis_config_adapter import RedisConfigAdapter
|
||||
from .redis_realtime_metrics_adapter import RedisRealtimeMetricsAdapter
|
||||
from .service_account_adapter import ServiceAccountAdapter
|
||||
from .sqlalchemy_transaction_adapter import SqlAlchemyTransactionAdapter
|
||||
from .structured_logger_adapter import StructuredLoggerAdapter
|
||||
|
||||
# 无状态适配器全局实例(Masking / Logger / Tracer),跨请求复用,避免重复构造。
|
||||
@ -174,7 +174,6 @@ __all__ = [
|
||||
"StructuredLoggerAdapter",
|
||||
"InMemoryTracerAdapter",
|
||||
"IdentityResolverAdapter",
|
||||
"SqlAlchemyTransactionAdapter",
|
||||
# 独立装配路径适配器(5 个,不经过工厂,不进入 DrivenAdapters 聚合)
|
||||
"ContentReviewRepositoryAdapter",
|
||||
"DefaultContentModerationAdapter",
|
||||
|
||||
@ -212,7 +212,7 @@ class ARQQueueAdapter(QueuePort):
|
||||
await self._arq_pool.ping()
|
||||
return True
|
||||
except Exception as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"arq queue ping failed",
|
||||
error_type=type(exc).__name__,
|
||||
error=str(exc),
|
||||
|
||||
@ -148,6 +148,7 @@ from yuxi.channels.contract.ports.driven.user_identity_repository_port import (
|
||||
UserIdentityRepositoryPort,
|
||||
)
|
||||
from yuxi.repositories.channels import Repositories, create_repositories
|
||||
from yuxi.repositories.channels.base import RepositoryConflictError, RepositoryValidationError
|
||||
from yuxi.storage.postgres.models_business import (
|
||||
Conversation as ConversationORM,
|
||||
)
|
||||
@ -315,13 +316,16 @@ class ChannelPersistenceAdapter(
|
||||
def _translate_db_error(self, exc: Exception, resource: str) -> Error:
|
||||
"""将数据库异常翻译为契约层错误。
|
||||
|
||||
``IntegrityError`` 与 ``StaleDataError`` 映射为 ``ConflictError``
|
||||
(并发冲突 / 唯一约束冲突 / 乐观锁版本不匹配),其余
|
||||
``SQLAlchemyError`` 映射为 ``DependencyError``(依赖故障),
|
||||
``IntegrityError``、``StaleDataError`` 与 ``RepositoryConflictError``
|
||||
映射为 ``ConflictError``(并发冲突 / 唯一约束冲突 / 乐观锁版本不匹配),
|
||||
``RepositoryValidationError`` 映射为 ``ValidationError``(数据校验失败),
|
||||
其余 ``SQLAlchemyError`` 映射为 ``DependencyError``(依赖故障),
|
||||
禁止原生异常穿透至核心层。
|
||||
|
||||
``StaleDataError`` 由 ORM ``version_id_col`` 在 flush 时抛出,
|
||||
表示加载后版本已被并发事务修改,语义上属于并发冲突。
|
||||
``RepositoryConflictError`` 为仓储层对 ``StaleDataError`` 的封装,
|
||||
由适配器层统一翻译为 ``ConflictError``。
|
||||
|
||||
Args:
|
||||
exc: 原始数据库异常。
|
||||
@ -330,8 +334,10 @@ class ChannelPersistenceAdapter(
|
||||
Returns:
|
||||
契约层 Error 实例。
|
||||
"""
|
||||
if isinstance(exc, IntegrityError | StaleDataError):
|
||||
if isinstance(exc, IntegrityError | StaleDataError | RepositoryConflictError):
|
||||
return ConflictError(resource)
|
||||
if isinstance(exc, RepositoryValidationError):
|
||||
return ValidationError(resource, str(exc))
|
||||
return DependencyError(resource, Error(str(exc)))
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@ -451,6 +457,8 @@ class ChannelPersistenceAdapter(
|
||||
raise self._translate_db_error(exc, "channel_account") from exc
|
||||
except SQLAlchemyError as exc:
|
||||
raise self._translate_db_error(exc, "channel_account") from exc
|
||||
except RepositoryConflictError as exc:
|
||||
raise self._translate_db_error(exc, "channel_account") from exc
|
||||
except (NotFoundError, ConflictError, DependencyError):
|
||||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||||
@ -659,6 +667,12 @@ class ChannelPersistenceAdapter(
|
||||
except IntegrityError as exc:
|
||||
raise self._translate_db_error(exc, "channel_account") from exc
|
||||
except SQLAlchemyError as exc:
|
||||
await self._logger.error(
|
||||
"channel_persistence_sqlalchemy_error",
|
||||
resource="channel_account",
|
||||
error_type=type(exc).__name__,
|
||||
error=str(exc),
|
||||
)
|
||||
raise self._translate_db_error(exc, "channel_account") from exc
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@ -753,7 +767,7 @@ class ChannelPersistenceAdapter(
|
||||
if cmd.expected_version is None:
|
||||
# 乐观锁缺口可见化:调用方未传 expected_version,跳过版本检查
|
||||
# (向后兼容),但记录 WARN 以便追溯潜在的丢失更新风险。
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"channel_session_update_without_expected_version",
|
||||
resource="channel_session",
|
||||
session_id=cmd.session_id,
|
||||
@ -784,6 +798,8 @@ class ChannelPersistenceAdapter(
|
||||
raise self._translate_db_error(exc, "channel_session") from exc
|
||||
except SQLAlchemyError as exc:
|
||||
raise self._translate_db_error(exc, "channel_session") from exc
|
||||
except RepositoryConflictError as exc:
|
||||
raise self._translate_db_error(exc, "channel_session") from exc
|
||||
except (NotFoundError, ConflictError, DependencyError):
|
||||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||||
@ -1485,7 +1501,8 @@ class ChannelPersistenceAdapter(
|
||||
except IntegrityError as exc:
|
||||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||||
except SQLAlchemyError as exc:
|
||||
# StaleDataError 是 SQLAlchemyError 子类,由 _translate_db_error 统一翻译为 ConflictError
|
||||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||||
except RepositoryConflictError as exc:
|
||||
raise self._translate_db_error(exc, "channel_pairing") from exc
|
||||
except (NotFoundError, ConflictError, DependencyError):
|
||||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||||
@ -2280,6 +2297,8 @@ class ChannelPersistenceAdapter(
|
||||
"expires_at": now + timedelta(seconds=ttl_seconds),
|
||||
"trace_id": cmd.trace_id,
|
||||
"stream_aborted_at_chunk": cmd.stream_aborted_at_chunk,
|
||||
"fan_out_batch_id": cmd.fan_out_batch_id,
|
||||
"fan_out_seq": cmd.fan_out_seq,
|
||||
}
|
||||
)
|
||||
orm = await repos.outbox.create(data, commit=commit)
|
||||
@ -2302,6 +2321,96 @@ class ChannelPersistenceAdapter(
|
||||
)
|
||||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||||
|
||||
async def saveOutboxEntries(
|
||||
self,
|
||||
cmds: list[SaveOutboxEntryCmd],
|
||||
tx: TransactionContext | None = None,
|
||||
) -> list[OutboxId]:
|
||||
"""批量创建 PENDING 状态的发件箱条目(M13 单事务批量 INSERT)。
|
||||
|
||||
覆写端口默认实现(循环调用单条),用 ``session.add_all`` + 单次
|
||||
``flush`` 将所有条目在单事务内写入,减少 fan-out 场景的 DB 往返。
|
||||
各 cmd 的解析逻辑(account_pk / message_id / conversation_id /
|
||||
channel_session_pk)与 ``saveOutboxEntry`` 单条方法一致。
|
||||
|
||||
Args:
|
||||
cmds: 保存发件箱条目命令列表。
|
||||
tx: 事务上下文,非空时加入应用层事务,**不得** 自主提交。
|
||||
|
||||
Raises:
|
||||
NotFoundError: 消息、渠道账户或渠道会话不存在。
|
||||
ConflictError: 唯一约束冲突。
|
||||
DependencyError: 数据库故障。
|
||||
"""
|
||||
async with self._session_scope(tx) as (db, repos, commit):
|
||||
try:
|
||||
now = utc_now_naive()
|
||||
ttl_seconds = self._outbox_config.ttl_seconds
|
||||
max_retry = self._outbox_config.max_retry
|
||||
expires_at = now + timedelta(seconds=ttl_seconds)
|
||||
rows: list[ChannelOutboxEntryORM] = []
|
||||
outbox_ids: list[OutboxId] = []
|
||||
for cmd in cmds:
|
||||
account_orm = await repos.account.get_by_type_and_account(cmd.channel_type, cmd.channel_account_id)
|
||||
if account_orm is None:
|
||||
raise NotFoundError("channel_account", cmd.channel_account_id)
|
||||
account_pk = account_orm.id
|
||||
try:
|
||||
message_id = int(cmd.message_id)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise NotFoundError("message", cmd.message_id) from exc
|
||||
msg_stmt = select(MessageORM.conversation_id).where(MessageORM.id == message_id)
|
||||
msg_result = await db.execute(msg_stmt)
|
||||
conversation_id = msg_result.scalar_one_or_none()
|
||||
if conversation_id is None:
|
||||
raise NotFoundError("message", cmd.message_id)
|
||||
channel_session_pk: int | None = None
|
||||
if cmd.channel_session_id:
|
||||
session_orm = await repos.session.get_by_session_id(cmd.channel_session_id)
|
||||
if session_orm is None:
|
||||
raise NotFoundError("channel_session", cmd.channel_session_id)
|
||||
channel_session_pk = session_orm.id
|
||||
outbox_id = uuid.uuid4().hex
|
||||
rows.append(
|
||||
ChannelOutboxEntryORM(
|
||||
outbox_id=outbox_id,
|
||||
message_id=message_id,
|
||||
account_id=account_pk,
|
||||
conversation_id=conversation_id,
|
||||
channel_session_id=channel_session_pk,
|
||||
status="pending",
|
||||
durability_policy=cmd.durability_policy,
|
||||
max_retry=max_retry,
|
||||
expires_at=expires_at,
|
||||
trace_id=cmd.trace_id,
|
||||
fan_out_batch_id=cmd.fan_out_batch_id,
|
||||
fan_out_seq=cmd.fan_out_seq,
|
||||
version=1,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
outbox_ids.append(OutboxId(outbox_id))
|
||||
db.add_all(rows)
|
||||
if commit:
|
||||
await db.commit()
|
||||
else:
|
||||
await db.flush()
|
||||
return outbox_ids
|
||||
except IntegrityError as exc:
|
||||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||||
except SQLAlchemyError as exc:
|
||||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||||
except (NotFoundError, ConflictError, DependencyError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
await self._logger.error(
|
||||
"channel_outbox_save_entries_failed",
|
||||
resource="channel_outbox",
|
||||
error=str(exc),
|
||||
)
|
||||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||||
|
||||
async def getOutboxEntry(self, outbox_id: str) -> OutboxEntry | None:
|
||||
"""按 outbox_id 查询发件箱条目;不存在返回 None。
|
||||
|
||||
@ -2374,6 +2483,65 @@ class ChannelPersistenceAdapter(
|
||||
)
|
||||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||||
|
||||
async def getOutboxEntriesByBatch(
|
||||
self,
|
||||
batch_id: str,
|
||||
*,
|
||||
status: OutboxStatus | None = None,
|
||||
max_seq: int | None = None,
|
||||
tx: TransactionContext | None = None,
|
||||
) -> list[OutboxEntry]:
|
||||
"""查询同批次 outbox 条目,用于 M8 重试保序检查。
|
||||
|
||||
按 ``batch_id`` 查询同批次 outbox 条目,支持 ``status`` 与 ``max_seq`` 过滤。
|
||||
按 ``fan_out_seq`` 升序返回,供 ``OutboxRetryWorker`` 检查前序 PENDING
|
||||
条目以维持 fan-out 投递顺序(M8)。
|
||||
|
||||
@pre: batch_id 非空
|
||||
@post: 返回匹配批次 ID 的条目列表;status 非空时仅返回该状态条目;
|
||||
max_seq 非空时仅返回 seq <= max_seq 的条目
|
||||
@failure: DependencyError - 数据库故障
|
||||
@consistency: Strong
|
||||
"""
|
||||
async with self._session_scope(tx) as (db, _, _):
|
||||
try:
|
||||
stmt = select(ChannelOutboxEntryORM).where(
|
||||
ChannelOutboxEntryORM.fan_out_batch_id == batch_id,
|
||||
ChannelOutboxEntryORM.is_deleted == 0,
|
||||
)
|
||||
if status is not None:
|
||||
stmt = stmt.where(ChannelOutboxEntryORM.status == status.value)
|
||||
if max_seq is not None:
|
||||
stmt = stmt.where(ChannelOutboxEntryORM.fan_out_seq <= max_seq)
|
||||
stmt = stmt.order_by(ChannelOutboxEntryORM.fan_out_seq.asc())
|
||||
result = await db.execute(stmt)
|
||||
orms = result.scalars().all()
|
||||
# 批量查询关联账户 ORM,避免 N+1 查询
|
||||
account_ids = {orm.account_id for orm in orms}
|
||||
account_orms_map: dict[int, ChannelAccountORM] = {}
|
||||
if account_ids:
|
||||
acct_stmt = select(ChannelAccountORM).where(ChannelAccountORM.id.in_(account_ids))
|
||||
result = await db.execute(acct_stmt)
|
||||
account_orms_map = {a.id: a for a in result.scalars().all()}
|
||||
return [orm_to_outbox_entry(orm, account_orms_map.get(orm.account_id)) for orm in orms]
|
||||
except IntegrityError as exc:
|
||||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||||
except SQLAlchemyError as exc:
|
||||
raise self._translate_db_error(exc, "channel_outbox") from exc
|
||||
except (NotFoundError, ConflictError, DependencyError):
|
||||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||||
raise
|
||||
except Exception as exc:
|
||||
# 兜底:mappers 调用过程中可能抛出 TypeError 等非契约异常,翻译为 DependencyError
|
||||
# 防止穿透至核心层(INV-7);保留异常链以便追踪
|
||||
await self._logger.error(
|
||||
"channel_outbox_get_entries_by_batch_failed",
|
||||
resource="channel_outbox",
|
||||
error=str(exc),
|
||||
)
|
||||
raise DependencyError("channel_outbox", Error(str(exc))) from exc
|
||||
|
||||
async def updateOutboxEntry(
|
||||
self,
|
||||
entry: OutboxEntry,
|
||||
@ -3520,6 +3688,8 @@ class ChannelPersistenceAdapter(
|
||||
raise self._translate_db_error(exc, "user_identity") from exc
|
||||
except SQLAlchemyError as exc:
|
||||
raise self._translate_db_error(exc, "user_identity") from exc
|
||||
except RepositoryValidationError as exc:
|
||||
raise self._translate_db_error(exc, "user_identity") from exc
|
||||
except (NotFoundError, ConflictError, DependencyError):
|
||||
# 契约层错误:mappers 翻译的 DependencyError 与本适配器抛出的 NotFoundError/ConflictError
|
||||
# 直接放行,不二次翻译,保留原始异常链与错误码
|
||||
@ -3875,7 +4045,7 @@ class ChannelPersistenceAdapter(
|
||||
await db.execute(text("SELECT 1"))
|
||||
return True
|
||||
except SQLAlchemyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"db_ping_failed",
|
||||
resource="db",
|
||||
error=str(exc),
|
||||
@ -5042,6 +5212,8 @@ class ChannelPersistenceAdapter(
|
||||
raise self._translate_db_error(exc, "route_binding") from exc
|
||||
except SQLAlchemyError as exc:
|
||||
raise self._translate_db_error(exc, "route_binding") from exc
|
||||
except RepositoryConflictError as exc:
|
||||
raise self._translate_db_error(exc, "route_binding") from exc
|
||||
except (NotFoundError, ConflictError, DependencyError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
@ -5091,7 +5263,16 @@ class ChannelPersistenceAdapter(
|
||||
"""
|
||||
async with self._session_scope(None) as (_, repos, _):
|
||||
try:
|
||||
orms = await repos.route_binding.list(filter=filter, limit=limit, offset=offset)
|
||||
orms = await repos.route_binding.list(
|
||||
channel_type=filter.channel_type,
|
||||
account_id=filter.account_id,
|
||||
match_source=filter.match_source,
|
||||
match_value=filter.match_value,
|
||||
enabled=filter.enabled,
|
||||
agent_binding=filter.agent_binding,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return tuple(orm_to_route_binding(orm) for orm in orms)
|
||||
except IntegrityError as exc:
|
||||
raise self._translate_db_error(exc, "route_binding") from exc
|
||||
@ -5118,7 +5299,14 @@ class ChannelPersistenceAdapter(
|
||||
"""
|
||||
async with self._session_scope(None) as (_, repos, _):
|
||||
try:
|
||||
return await repos.route_binding.count(filter=filter)
|
||||
return await repos.route_binding.count(
|
||||
channel_type=filter.channel_type,
|
||||
account_id=filter.account_id,
|
||||
match_source=filter.match_source,
|
||||
match_value=filter.match_value,
|
||||
enabled=filter.enabled,
|
||||
agent_binding=filter.agent_binding,
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
raise self._translate_db_error(exc, "route_binding") from exc
|
||||
except SQLAlchemyError as exc:
|
||||
@ -5143,6 +5331,7 @@ class ChannelPersistenceAdapter(
|
||||
|
||||
Raises:
|
||||
NotFoundError: binding_id 不存在(含已软删除)。
|
||||
ConflictError: 并发冲突。
|
||||
DependencyError: 数据库故障。
|
||||
"""
|
||||
async with self._session_scope(tx) as (_, repos, commit):
|
||||
@ -5155,6 +5344,8 @@ class ChannelPersistenceAdapter(
|
||||
raise self._translate_db_error(exc, "route_binding") from exc
|
||||
except SQLAlchemyError as exc:
|
||||
raise self._translate_db_error(exc, "route_binding") from exc
|
||||
except RepositoryConflictError as exc:
|
||||
raise self._translate_db_error(exc, "route_binding") from exc
|
||||
except (NotFoundError, ConflictError, DependencyError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
|
||||
@ -64,6 +64,7 @@ from yuxi.channels.contract.ports.driven.content_review_repository_port import (
|
||||
)
|
||||
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
||||
from yuxi.repositories.channels import ChannelContentReviewRecordRepository
|
||||
from yuxi.repositories.channels.base import RepositoryConflictError
|
||||
from yuxi.utils.datetime_utils import UTC
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -607,6 +608,8 @@ class ContentReviewRepositoryAdapter(ContentReviewRepositoryPort):
|
||||
raise self._translate_db_error(exc, "content_review_record") from exc
|
||||
except (ConflictError, DependencyError):
|
||||
raise
|
||||
except RepositoryConflictError as exc:
|
||||
raise ConflictError("content_review_record") from exc
|
||||
except Exception as exc:
|
||||
await self._logger.error(
|
||||
"content_review_repository_failed",
|
||||
|
||||
@ -93,7 +93,7 @@ class InMemoryTracerAdapter(TracerPort):
|
||||
return span
|
||||
except Exception as e:
|
||||
# 降级:返回 fallback Span,不抛异常,但记录故障以便运维感知
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"tracer startSpan failed, returning fallback span",
|
||||
error=str(e),
|
||||
name=name,
|
||||
@ -125,7 +125,7 @@ class InMemoryTracerAdapter(TracerPort):
|
||||
span.ended_at = utc_now_naive()
|
||||
except Exception as e:
|
||||
# 降级:状态/结束时间记录失败不阻断主流程,但记录故障
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"tracer endSpan status update failed",
|
||||
error=str(e),
|
||||
span_name=span.name,
|
||||
|
||||
@ -100,7 +100,7 @@ class MessageOpsPortAdapter(MessageOpsPort):
|
||||
"""
|
||||
adapter = self._registry.get(channel_type)
|
||||
if adapter is None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"message_ops_adapter_not_registered",
|
||||
channel_type=str(channel_type),
|
||||
)
|
||||
|
||||
@ -137,7 +137,7 @@ class RedisCacheAdapter(CachePort):
|
||||
return True
|
||||
except Exception as exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(f"cache write failed: key={key}, error={exc}")
|
||||
await self._logger.warning(f"cache write failed: key={key}, error={exc}")
|
||||
return False
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
@ -154,7 +154,7 @@ class RedisCacheAdapter(CachePort):
|
||||
return deleted > 0
|
||||
except Exception as exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(f"cache delete failed: key={key}, error={exc}")
|
||||
await self._logger.warning(f"cache delete failed: key={key}, error={exc}")
|
||||
return False
|
||||
|
||||
async def incr(self, key: str, amount: int = 1) -> int:
|
||||
@ -248,7 +248,7 @@ class RedisCacheAdapter(CachePort):
|
||||
return count
|
||||
except Exception as exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(f"cache invalidate failed: pattern={pattern}, error={exc}")
|
||||
await self._logger.warning(f"cache invalidate failed: pattern={pattern}, error={exc}")
|
||||
return 0
|
||||
|
||||
async def acquireAdvisoryLock(self, key: str, ttl_seconds: int = 300) -> LockToken | None:
|
||||
@ -326,7 +326,7 @@ class RedisCacheAdapter(CachePort):
|
||||
return True
|
||||
except Exception as exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(f"cache ping failed: error={exc}")
|
||||
await self._logger.warning(f"cache ping failed: error={exc}")
|
||||
return False
|
||||
|
||||
async def getStreamStatus(self) -> RedisStreamStatus:
|
||||
|
||||
@ -243,7 +243,7 @@ class RedisConfigAdapter(ConfigPort):
|
||||
mismatches.append((key, declared_scope.value, scope.value))
|
||||
|
||||
if mismatches:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"config scope mismatch in batch get",
|
||||
mismatches=mismatches,
|
||||
target=target or "",
|
||||
@ -414,7 +414,7 @@ class RedisConfigAdapter(ConfigPort):
|
||||
try:
|
||||
updated_at = datetime.fromisoformat(updated_at_raw)
|
||||
except ValueError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"redis config updated_at parse failed",
|
||||
key=key,
|
||||
raw_value=updated_at_raw,
|
||||
@ -572,7 +572,7 @@ class RedisConfigAdapter(ConfigPort):
|
||||
try:
|
||||
updated_at = datetime.fromisoformat(updated_at_raw)
|
||||
except ValueError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"redis config history updated_at parse failed",
|
||||
key=key,
|
||||
raw_value=updated_at_raw,
|
||||
@ -687,7 +687,7 @@ class RedisConfigAdapter(ConfigPort):
|
||||
if not skip_scope_check and self._key_to_scope_map is not None:
|
||||
declared_scope = self._key_to_scope_map.get(key)
|
||||
if declared_scope is not None and declared_scope != scope:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"config scope mismatch",
|
||||
key=key,
|
||||
declared_scope=declared_scope.value,
|
||||
@ -787,7 +787,7 @@ class RedisConfigAdapter(ConfigPort):
|
||||
await self._redis.ping()
|
||||
return True
|
||||
except Exception as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"redis config ping failed",
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
@ -1,146 +0,0 @@
|
||||
"""SQLAlchemy 事务适配器。
|
||||
|
||||
实现 ``TransactionPort`` 契约,基于 SQLAlchemy ``AsyncSession`` 提供事务
|
||||
边界控制能力。事务边界由应用层(管道或用例编排器)显式调用,被驱动适配
|
||||
器通过 ``TransactionContext`` 透传(C-I1)加入同一事务,**不得** 自主提交。
|
||||
|
||||
事务共享机制:``create_driven_adapters(db, session_factory, ...)`` 将同一
|
||||
请求级 ``AsyncSession`` 注入到 ``ConversationAdapter`` /
|
||||
``SqlAlchemyTransactionAdapter``(请求级事务边界适配器,共享 ``db`` 保证
|
||||
事务一致性);``ChannelPersistenceAdapter`` 为无状态适配器(注入
|
||||
``session_factory``),通过 ``_session_scope(tx)`` 按需获取 session:
|
||||
``tx`` 非空时复用 ``tx.get_session()`` 返回的请求级 ``db``(加入应用层
|
||||
事务),``tx`` 为 ``None`` 时通过 ``session_factory()`` 创建独立 session
|
||||
并自主提交。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from yuxi.channels.contract.ports.driven.transaction_port import (
|
||||
TransactionPort,
|
||||
)
|
||||
|
||||
__all__ = ["SqlAlchemyTransactionAdapter", "SqlAlchemyTransactionContext"]
|
||||
|
||||
|
||||
class SqlAlchemyTransactionContext:
|
||||
"""SQLAlchemy 事务上下文。
|
||||
|
||||
封装 ``AsyncSession`` 与其事务。被驱动适配器在构造时共享同一
|
||||
``AsyncSession``,``begin()`` 在共享 session 上开启事务,所有适配器
|
||||
的写操作自动加入。上下文管理器退出时自动提交(无异常)或回滚
|
||||
(有异常)。
|
||||
|
||||
被驱动适配器 **不得** 调用 ``commit`` / ``rollback``,仅由应用层
|
||||
通过 ``TransactionPort`` 控制。
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
"""初始化事务上下文。
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy 异步会话,事务边界由本上下文控制。
|
||||
"""
|
||||
self._session = session
|
||||
self._txn: Any = None
|
||||
|
||||
def get_session(self) -> AsyncSession:
|
||||
"""返回底层共享 ``AsyncSession``,供未在构造时共享 session 的适配器复用主事务。
|
||||
|
||||
C-I1:``AgentRunAdapter`` 等适配器在构造时未与事务适配器共享 session,
|
||||
通过本方法获取主事务的共享 session,复用同一事务,避免独立提交产生
|
||||
孤儿记录。
|
||||
"""
|
||||
return self._session
|
||||
|
||||
async def commit(self) -> None:
|
||||
"""提交当前事务。
|
||||
|
||||
仅由应用层调用,被驱动适配器 **不得** 调用。提交后清空事务对象,
|
||||
避免 ``__aexit__`` 重复提交。
|
||||
"""
|
||||
if self._txn is not None:
|
||||
await self._txn.commit()
|
||||
self._txn = None
|
||||
|
||||
async def rollback(self) -> None:
|
||||
"""回滚当前事务。
|
||||
|
||||
仅由应用层调用,被驱动适配器 **不得** 调用。回滚后清空事务对象,
|
||||
避免 ``__aexit__`` 重复回滚。
|
||||
"""
|
||||
if self._txn is not None:
|
||||
await self._txn.rollback()
|
||||
self._txn = None
|
||||
|
||||
async def __aenter__(self) -> SqlAlchemyTransactionContext:
|
||||
"""进入事务上下文,开启 SQLAlchemy 事务。
|
||||
|
||||
SQLAlchemy 2.0 autobegin 语义下,前置读操作(如 ``getChannelSessionByPeer``
|
||||
等 SELECT 查询)会在 session 上隐式开启只读事务。若不处理,
|
||||
``begin()`` 会抛 ``InvalidRequestError: A transaction is already
|
||||
begun on this Session.``。
|
||||
|
||||
此处检测并提交隐式事务后再开启显式事务。安全性保证:按适配器契约,
|
||||
``tx=None`` 的写操作自主提交,autobegin 事务仅含读操作,提交不会
|
||||
产生副作用数据落库。应用层显式事务边界(§10.1)由此方法独占控制。
|
||||
"""
|
||||
if self._session.in_transaction():
|
||||
await self._session.commit()
|
||||
self._txn = await self._session.begin()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
||||
"""退出事务上下文。
|
||||
|
||||
若事务未被显式提交/回滚(``self._txn`` 非空且 ``is_active``),则按
|
||||
异常状态自动提交(无异常)或回滚(有异常);若已被显式提交/回滚
|
||||
(``self._txn`` 为空)或已被 SQLAlchemy 内部关闭(``is_active=False``,
|
||||
如 flush 失败自动关闭事务),则跳过,避免 ``ResourceClosedError``。
|
||||
事务对象退出后释放引用,避免泄漏。
|
||||
"""
|
||||
try:
|
||||
if self._txn is not None and self._txn.is_active:
|
||||
if exc is None:
|
||||
await self._txn.commit()
|
||||
else:
|
||||
await self._txn.rollback()
|
||||
finally:
|
||||
self._txn = None
|
||||
|
||||
|
||||
class SqlAlchemyTransactionAdapter(TransactionPort):
|
||||
"""SQLAlchemy 事务适配器。
|
||||
|
||||
实现 ``TransactionPort`` 契约,基于共享的 ``AsyncSession`` 提供事务
|
||||
边界控制。事务边界由应用层显式调用 ``begin()`` 开启,被驱动适配器
|
||||
通过构造时共享的 session 加入同一事务。
|
||||
|
||||
关键约束:
|
||||
- 事务边界 **必须** 由应用层控制。
|
||||
- 被驱动适配器 **不得** 自主调用 ``commit`` / ``rollback``。
|
||||
- 事务范围 **必须** 由应用层显式声明。
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
"""初始化事务适配器。
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy 异步会话,与被驱动适配器共享以保证事务
|
||||
一致性。
|
||||
"""
|
||||
self._session = session
|
||||
|
||||
def begin(self) -> SqlAlchemyTransactionContext:
|
||||
"""开启一个新事务,返回 SQLAlchemy 事务上下文。
|
||||
|
||||
Returns:
|
||||
SQLAlchemy 事务上下文,被驱动适配器通过构造时共享的 session
|
||||
加入同一事务。上下文管理器退出时自动提交(无异常)或回滚
|
||||
(有异常)。
|
||||
"""
|
||||
return SqlAlchemyTransactionContext(self._session)
|
||||
@ -135,14 +135,14 @@ class StructuredLoggerAdapter(LoggerPort):
|
||||
"""
|
||||
self._emit("INFO", message, trace_id=trace_id, **kwargs)
|
||||
|
||||
async def warn(
|
||||
async def warning(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
trace_id: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""记录 WARN 级别日志。
|
||||
"""记录 WARNING 级别日志。
|
||||
|
||||
Args:
|
||||
message: 日志消息。
|
||||
|
||||
@ -40,7 +40,7 @@ from yuxi.channels.core.service.config_manager import ConfigManager
|
||||
async def _log_warn(logger: LoggerPort | None, message: str, trace_id: str | None) -> None:
|
||||
"""记录警告日志(best-effort,失败不影响业务流程)。"""
|
||||
if logger is not None:
|
||||
await logger.warn(message, trace_id=trace_id)
|
||||
await logger.warning(message, trace_id=trace_id)
|
||||
|
||||
|
||||
class WhitelistConfigAdapter:
|
||||
@ -168,7 +168,7 @@ class ChannelCircuitBreaker:
|
||||
try:
|
||||
lock_token = await self._cache.acquireAdvisoryLock(lock_key, ttl_seconds=5)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"circuit_breaker state lock acquire failed, skip state transition",
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
@ -209,7 +209,7 @@ class ChannelCircuitBreaker:
|
||||
except Exception as exc:
|
||||
# fail-closed:原子递减失败表示缓存依赖故障,抛 DependencyError
|
||||
# 由上层决定阻断或放行(INV-7,不回退 read-modify-write)
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"circuit_breaker half_open permits incr failed, fail-closed",
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
@ -246,7 +246,7 @@ class ChannelCircuitBreaker:
|
||||
try:
|
||||
lock_token = await self._cache.acquireAdvisoryLock(lock_key, ttl_seconds=5)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"circuit_breaker state lock acquire failed, skip state transition",
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
@ -299,7 +299,7 @@ class ChannelCircuitBreaker:
|
||||
try:
|
||||
lock_token = await self._cache.acquireAdvisoryLock(lock_key, ttl_seconds=5)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"circuit_breaker state lock acquire failed, skip state transition",
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
@ -352,7 +352,7 @@ class ChannelCircuitBreaker:
|
||||
except Exception as exc:
|
||||
# fail-closed:原子递增失败表示缓存依赖故障,改为抛出
|
||||
# DependencyError,由上层重试机制处理,避免错误掩盖
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"circuit_breaker incr failed, fail-closed",
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
@ -598,7 +598,7 @@ class ChannelCircuitBreaker:
|
||||
try:
|
||||
config = await self._config.get(self._CONFIG_KEY, ConfigScope.GLOBAL)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"熔断阈值配置端口故障,fail-fast",
|
||||
error=str(exc),
|
||||
)
|
||||
@ -641,7 +641,7 @@ class ChannelCircuitBreaker:
|
||||
try:
|
||||
config = await self._config.get(self._CONFIG_KEY, ConfigScope.GLOBAL)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"恢复超时配置端口故障,fail-fast",
|
||||
error=str(exc),
|
||||
)
|
||||
@ -688,7 +688,7 @@ class ChannelCircuitBreaker:
|
||||
trace_id=trace_id,
|
||||
)
|
||||
await self._event_publisher.publish(event.toDomainEvent())
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"渠道熔断器已打开",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type,
|
||||
|
||||
@ -24,12 +24,32 @@ from yuxi.channels.contract.dtos.outbound import (
|
||||
RichMessage,
|
||||
TrustedMessage,
|
||||
)
|
||||
from yuxi.channels.contract.dtos.outbox import MultiPartReceipt, OutboxEntry
|
||||
from yuxi.channels.contract.dtos.outbox import MultiPartReceipt, OutboxEntry, OutboxId
|
||||
from yuxi.channels.contract.dtos.streaming import StreamingCompleted
|
||||
from yuxi.channels.contract.dtos.truncation import TruncationResult
|
||||
from yuxi.channels.contract.errors import Error
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FanOutTarget:
|
||||
"""fan-out 扇出目标(M13)。
|
||||
|
||||
描述一次 fan-out 投递中单个目标的渠道定位信息,由上游阶段(如
|
||||
``AdminMessageService``)按 target 列表解析后填充到
|
||||
``OutboundContext.fan_out_entries``,供 outbox-persist 阶段批量构造
|
||||
``SaveOutboxEntryCmd`` 在单事务内保存。
|
||||
|
||||
字段:
|
||||
channel_type: 目标渠道类型。
|
||||
account_id: 目标渠道账户业务 ID。
|
||||
channel_session_id: 目标渠道会话 ID(可选)。
|
||||
"""
|
||||
|
||||
channel_type: ChannelType
|
||||
account_id: str
|
||||
channel_session_id: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutboundContext:
|
||||
"""出站管道可变局部上下文。
|
||||
@ -58,6 +78,14 @@ class OutboundContext:
|
||||
# 出站投递幂等键(可选)。跨重试稳定,供 deliver 阶段调用适配器
|
||||
# ``sendMessage`` / ``sendMessageContinuation`` 时传入,实现渠道侧去重。
|
||||
idempotency_key: str | None = None
|
||||
# fan-out 批次 ID(可选)。标识同一次 fan-out 产生的一组出站请求,
|
||||
# 由 AdminMessageService 在 fan-out 时填充,outbox-persist 阶段据此
|
||||
# 写入 OutboxEntry.fan_out_batch_id,供 M8 outbox 重试保序使用。
|
||||
fan_out_batch_id: str | None = None
|
||||
# fan-out 批次内序号(可选)。标识同批次内的投递顺序,由
|
||||
# AdminMessageService 在 fan-out 时按目标遍历顺序填充,供 M8 重试保序
|
||||
# 按序号恢复原始顺序。
|
||||
fan_out_seq: int | None = None
|
||||
|
||||
# ---- 会话定位字段 ----
|
||||
conversation_id: str | None = None
|
||||
@ -141,3 +169,11 @@ class OutboundContext:
|
||||
# ---- 降级标志字段 ----
|
||||
degraded: bool = False
|
||||
degraded_reason: Error | None = None
|
||||
|
||||
# ---- fan-out 扇出字段(M13)----
|
||||
# 由上游阶段(如 AdminMessageService)填充,outbox-persist 阶段据此
|
||||
# 走批量保存路径(单事务保存所有 OutboxEntry)。is_fan_out 为 False
|
||||
# 或 fan_out_entries 为 None 时走原单条保存路径。
|
||||
is_fan_out: bool = False
|
||||
fan_out_entries: list[FanOutTarget] | None = None
|
||||
fan_out_outbox_ids: list[OutboxId] | None = None
|
||||
|
||||
@ -202,7 +202,7 @@ class MessageOperationExecutor:
|
||||
trusted_fields, clean_parameters, detected_untrusted = self._extractTrustedFields(parameters)
|
||||
if detected_untrusted and self._logger is not None:
|
||||
keys_desc = ", ".join(detected_untrusted)
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"工具参数包含不可信字段 [{keys_desc}],已忽略并使用服务端注入的发送者(FR-25 §AC-53)",
|
||||
)
|
||||
|
||||
@ -226,7 +226,7 @@ class MessageOperationExecutor:
|
||||
# 可信注入拒绝:非渠道 Agent Run 试图执行受限操作,或会话
|
||||
# 无所有者。记录告警日志并返回失败结果(FR-25 异常边界)。
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"trusted injection rejected for operation '{definition.operation.value}': {exc}",
|
||||
)
|
||||
return ToolResult(success=False, error=str(exc))
|
||||
@ -322,7 +322,7 @@ class MessageOperationExecutor:
|
||||
# 操作历史为审计辅助功能,不应阻塞业务结果返回。但日志必须
|
||||
# 携带 trace_id(INV-10),便于跨链路追踪。当前上下文无
|
||||
# trace_id 来源,显式传入 None 表达契约字段完整性。
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"appendOperationHistory failed for channel_msg_id='{channel_msg_id}': {exc}",
|
||||
trace_id=None,
|
||||
)
|
||||
@ -393,7 +393,7 @@ class MessageOperationExecutor:
|
||||
) -> tuple[dict[str, Any], dict[str, Any], list[str]]:
|
||||
"""从 ``parameters`` 提取可信上下文保留键并剥离不可信发送者字段。
|
||||
|
||||
本方法为纯数据变换,不调用日志端口(``LoggerPort.warn`` 为异步方法,
|
||||
本方法为纯数据变换,不调用日志端口(``LoggerPort.warning`` 为异步方法,
|
||||
无法在同步方法中调用)。检测到的不可信字段通过返回值传出,由调用
|
||||
方在异步上下文中记录告警日志。
|
||||
|
||||
|
||||
@ -106,7 +106,7 @@ class ChannelEventBroadcaster:
|
||||
for subscription_id, queue in subscriptions:
|
||||
if queue.full():
|
||||
dropped = queue.get_nowait()
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"SSE 订阅队列已满,丢弃最旧事件",
|
||||
trace_id=event.trace_id,
|
||||
subscription_id=subscription_id,
|
||||
|
||||
@ -106,7 +106,7 @@ class ConfigSourceRegistry:
|
||||
except Exception as e:
|
||||
strategy = source.failure_policy
|
||||
strategy_val = strategy.value if hasattr(strategy, "value") else strategy
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"配置源加载失败,按策略 {strategy_val} 跳过: source_id={source.source_id}, error={e}",
|
||||
source_id=source.source_id,
|
||||
error=str(e),
|
||||
|
||||
@ -120,13 +120,13 @@ class EventBus:
|
||||
f"handler timeout: {self._HANDLER_TIMEOUT}s",
|
||||
)
|
||||
except Exception as degrade_err:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"降级管理器调用失败: {degrade_err}",
|
||||
plugin_id=entry.plugin_id,
|
||||
error=str(degrade_err),
|
||||
)
|
||||
# handler 超时不得拖垮宿主,记录告警后继续分发其他订阅者
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"事件订阅 handler 超时: event_type={event.event_type}, strategy={strategy}",
|
||||
trace_id=event.trace_id,
|
||||
plugin_id=entry.plugin_id,
|
||||
@ -143,13 +143,13 @@ class EventBus:
|
||||
str(e),
|
||||
)
|
||||
except Exception as degrade_err:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"降级管理器调用失败: {degrade_err}",
|
||||
plugin_id=entry.plugin_id,
|
||||
error=str(degrade_err),
|
||||
)
|
||||
# 插件 handler 失败不得拖垮宿主,记录日志后继续分发其他订阅者
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"事件订阅 handler 失败: event_type={event.event_type}, strategy={strategy.value}",
|
||||
trace_id=event.trace_id,
|
||||
plugin_id=entry.plugin_id,
|
||||
|
||||
@ -68,5 +68,8 @@ class OutboxStateAuditHandler:
|
||||
"new_status": payload.get("new_status"),
|
||||
},
|
||||
trace_id=event.trace_id,
|
||||
# target_channel 列为 NOT NULL,outbox 状态变更为系统级事件,
|
||||
# payload 不含渠道类型,填充 "global"。
|
||||
target_channel="global",
|
||||
)
|
||||
await self._persistence.saveAuditLog(cmd)
|
||||
|
||||
@ -53,7 +53,7 @@ class PluginLifecycleAuditHandler:
|
||||
任务触发,无终端用户上下文)。
|
||||
- ``target``:插件 ID(从事件 payload 提取)。
|
||||
- ``target_channel``:插件绑定的渠道类型(从 ``PluginRegistry``
|
||||
查询;插件已注销时为 ``None``)。
|
||||
查询;插件已注销时回退 ``"global"``)。
|
||||
- ``result``:``"success"``(正常生命周期事件)或 ``"failed"``
|
||||
(``PluginFailed`` 事件)。
|
||||
- ``params_summary``:携带 ``version`` / ``reason`` / ``error`` 等
|
||||
@ -99,8 +99,9 @@ class PluginLifecycleAuditHandler:
|
||||
plugin_id: str = payload.get("plugin_id", "")
|
||||
result: str = "failed" if audit_op is AuditOperationType.PLUGIN_FAILED else "success"
|
||||
|
||||
# 查询插件绑定的渠道类型(插件已注销时为 None)
|
||||
target_channel: str | None = None
|
||||
# 查询插件绑定的渠道类型,插件已注销时回退 "global"
|
||||
# (target_channel 列为 NOT NULL,注释:渠道类型或 global)
|
||||
target_channel: str = "global"
|
||||
plugin_manifest = self._plugin_registry.getPlugin(plugin_id)
|
||||
if plugin_manifest is not None:
|
||||
target_channel = plugin_manifest.manifest.channel_type
|
||||
|
||||
@ -77,7 +77,7 @@ class WhitelistConfigHandler:
|
||||
entries, skipped = _to_whitelist_entries(value)
|
||||
if self._logger is not None:
|
||||
for item in skipped:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"whitelist entry skipped: missing or invalid peer_id",
|
||||
entry=item,
|
||||
account_id=target,
|
||||
|
||||
@ -81,7 +81,7 @@ class ChannelAuditLogRetentionHandler:
|
||||
try:
|
||||
lock_token = await self._cache_port.acquireAdvisoryLock(lock_key, ttl_seconds=self._LOCK_TTL_SECONDS)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"scheduler lock acquire failed, skip this cycle",
|
||||
handler=self.name,
|
||||
error=str(exc),
|
||||
|
||||
@ -83,7 +83,7 @@ class ChannelContentReviewRetentionHandler:
|
||||
try:
|
||||
lock_token = await self._cache_port.acquireAdvisoryLock(lock_key, ttl_seconds=self._LOCK_TTL_SECONDS)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"scheduler lock acquire failed, skip this cycle",
|
||||
handler=self.name,
|
||||
error=str(exc),
|
||||
|
||||
@ -72,7 +72,7 @@ class ChannelIdempotencyCleanupHandler:
|
||||
try:
|
||||
lock_token = await self._cache_port.acquireAdvisoryLock(lock_key, ttl_seconds=self._LOCK_TTL_SECONDS)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"scheduler lock acquire failed, skip this cycle",
|
||||
handler=self.name,
|
||||
error=str(exc),
|
||||
|
||||
@ -115,7 +115,7 @@ class ChannelOutboxRecoveryHandler:
|
||||
try:
|
||||
lock_token = await self._cache_port.acquireAdvisoryLock(lock_key, ttl_seconds=self._LOCK_TTL_SECONDS)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"scheduler lock acquire failed, skip this cycle",
|
||||
handler=self.name,
|
||||
error=str(exc),
|
||||
@ -230,7 +230,7 @@ class ChannelOutboxRecoveryHandler:
|
||||
"""扫描 PENDING 状态条目,超时标记 DEAD,其余入队重试。"""
|
||||
while True:
|
||||
if self._is_scan_expired(scan_start, scan_ttl_seconds):
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"outbox 恢复扫描超时,中止 PENDING 扫描",
|
||||
processed_count=len(processed_ids),
|
||||
)
|
||||
@ -303,7 +303,7 @@ class ChannelOutboxRecoveryHandler:
|
||||
"""扫描 SENT_UNCONFIRMED 状态条目。"""
|
||||
while True:
|
||||
if self._is_scan_expired(scan_start, scan_ttl_seconds):
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"outbox 恢复扫描超时,中止 SENT_UNCONFIRMED 扫描",
|
||||
processed_count=len(processed_ids),
|
||||
)
|
||||
@ -398,7 +398,7 @@ class ChannelOutboxRecoveryHandler:
|
||||
)
|
||||
while True:
|
||||
if self._is_scan_expired(scan_start, scan_ttl_seconds):
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"outbox 恢复扫描超时,中止 FAILED 扫描",
|
||||
processed_count=len(processed_ids),
|
||||
)
|
||||
@ -452,7 +452,7 @@ class ChannelOutboxRecoveryHandler:
|
||||
try:
|
||||
await outbox_repo.updateOutboxEntry(aggregate, expected_status=entry.status)
|
||||
except IdempotencyConflictError:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"requeueForRetry conflict, skipping",
|
||||
outbox_id=aggregate.outbox_id,
|
||||
)
|
||||
@ -519,7 +519,7 @@ class ChannelOutboxRecoveryHandler:
|
||||
latest = await outbox_repo.getOutboxEntry(aggregate.outbox_id)
|
||||
if latest is not None and latest.status in (OutboxStatus.SENT, OutboxStatus.DEAD):
|
||||
return
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"markDead conflict, skipping",
|
||||
outbox_id=aggregate.outbox_id,
|
||||
)
|
||||
@ -540,7 +540,7 @@ class ChannelOutboxRecoveryHandler:
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"outbox 死信审计日志写入失败",
|
||||
outbox_id=entry.outbox_id,
|
||||
error=str(exc),
|
||||
|
||||
@ -92,7 +92,7 @@ class ChannelOutboxTerminalCleanupHandler:
|
||||
try:
|
||||
lock_token = await self._cache_port.acquireAdvisoryLock(lock_key, ttl_seconds=self._LOCK_TTL_SECONDS)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"scheduler lock acquire failed, skip this cycle",
|
||||
handler=self.name,
|
||||
error=str(exc),
|
||||
@ -144,7 +144,7 @@ class ChannelOutboxTerminalCleanupHandler:
|
||||
)
|
||||
await self._event_publisher.publishOutboxEntryPurged(event)
|
||||
except Exception as pub_exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"OutboxEntryPurgedEvent 发布失败",
|
||||
error=str(pub_exc),
|
||||
)
|
||||
|
||||
@ -85,7 +85,7 @@ class ChannelPairingExpirationHandler:
|
||||
try:
|
||||
lock_token = await self._cache_port.acquireAdvisoryLock(lock_key, ttl_seconds=self._LOCK_TTL_SECONDS)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"scheduler lock acquire failed, skip this cycle",
|
||||
handler=self.name,
|
||||
error=str(exc),
|
||||
|
||||
@ -80,7 +80,7 @@ class ChannelPairingTerminalCleanupHandler:
|
||||
try:
|
||||
lock_token = await self._cache_port.acquireAdvisoryLock(lock_key, ttl_seconds=self._LOCK_TTL_SECONDS)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"scheduler lock acquire failed, skip this cycle",
|
||||
handler=self.name,
|
||||
error=str(exc),
|
||||
|
||||
@ -85,7 +85,7 @@ class ChannelSessionInactiveCleanupHandler:
|
||||
try:
|
||||
lock_token = await self._cache_port.acquireAdvisoryLock(lock_key, ttl_seconds=self._LOCK_TTL_SECONDS)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"scheduler lock acquire failed, skip this cycle",
|
||||
handler=self.name,
|
||||
error=str(exc),
|
||||
|
||||
@ -100,7 +100,7 @@ class ChannelProbe:
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
latency_ms = int((time.monotonic() - start) * 1000)
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"渠道探测超时",
|
||||
channel_type=channel_type,
|
||||
timeout=str(self.PROBE_TIMEOUT),
|
||||
@ -126,7 +126,7 @@ class ChannelProbe:
|
||||
try:
|
||||
db_ok = await self._persistence_port.ping()
|
||||
except Exception as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"渠道探测下游检查:DB ping 异常",
|
||||
channel_type=channel_type,
|
||||
error=str(exc),
|
||||
@ -147,7 +147,7 @@ class ChannelProbe:
|
||||
try:
|
||||
redis_ok = await self._cache_port.ping()
|
||||
except Exception as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"渠道探测下游检查:Redis ping 异常",
|
||||
channel_type=channel_type,
|
||||
error=str(exc),
|
||||
@ -165,7 +165,7 @@ class ChannelProbe:
|
||||
try:
|
||||
worker_status = await self._queue_port.getWorkerStatus()
|
||||
except Exception as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"渠道探测下游检查:Worker 状态查询异常",
|
||||
channel_type=channel_type,
|
||||
error=str(exc),
|
||||
@ -200,7 +200,7 @@ class ChannelProbe:
|
||||
outcome = await adapter.probe()
|
||||
except Exception as e:
|
||||
latency_ms = int((time.monotonic() - start) * 1000)
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"渠道探测异常",
|
||||
channel_type=channel_type,
|
||||
error=str(e),
|
||||
|
||||
@ -119,7 +119,7 @@ class DiagnosticsExporter:
|
||||
try:
|
||||
audit_logs = await self._collectAuditLogs(request)
|
||||
except Exception as e:
|
||||
await self._logger.warn(f"collect audit logs failed, skip: {e}")
|
||||
await self._logger.warning(f"collect audit logs failed, skip: {e}")
|
||||
audit_logs = ()
|
||||
|
||||
# 下游依赖状态复用健康快照已采集结果,避免重复查询
|
||||
@ -352,6 +352,7 @@ class DiagnosticsExporter:
|
||||
trace_id=resolved_trace_id,
|
||||
source_ip=operator.ip,
|
||||
request_id=operator.request_id,
|
||||
target_channel="global",
|
||||
)
|
||||
try:
|
||||
await self._persistence.saveAuditLog(cmd)
|
||||
|
||||
@ -227,7 +227,7 @@ class HealthAggregator:
|
||||
)
|
||||
except DependencyError as exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"singleflight lock acquire failed, fallback to direct aggregation",
|
||||
error=str(exc),
|
||||
)
|
||||
@ -263,7 +263,7 @@ class HealthAggregator:
|
||||
cached = await self._cache_port.get(self.CACHE_KEY)
|
||||
except DependencyError as exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"健康快照缓存读取失败,跳过缓存继续聚合",
|
||||
cache_key=self.CACHE_KEY,
|
||||
error=str(exc),
|
||||
@ -329,7 +329,7 @@ class HealthAggregator:
|
||||
worker_status = await self._queue_port.getWorkerStatus()
|
||||
except Exception as exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(f"worker status query failed: {exc}")
|
||||
await self._logger.warning(f"worker status query failed: {exc}")
|
||||
worker_status = None
|
||||
|
||||
# Redis 流状态:异常时降级为 None(供诊断导出复用)
|
||||
@ -337,7 +337,7 @@ class HealthAggregator:
|
||||
redis_stream_status = await self._cache_port.getStreamStatus()
|
||||
except Exception as exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(f"redis stream status query failed: {exc}")
|
||||
await self._logger.warning(f"redis stream status query failed: {exc}")
|
||||
redis_stream_status = None
|
||||
|
||||
# 数据库连接池状态:异常时降级为 None(供诊断导出复用)
|
||||
@ -345,7 +345,7 @@ class HealthAggregator:
|
||||
db_pool_status = await self._persistence_port.getConnectionPoolStatus()
|
||||
except Exception as exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(f"db connection pool status query failed: {exc}")
|
||||
await self._logger.warning(f"db connection pool status query failed: {exc}")
|
||||
db_pool_status = None
|
||||
|
||||
# 传输引擎 per-account 状态(FR-18):通过 TransportHealthPort 聚合,
|
||||
@ -356,7 +356,7 @@ class HealthAggregator:
|
||||
transport_status = await self._transport_health_port.getTransportHealth()
|
||||
except Exception as exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"transport health query failed, degrade to None",
|
||||
error=str(exc),
|
||||
)
|
||||
@ -402,7 +402,7 @@ class HealthAggregator:
|
||||
accounts = await self._persistence_port.listChannelAccounts()
|
||||
except Exception as exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(f"list channel accounts failed: {exc}")
|
||||
await self._logger.warning(f"list channel accounts failed: {exc}")
|
||||
accounts = ()
|
||||
account_map: dict[ChannelType, str] = {}
|
||||
for account in accounts:
|
||||
@ -445,7 +445,7 @@ class HealthAggregator:
|
||||
cb_state = await self._circuit_breaker.getState(channel_type, account_id)
|
||||
except DependencyError as exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"circuit_breaker getState failed, degrade to None",
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
|
||||
@ -340,7 +340,7 @@ class PluginCapabilityChecker:
|
||||
# 声明但未使用:记录 WARNING,不阻断加载
|
||||
unused = declared - used
|
||||
if unused:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"声明了未使用的 accessible_ports,建议清理冗余声明: {sorted(unused)}",
|
||||
unused_ports=sorted(unused),
|
||||
)
|
||||
|
||||
@ -37,6 +37,7 @@ from yuxi.channels.contract.ports.driven import (
|
||||
ChannelSessionRepositoryPort,
|
||||
ConfigPort,
|
||||
ConversationPort,
|
||||
EventPublisherPort,
|
||||
IdempotencyRepositoryPort,
|
||||
IdentityResolverPort,
|
||||
LoggerPort,
|
||||
@ -320,6 +321,17 @@ class PluginHostImpl(PluginHost):
|
||||
self._checkPortAccess("PersistenceHealthPort")
|
||||
return self._adapters.persistence
|
||||
|
||||
def getEventPublisherPort(self) -> EventPublisherPort:
|
||||
"""获取事件发布端口。
|
||||
|
||||
返回 ``EventBus`` 实例(结构化满足 ``EventPublisherPort`` Protocol),
|
||||
供插件适配器发布领域事件(如媒体下载失败告警)。与 ``publishEvent``
|
||||
一致不做端口访问校验——事件发布为通用能力,不绑定特定资源。
|
||||
|
||||
@consistency: 无状态(stateless),返回注入的端口实例。
|
||||
"""
|
||||
return self._event_bus
|
||||
|
||||
# === 适配器注册 ===
|
||||
|
||||
def registerAdapter(
|
||||
|
||||
@ -366,7 +366,7 @@ class PluginLifecycleManager:
|
||||
try:
|
||||
await self.stop(plugin_manifest.manifest.id)
|
||||
except Exception as e:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"停止插件异常,继续停止其他插件: {e}",
|
||||
plugin_id=plugin_manifest.manifest.id,
|
||||
error=str(e),
|
||||
@ -483,7 +483,7 @@ class PluginLifecycleManager:
|
||||
try:
|
||||
result = await self.reload(plugin_id)
|
||||
except Exception as e:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"失败插件重载异常: {e}",
|
||||
plugin_id=plugin_id,
|
||||
error=str(e),
|
||||
@ -493,7 +493,7 @@ class PluginLifecycleManager:
|
||||
await self._cache.releaseAdvisoryLock(lock_token)
|
||||
|
||||
if result.state != "started":
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"失败插件重载未成功,等待下一轮退避: plugin_id={plugin_id}, state={result.state}",
|
||||
plugin_id=plugin_id,
|
||||
state=result.state,
|
||||
@ -978,7 +978,7 @@ class PluginLifecycleManager:
|
||||
try:
|
||||
unregister_call(plugin_id)
|
||||
except Exception as e:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"注销插件扩展点失败,继续清理其他资源: {registry_attr}",
|
||||
plugin_id=plugin_id,
|
||||
error=str(e),
|
||||
@ -989,7 +989,7 @@ class PluginLifecycleManager:
|
||||
try:
|
||||
await host.close()
|
||||
except Exception as close_err:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"被驱动适配器关闭失败: {close_err}",
|
||||
plugin_id=plugin_id,
|
||||
error=str(close_err),
|
||||
@ -1019,7 +1019,7 @@ class PluginLifecycleManager:
|
||||
self._plugin_registry.setState(plugin_id, LifecycleState.FAILED)
|
||||
self._plugin_registry.setPluginError(plugin_id, str(error))
|
||||
except Exception as state_err:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"标记 FAILED 状态或记录错误失败(插件可能已注销): {state_err}",
|
||||
plugin_id=plugin_id,
|
||||
)
|
||||
@ -1033,7 +1033,7 @@ class PluginLifecycleManager:
|
||||
try:
|
||||
await self._event_bus.publish(failed_event.toDomainEvent())
|
||||
except Exception as publish_err:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"发布 PluginFailed 事件失败,继续执行降级流程: {publish_err}",
|
||||
plugin_id=plugin_id,
|
||||
trace_id=trace_id,
|
||||
@ -1051,7 +1051,7 @@ class PluginLifecycleManager:
|
||||
)
|
||||
except Exception as fail_err:
|
||||
# onFail 自身失败不得阻断后续降级流程
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"onFail 钩子执行失败,忽略: {fail_err}",
|
||||
plugin_id=plugin_id,
|
||||
error=str(fail_err),
|
||||
@ -1061,7 +1061,7 @@ class PluginLifecycleManager:
|
||||
try:
|
||||
await self._degradation.onPluginFailed(plugin_id, str(error))
|
||||
except Exception as degrade_err:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"降级管理器调用失败: {degrade_err}",
|
||||
plugin_id=plugin_id,
|
||||
)
|
||||
@ -1083,7 +1083,7 @@ class PluginLifecycleManager:
|
||||
try:
|
||||
await self._event_bus.publish(degraded_event.toDomainEvent())
|
||||
except Exception as publish_err:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"发布 ChannelDegraded 事件失败: {publish_err}",
|
||||
plugin_id=plugin_id,
|
||||
trace_id=trace_id,
|
||||
@ -1210,7 +1210,7 @@ class PluginLifecycleManager:
|
||||
)
|
||||
if version_info.version == 0:
|
||||
# 键不存在:首次加载,合法降级为空列表
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"applied_migrations 配置键不存在,按空列表继续: {e}",
|
||||
target=target,
|
||||
trace_id=trace_id,
|
||||
|
||||
@ -210,7 +210,7 @@ class PluginLoader:
|
||||
target_plugin_dir = os.path.join(target_dir, plugin_id)
|
||||
if os.path.exists(target_plugin_dir):
|
||||
if not force:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"插件目录已存在,未启用 force 覆盖: {target_plugin_dir}",
|
||||
plugin_id=plugin_id,
|
||||
target_plugin_dir=target_plugin_dir,
|
||||
|
||||
@ -56,7 +56,7 @@ class RouteMatchRegistryLoader:
|
||||
self._registry.applyRule(rule)
|
||||
loaded += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"skip invalid route binding rule",
|
||||
binding_id=rule.binding_id,
|
||||
match_source=rule.match_source,
|
||||
|
||||
@ -35,6 +35,7 @@ from yuxi.channels.contract.dtos.outbox import (
|
||||
OutboxStatus,
|
||||
RetryContext,
|
||||
)
|
||||
from yuxi.channels.contract.dtos.queue import EnqueueCmd
|
||||
from yuxi.channels.contract.errors import (
|
||||
ChannelDegradedError,
|
||||
DependencyError,
|
||||
@ -54,6 +55,7 @@ from yuxi.channels.contract.ports.driven.cache_port import CachePort
|
||||
from yuxi.channels.contract.ports.driven.event_publisher_port import EventPublisherPort
|
||||
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
||||
from yuxi.channels.contract.ports.driven.outbox_repository_port import OutboxRepositoryPort
|
||||
from yuxi.channels.contract.ports.driven.queue_port import QueuePort
|
||||
from yuxi.channels.contract.ports.driven.transaction_port import TransactionContext
|
||||
from yuxi.channels.core.event.channel import ChannelDegraded
|
||||
from yuxi.channels.core.model.outbox_entry import OutboxEntry as OutboxAggregate
|
||||
@ -66,6 +68,11 @@ __all__ = ["OutboxRetryWorker"]
|
||||
# 的"仍有失败分片"(返回 None,调用方直接返回)与"全部分片已投递"(调用方
|
||||
# 需传 None 给 markSent 推进至 SENT)。
|
||||
_ALL_DELIVERED = object()
|
||||
# M8: outbox 重试保序。同批次前序(seq < 当前)仍有 PENDING 条目时,延后
|
||||
# 重试的退避秒数。前序完成后由恢复扫描器或前序重试触发本条目重试。
|
||||
_PREDECESSOR_RETRY_DELAY_SECONDS = 30
|
||||
# ARQ 重试任务名,与 ChannelOutboxRecoveryHandler._RETRY_TASK_NAME 对齐。
|
||||
_RETRY_TASK_NAME = "outbox_retry"
|
||||
|
||||
|
||||
class OutboxRetryWorker:
|
||||
@ -107,6 +114,7 @@ class OutboxRetryWorker:
|
||||
outbox_config: OutboxConfig,
|
||||
logger: LoggerPort,
|
||||
cache_port: CachePort,
|
||||
queue_port: QueuePort,
|
||||
) -> None:
|
||||
"""初始化 Outbox 重试 Worker。
|
||||
|
||||
@ -130,6 +138,8 @@ class OutboxRetryWorker:
|
||||
logger: 日志被驱动端口,记录重试过程与错误。
|
||||
cache_port: 缓存被驱动端口,用于获取/释放分布式咨询锁,串行化
|
||||
同一 outbox_id 的并发重试(FR-22)。
|
||||
queue_port: 队列被驱动端口,用于 M8 保序延后重试:同批次前序
|
||||
未完成时重新入队 ARQ 任务(``scheduled_at`` 退避 30s)。
|
||||
"""
|
||||
self._persistence = persistence_port
|
||||
self._adapter_registry = outbound_adapter_registry
|
||||
@ -139,6 +149,7 @@ class OutboxRetryWorker:
|
||||
self._outbox_config = outbox_config
|
||||
self._logger = logger
|
||||
self._cache = cache_port
|
||||
self._queue = queue_port
|
||||
|
||||
async def retry(
|
||||
self,
|
||||
@ -242,6 +253,33 @@ class OutboxRetryWorker:
|
||||
)
|
||||
return
|
||||
|
||||
# M8: outbox 重试保序。同批次前序(seq < 当前)仍有 PENDING 条目时,
|
||||
# 延后重试(重新入队 ARQ,退避 30s),避免后序条目先于前序投递破坏
|
||||
# fan-out 原始顺序。前序完成(无 PENDING 前序)或非 fan-out 场景
|
||||
# (batch_id 为 None)时正常执行重试。
|
||||
if entry.fan_out_batch_id is not None and entry.fan_out_seq is not None:
|
||||
pending_predecessors = await self._persistence.getOutboxEntriesByBatch(
|
||||
entry.fan_out_batch_id,
|
||||
status=OutboxStatus.PENDING,
|
||||
max_seq=entry.fan_out_seq - 1,
|
||||
)
|
||||
if pending_predecessors:
|
||||
await self._queue.enqueue(
|
||||
EnqueueCmd(
|
||||
task_name=_RETRY_TASK_NAME,
|
||||
payload={"outbox_id": outbox_id, "trace_id": trace_id},
|
||||
scheduled_at=utc_now_naive() + timedelta(seconds=_PREDECESSOR_RETRY_DELAY_SECONDS),
|
||||
)
|
||||
)
|
||||
await self._logger.info(
|
||||
"outbox 重试延后:同批次前序未完成",
|
||||
outbox_id=outbox_id,
|
||||
batch_id=entry.fan_out_batch_id,
|
||||
seq=entry.fan_out_seq,
|
||||
pending_count=len(pending_predecessors),
|
||||
)
|
||||
return
|
||||
|
||||
context = await self._persistence.resolveRetryContext(entry.outbox_id)
|
||||
# RetryContext.channel_type 为 str(契约 DTO 解耦枚举依赖),下游
|
||||
# 熔断器 / 适配器注册表 / 插件注册表等需 ChannelType 枚举,在此处
|
||||
@ -322,7 +360,7 @@ class OutboxRetryWorker:
|
||||
trace_id=trace_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"queryMessageByRequestId failed, proceed to send",
|
||||
trace_id=trace_id,
|
||||
outbox_id=outbox_id,
|
||||
@ -517,7 +555,7 @@ class OutboxRetryWorker:
|
||||
aggregate.status,
|
||||
self._logger,
|
||||
)
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"outbox 重试部分失败",
|
||||
outbox_id=aggregate.outbox_id,
|
||||
channel_type=channel_type,
|
||||
@ -581,7 +619,7 @@ class OutboxRetryWorker:
|
||||
try:
|
||||
await self._persistence.updateOutboxEntry(aggregate, tx=tx, expected_status=old_status)
|
||||
except IdempotencyConflictError:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"outbox 重试状态更新冲突,跳过",
|
||||
outbox_id=aggregate.outbox_id,
|
||||
old_status=old_status.value,
|
||||
@ -636,7 +674,7 @@ class OutboxRetryWorker:
|
||||
try:
|
||||
await self._persistence.updateOutboxEntry(aggregate, tx=tx, expected_status=old_status)
|
||||
except IdempotencyConflictError:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"outbox 重试状态更新冲突,跳过",
|
||||
outbox_id=aggregate.outbox_id,
|
||||
old_status=old_status.value,
|
||||
@ -651,7 +689,7 @@ class OutboxRetryWorker:
|
||||
aggregate.status,
|
||||
self._logger,
|
||||
)
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"outbox 重试失败",
|
||||
outbox_id=aggregate.outbox_id,
|
||||
channel_type=channel_type,
|
||||
@ -687,7 +725,7 @@ class OutboxRetryWorker:
|
||||
try:
|
||||
await self._persistence.updateOutboxEntry(aggregate, tx=tx, expected_status=old_status)
|
||||
except IdempotencyConflictError:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"outbox 重试状态更新冲突,跳过",
|
||||
outbox_id=aggregate.outbox_id,
|
||||
old_status=old_status.value,
|
||||
@ -702,7 +740,7 @@ class OutboxRetryWorker:
|
||||
aggregate.status,
|
||||
self._logger,
|
||||
)
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"流式续发不支持,标记 DEAD 避免重复投递",
|
||||
outbox_id=aggregate.outbox_id,
|
||||
channel_type=channel_type,
|
||||
@ -752,7 +790,7 @@ class OutboxRetryWorker:
|
||||
trace_id=trace_id,
|
||||
)
|
||||
await self._event_publisher.publish(event.toDomainEvent())
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"出站投递暂停:渠道插件降级",
|
||||
channel_type=channel_type,
|
||||
account_id=context.account_id,
|
||||
|
||||
@ -191,7 +191,7 @@ class Pipeline:
|
||||
span = None
|
||||
try:
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
f"tracer startSpan failed: {stage.id}",
|
||||
trace_id=trace_id,
|
||||
stage_id=stage.id,
|
||||
@ -217,7 +217,7 @@ class Pipeline:
|
||||
except Exception as exc:
|
||||
try:
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
f"tracer endSpan failed: {stage.id}",
|
||||
trace_id=trace_id,
|
||||
stage_id=stage.id,
|
||||
@ -291,7 +291,7 @@ class Pipeline:
|
||||
# 清理阶段异常仅 warn 日志,不掩盖原始异常
|
||||
if self.logger is not None:
|
||||
try:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
f"cleanup stage {stage.id} failed: {exc}",
|
||||
trace_id=trace_id,
|
||||
stage_id=stage.id,
|
||||
@ -383,7 +383,7 @@ class Pipeline:
|
||||
**fields,
|
||||
)
|
||||
else:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
f"stage failed: {stage_id}",
|
||||
**fields,
|
||||
)
|
||||
|
||||
@ -244,7 +244,10 @@ class AuditContextBuilder:
|
||||
request_id=ctx.request_id,
|
||||
message_id=message_id,
|
||||
content_summary=content_summary,
|
||||
target_channel=ctx.target_channel if ctx.target_channel else None,
|
||||
# target_channel 列为 NOT NULL(注释:渠道类型或 global),
|
||||
# 全局操作(plugin/catalog、config/get、dashboard/* 等)无特定
|
||||
# 渠道时填充 "global",避免 NotNullViolationError。
|
||||
target_channel=ctx.target_channel if ctx.target_channel else "global",
|
||||
target_account=ctx.params.get("target_account"),
|
||||
)
|
||||
|
||||
@ -336,7 +339,7 @@ class AuditContextBuilder:
|
||||
trace_id=ctx.trace_id,
|
||||
cause=e,
|
||||
)
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"audit log write failed (best-effort): {e}",
|
||||
trace_id=ctx.trace_id,
|
||||
operation=ctx.operation,
|
||||
@ -386,13 +389,13 @@ class AuditContextBuilder:
|
||||
trace_id=ctx.trace_id,
|
||||
source_ip=ctx.operator.ip,
|
||||
request_id=ctx.request_id,
|
||||
target_channel=ctx.target_channel if ctx.target_channel else None,
|
||||
target_channel=ctx.target_channel if ctx.target_channel else "global",
|
||||
target_account=account_id,
|
||||
)
|
||||
try:
|
||||
await self._audit_log_repo.saveAuditLog(cmd, tx=tx)
|
||||
except Exception as e:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"callback audit write failed (cooperative): {audit_type}, account_id={account_id}, error={e}",
|
||||
trace_id=ctx.trace_id,
|
||||
)
|
||||
|
||||
@ -209,7 +209,7 @@ class AccountHandler(ControlPlaneHandler):
|
||||
)
|
||||
except Exception as exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"callback audit write failed: {audit_type}, account_id={account_id}, error={exc}",
|
||||
trace_id=ctx.trace_id,
|
||||
)
|
||||
@ -691,7 +691,7 @@ class AccountHandler(ControlPlaneHandler):
|
||||
is_available = False
|
||||
reason = "dependency unavailable"
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"test connection failed: dependency unavailable",
|
||||
trace_id=ctx.trace_id,
|
||||
channel_type=channel_type,
|
||||
@ -720,7 +720,7 @@ class AccountHandler(ControlPlaneHandler):
|
||||
if reason is None:
|
||||
reason = f"credential validation failed: {exc}"
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"validateCredentials dependency unavailable",
|
||||
trace_id=ctx.trace_id,
|
||||
channel_type=channel_type,
|
||||
|
||||
@ -22,12 +22,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime
|
||||
|
||||
import anyio
|
||||
|
||||
from yuxi.channels.application.context.control_plane_context import (
|
||||
ControlPlaneContext,
|
||||
)
|
||||
@ -343,7 +342,7 @@ class AnalyticsHandler(ControlPlaneHandler):
|
||||
"""账户活跃度分析 handler(ANL-ACCOUNTS)。
|
||||
|
||||
委托 ``MessageRepositoryPort.getAccountActivity`` + ``getAccountTrend``
|
||||
聚合账户活跃度指标,两个查询相互独立,通过 ``anyio.gather`` 并行
|
||||
聚合账户活跃度指标,两个查询相互独立,通过 ``asyncio.gather`` 并行
|
||||
执行降低延迟。返回 ``AccountAnalyticsResult`` 由 DTO ``to_dict``
|
||||
统一序列化。
|
||||
"""
|
||||
@ -366,7 +365,7 @@ class AnalyticsHandler(ControlPlaneHandler):
|
||||
|
||||
t0 = time.monotonic()
|
||||
# 两个查询相互独立,并行执行降低延迟
|
||||
by_account, trend = await anyio.gather(
|
||||
by_account, trend = await asyncio.gather(
|
||||
self._persistence_port.getAccountActivity(query),
|
||||
self._persistence_port.getAccountTrend(query),
|
||||
)
|
||||
@ -425,7 +424,7 @@ class AnalyticsHandler(ControlPlaneHandler):
|
||||
"""
|
||||
if self._review_repository is None:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"analytics/content_review requested but ContentReviewRepositoryPort "
|
||||
"not injected, raising NotImplementedError",
|
||||
trace_id=ctx.trace_id,
|
||||
|
||||
@ -55,7 +55,7 @@ async def log_warn(logger: LoggerPort | None, message: str, trace_id: str | None
|
||||
替代原 ``DispatchStage._log_warn``,``logger`` 为 ``None`` 时静默跳过。
|
||||
"""
|
||||
if logger is not None:
|
||||
await logger.warn(message, trace_id=trace_id)
|
||||
await logger.warning(message, trace_id=trace_id)
|
||||
|
||||
|
||||
class BatchExecutor:
|
||||
|
||||
@ -412,7 +412,7 @@ class CapabilityHandler(ControlPlaneHandler):
|
||||
)
|
||||
except NotFoundError:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"capability matrix config missing: key={key} scope={scope.value} channel_type={channel_type}",
|
||||
trace_id=trace_id,
|
||||
)
|
||||
@ -423,7 +423,7 @@ class CapabilityHandler(ControlPlaneHandler):
|
||||
# 不应因 schema 声明缺口导致整体失败,按未配置处理并标记为
|
||||
# UNAVAILABLE,由调用方决定是否需要补齐 schema。
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"capability matrix config key not declared in schema: "
|
||||
f"key={key} scope={scope.value} channel_type={channel_type}",
|
||||
trace_id=trace_id,
|
||||
|
||||
@ -170,7 +170,7 @@ class DirectoryHandler(ControlPlaneHandler):
|
||||
cached = await self.cache_port.get(cache_key)
|
||||
except DependencyError as exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"directory cache get failed, skip cache",
|
||||
cache_key=cache_key,
|
||||
error=str(exc),
|
||||
@ -631,7 +631,7 @@ class DirectoryHandler(ControlPlaneHandler):
|
||||
await self.cache_port.invalidate(pattern)
|
||||
except DependencyError:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"directory cache invalidate failed, non-blocking",
|
||||
pattern=pattern,
|
||||
trace_id=ctx.trace_id,
|
||||
|
||||
@ -629,7 +629,7 @@ class OutboxHandler(ControlPlaneHandler):
|
||||
)
|
||||
await self.config_manager.update(cmd)
|
||||
elif self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"config_manager not injected, retry policy update persisted in-memory only",
|
||||
operation=ctx.operation,
|
||||
trace_id=ctx.trace_id,
|
||||
|
||||
@ -387,7 +387,7 @@ class PluginHandler(ControlPlaneHandler):
|
||||
await self._plugin_repository.updatePluginState(plugin_id, state)
|
||||
except Exception as exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"plugin state sync to DB failed (non-critical): {exc}",
|
||||
trace_id=trace_id,
|
||||
plugin_id=plugin_id,
|
||||
@ -488,7 +488,7 @@ class PluginHandler(ControlPlaneHandler):
|
||||
await loader.uninstall(plugin_manifest.manifest.id)
|
||||
except Exception as cleanup_exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"plugin install cleanup failed, orphan files may remain",
|
||||
plugin_id=plugin_manifest.manifest.id,
|
||||
cleanup_error=str(cleanup_exc),
|
||||
@ -538,7 +538,7 @@ class PluginHandler(ControlPlaneHandler):
|
||||
)
|
||||
except Exception as exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"plugin install record persistence failed (non-critical): {exc}",
|
||||
trace_id=ctx.trace_id,
|
||||
plugin_id=plugin_manifest.manifest.id,
|
||||
@ -584,7 +584,7 @@ class PluginHandler(ControlPlaneHandler):
|
||||
)
|
||||
except Exception as exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"plugin uninstall record soft-delete failed (non-critical): {exc}",
|
||||
trace_id=ctx.trace_id,
|
||||
plugin_id=plugin_id,
|
||||
@ -638,7 +638,7 @@ class PluginHandler(ControlPlaneHandler):
|
||||
config[field.key] = value.value if value.value is not None else field.default
|
||||
except ConfigValidationError as exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"plugin config key '{field.key}' not persisted, using schema default: {exc}",
|
||||
trace_id=ctx.trace_id,
|
||||
plugin_id=plugin_id,
|
||||
@ -683,7 +683,7 @@ class PluginHandler(ControlPlaneHandler):
|
||||
version = await self.config_manager.config_port.getVersion(field.key, scope=scope, target=target)
|
||||
except ConfigValidationError as exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"plugin config key '{field.key}' version lookup failed: {exc}",
|
||||
trace_id=trace_id,
|
||||
plugin_id=plugin_id,
|
||||
|
||||
@ -296,7 +296,7 @@ class RouteBindingHandler(ControlPlaneHandler):
|
||||
)
|
||||
skipped = self._registry.replaceAll(rules)
|
||||
if skipped:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"skip invalid route binding rule during reload",
|
||||
total=len(rules),
|
||||
skipped=skipped,
|
||||
@ -321,7 +321,7 @@ class RouteBindingHandler(ControlPlaneHandler):
|
||||
try:
|
||||
self._registry.applyRule(rule)
|
||||
except Exception:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"applyRule failed, fallback to full reload",
|
||||
binding_id=rule.binding_id,
|
||||
)
|
||||
|
||||
@ -240,7 +240,7 @@ class SessionHandler(ControlPlaneHandler):
|
||||
)
|
||||
)
|
||||
elif self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"session not found for session_id {cmd.session_id} after owner transfer, "
|
||||
"skipping ChannelSessionUpdated event",
|
||||
trace_id=ctx.trace_id,
|
||||
|
||||
@ -430,6 +430,14 @@ class WhitelistHandler(ControlPlaneHandler):
|
||||
return None
|
||||
|
||||
batch_result = await self._batch_executor.execute(ctx, peer_ids, _removeOne)
|
||||
# 全量不存在时返回 404(对齐单条删除契约与 list/add 等端点的非存在账户语义),
|
||||
# 部分成功时返回 200 + failed 列表(模式 D 部分成功语义)。
|
||||
if not batch_result["succeeded"] and batch_result["failed"]:
|
||||
raise NotFoundError(
|
||||
"whitelist_entry",
|
||||
",".join(peer_ids),
|
||||
trace_id=ctx.trace_id,
|
||||
)
|
||||
# 响应字段对齐契约 ``BatchWhitelistDeleteResult``:``deleted`` 承载
|
||||
# 成功删除的 peer_id 元组(非 succeeded 计数),便于前端展示与审计。
|
||||
return ControlPlaneResult(
|
||||
|
||||
@ -171,7 +171,7 @@ class RateLimitStage:
|
||||
if not meta.bypass_rate_limit_on_dep_failure:
|
||||
raise
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"rate_limit dep failure, fail-open for read-only diagnostic op",
|
||||
trace_id=context.trace_id,
|
||||
operation=context.operation,
|
||||
@ -192,7 +192,7 @@ class RateLimitStage:
|
||||
if not meta.bypass_rate_limit_on_dep_failure:
|
||||
raise
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"rate_limit dep failure, fail-open for read-only diagnostic op",
|
||||
trace_id=context.trace_id,
|
||||
operation=context.operation,
|
||||
|
||||
@ -469,7 +469,7 @@ class AgentRunEnqueueStage:
|
||||
adapter = self.channel_context_provider_registry.get(context.channel_type)
|
||||
if adapter is None:
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"channel context provider not registered, skip context note generation",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
@ -511,7 +511,7 @@ class AgentRunEnqueueStage:
|
||||
# 插件契约错误(Error)时记录日志并降级为原上下文,避免中断 Agent 运行;
|
||||
# 编程错误(Exception)穿透至管道层翻译为 InternalError(INB-P0-004)。
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
f"context note enrichment failed, skip: {exc}",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
@ -561,7 +561,7 @@ class AgentRunEnqueueStage:
|
||||
adapter = self.tools_adapter_registry.get(context.channel_type)
|
||||
if adapter is None:
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"tools adapter not registered, skip channel tools enrichment",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
@ -575,7 +575,7 @@ class AgentRunEnqueueStage:
|
||||
# 插件契约错误(Error)时记录日志并降级为空列表,避免中断 Agent 运行;
|
||||
# 编程错误(Exception)穿透至管道层翻译为 InternalError(INB-P0-004)。
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
f"getChannelTools failed, fallback to empty tool list: {exc}",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
@ -631,7 +631,7 @@ class AgentRunEnqueueStage:
|
||||
adapter = self.message_ops_adapter_registry.get(context.channel_type)
|
||||
if adapter is None:
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"message ops adapter not registered, skip message ops enrichment",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
@ -645,7 +645,7 @@ class AgentRunEnqueueStage:
|
||||
# 插件契约错误(Error)时记录日志并降级,不中断 Agent 运行;
|
||||
# 编程错误(Exception)穿透至管道层翻译为 InternalError(INB-P0-004)。
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
f"getMessageOperations failed, skip message ops tools: {exc}",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
@ -742,7 +742,7 @@ class AgentRunEnqueueStage:
|
||||
# 配置项不存在为合法降级场景:记录日志并回退默认值(INV-7)。
|
||||
# 配置端口故障(DependencyError)等不在此捕获,穿透至管道层。
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
f"config read failed, fallback to default: key={key}, default={default}, error={exc}",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
|
||||
@ -136,7 +136,7 @@ class ClassifyStage:
|
||||
if event_type == EventType.AGENT_MENTION:
|
||||
# v1 不支持多 Agent 协作,记录 warning 后按 MESSAGE 处理
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"AGENT_MENTION event received, treating as MESSAGE (multi-agent not supported in v1)",
|
||||
trace_id=context.trace_id,
|
||||
)
|
||||
|
||||
@ -164,7 +164,7 @@ class InboundIdempotencyStage:
|
||||
# in_progress 视为崩溃遗留,删除重建;未超时才抛冲突。
|
||||
if _isStaleInProgress(record, _IDEMPOTENCY_TIMEOUT_SECONDS):
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"stale in_progress idempotency record detected, rebuilding",
|
||||
trace_id=context.trace_id,
|
||||
idempotency_key=idempotency_key,
|
||||
|
||||
@ -173,7 +173,7 @@ class MediaFetchStage:
|
||||
if adapter is None or not hasattr(adapter, "downloadAttachment"):
|
||||
# AC-14: 适配器未实现 downloadAttachment
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"adapter not implemented downloadAttachment, skip download",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
@ -189,7 +189,7 @@ class MediaFetchStage:
|
||||
# FR-49 异常边界: 渠道 SDK 原生异常包装为 InternalError(与
|
||||
# agent_run_enqueue_stage 一致的异常翻译模式,INV-7)
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"downloadAttachment raised exception, skip attachment",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
@ -205,7 +205,7 @@ class MediaFetchStage:
|
||||
if downloaded is None or downloaded.content is None:
|
||||
# 下载失败
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"downloadAttachment returned None, skip attachment",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
@ -220,7 +220,7 @@ class MediaFetchStage:
|
||||
# 步骤2: 体积校验(框架级上限,超过即剔除附件并降级)
|
||||
if len(content) > self.MAX_ATTACHMENT_SIZE:
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"image size exceeds limit, skip attachment",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
@ -241,7 +241,7 @@ class MediaFetchStage:
|
||||
except Exception as exc:
|
||||
# AC-12: image_processor 原生异常包装为 InternalError,剔除附件,降级
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"image_processor failed, skip attachment",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
@ -256,7 +256,7 @@ class MediaFetchStage:
|
||||
|
||||
if not result.get("success"):
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"image_processor returned failure, skip attachment",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
|
||||
@ -292,7 +292,7 @@ class ReplyStage:
|
||||
await self.ack_decision_maker.recordAck(idempotency_key)
|
||||
except Exception as exc:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"post_commit recordAck failed, rolling back idempotency record",
|
||||
trace_id=context.trace_id,
|
||||
error=str(exc),
|
||||
|
||||
@ -162,7 +162,7 @@ class RouteStage:
|
||||
peer_id=context.peer_id,
|
||||
account_id=context.account_id,
|
||||
)
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"temporary session message cannot route to agent, pipeline will terminate",
|
||||
trace_id=context.trace_id,
|
||||
peer_id=context.peer_id,
|
||||
@ -182,7 +182,7 @@ class RouteStage:
|
||||
is_owner = True
|
||||
else:
|
||||
if session.owner_peer_id is None and self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"session owner missing, skip owner protection (historical data)",
|
||||
trace_id=context.trace_id,
|
||||
peer_id=context.peer_id,
|
||||
@ -265,7 +265,7 @@ class RouteStage:
|
||||
)
|
||||
except Exception as exc:
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"touch route info failed, non-fatal",
|
||||
trace_id=context.trace_id,
|
||||
session_id=session.session_id,
|
||||
|
||||
@ -206,7 +206,7 @@ class SecurityStage:
|
||||
context.degraded = True
|
||||
identity_id = f"peer:{context.peer_id or ''}"
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"identity resolution failed, degrade to peer-level rate limit",
|
||||
channel_type=context.channel_type,
|
||||
account_id=context.account_id,
|
||||
|
||||
@ -308,7 +308,7 @@ class SessionResolveStage:
|
||||
is_temporary = ChannelSession.isTemporarySession(context.peer_id)
|
||||
if not is_temporary and context.peer_id.startswith("cron:"):
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"malformed cron session key, defaulting to not temporary",
|
||||
trace_id=context.trace_id,
|
||||
peer_id=context.peer_id,
|
||||
@ -399,7 +399,7 @@ class SessionResolveStage:
|
||||
await self.persistence_port.touchChannelSessionLastMessageAt(session_id, tx=context.tx)
|
||||
except Exception as exc:
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"touch last_message_at failed, non-fatal",
|
||||
trace_id=context.trace_id,
|
||||
session_id=session_id,
|
||||
@ -482,7 +482,7 @@ class SessionResolveStage:
|
||||
return value
|
||||
except ConfigValidationError as exc:
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"cross_channel_identity_strategy read failed, fallback to isolation",
|
||||
error=str(exc),
|
||||
trace_id=context.trace_id,
|
||||
|
||||
@ -112,7 +112,7 @@ class SignatureVerifyStage:
|
||||
# 防止适配器误用内置异常导致管道终止(M-16)。
|
||||
if isinstance(exc, builtins.NotImplementedError) and not isinstance(exc, NotImplementedError):
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"signature verification skipped: adapter raised built-in NotImplementedError, "
|
||||
"should use contract NotImplementedError",
|
||||
trace_id=context.trace_id,
|
||||
@ -120,7 +120,7 @@ class SignatureVerifyStage:
|
||||
account_id=context.account_id,
|
||||
)
|
||||
elif self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"signature verification skipped: adapter not implemented",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
|
||||
@ -207,7 +207,7 @@ class DeliverStage:
|
||||
|
||||
if not adapter.supportsOutbound():
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"outbound adapter does not support outbound, skip deliver stage",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
@ -431,7 +431,7 @@ class DeliverStage:
|
||||
)
|
||||
except NotImplementedError:
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"stream continuation not supported, mark as FAILED",
|
||||
trace_id=context.trace_id,
|
||||
outbox_id=aggregate.outbox_id,
|
||||
@ -439,7 +439,7 @@ class DeliverStage:
|
||||
return None
|
||||
except Exception:
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"stream continuation failed, mark as FAILED",
|
||||
trace_id=context.trace_id,
|
||||
outbox_id=aggregate.outbox_id,
|
||||
@ -542,7 +542,7 @@ class DeliverStage:
|
||||
)
|
||||
except Exception as exc:
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"bot loop budget release failed on deliver failure",
|
||||
trace_id=context.trace_id,
|
||||
account_id=context.account_id,
|
||||
|
||||
@ -136,7 +136,7 @@ class OutboxMarkFailed:
|
||||
aggregate.markFailed("deliver compensation")
|
||||
except StateTransitionError as exc:
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"outbox-mark-failed: state transition not allowed, skip",
|
||||
outbox_id=dto.outbox_id,
|
||||
current_status=latest.status.value,
|
||||
@ -152,7 +152,7 @@ class OutboxMarkFailed:
|
||||
if latest is not None and latest.status in (OutboxStatus.SENT, OutboxStatus.DEAD):
|
||||
return True
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"compensation conflict, status may be inconsistent",
|
||||
outbox_id=aggregate.outbox_id,
|
||||
trace_id=context.trace_id,
|
||||
|
||||
@ -213,35 +213,74 @@ class OutboxPersistStage:
|
||||
async with self.transaction_port.begin() as tx:
|
||||
message = await self.conversation_port.saveMessage(save_msg_cmd, tx=tx)
|
||||
context.message_id = message.message_id
|
||||
outbox_cmd = SaveOutboxEntryCmd(
|
||||
channel_type=context.channel_type,
|
||||
message_id=context.message_id,
|
||||
channel_account_id=context.account_id,
|
||||
durability_policy=durability_policy,
|
||||
trace_id=context.trace_id,
|
||||
channel_session_id=context.channel_session_id,
|
||||
stream_aborted_at_chunk=context.stream_aborted_at_chunk,
|
||||
)
|
||||
outbox_id = await self.persistence_port.saveOutboxEntry(outbox_cmd, tx=tx)
|
||||
# H2-O1: 使用 outbox_cmd 数据构造 DTO,避免事务内二次查询
|
||||
# (getOutboxEntry 未传 tx 会开启独立事务,延长锁持有时间)
|
||||
entry = OutboxEntry(
|
||||
outbox_id=outbox_id.value,
|
||||
message_id=outbox_cmd.message_id,
|
||||
channel_account_id=outbox_cmd.channel_account_id,
|
||||
status=OutboxStatus.PENDING,
|
||||
durability_policy=outbox_cmd.durability_policy,
|
||||
channel_type=outbox_cmd.channel_type,
|
||||
channel_session_id=outbox_cmd.channel_session_id,
|
||||
stream_aborted_at_chunk=outbox_cmd.stream_aborted_at_chunk,
|
||||
)
|
||||
if context.is_fan_out and context.fan_out_entries:
|
||||
# M13 批量路径:fan-out 场景单事务保存所有 OutboxEntry
|
||||
# fan_out_seq 按批次内目标列表位置生成(M8 保序),供 outbox
|
||||
# 重试 worker 在同一批次内按 seq 排序投递。
|
||||
cmds = [
|
||||
SaveOutboxEntryCmd(
|
||||
channel_type=target.channel_type,
|
||||
message_id=context.message_id,
|
||||
channel_account_id=target.account_id,
|
||||
durability_policy=durability_policy,
|
||||
trace_id=context.trace_id,
|
||||
channel_session_id=target.channel_session_id,
|
||||
stream_aborted_at_chunk=context.stream_aborted_at_chunk,
|
||||
fan_out_batch_id=context.fan_out_batch_id,
|
||||
fan_out_seq=idx,
|
||||
)
|
||||
for idx, target in enumerate(context.fan_out_entries)
|
||||
]
|
||||
outbox_ids = await self.persistence_port.saveOutboxEntries(cmds, tx=tx)
|
||||
context.fan_out_outbox_ids = outbox_ids
|
||||
# 使用首条 cmd 数据构造 DTO,保持与单条路径一致的 PENDING 状态
|
||||
first_cmd = cmds[0]
|
||||
entry = OutboxEntry(
|
||||
outbox_id=outbox_ids[0].value,
|
||||
message_id=first_cmd.message_id,
|
||||
channel_account_id=first_cmd.channel_account_id,
|
||||
status=OutboxStatus.PENDING,
|
||||
durability_policy=first_cmd.durability_policy,
|
||||
channel_type=first_cmd.channel_type,
|
||||
channel_session_id=first_cmd.channel_session_id,
|
||||
stream_aborted_at_chunk=first_cmd.stream_aborted_at_chunk,
|
||||
fan_out_batch_id=first_cmd.fan_out_batch_id,
|
||||
fan_out_seq=first_cmd.fan_out_seq,
|
||||
)
|
||||
else:
|
||||
outbox_cmd = SaveOutboxEntryCmd(
|
||||
channel_type=context.channel_type,
|
||||
message_id=context.message_id,
|
||||
channel_account_id=context.account_id,
|
||||
durability_policy=durability_policy,
|
||||
trace_id=context.trace_id,
|
||||
channel_session_id=context.channel_session_id,
|
||||
stream_aborted_at_chunk=context.stream_aborted_at_chunk,
|
||||
fan_out_batch_id=context.fan_out_batch_id,
|
||||
fan_out_seq=context.fan_out_seq,
|
||||
)
|
||||
outbox_id = await self.persistence_port.saveOutboxEntry(outbox_cmd, tx=tx)
|
||||
# H2-O1: 使用 outbox_cmd 数据构造 DTO,避免事务内二次查询
|
||||
# (getOutboxEntry 未传 tx 会开启独立事务,延长锁持有时间)
|
||||
entry = OutboxEntry(
|
||||
outbox_id=outbox_id.value,
|
||||
message_id=outbox_cmd.message_id,
|
||||
channel_account_id=outbox_cmd.channel_account_id,
|
||||
status=OutboxStatus.PENDING,
|
||||
durability_policy=outbox_cmd.durability_policy,
|
||||
channel_type=outbox_cmd.channel_type,
|
||||
channel_session_id=outbox_cmd.channel_session_id,
|
||||
stream_aborted_at_chunk=outbox_cmd.stream_aborted_at_chunk,
|
||||
fan_out_batch_id=outbox_cmd.fan_out_batch_id,
|
||||
fan_out_seq=outbox_cmd.fan_out_seq,
|
||||
)
|
||||
|
||||
context.outbox_entry = entry
|
||||
if self._logger is not None:
|
||||
await self._logger.info(
|
||||
"outbox entry created",
|
||||
trace_id=context.trace_id,
|
||||
outbox_id=outbox_id.value,
|
||||
outbox_id=entry.outbox_id,
|
||||
message_id=context.message_id,
|
||||
durability_policy=durability_policy.value,
|
||||
)
|
||||
@ -359,7 +398,7 @@ class OutboxPersistStage:
|
||||
# m-12: 轻量记录成功则视同正常 outbox 跟踪路径,degraded 标志保留用于
|
||||
# 诊断但不影响投递路径(deliver_stage 降级条件 outbox_entry is None 不成立)
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"best_effort lightweight outbox record created, continue with normal outbox tracking",
|
||||
trace_id=context.trace_id,
|
||||
outbox_id=entry.outbox_id,
|
||||
|
||||
@ -116,7 +116,7 @@ class OutboxRollback:
|
||||
if latest is not None and latest.status in (OutboxStatus.SENT, OutboxStatus.DEAD):
|
||||
return True # 已达终态,跳过
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"compensation conflict, status may be inconsistent",
|
||||
outbox_id=aggregate.outbox_id,
|
||||
trace_id=context.trace_id,
|
||||
|
||||
@ -221,7 +221,7 @@ class StreamChunkStage:
|
||||
elapsed = time.monotonic() - started_at
|
||||
if elapsed > ttl_s:
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
f"streaming TTL expired (elapsed={elapsed:.2f}s, "
|
||||
f"ttl={ttl_s:.2f}s), stop sending chunks "
|
||||
f"(sent={len(results)})",
|
||||
|
||||
@ -135,7 +135,7 @@ class TruncationCheckStage:
|
||||
|
||||
context.delivery_mode = "persistent"
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
f"truncation completion failed, fallback to persistent mode: "
|
||||
f"original_length={original_length} completed_length={completed_length}",
|
||||
trace_id=context.trace_id,
|
||||
|
||||
@ -124,7 +124,7 @@ class TypingIndicatorStage:
|
||||
# 记录 warning 不重复启动,避免渠道侧资源泄漏(L-18)
|
||||
if context.typing_started:
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"typing_indicator already started, skip duplicate startTypingIndicator",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
@ -139,7 +139,7 @@ class TypingIndicatorStage:
|
||||
if ok:
|
||||
context.typing_started = True
|
||||
elif self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"startTypingIndicator returned False, typing indicator not started",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
@ -150,7 +150,7 @@ class TypingIndicatorStage:
|
||||
# 据 ``typing_started`` 决定是否调用 ``stopTypingIndicator``,
|
||||
# 因此此处不置 True 即可保证不调用停止方法。
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
f"startTypingIndicator failed: {exc!r}",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
|
||||
@ -112,7 +112,7 @@ class TypingStopStage:
|
||||
# (streaming_ttl_ms),即使 endStreaming 失败也会在 TTL 到期后
|
||||
# 自动清理。
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
f"endStreaming failed: {exc!r}",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
@ -132,7 +132,7 @@ class TypingStopStage:
|
||||
context.account_id, context.peer_id
|
||||
)
|
||||
if not ok and self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
"stopTypingIndicator returned False, typing indicator resource may leak",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
@ -143,7 +143,7 @@ class TypingStopStage:
|
||||
# 通常有 TTL 自动过期机制(typing_ttl_ms),即使停止失败也会
|
||||
# 在 TTL 到期后自动消失。
|
||||
if self.logger is not None:
|
||||
await self.logger.warn(
|
||||
await self.logger.warning(
|
||||
f"stopTypingIndicator failed: {exc!r}",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
|
||||
@ -188,7 +188,7 @@ class BaseTransportWorker(ABC):
|
||||
timeout=shutdown_timeout,
|
||||
)
|
||||
except TimeoutError:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"transport worker shutdown timeout, some tasks may still be running",
|
||||
transport_mode=self.transport_mode,
|
||||
timeout_s=shutdown_timeout,
|
||||
@ -250,7 +250,7 @@ class BaseTransportWorker(ABC):
|
||||
self._restart_counts.pop(account_key, None)
|
||||
|
||||
if await self._circuit_breaker.isOpen(channel_type, account_id):
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"circuit breaker open, skip starting account",
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
@ -452,7 +452,7 @@ class BaseTransportWorker(ABC):
|
||||
await self._circuit_breaker.recordFailure(channel_type, account_id)
|
||||
is_open = await self._circuit_breaker.isOpen(channel_type, account_id)
|
||||
if is_open:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"circuit breaker open, not restarting account",
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
@ -550,7 +550,7 @@ class BaseTransportWorker(ABC):
|
||||
try:
|
||||
await adapter.onTransportReset(account_id)
|
||||
except Exception as reset_exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"adapter onTransportReset failed after stall",
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
@ -560,7 +560,7 @@ class BaseTransportWorker(ABC):
|
||||
await self._circuit_breaker.recordFailure(channel_type, account_id)
|
||||
is_open = await self._circuit_breaker.isOpen(channel_type, account_id)
|
||||
if is_open:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"circuit breaker open after stall, not restarting",
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
@ -705,7 +705,7 @@ class BaseTransportWorker(ABC):
|
||||
except NotFoundError:
|
||||
# 配置项不存在(且无 schema 默认值):合法的"未配置"状态,
|
||||
# 降级为默认值并记录 warning,便于运维感知
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"transport config key not found, fallback to default",
|
||||
key=key,
|
||||
)
|
||||
@ -910,7 +910,7 @@ class BaseTransportWorker(ABC):
|
||||
True表示应停止当前账号循环,False表示应退避后重试。
|
||||
"""
|
||||
trace_id = error.trace_id or str(uuid.uuid4())
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"transport error occurred",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type,
|
||||
@ -1108,7 +1108,7 @@ class BaseTransportWorker(ABC):
|
||||
continue
|
||||
if now - task_info.last_activity_at > stall_timeout_s:
|
||||
trace_id = str(uuid.uuid4())
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"transport stall detected, will restart account",
|
||||
trace_id=trace_id,
|
||||
channel_type=task_info.channel_type,
|
||||
|
||||
@ -10,6 +10,7 @@ import asyncio
|
||||
import dataclasses
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from yuxi.channels.application.transport.base_worker import (
|
||||
@ -19,6 +20,7 @@ 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.config import ConfigScope
|
||||
from yuxi.channels.contract.dtos.event import ChannelTransportFailedEvent
|
||||
from yuxi.channels.contract.dtos.health import TransportHealthSnapshot
|
||||
from yuxi.channels.contract.dtos.option import Some
|
||||
from yuxi.channels.contract.dtos.plugin import DomainEvent, EventHandler
|
||||
@ -38,6 +40,17 @@ __all__ = ["TransportManager"]
|
||||
# 管理循环的轮询间隔(秒):仅用于等待取消信号,不参与业务逻辑
|
||||
_MANAGER_LOOP_INTERVAL_S: float = 1.0
|
||||
|
||||
# H6+H8:分布式锁 TTL(秒)
|
||||
_RESTORE_LOCK_TTL_S: int = 60
|
||||
_ACCOUNT_LOCK_TTL_S: int = 30
|
||||
|
||||
# M11:降级标记 TTL(秒),避免永久残留
|
||||
_DEGRADED_TTL_S: int = 3600
|
||||
|
||||
# N-M1:降级失败重试参数
|
||||
_DEGRADE_MAX_RETRY: int = 3
|
||||
_DEGRADE_RETRY_INTERVAL_S: float = 30.0
|
||||
|
||||
|
||||
class TransportManager:
|
||||
"""入站传输全局管理器。
|
||||
@ -78,8 +91,9 @@ class TransportManager:
|
||||
logger: 日志端口。
|
||||
message_deliverer: 入站消息投递回调。
|
||||
transport_config: 传输配置,为 None 时使用默认配置。
|
||||
cache_port: 缓存端口,用于启动前预热凭证缓存。为 None 时
|
||||
跳过预热(向后兼容)。
|
||||
cache_port: 缓存端口,用于启动前预热凭证缓存、分布式锁
|
||||
(H6+H8)与降级状态分布式化(M11)。为 None 时跳过预热
|
||||
(向后兼容),但分布式锁与降级状态功能不可用。
|
||||
"""
|
||||
self._plugin_registry = plugin_registry
|
||||
self._persistence_port = persistence_port
|
||||
@ -98,11 +112,6 @@ class TransportManager:
|
||||
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:
|
||||
"""启动传输管理器。
|
||||
@ -254,7 +263,7 @@ class TransportManager:
|
||||
return self.getHealth()
|
||||
except Exception as exc:
|
||||
# 健康检查不得抛异常阻塞调用方(与 HealthAggregator 超时降级策略一致)
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"transport health query failed, returning empty state",
|
||||
error=str(exc),
|
||||
)
|
||||
@ -346,9 +355,26 @@ class TransportManager:
|
||||
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}"
|
||||
def _degraded_key(channel_type: ChannelType, account_id: str) -> str:
|
||||
"""构造降级标记的分布式缓存键(M11)。"""
|
||||
return f"degraded:{channel_type}:{account_id}"
|
||||
|
||||
async def _is_degraded(self, channel_type: ChannelType, account_id: str) -> bool:
|
||||
"""查询账号是否处于降级状态(M11,分布式)。"""
|
||||
cached = await self._cache_port.get(self._degraded_key(channel_type, account_id))
|
||||
return isinstance(cached, Some)
|
||||
|
||||
async def _mark_degraded(self, channel_type: ChannelType, account_id: str) -> None:
|
||||
"""标记账号为降级状态(M11,分布式,TTL=1h)。"""
|
||||
await self._cache_port.set(
|
||||
self._degraded_key(channel_type, account_id),
|
||||
"1",
|
||||
ttl_seconds=_DEGRADED_TTL_S,
|
||||
)
|
||||
|
||||
async def _clear_degraded(self, channel_type: ChannelType, account_id: str) -> None:
|
||||
"""清除账号降级标记(M11,分布式)。"""
|
||||
await self._cache_port.delete(self._degraded_key(channel_type, account_id))
|
||||
|
||||
async def _on_account_online(self, event: DomainEvent) -> None:
|
||||
"""处理账号上线事件。
|
||||
@ -366,7 +392,7 @@ class TransportManager:
|
||||
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(
|
||||
await self._logger.warning(
|
||||
"account online event missing required fields, skip",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type_raw,
|
||||
@ -386,9 +412,8 @@ class TransportManager:
|
||||
# 重新登录触发 ChannelAccountOnline)时,清除降级标记并停止降级启动
|
||||
# 的 PullerWorker,由 _startTransportForAccount 按 transport_mode
|
||||
# 重新选择(both 模式恢复 StreamWorker,SSE 仍不可用时再次降级)。
|
||||
account_key = self._make_account_key(channel_type, account_id)
|
||||
if account_key in self._degraded_accounts:
|
||||
self._degraded_accounts.discard(account_key)
|
||||
if await self._is_degraded(channel_type, account_id):
|
||||
await self._clear_degraded(channel_type, account_id)
|
||||
if self._puller_worker is not None:
|
||||
await self._puller_worker.stop_account(channel_type, account_id, reason="recovered")
|
||||
await self._logger.info(
|
||||
@ -425,53 +450,70 @@ class TransportManager:
|
||||
覆盖 ``_resolveTransportMode`` 的解析结果。供 P0-1 降级机制
|
||||
强制以 pull 模式启动 PullerWorker 使用。
|
||||
"""
|
||||
transport_mode = (
|
||||
force_mode if force_mode is not None else await self._resolveTransportMode(channel_type, account_id)
|
||||
# H6+H8:账号级分布式锁,防止多实例并发启动同一账号
|
||||
account_lock_key = f"transport:{channel_type}:{account_id}"
|
||||
account_lock_token = await self._cache_port.acquireAdvisoryLock(
|
||||
account_lock_key, ttl_seconds=_ACCOUNT_LOCK_TTL_S
|
||||
)
|
||||
puller_adapter = self._puller_registry.get(channel_type)
|
||||
stream_adapter = self._stream_connector_registry.get(channel_type)
|
||||
if account_lock_token is None:
|
||||
await self._logger.info(
|
||||
"transport start already in progress",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
source=source,
|
||||
)
|
||||
return
|
||||
try:
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
# 先持久化插件运行态为 running(FR-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)
|
||||
# 先持久化插件运行态为 running(FR-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)
|
||||
|
||||
# 启动前预热凭证缓存:CachePort 命中则跳过,未命中从 ConfigPort 解密回填。
|
||||
# 覆盖重启恢复与事件驱动路径,确保 worker 首次 poll 时凭证已就绪。
|
||||
if self._cache_port is not None:
|
||||
await self._preheatCredentials(account_id, trace_id)
|
||||
# 启动前预热凭证缓存:CachePort 命中则跳过,未命中从 ConfigPort 解密回填。
|
||||
# 覆盖重启恢复与事件驱动路径,确保 worker 首次 poll 时凭证已就绪。
|
||||
if self._cache_port is not None:
|
||||
await self._preheatCredentials(account_id, 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 模式优先 Stream,Puller 作为降级。
|
||||
# Stream 适配器可用时仅启动 Stream(Stream 健康时不 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)
|
||||
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 模式优先 Stream,Puller 作为降级。
|
||||
# Stream 适配器可用时仅启动 Stream(Stream 健康时不 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)
|
||||
finally:
|
||||
await self._cache_port.releaseAdvisoryLock(account_lock_token)
|
||||
|
||||
async def _preheatCredentials(self, account_id: str, trace_id: str) -> None:
|
||||
"""启动前预热凭证缓存。
|
||||
@ -489,7 +531,7 @@ class TransportManager:
|
||||
if isinstance(cached, Some) and cached.unwrap():
|
||||
return
|
||||
except Exception as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"credential preheat: cache read failed, continue to ConfigPort",
|
||||
trace_id=trace_id,
|
||||
account_id=account_id,
|
||||
@ -506,6 +548,9 @@ class TransportManager:
|
||||
# ConfigPort 无凭证记录(模式 A 渠道或未接入凭证),跳过预热
|
||||
return
|
||||
|
||||
if config_value is None:
|
||||
return
|
||||
|
||||
encrypted = config_value.value
|
||||
if not encrypted:
|
||||
return
|
||||
@ -515,7 +560,7 @@ class TransportManager:
|
||||
if decrypted:
|
||||
await self._cache_port.set(cache_key, decrypted, ttl_seconds=None)
|
||||
except Exception as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"credential preheat: decrypt or cache write failed",
|
||||
trace_id=trace_id,
|
||||
account_id=account_id,
|
||||
@ -536,9 +581,18 @@ class TransportManager:
|
||||
参数:
|
||||
trace_id: 启动链路追踪 ID。
|
||||
"""
|
||||
# H-14:持有恢复锁,与 _on_config_changed 的 reloadConfig 互斥,
|
||||
# 防止恢复扫描与配置热更新竞态。异常时由 async with 保证锁释放。
|
||||
async with self._restore_lock:
|
||||
# H6+H8:分布式恢复锁,防止多实例并发恢复同一批账号。
|
||||
# 与 _on_config_changed 共用同一锁键,互斥配置热更新与恢复扫描。
|
||||
restore_lock_token = await self._cache_port.acquireAdvisoryLock(
|
||||
"transport:restore", ttl_seconds=_RESTORE_LOCK_TTL_S
|
||||
)
|
||||
if restore_lock_token is None:
|
||||
await self._logger.info(
|
||||
"transport restore already in progress on another instance",
|
||||
trace_id=trace_id,
|
||||
)
|
||||
return
|
||||
try:
|
||||
restored = 0
|
||||
failed = 0
|
||||
# 合并 puller 和 stream 注册表的渠道类型,避免遗漏
|
||||
@ -550,7 +604,7 @@ class TransportManager:
|
||||
AccountFilter(channel_type=channel_type, status=AccountStatus.ACTIVE)
|
||||
)
|
||||
except Exception as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"transport restore: failed to query accounts for channel",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type,
|
||||
@ -569,7 +623,7 @@ class TransportManager:
|
||||
restored += 1
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"transport restore: failed to start account",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type,
|
||||
@ -584,6 +638,8 @@ class TransportManager:
|
||||
failed_accounts=failed,
|
||||
channel_count=len(channel_types),
|
||||
)
|
||||
finally:
|
||||
await self._cache_port.releaseAdvisoryLock(restore_lock_token)
|
||||
|
||||
async def _resolveTransportMode(
|
||||
self,
|
||||
@ -606,7 +662,7 @@ class TransportManager:
|
||||
try:
|
||||
account = await self._persistence_port.getChannelAccount(channel_type, account_id)
|
||||
except Exception as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"failed to get channel account for transport_mode resolution, fallback to manifest default",
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
@ -634,7 +690,7 @@ class TransportManager:
|
||||
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(
|
||||
await self._logger.warning(
|
||||
"account offline event missing required fields, skip",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type_raw,
|
||||
@ -652,21 +708,37 @@ class TransportManager:
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
if self._puller_worker is not None:
|
||||
await self._puller_worker.stop_account(channel_type, account_id, reason)
|
||||
# H6+H8:账号级分布式锁,防止多实例并发停止同一账号
|
||||
account_lock_key = f"transport:{channel_type}:{account_id}"
|
||||
account_lock_token = await self._cache_port.acquireAdvisoryLock(
|
||||
account_lock_key, ttl_seconds=_ACCOUNT_LOCK_TTL_S
|
||||
)
|
||||
if account_lock_token is None:
|
||||
await self._logger.info(
|
||||
"transport stop already in progress",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
)
|
||||
return
|
||||
try:
|
||||
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)
|
||||
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)
|
||||
# 传输任务停止后持久化插件运行态(FR-32)。
|
||||
await self._touchPluginStatus(channel_type, account_id, "stopped", trace_id)
|
||||
finally:
|
||||
await self._cache_port.releaseAdvisoryLock(account_lock_token)
|
||||
|
||||
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
|
||||
PullerWorker。通过分布式降级标记(M11)去重,避免 StreamWorker
|
||||
反复重启时反复降级。
|
||||
|
||||
事件由 ``base_worker._handleTransportError`` permanent 分支发布
|
||||
@ -684,7 +756,7 @@ class TransportManager:
|
||||
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(
|
||||
await self._logger.warning(
|
||||
"channel degraded event missing required fields, skip",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type_raw,
|
||||
@ -692,10 +764,9 @@ class TransportManager:
|
||||
)
|
||||
return
|
||||
channel_type = ChannelType(channel_type_raw)
|
||||
account_key = self._make_account_key(channel_type, account_id)
|
||||
if account_key in self._degraded_accounts:
|
||||
if await self._is_degraded(channel_type, account_id):
|
||||
return
|
||||
self._degraded_accounts.add(account_key)
|
||||
await self._mark_degraded(channel_type, account_id)
|
||||
reason = event.payload.get("reason", "permanent_failure")
|
||||
|
||||
await self._logger.info(
|
||||
@ -712,7 +783,7 @@ class TransportManager:
|
||||
name=f"transport-degrade-{channel_type}-{account_id}",
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
self._degraded_accounts.discard(account_key)
|
||||
await self._clear_degraded(channel_type, account_id)
|
||||
await self._logger.error(
|
||||
"schedule degrade failed, no running event loop",
|
||||
trace_id=trace_id,
|
||||
@ -734,37 +805,63 @@ class TransportManager:
|
||||
任务中执行,不在事件发布栈内同步调用 ``stop_account``,避免与当前
|
||||
StreamWorker 任务栈形成反馈环路死锁。
|
||||
|
||||
降级失败时清除 ``_degraded_accounts`` 标记,允许下次事件重试。
|
||||
N-M1:降级失败后启动兜底重试循环(3 次/30s 间隔),覆盖 bridge
|
||||
短暂故障。重试期间降级标记保持(不清除),避免重复触发降级。重试
|
||||
仍失败则发布 ``ChannelTransportFailedEvent`` 触发告警。降级标记
|
||||
由 ``_on_account_online`` / ``_on_account_config_changed`` 在账号
|
||||
恢复或配置变更时清除。
|
||||
"""
|
||||
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(
|
||||
for attempt in range(1, _DEGRADE_MAX_RETRY + 1):
|
||||
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.warning(
|
||||
"channel transport degraded to puller mode",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
reason=reason,
|
||||
)
|
||||
return
|
||||
except Exception as exc:
|
||||
await self._logger.error(
|
||||
"failed to degrade transport to puller mode, retrying",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
reason=reason,
|
||||
attempt=attempt,
|
||||
max_retry=_DEGRADE_MAX_RETRY,
|
||||
error=str(exc),
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
if attempt < _DEGRADE_MAX_RETRY:
|
||||
await asyncio.sleep(_DEGRADE_RETRY_INTERVAL_S)
|
||||
continue
|
||||
|
||||
# 重试仍失败:发布 ChannelTransportFailedEvent 告警(不清除降级标记)
|
||||
await self._logger.error(
|
||||
"channel transport permanently failed after degrade retries",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
reason=reason,
|
||||
)
|
||||
await self._event_bus.publish(
|
||||
ChannelTransportFailedEvent(
|
||||
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__,
|
||||
)
|
||||
reason=f"degrade_failed_after_{_DEGRADE_MAX_RETRY}_retries",
|
||||
occurred_at=datetime.now(UTC),
|
||||
).toDomainEvent()
|
||||
)
|
||||
|
||||
async def _on_channel_recovered(self, event: DomainEvent) -> None:
|
||||
"""处理渠道恢复事件。
|
||||
@ -788,7 +885,7 @@ class TransportManager:
|
||||
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(
|
||||
await self._logger.warning(
|
||||
"transport error occurred",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type_raw,
|
||||
@ -825,7 +922,7 @@ class TransportManager:
|
||||
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(
|
||||
await self._logger.warning(
|
||||
"account config changed event missing required fields, skip",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type_raw,
|
||||
@ -838,7 +935,7 @@ class TransportManager:
|
||||
try:
|
||||
account = await self._persistence_port.getChannelAccount(channel_type, account_id)
|
||||
except Exception as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"failed to get channel account for config change, skip worker rebuild",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type,
|
||||
@ -863,9 +960,8 @@ class TransportManager:
|
||||
account_id=account_id,
|
||||
)
|
||||
|
||||
# 清除降级标记,给新配置一个重新尝试 Stream 的机会
|
||||
account_key = self._make_account_key(channel_type, account_id)
|
||||
self._degraded_accounts.discard(account_key)
|
||||
# 清除降级标记,给新配置一个重新尝试 Stream 的机会(M11 分布式)
|
||||
await self._clear_degraded(channel_type, account_id)
|
||||
|
||||
# 停止现有 Worker(no-op if not running)
|
||||
if self._puller_worker is not None:
|
||||
@ -893,7 +989,7 @@ class TransportManager:
|
||||
try:
|
||||
await self._persistence_port.updatePluginStatus(channel_type, account_id, plugin_status)
|
||||
except Exception as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"failed to update plugin_status",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type,
|
||||
@ -926,7 +1022,7 @@ class TransportManager:
|
||||
|
||||
trace_id = event.trace_id or str(uuid.uuid4())
|
||||
if key in _RESTART_REQUIRED_CONFIG_KEYS:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"transport config change requires restart to take effect",
|
||||
trace_id=trace_id,
|
||||
key=key,
|
||||
@ -945,9 +1041,23 @@ class TransportManager:
|
||||
key=key,
|
||||
event_type=event.event_type,
|
||||
)
|
||||
# H-14:与 _restoreOnlineAccounts 互斥,等待恢复扫描完成后再重载配置
|
||||
async with self._restore_lock:
|
||||
# H6+H8:与 _restoreOnlineAccounts 共用分布式恢复锁,互斥配置热更新
|
||||
# 与恢复扫描。锁被恢复扫描持有时跳过本次 reload(非阻塞),下一次
|
||||
# 配置变更事件会再次触发。
|
||||
config_lock_token = await self._cache_port.acquireAdvisoryLock(
|
||||
"transport:restore", ttl_seconds=_RESTORE_LOCK_TTL_S
|
||||
)
|
||||
if config_lock_token is None:
|
||||
await self._logger.info(
|
||||
"transport config reload skipped, restore in progress",
|
||||
trace_id=trace_id,
|
||||
key=key,
|
||||
)
|
||||
return
|
||||
try:
|
||||
await self.reloadConfig()
|
||||
finally:
|
||||
await self._cache_port.releaseAdvisoryLock(config_lock_token)
|
||||
|
||||
async def reloadConfig(self) -> None:
|
||||
"""重新加载传输配置并更新 Worker(FR-04 hot 模式)。
|
||||
|
||||
@ -211,7 +211,7 @@ class StreamWorker(BaseTransportWorker):
|
||||
except Exception as exc:
|
||||
# H-3 修复:原 ``except: pass`` 静默吞异常违反硬约束,
|
||||
# 心跳任务异常往往是适配器 bug 信号,需记录 warn 日志
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"stream heartbeat task cleanup error",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type,
|
||||
@ -225,7 +225,7 @@ class StreamWorker(BaseTransportWorker):
|
||||
except Exception as exc:
|
||||
# H-3 修复:原 ``except: pass`` 静默吞异常违反硬约束,
|
||||
# 连接关闭异常需记录 warn 日志便于排查适配器问题
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"stream connection close error",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type,
|
||||
@ -300,7 +300,7 @@ class StreamWorker(BaseTransportWorker):
|
||||
except Exception as exc:
|
||||
# Task 10.1: 接收异常不 raise 逃逸,记录 warn 并退出循环,
|
||||
# 让 _runAccountLoop 的退避重连逻辑(Task 10.2)可达。
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"stream receive error, will reconnect",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type,
|
||||
@ -339,7 +339,7 @@ class StreamWorker(BaseTransportWorker):
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
else:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"stream message delivery failed, retrying",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type,
|
||||
@ -568,7 +568,7 @@ class StreamWorker(BaseTransportWorker):
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"stream heartbeat failed",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type,
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import os
|
||||
from datetime import datetime
|
||||
@ -94,6 +95,9 @@ _RATE_LIMIT_RETRY_AFTER_MS = int(os.environ.get("YUXI_RATE_LIMIT_RETRY_AFTER_MS"
|
||||
# 避免幂等键被永久锁死导致客户端无法重试(FR-19)。
|
||||
# 从环境变量 YUXI_IDEMPOTENCY_TIMEOUT_SECONDS 读取(默认 300)。
|
||||
_IDEMPOTENCY_TIMEOUT_SECONDS = int(os.environ.get("YUXI_IDEMPOTENCY_TIMEOUT_SECONDS", "300"))
|
||||
# fan-out 有界并发度:限制 DB 并发(会话解析 / outbox 持久化 / 管道执行),
|
||||
# 非 bridge 限流——发送段串行化由 ``_send_locks`` 保证(H2)。
|
||||
_FAN_OUT_CONCURRENCY = 20
|
||||
|
||||
|
||||
class AdminMessageService:
|
||||
@ -218,7 +222,7 @@ class AdminMessageService:
|
||||
if existing.in_progress_started_at is not None and _isStaleInProgress(
|
||||
existing.in_progress_started_at, _IDEMPOTENCY_TIMEOUT_SECONDS
|
||||
):
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"stale in_progress idempotency record detected, rebuilding",
|
||||
idempotency_key=cmd.idempotency_key,
|
||||
started_at=existing.in_progress_started_at.isoformat(),
|
||||
@ -539,184 +543,51 @@ class AdminMessageService:
|
||||
if cmd.content.format == MessageFormat.RICH:
|
||||
rich_message = RichMessage(text=cmd.content.text)
|
||||
|
||||
# H2: fan-out 有界并发化。Semaphore 限制 DB 并发(会话解析 / outbox
|
||||
# 持久化 / 管道执行),非 bridge 限流——发送段串行化由 ``_send_locks``
|
||||
# 保证。per-target 容错通过 ``return_exceptions=True`` 保持:单目标
|
||||
# 失败不中断整批,结果聚合到 AdminSendResult。
|
||||
# M8: 为每个 target 填充 fan_out_batch_id / fan_out_seq,供 outbox
|
||||
# 重试保序按批次聚合查询前序状态。
|
||||
batch_id = str(uuid4())
|
||||
semaphore = asyncio.Semaphore(_FAN_OUT_CONCURRENCY)
|
||||
|
||||
async def _execute_single(seq: int, target: str) -> tuple[list[str], list[FailureDetail], list[SkipDetail]]:
|
||||
async with semaphore:
|
||||
return await self._executeSingleTarget(
|
||||
target=target,
|
||||
seq=seq,
|
||||
batch_id=batch_id,
|
||||
cmd=cmd,
|
||||
trace_id=trace_id,
|
||||
policy=policy,
|
||||
delivery_mode=delivery_mode,
|
||||
rich_message=rich_message,
|
||||
)
|
||||
|
||||
raw_results = await asyncio.gather(
|
||||
*[_execute_single(seq, target) for seq, target in enumerate(targets)],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
message_ids: list[str] = []
|
||||
failures: list[FailureDetail] = []
|
||||
skipped: list[SkipDetail] = []
|
||||
|
||||
for target in targets:
|
||||
if target.lower() in _BROADCAST_MARKERS:
|
||||
skipped.append(
|
||||
SkipDetail(
|
||||
target=target,
|
||||
reason="broadcast_not_supported",
|
||||
policy="fan_out_delegation_required",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
channel_type, account_id, session_id = self._resolveTarget(target)
|
||||
if channel_type is None:
|
||||
failures.append(
|
||||
FailureDetail(
|
||||
target=target,
|
||||
error_code="VALIDATION_ERROR",
|
||||
message=f"invalid channel_type in target: {target}",
|
||||
retryable=False,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# 内容长度校验:优先取渠道 manifest 的 max_message_length,
|
||||
# 未声明 manifest 时兜底默认值(FR19-P0-5)。per-target 容错:
|
||||
# 超限时归入 failures 而非中止整批 fan-out,与其他 per-target
|
||||
# 失败策略一致(会话创建失败、管道崩溃均为 per-target 容错)。
|
||||
max_length = self._getMaxMessageLength(channel_type)
|
||||
if len(cmd.content.text) > max_length:
|
||||
failures.append(
|
||||
FailureDetail(
|
||||
target=target,
|
||||
error_code="VALIDATION_ERROR",
|
||||
message=(
|
||||
f"message length {len(cmd.content.text)} exceeds max "
|
||||
f"{max_length} for channel {channel_type}"
|
||||
),
|
||||
retryable=False,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# 会话策略:reuse 查不到则跳过;reuse-or-create 查不到则创建;
|
||||
# new 强制创建新会话
|
||||
try:
|
||||
session = await self._getOrCreateSession(
|
||||
channel_type,
|
||||
account_id,
|
||||
session_id,
|
||||
policy,
|
||||
target_type=cmd.target_type,
|
||||
)
|
||||
except NotFoundError as exc:
|
||||
# FR-19:fan-out 单目标资源不存在时不应阻断整批请求,
|
||||
# 转换为 per-target failure 返回给调用方。
|
||||
failures.append(toFailureDetail(target, exc))
|
||||
await self._logger.warn(
|
||||
"admin_message_target_not_found",
|
||||
target=target,
|
||||
resource=exc.resource,
|
||||
resource_id=exc.id,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
await self._logger.error(
|
||||
f"session create failed for target {target}: {e}",
|
||||
trace_id=trace_id,
|
||||
target=target,
|
||||
)
|
||||
for target, result in zip(targets, raw_results):
|
||||
if isinstance(result, Exception):
|
||||
failures.append(
|
||||
FailureDetail(
|
||||
target=target,
|
||||
error_code="INTERNAL",
|
||||
message=f"session create failed: {e}",
|
||||
message=f"fan-out target crashed: {result}",
|
||||
retryable=True,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if session is None:
|
||||
skipped.append(
|
||||
SkipDetail(
|
||||
target=target,
|
||||
reason="target_not_found",
|
||||
policy="session_lookup",
|
||||
)
|
||||
)
|
||||
continue
|
||||
# reuse 策略保留原有跳过语义:未关联内部会话则跳过;
|
||||
# reuse-or-create / new 创建的新会话已保证 conversation_id 与 owner_peer_id。
|
||||
if session.conversation_id is None and policy == "reuse":
|
||||
skipped.append(
|
||||
SkipDetail(
|
||||
target=target,
|
||||
reason="no_conversation",
|
||||
policy="session_lookup",
|
||||
)
|
||||
)
|
||||
continue
|
||||
conversation_id = session.conversation_id
|
||||
|
||||
request_id = str(uuid4())
|
||||
ctx = OutboundContext(
|
||||
trace_id=trace_id,
|
||||
request_id=request_id,
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
channel_session_id=session.session_id,
|
||||
conversation_id=conversation_id,
|
||||
peer_id=session.peer_id,
|
||||
agent_run_id="",
|
||||
delivery_mode=delivery_mode,
|
||||
# Admin 消息由系统侧发起,不声明渠道客户端发送者身份,
|
||||
# 使 trusted-inject 阶段跳过 FR-25 客户端声明一致性校验。
|
||||
trusted_sender_id="",
|
||||
stream_chunks=[cmd.content.text],
|
||||
rich_message=rich_message,
|
||||
attachments=cmd.content.attachments,
|
||||
sender_role="admin",
|
||||
)
|
||||
|
||||
# H-O5: 按会话串行化出站管道,避免并发出站互相覆盖
|
||||
lock_key = f"outbound:{ctx.conversation_id}"
|
||||
lock_token = await self._cache_port.acquireAdvisoryLock(lock_key, ttl_seconds=60)
|
||||
if lock_token is None:
|
||||
failures.append(
|
||||
FailureDetail(
|
||||
target=target,
|
||||
error_code="CONFLICT",
|
||||
message="outbound pipeline busy for conversation",
|
||||
retryable=True,
|
||||
)
|
||||
)
|
||||
continue
|
||||
try:
|
||||
ok, err = await self._outbound_pipeline.run(ctx)
|
||||
except Exception as e:
|
||||
await self._logger.error(
|
||||
f"outbound pipeline crashed for target {target}: {e}",
|
||||
trace_id=trace_id,
|
||||
target=target,
|
||||
)
|
||||
failures.append(
|
||||
FailureDetail(
|
||||
target=target,
|
||||
error_code="INTERNAL",
|
||||
message=f"outbound pipeline crashed: {e}",
|
||||
retryable=True,
|
||||
)
|
||||
)
|
||||
continue
|
||||
finally:
|
||||
if lock_token is not None:
|
||||
await self._cache_port.releaseAdvisoryLock(lock_token)
|
||||
|
||||
if not ok:
|
||||
# 白名单拒绝归类为 skipped(AC-68),其余失败归 failures
|
||||
if isinstance(err, DmDeniedError):
|
||||
skipped.append(
|
||||
SkipDetail(
|
||||
target=target,
|
||||
reason="in_denylist",
|
||||
policy="whitelist_check",
|
||||
)
|
||||
)
|
||||
else:
|
||||
failures.append(self._toFailureDetail(target, err))
|
||||
continue
|
||||
|
||||
if ctx.message_id is not None:
|
||||
message_ids.append(ctx.message_id)
|
||||
elif ctx.channel_msg_id is not None:
|
||||
message_ids.append(ctx.channel_msg_id)
|
||||
msg_ids, fails, skips = result
|
||||
message_ids.extend(msg_ids)
|
||||
failures.extend(fails)
|
||||
skipped.extend(skips)
|
||||
|
||||
return AdminSendResult(
|
||||
message_ids=tuple(message_ids),
|
||||
@ -724,6 +595,215 @@ class AdminMessageService:
|
||||
skipped=tuple(skipped),
|
||||
)
|
||||
|
||||
async def _executeSingleTarget(
|
||||
self,
|
||||
target: str,
|
||||
seq: int,
|
||||
batch_id: str,
|
||||
cmd: AdminSendCmd,
|
||||
trace_id: str,
|
||||
policy: str,
|
||||
delivery_mode: str,
|
||||
rich_message: RichMessage | None,
|
||||
) -> tuple[list[str], list[FailureDetail], list[SkipDetail]]:
|
||||
"""执行单个目标的 fan-out 投递,返回 (message_ids, failures, skipped)。
|
||||
|
||||
从 ``_executeFanOut`` 提取的 per-target 逻辑:广播跳过 / 格式校验 /
|
||||
内容长度校验 / 会话解析 / 出站管道执行。每个列表至多含 1 个元素,
|
||||
由调用方聚合。per-target 容错:任一失败/跳过均返回而非抛出,单目标
|
||||
失败不中断整批(H2)。
|
||||
|
||||
参数:
|
||||
target: 单个目标字符串。
|
||||
seq: fan-out 批次内序号(M8 保序)。
|
||||
batch_id: fan-out 批次 ID(M8 保序)。
|
||||
cmd: 管理员发送命令。
|
||||
trace_id: 链路 ID。
|
||||
policy: 会话策略。
|
||||
delivery_mode: 投递模式。
|
||||
rich_message: 富消息源数据(可选)。
|
||||
"""
|
||||
message_ids: list[str] = []
|
||||
failures: list[FailureDetail] = []
|
||||
skipped: list[SkipDetail] = []
|
||||
|
||||
if target.lower() in _BROADCAST_MARKERS:
|
||||
skipped.append(
|
||||
SkipDetail(
|
||||
target=target,
|
||||
reason="broadcast_not_supported",
|
||||
policy="fan_out_delegation_required",
|
||||
)
|
||||
)
|
||||
return message_ids, failures, skipped
|
||||
|
||||
channel_type, account_id, session_id = self._resolveTarget(target)
|
||||
if channel_type is None:
|
||||
failures.append(
|
||||
FailureDetail(
|
||||
target=target,
|
||||
error_code="VALIDATION_ERROR",
|
||||
message=f"invalid channel_type in target: {target}",
|
||||
retryable=False,
|
||||
)
|
||||
)
|
||||
return message_ids, failures, skipped
|
||||
|
||||
# 内容长度校验:优先取渠道 manifest 的 max_message_length,
|
||||
# 未声明 manifest 时兜底默认值(FR19-P0-5)。per-target 容错:
|
||||
# 超限时归入 failures 而非中止整批 fan-out,与其他 per-target
|
||||
# 失败策略一致(会话创建失败、管道崩溃均为 per-target 容错)。
|
||||
max_length = self._getMaxMessageLength(channel_type)
|
||||
if len(cmd.content.text) > max_length:
|
||||
failures.append(
|
||||
FailureDetail(
|
||||
target=target,
|
||||
error_code="VALIDATION_ERROR",
|
||||
message=(
|
||||
f"message length {len(cmd.content.text)} exceeds max {max_length} for channel {channel_type}"
|
||||
),
|
||||
retryable=False,
|
||||
)
|
||||
)
|
||||
return message_ids, failures, skipped
|
||||
|
||||
# 会话策略:reuse 查不到则跳过;reuse-or-create 查不到则创建;
|
||||
# new 强制创建新会话
|
||||
try:
|
||||
session = await self._getOrCreateSession(
|
||||
channel_type,
|
||||
account_id,
|
||||
session_id,
|
||||
policy,
|
||||
target_type=cmd.target_type,
|
||||
)
|
||||
except NotFoundError as exc:
|
||||
# FR-19:fan-out 单目标资源不存在时不应阻断整批请求,
|
||||
# 转换为 per-target failure 返回给调用方。
|
||||
failures.append(toFailureDetail(target, exc))
|
||||
await self._logger.warning(
|
||||
"admin_message_target_not_found",
|
||||
target=target,
|
||||
resource=exc.resource,
|
||||
resource_id=exc.id,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
return message_ids, failures, skipped
|
||||
except Exception as e:
|
||||
await self._logger.error(
|
||||
f"session create failed for target {target}: {e}",
|
||||
trace_id=trace_id,
|
||||
target=target,
|
||||
)
|
||||
failures.append(
|
||||
FailureDetail(
|
||||
target=target,
|
||||
error_code="INTERNAL",
|
||||
message=f"session create failed: {e}",
|
||||
retryable=True,
|
||||
)
|
||||
)
|
||||
return message_ids, failures, skipped
|
||||
|
||||
if session is None:
|
||||
skipped.append(
|
||||
SkipDetail(
|
||||
target=target,
|
||||
reason="target_not_found",
|
||||
policy="session_lookup",
|
||||
)
|
||||
)
|
||||
return message_ids, failures, skipped
|
||||
# reuse 策略保留原有跳过语义:未关联内部会话则跳过;
|
||||
# reuse-or-create / new 创建的新会话已保证 conversation_id 与 owner_peer_id。
|
||||
if session.conversation_id is None and policy == "reuse":
|
||||
skipped.append(
|
||||
SkipDetail(
|
||||
target=target,
|
||||
reason="no_conversation",
|
||||
policy="session_lookup",
|
||||
)
|
||||
)
|
||||
return message_ids, failures, skipped
|
||||
conversation_id = session.conversation_id
|
||||
|
||||
request_id = str(uuid4())
|
||||
ctx = OutboundContext(
|
||||
trace_id=trace_id,
|
||||
request_id=request_id,
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
channel_session_id=session.session_id,
|
||||
conversation_id=conversation_id,
|
||||
peer_id=session.peer_id,
|
||||
agent_run_id="",
|
||||
delivery_mode=delivery_mode,
|
||||
# Admin 消息由系统侧发起,不声明渠道客户端发送者身份,
|
||||
# 使 trusted-inject 阶段跳过 FR-25 客户端声明一致性校验。
|
||||
trusted_sender_id="",
|
||||
stream_chunks=[cmd.content.text],
|
||||
rich_message=rich_message,
|
||||
attachments=cmd.content.attachments,
|
||||
sender_role="admin",
|
||||
fan_out_batch_id=batch_id,
|
||||
fan_out_seq=seq,
|
||||
)
|
||||
|
||||
# H-O5: 按会话串行化出站管道,避免并发出站互相覆盖
|
||||
lock_key = f"outbound:{ctx.conversation_id}"
|
||||
lock_token = await self._cache_port.acquireAdvisoryLock(lock_key, ttl_seconds=60)
|
||||
if lock_token is None:
|
||||
failures.append(
|
||||
FailureDetail(
|
||||
target=target,
|
||||
error_code="CONFLICT",
|
||||
message="outbound pipeline busy for conversation",
|
||||
retryable=True,
|
||||
)
|
||||
)
|
||||
return message_ids, failures, skipped
|
||||
try:
|
||||
ok, err = await self._outbound_pipeline.run(ctx)
|
||||
except Exception as e:
|
||||
await self._logger.error(
|
||||
f"outbound pipeline crashed for target {target}: {e}",
|
||||
trace_id=trace_id,
|
||||
target=target,
|
||||
)
|
||||
failures.append(
|
||||
FailureDetail(
|
||||
target=target,
|
||||
error_code="INTERNAL",
|
||||
message=f"outbound pipeline crashed: {e}",
|
||||
retryable=True,
|
||||
)
|
||||
)
|
||||
return message_ids, failures, skipped
|
||||
finally:
|
||||
if lock_token is not None:
|
||||
await self._cache_port.releaseAdvisoryLock(lock_token)
|
||||
|
||||
if not ok:
|
||||
# 白名单拒绝归类为 skipped(AC-68),其余失败归 failures
|
||||
if isinstance(err, DmDeniedError):
|
||||
skipped.append(
|
||||
SkipDetail(
|
||||
target=target,
|
||||
reason="in_denylist",
|
||||
policy="whitelist_check",
|
||||
)
|
||||
)
|
||||
else:
|
||||
failures.append(self._toFailureDetail(target, err))
|
||||
return message_ids, failures, skipped
|
||||
|
||||
if ctx.message_id is not None:
|
||||
message_ids.append(ctx.message_id)
|
||||
elif ctx.channel_msg_id is not None:
|
||||
message_ids.append(ctx.channel_msg_id)
|
||||
|
||||
return message_ids, failures, skipped
|
||||
|
||||
async def _getOrCreateSession(
|
||||
self,
|
||||
channel_type: ChannelType,
|
||||
|
||||
@ -315,7 +315,7 @@ class ChannelControlService:
|
||||
# 502/504 状态码,不在此处降级为 failed 结果。
|
||||
raise
|
||||
except Error as e:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"control plane pipeline failed: {e}",
|
||||
trace_id=trace_id,
|
||||
operation=cmd.operation,
|
||||
@ -344,7 +344,7 @@ class ChannelControlService:
|
||||
try:
|
||||
await hook()
|
||||
except Exception as hook_exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"post-commit hook failed: {hook_exc}",
|
||||
trace_id=trace_id,
|
||||
operation=cmd.operation,
|
||||
@ -353,7 +353,7 @@ class ChannelControlService:
|
||||
# INDEPENDENT 审计策略下 audit 写入失败时,管道仍返回成功(外部
|
||||
# 副作用已发生)。记录告警日志,运维通过告警发现审计缺失(§10.1)。
|
||||
if ctx.audit_error is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"audit log write failed (best-effort): {ctx.audit_error}",
|
||||
trace_id=trace_id,
|
||||
operation=cmd.operation,
|
||||
@ -2073,7 +2073,7 @@ class ChannelControlService:
|
||||
tx=tx,
|
||||
)
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"identity_merge_pending_review",
|
||||
canonical_identity_id=canonical_identity_id,
|
||||
sibling_identity_id=sibling.identity_id,
|
||||
@ -3857,7 +3857,7 @@ class ChannelControlService:
|
||||
await self._audit_builder.writeAuditLog(ctx=ctx, result="failed", tx=None)
|
||||
except Exception as audit_err:
|
||||
# best-effort:审计日志写入失败不掩盖原始错误,仅记录告警。
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"failure audit log write failed: {audit_err}",
|
||||
trace_id=ctx.trace_id,
|
||||
operation=ctx.operation,
|
||||
|
||||
@ -306,7 +306,7 @@ class HealthCheckService:
|
||||
try:
|
||||
await self._account_repository.updateLastHealthCheckAt(channel_type, account_id)
|
||||
except Exception as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"failed to update last_health_check_at",
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
@ -358,7 +358,7 @@ class HealthCheckService:
|
||||
except Exception as exc:
|
||||
# 审计日志写入失败时仅记录告警,不中止探测结果返回
|
||||
# (探测已执行,副作用已产生,审计失败不应丢弃探测结果)。
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"probe audit log write failed",
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
|
||||
@ -331,7 +331,7 @@ class InboundMessageService:
|
||||
updated_by=ctx.account_id,
|
||||
)
|
||||
except Exception as idempotency_exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"idempotency status update failed",
|
||||
trace_id=trace_id,
|
||||
record_id=ctx.idempotency_record_id,
|
||||
@ -356,7 +356,7 @@ class InboundMessageService:
|
||||
try:
|
||||
await hook()
|
||||
except Exception as e:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"post_commit hook failed",
|
||||
trace_id=trace_id,
|
||||
exc_info=e,
|
||||
@ -813,7 +813,7 @@ class InboundMessageService:
|
||||
if not already_acked:
|
||||
await self._ack_decision_maker.recordAck(idempotency_key)
|
||||
except Exception as e:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"ack decision failed after outbound success, rollback idempotency record",
|
||||
trace_id=ctx.trace_id,
|
||||
account_id=ctx.account_id,
|
||||
@ -890,7 +890,7 @@ class InboundMessageService:
|
||||
)
|
||||
except Exception as e:
|
||||
# 回退失败仅告警,不掩盖原始 outbound_error 传播(C-I2 约束)。
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"idempotency status rollback to failed after outbound failure failed",
|
||||
trace_id=ctx.trace_id,
|
||||
record_id=ctx.idempotency_record_id,
|
||||
@ -1034,7 +1034,7 @@ class InboundMessageService:
|
||||
)
|
||||
timeout = config.value if isinstance(config.value, int) else _DEFAULT_ACK_FALLBACK_TIMEOUT_SECONDS
|
||||
except Exception as e:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"failed to read ack_fallback_timeout_seconds, using default 60s",
|
||||
trace_id=trace_id,
|
||||
account_id=account_id,
|
||||
@ -1060,7 +1060,7 @@ class InboundMessageService:
|
||||
try:
|
||||
await self._ack_decision_maker.recordAck(idempotency_key)
|
||||
except Exception as e:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"manual fallback ack failed, channel may retry",
|
||||
trace_id=trace_id,
|
||||
error=str(e),
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
|
||||
from yuxi.channels.contract.errors import ValidationError
|
||||
@ -134,11 +134,13 @@ class ConnectivityResult:
|
||||
reachable: 是否可达。
|
||||
latency_ms: 延迟(毫秒,不可达时为 None)。
|
||||
error: 错误信息(可达时为 None)。
|
||||
warnings: 结构化告警信息(默认空列表,向后兼容)。
|
||||
"""
|
||||
|
||||
reachable: bool
|
||||
latency_ms: int | None = None
|
||||
error: str | None = None
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@ -397,6 +397,104 @@ class ChannelMessageDeliveredEvent:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MediaDownloadFailedEvent:
|
||||
"""媒体下载永久失败事件。
|
||||
|
||||
描述入站适配器在有限重试后仍无法下载媒体附件(如 bridge 404 在重试
|
||||
窗口内未恢复),由适配器发布以触发告警,避免媒体静默丢失。订阅者可
|
||||
据此记录审计、发出告警或触发补偿流程。
|
||||
|
||||
字段:
|
||||
account_id: 渠道账户 ID。
|
||||
msg_id: bridge 全局唯一消息 ID。
|
||||
reason: 失败原因(如 ``not_found_after_retries``)。
|
||||
occurred_at: 事件发生时间。
|
||||
"""
|
||||
|
||||
account_id: str
|
||||
msg_id: str
|
||||
reason: str
|
||||
occurred_at: datetime
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""校验必填字段非空。"""
|
||||
if not self.account_id:
|
||||
raise ValidationError("account_id", "must not be empty")
|
||||
if not self.msg_id:
|
||||
raise ValidationError("msg_id", "must not be empty")
|
||||
if not self.reason:
|
||||
raise ValidationError("reason", "must not be empty")
|
||||
if self.occurred_at is None:
|
||||
raise ValidationError("occurred_at", "must not be None")
|
||||
if not isinstance(self.occurred_at, datetime):
|
||||
raise ValidationError("occurred_at", "must be a datetime")
|
||||
|
||||
def toDomainEvent(self) -> DomainEvent:
|
||||
"""转换为契约层 ``DomainEvent``。"""
|
||||
return DomainEvent(
|
||||
event_id=str(uuid.uuid4()),
|
||||
event_type="MediaDownloadFailed",
|
||||
payload={
|
||||
"account_id": self.account_id,
|
||||
"msg_id": self.msg_id,
|
||||
"reason": self.reason,
|
||||
"occurred_at": self.occurred_at.isoformat(),
|
||||
},
|
||||
timestamp=self.occurred_at,
|
||||
trace_id=None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelTransportFailedEvent:
|
||||
"""渠道传输永久失败事件(N-M1)。
|
||||
|
||||
描述 TransportManager 在降级重试耗尽后仍无法恢复账号传输任务,
|
||||
由 ``_degradeToPuller`` 在 3 次重试均失败后发布,触发告警。订阅者
|
||||
可据此记录审计、发出告警或触发人工介入流程。
|
||||
|
||||
字段:
|
||||
channel_type: 渠道类型。
|
||||
account_id: 渠道账户 ID。
|
||||
reason: 失败原因(如 ``degrade_failed_after_3_retries``)。
|
||||
occurred_at: 事件发生时间。
|
||||
"""
|
||||
|
||||
channel_type: ChannelType
|
||||
account_id: str
|
||||
reason: str
|
||||
occurred_at: datetime
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""校验必填字段非空。"""
|
||||
if self.channel_type is None or not self.channel_type:
|
||||
raise ValidationError("channel_type", "must not be empty")
|
||||
if not self.account_id:
|
||||
raise ValidationError("account_id", "must not be empty")
|
||||
if not self.reason:
|
||||
raise ValidationError("reason", "must not be empty")
|
||||
if self.occurred_at is None:
|
||||
raise ValidationError("occurred_at", "must not be None")
|
||||
if not isinstance(self.occurred_at, datetime):
|
||||
raise ValidationError("occurred_at", "must be a datetime")
|
||||
|
||||
def toDomainEvent(self) -> DomainEvent:
|
||||
"""转换为契约层 ``DomainEvent``。"""
|
||||
return DomainEvent(
|
||||
event_id=str(uuid.uuid4()),
|
||||
event_type="ChannelTransportFailed",
|
||||
payload={
|
||||
"channel_type": str(self.channel_type),
|
||||
"account_id": self.account_id,
|
||||
"reason": self.reason,
|
||||
"occurred_at": self.occurred_at.isoformat(),
|
||||
},
|
||||
timestamp=self.occurred_at,
|
||||
trace_id=None,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OutboxStateChangedEvent",
|
||||
"OutboxEntryPurgedEvent",
|
||||
@ -405,4 +503,6 @@ __all__ = [
|
||||
"ChannelMessageSentEvent",
|
||||
"ChannelMessagePersistedEvent",
|
||||
"ChannelMessageDeliveredEvent",
|
||||
"MediaDownloadFailedEvent",
|
||||
"ChannelTransportFailedEvent",
|
||||
]
|
||||
|
||||
@ -294,6 +294,11 @@ class OutboxEntry:
|
||||
与审计使用。
|
||||
delivered_parts: 已成功投递的分片序号列表(默认空列表)。多分片
|
||||
投递部分失败时记录已投递分片,重试时据此仅发送未投递分片(H-15)。
|
||||
fan_out_batch_id: 扇出批次 ID(可选)。标识同一次 fan-out 产生的
|
||||
一组 outbox 条目,供 M8 outbox 重试保序按批次聚合查询使用。默认
|
||||
``None`` 保证向后兼容历史数据。
|
||||
fan_out_seq: 扇出批次内序号(可选)。标识同批次内的投递顺序,供
|
||||
M8 重试保序按序号恢复原始顺序。默认 ``None`` 保证向后兼容历史数据。
|
||||
"""
|
||||
|
||||
outbox_id: str
|
||||
@ -322,6 +327,8 @@ class OutboxEntry:
|
||||
stream_aborted_at_chunk: int | None = None
|
||||
degraded_reason: str | None = None
|
||||
delivered_parts: list[int] = field(default_factory=list)
|
||||
fan_out_batch_id: str | None = None
|
||||
fan_out_seq: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -372,6 +379,8 @@ class OutboxQueryFilter:
|
||||
channel_msg_id_like: 渠道侧消息 ID 模糊搜索(可选,SQL ``LIKE``)。
|
||||
channel_account_id_like: 渠道账户 ID 模糊搜索(可选,SQL ``LIKE``)。
|
||||
last_error_like: 最近错误关键词筛选(可选,SQL ``LIKE``)。
|
||||
fan_out_batch_id: 扇出批次 ID(可选)。按 fan-out 批次过滤,供
|
||||
M8 outbox 重试保序按批次聚合查询使用。
|
||||
"""
|
||||
|
||||
channel_type: ChannelType | None = None
|
||||
@ -387,6 +396,7 @@ class OutboxQueryFilter:
|
||||
channel_msg_id_like: str | None = None
|
||||
channel_account_id_like: str | None = None
|
||||
last_error_like: str | None = None
|
||||
fan_out_batch_id: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""校验过滤条件合法性。
|
||||
@ -437,6 +447,8 @@ class OutboxQueryFilter:
|
||||
result["channel_account_id_like"] = self.channel_account_id_like
|
||||
if self.last_error_like is not None:
|
||||
result["last_error_like"] = self.last_error_like
|
||||
if self.fan_out_batch_id is not None:
|
||||
result["fan_out_batch_id"] = self.fan_out_batch_id
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@ -372,6 +372,14 @@ class SaveOutboxEntryCmd:
|
||||
由出站管道从 ``OutboundContext.stream_aborted_at_chunk`` 透传,
|
||||
流式投递被中断时记录已发送分片序号,供重试 worker 调用
|
||||
``sendMessageContinuation`` 从断点续发剩余内容。
|
||||
fan_out_batch_id: fan-out 批次 ID(可选,M8 保序)。标识同一次
|
||||
fan-out 批次,由 ``AdminMessageService`` 在 fan-out 时填充到
|
||||
``OutboundContext.fan_out_batch_id``,再由 outbox-persist 阶段
|
||||
透传至 DB,供 outbox 重试 worker 按批次保序拉取(M8)。
|
||||
fan_out_seq: fan-out 批次内序号(可选,M8 保序)。由 outbox-persist
|
||||
阶段在批量路径下按 ``fan_out_entries`` 列表位置 ``enumerate``
|
||||
生成,单条路径直接取 ``OutboundContext.fan_out_seq``,供 outbox
|
||||
重试 worker 在同一批次内按 seq 排序投递(M8)。
|
||||
"""
|
||||
|
||||
channel_type: ChannelType
|
||||
@ -381,6 +389,8 @@ class SaveOutboxEntryCmd:
|
||||
trace_id: str | None = None
|
||||
channel_session_id: str | None = None
|
||||
stream_aborted_at_chunk: int | None = None
|
||||
fan_out_batch_id: str | None = None
|
||||
fan_out_seq: int | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""校验必填字段非空与持久化策略取值(FR-22)。
|
||||
|
||||
@ -15,8 +15,8 @@
|
||||
``ServiceAccountCreationError``。
|
||||
- ``transport``:传输层错误 ``TransportError``。
|
||||
- ``domain``:领域错误,按子领域拆分为 ``base`` / ``config`` / ``plugin`` /
|
||||
``agent`` / ``content`` / ``credential`` / ``onboarding`` / ``state``,详见
|
||||
``domain/__init__.py``。
|
||||
``agent`` / ``content`` / ``credential`` / ``onboarding`` / ``state`` /
|
||||
``media``,详见 ``domain/__init__.py``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -61,6 +61,7 @@ from yuxi.channels.contract.errors.domain import (
|
||||
OnboardingConflictError,
|
||||
OnboardingInvalidTransitionError,
|
||||
PairingExpiredError,
|
||||
PayloadTooLargeError,
|
||||
PipelineConfigError,
|
||||
PluginAlreadyRegisteredError,
|
||||
PluginFailedError,
|
||||
@ -139,4 +140,6 @@ __all__ = [
|
||||
"StateTransitionError",
|
||||
"IdempotencyConflictError",
|
||||
"IllegalStateError",
|
||||
# media(媒体下载超限)
|
||||
"PayloadTooLargeError",
|
||||
]
|
||||
|
||||
@ -59,4 +59,6 @@ CHANNEL_ERROR_STATUS_MAP: dict[str, int] = {
|
||||
"STATE_TRANSITION_ERROR": 409,
|
||||
"IDEMPOTENCY_CONFLICT": 409,
|
||||
"ILLEGAL_STATE": 409,
|
||||
# 媒体下载大小限制相关错误(N-M2)
|
||||
"PAYLOAD_TOO_LARGE": 413,
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
- ``credential``:凭证管理相关错误。
|
||||
- ``onboarding``:账号接入与身份绑定相关错误。
|
||||
- ``state``:运行态状态机非法跃迁、幂等冲突与非法状态调用相关错误。
|
||||
- ``media``:媒体下载超限等媒体相关错误。
|
||||
|
||||
本 ``__init__`` 重新导出全部领域错误类,外部可直接
|
||||
``from yuxi.channels.contract.errors.domain import XxxError`` 导入。
|
||||
@ -54,6 +55,7 @@ from yuxi.channels.contract.errors.domain.credential import (
|
||||
CredentialInvalidError,
|
||||
CredentialNotFoundError,
|
||||
)
|
||||
from yuxi.channels.contract.errors.domain.media import PayloadTooLargeError
|
||||
from yuxi.channels.contract.errors.domain.onboarding import (
|
||||
ConnectivityVerifyFailedError,
|
||||
IdentityAlreadyBoundError,
|
||||
@ -116,4 +118,6 @@ __all__ = [
|
||||
"StateTransitionError",
|
||||
"IdempotencyConflictError",
|
||||
"IllegalStateError",
|
||||
# media(媒体下载超限)
|
||||
"PayloadTooLargeError",
|
||||
]
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
"""媒体子域领域错误。
|
||||
|
||||
定义媒体下载超限等媒体相关的领域错误。继承 ``DomainError``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from yuxi.channels.contract.errors.domain.base import DomainError
|
||||
|
||||
|
||||
class PayloadTooLargeError(DomainError):
|
||||
"""媒体载荷超限错误。
|
||||
|
||||
媒体下载体积超过允许上限时抛出(HTTP 413,error_code=
|
||||
``PAYLOAD_TOO_LARGE``)。``details`` 包含 ``resource``、``id``、
|
||||
``size_bytes`` 与 ``max_bytes``,便于定位超限的资源与量化限制。
|
||||
|
||||
触发场景:``download_media`` 流式读取累计字节数超过 ``_MAX_MEDIA_SIZE_BYTES``
|
||||
时抛出(N-M2)。
|
||||
"""
|
||||
|
||||
error_code = "PAYLOAD_TOO_LARGE"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource: str,
|
||||
id: str,
|
||||
size_bytes: int,
|
||||
max_bytes: int,
|
||||
*,
|
||||
trace_id: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
f"Payload too large: {resource} {id} size {size_bytes} exceeds limit {max_bytes}",
|
||||
trace_id=trace_id,
|
||||
)
|
||||
self.resource = resource
|
||||
self.id = id
|
||||
self.size_bytes = size_bytes
|
||||
self.max_bytes = max_bytes
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = super().to_dict()
|
||||
data["resource"] = self.resource
|
||||
data["id"] = self.id
|
||||
data["size_bytes"] = self.size_bytes
|
||||
data["max_bytes"] = self.max_bytes
|
||||
return data
|
||||
@ -84,6 +84,10 @@ class DependencyError(ServerError):
|
||||
"""依赖故障错误。
|
||||
|
||||
外部依赖(如数据库、Redis、第三方服务)故障时抛出(HTTP 502)。
|
||||
|
||||
``retry_after_ms`` 携带上游建议的重试等待时间(毫秒),用于 503 等
|
||||
场景解析 ``Retry-After`` header 后透传给全局错误处理器(M10)。
|
||||
默认 ``None`` 表示无明确重试建议,保持向后兼容。
|
||||
"""
|
||||
|
||||
error_code = "DEPENDENCY"
|
||||
@ -96,6 +100,7 @@ class DependencyError(ServerError):
|
||||
message: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
category_hint: TransportErrorCategory | None = None,
|
||||
retry_after_ms: int | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
message or f"Dependency {dep} failed",
|
||||
@ -104,10 +109,13 @@ class DependencyError(ServerError):
|
||||
category_hint=category_hint,
|
||||
)
|
||||
self.dep = dep
|
||||
self.retry_after_ms = retry_after_ms
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = super().to_dict()
|
||||
data["dep"] = self.dep
|
||||
if self.retry_after_ms is not None:
|
||||
data["retry_after_ms"] = self.retry_after_ms
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ class LoggerPort(Protocol):
|
||||
"""日志被驱动端口。
|
||||
|
||||
覆盖结构化日志的记录用例。由 application 层调用,framework 层实现。
|
||||
端口方法遵循领域语义命名(``log`` / ``debug`` / ``info`` / ``warn`` /
|
||||
端口方法遵循领域语义命名(``log`` / ``debug`` / ``info`` / ``warning`` /
|
||||
``error``),不暴露 HTTP / DB 等技术细节。
|
||||
|
||||
关键约束:
|
||||
@ -105,21 +105,21 @@ class LoggerPort(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
async def warn(
|
||||
async def warning(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
trace_id: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""记录 WARN 级别日志。
|
||||
"""记录 WARNING 级别日志。
|
||||
|
||||
@pre
|
||||
- message 非空
|
||||
- kwargs 中的敏感字段已由调用方脱敏
|
||||
|
||||
@post
|
||||
- WARN 级别日志已输出,携带 trace_id(若提供)与 kwargs 上下文
|
||||
- WARNING 级别日志已输出,携带 trace_id(若提供)与 kwargs 上下文
|
||||
|
||||
@failure
|
||||
- 无(日志故障不得阻断主流程)
|
||||
|
||||
@ -91,6 +91,23 @@ class OutboxRepositoryPort(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
async def saveOutboxEntries(
|
||||
self,
|
||||
cmds: list[SaveOutboxEntryCmd],
|
||||
tx: TransactionContext | None = None,
|
||||
) -> list[OutboxId]:
|
||||
"""批量保存 OutboxEntry,单事务。默认实现循环调用单条(可覆写优化)。
|
||||
|
||||
供 M13 批量化 DB 操作使用,适配器可覆写为单次批量 INSERT 以减少
|
||||
往返。返回 OutboxId 列表,顺序与 ``cmds`` 一致。
|
||||
|
||||
@pre: cmds 非空;tx 非空时加入应用层事务不自主提交,为 None 时单方法提交
|
||||
@post: 返回 OutboxId 列表,顺序与 cmds 一致;各条目初始状态为 PENDING
|
||||
@failure: ValidationError - cmd 字段非法;DependencyError - 数据库故障
|
||||
@consistency: Strong
|
||||
"""
|
||||
return [await self.saveOutboxEntry(cmd, tx=tx) for cmd in cmds]
|
||||
|
||||
async def getOutboxEntry(self, outbox_id: str) -> OutboxEntry | None:
|
||||
"""按 outbox_id 查询发件箱条目;不存在返回 None。"""
|
||||
...
|
||||
@ -112,6 +129,27 @@ class OutboxRepositoryPort(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
async def getOutboxEntriesByBatch(
|
||||
self,
|
||||
batch_id: str,
|
||||
*,
|
||||
status: OutboxStatus | None = None,
|
||||
max_seq: int | None = None,
|
||||
tx: TransactionContext | None = None,
|
||||
) -> list[OutboxEntry]:
|
||||
"""查询同批次 outbox 条目,用于 M8 重试保序检查。
|
||||
|
||||
按 ``batch_id`` 查询同批次 outbox 条目,支持 ``status`` 与
|
||||
``max_seq`` 过滤。默认实现返回空列表(可由适配器覆写)。
|
||||
|
||||
@pre: batch_id 非空
|
||||
@post: 返回匹配批次 ID 的条目列表;status 非空时仅返回该状态条目;
|
||||
max_seq 非空时仅返回 seq <= max_seq 的条目
|
||||
@failure: DependencyError - 数据库故障
|
||||
@consistency: Strong
|
||||
"""
|
||||
return []
|
||||
|
||||
async def updateOutboxEntry(
|
||||
self,
|
||||
entry: OutboxEntry,
|
||||
|
||||
@ -1,146 +1,25 @@
|
||||
"""事务被驱动端口。
|
||||
"""事务被驱动端口(re-export 共享契约)。
|
||||
|
||||
定义应用层对事务控制能力的依赖契约,由 application 层调用、framework 层
|
||||
实现。事务边界 **必须** 由应用层(管道或用例编排器)显式开启与提交,
|
||||
被驱动适配器 **不得** 自主开启跨调用的事务(§10.1)。
|
||||
本模块原为 channels 限界上下文本地定义的事务端口契约,已收敛到共享事务
|
||||
基础设施 ``yuxi.storage.transactions``(见三模块事务管理优化方案)。
|
||||
本模块保留以维持 channels 调用方的导入路径稳定,仅做 re-export。
|
||||
|
||||
事务透传机制(C-I1):被驱动适配器(如 ``ChannelPersistenceAdapter`` /
|
||||
``ContentReviewRepositoryAdapter`` / ``AgentRunAdapter``)为无状态协议
|
||||
转换器,构造时仅注入 ``session_factory``(``Callable[[], AsyncSession]``),
|
||||
不持有 session 实例。适配器通过 ``_session_scope(tx)`` 统一管理 session:
|
||||
``tx`` 非空时通过 ``tx.get_session()`` 复用应用层主事务 session(C-I1
|
||||
透传,``commit=False``,由应用层统一提交);``tx`` 为 ``None`` 时通过
|
||||
``session_factory`` 创建独立 session 并自主提交(``commit=True``)。
|
||||
事务透传机制(C-I1)与事务边界由应用层独占的约束不变,详见
|
||||
``yuxi.storage.transactions.ports`` 模块 docstring。
|
||||
|
||||
注:从 ``yuxi.storage.transactions.ports`` 子模块直接导入而非包顶层,
|
||||
以避免循环导入——``yuxi.storage.transactions.__init__`` 加载 ``exceptions``
|
||||
时会触发 ``yuxi.channels.contract`` 初始化链,最终回到本模块;若从包顶层
|
||||
导入,``TransactionContext`` / ``TransactionPort`` 尚未绑定到包命名空间
|
||||
(``__init__`` 未执行完毕),导致 ``ImportError``。``ports`` 子模块运行时
|
||||
仅依赖 ``typing`` / ``collections.abc``,可在 ``__init__`` 完成前独立加载。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
||||
from yuxi.storage.transactions.ports import (
|
||||
TransactionContext,
|
||||
TransactionPort,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TransactionPort(Protocol):
|
||||
"""事务被驱动端口。
|
||||
|
||||
提供事务边界控制能力,由应用层在管道或用例编排器中显式调用。
|
||||
被驱动适配器通过共享事务上下文(``TransactionContext``)加入同一
|
||||
事务,**不得** 自主提交。
|
||||
|
||||
约束(§10.1):
|
||||
- 事务边界 **必须** 定义在应用服务层。
|
||||
- 领域核心 **不得** 持有事务上下文,**不得** 直接调用事务 API。
|
||||
- 被驱动适配器 **不得** 自主开启跨调用的事务,事务范围 **必须**
|
||||
由应用层控制。
|
||||
|
||||
使用示例::
|
||||
|
||||
async with transaction.begin() as tx:
|
||||
await persistence_port.saveChannelAccount(cmd, tx=tx)
|
||||
await persistence_port.saveAuditLog(audit_cmd, tx=tx)
|
||||
# 退出 with 块时自动提交,异常时自动回滚
|
||||
"""
|
||||
|
||||
def begin(self) -> TransactionContext:
|
||||
"""开启一个新事务,返回事务上下文。
|
||||
|
||||
@pre
|
||||
- 当前无活动事务(或适配器允许嵌套,由实现决定)
|
||||
|
||||
@post
|
||||
- 返回事务上下文,被驱动适配器通过该上下文加入同一事务
|
||||
- 上下文管理器退出时自动提交(无异常)或回滚(有异常)
|
||||
|
||||
@failure
|
||||
- DependencyError: 事务管理器故障
|
||||
|
||||
@consistency
|
||||
- Strong:事务边界由 ``TransactionContext`` 管理
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class TransactionContext(Protocol):
|
||||
"""事务上下文。
|
||||
|
||||
由 ``TransactionPort.begin()`` 创建,上下文管理器退出时自动提交
|
||||
(无异常)或回滚(有异常)。
|
||||
|
||||
事务透传机制(C-I1):无状态适配器通过 ``get_session()`` 获取底层
|
||||
共享会话,复用主事务的 session,避免独立提交产生孤儿记录。适配器
|
||||
在 ``_session_scope(tx)`` 中判断 ``tx`` 非空且 ``get_session()``
|
||||
返回 session 时使用该 session(``commit=False``),否则创建独立
|
||||
session(``commit=True``)。
|
||||
|
||||
被驱动适配器 **不得** 自主调用 ``commit`` / ``rollback``。
|
||||
|
||||
说明:本 Protocol 未声明 ``@runtime_checkable``,不参与 ``isinstance``
|
||||
检查。``TransactionContext`` 仅作为 ``tx`` 参数的类型注解,运行时由
|
||||
``TransactionPort.begin()`` 返回的具体实现承载,无需运行时类型校验。
|
||||
"""
|
||||
|
||||
def get_session(self) -> AsyncSession | None:
|
||||
"""返回底层共享会话,供无状态适配器复用主事务 session。
|
||||
|
||||
仅 SQL 实现返回真实 ``AsyncSession``,非 SQL 实现返回 ``None``
|
||||
(由适配器回退到 ``session_factory`` 创建独立 session 的路径)。
|
||||
|
||||
@pre
|
||||
- 事务已开启(``__aenter__`` 已调用)
|
||||
|
||||
@post
|
||||
- 返回底层共享会话;非 SQL 实现返回 ``None``
|
||||
|
||||
@consistency
|
||||
- 调用方通过此 session 写入的数据自动加入主事务,
|
||||
由应用层统一提交/回滚(C-I1)
|
||||
"""
|
||||
...
|
||||
|
||||
async def commit(self) -> None:
|
||||
"""提交当前事务。
|
||||
|
||||
仅由应用层(管道或用例编排器)调用,被驱动适配器 **不得** 调用。
|
||||
|
||||
@pre
|
||||
- 事务已开启且未提交/回滚
|
||||
|
||||
@post
|
||||
- 事务内所有写操作持久化生效
|
||||
|
||||
@failure
|
||||
- DependencyError: 提交失败(事务自动回滚)
|
||||
|
||||
@consistency
|
||||
- Strong:提交成功后写操作立即可见
|
||||
"""
|
||||
...
|
||||
|
||||
async def rollback(self) -> None:
|
||||
"""回滚当前事务。
|
||||
|
||||
仅由应用层(管道或用例编排器)调用,被驱动适配器 **不得** 调用。
|
||||
|
||||
@pre
|
||||
- 事务已开启且未提交/回滚
|
||||
|
||||
@post
|
||||
- 事务内所有写操作被撤销
|
||||
|
||||
@failure
|
||||
- DependencyError: 回滚失败(连接异常,需关闭会话)
|
||||
|
||||
@consistency
|
||||
- Strong:回滚后数据库恢复到事务开启前状态
|
||||
"""
|
||||
...
|
||||
|
||||
async def __aenter__(self) -> TransactionContext:
|
||||
"""进入事务上下文。"""
|
||||
...
|
||||
|
||||
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
||||
"""退出事务上下文,自动提交(无异常)或回滚(有异常)。"""
|
||||
...
|
||||
__all__ = ["TransactionPort", "TransactionContext"]
|
||||
|
||||
@ -117,7 +117,7 @@ class AccountLifecycleService:
|
||||
# 适配器抛出未预期异常(非 ValidationError / ChannelDegradedError)
|
||||
# 视为适配器实现缺陷,翻译为 InternalError(5xx)而非
|
||||
# ValidationError(4xx),避免将服务端缺陷误判为客户端入参错误。
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"resolveAccountId unexpected error: {type(exc).__name__}: {exc}",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type,
|
||||
@ -156,7 +156,7 @@ class AccountLifecycleService:
|
||||
except Exception as exc:
|
||||
# 适配器抛出未预期异常,翻译为 InternalError(5xx),避免将
|
||||
# 服务端实现缺陷误判为客户端入参错误(ValidationError)。
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"applyAccountConfig unexpected error: {type(exc).__name__}: {exc}",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type,
|
||||
@ -247,7 +247,7 @@ class AccountLifecycleService:
|
||||
except DependencyError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"validateCredentials unexpected error: {type(exc).__name__}: {exc}",
|
||||
trace_id=trace_id,
|
||||
channel_type=channel_type,
|
||||
@ -305,7 +305,7 @@ class AccountLifecycleService:
|
||||
return LifecycleCallbackResult(status="ok", hook=hook)
|
||||
except TimeoutError:
|
||||
msg = f"timeout after {timeout}s"
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"lifecycle callback timeout: hook={hook}, "
|
||||
f"channel_type={channel_type}, "
|
||||
f"account_id={account.account_id}, {msg}",
|
||||
@ -313,7 +313,7 @@ class AccountLifecycleService:
|
||||
)
|
||||
return LifecycleCallbackResult(status="warn", hook=hook, error_message=msg)
|
||||
except LifecycleHookError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"lifecycle callback failed: hook={hook}, "
|
||||
f"channel_type={channel_type}, "
|
||||
f"account_id={account.account_id}, reason={exc.reason}",
|
||||
@ -322,7 +322,7 @@ class AccountLifecycleService:
|
||||
return LifecycleCallbackResult(status="warn", hook=hook, error_message=exc.reason)
|
||||
except (ChannelDegradedError, DependencyError) as exc:
|
||||
msg = f"{type(exc).__name__}: {exc}"
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
f"lifecycle callback dependency error: hook={hook}, "
|
||||
f"channel_type={channel_type}, "
|
||||
f"account_id={account.account_id}, {msg}",
|
||||
|
||||
@ -141,7 +141,7 @@ class AckDecisionMaker:
|
||||
value = await self.cache_port.get(redis_key)
|
||||
return value.is_some()
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"ack idempotency check failed, treating as not acked",
|
||||
idempotency_key=idempotency_key,
|
||||
error=str(exc),
|
||||
@ -167,7 +167,7 @@ class AckDecisionMaker:
|
||||
try:
|
||||
await self.cache_port.set(redis_key, True, ttl_seconds=_ACK_IDEMPOTENCY_TTL)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"ack idempotency record failed, duplicate ack possible on retry",
|
||||
idempotency_key=idempotency_key,
|
||||
error=str(exc),
|
||||
|
||||
@ -107,7 +107,7 @@ class BotLoopBudgetGuard:
|
||||
try:
|
||||
cached = await self.cache_port.get(budget_key)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"bot_loop_budget cache get failed, degrade to cache-miss",
|
||||
budget_key=budget_key,
|
||||
error=str(exc),
|
||||
@ -123,7 +123,7 @@ class BotLoopBudgetGuard:
|
||||
try:
|
||||
await self.cache_port.set(budget_key, current_count - 1, cooldown)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"bot_loop_budget cache set failed, skip writeback",
|
||||
budget_key=budget_key,
|
||||
error=str(exc),
|
||||
@ -150,7 +150,7 @@ class BotLoopBudgetGuard:
|
||||
try:
|
||||
cached = await self.cache_port.get(budget_key)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"bot_loop_budget cache get failed on release, skip",
|
||||
budget_key=budget_key,
|
||||
error=str(exc),
|
||||
@ -166,7 +166,7 @@ class BotLoopBudgetGuard:
|
||||
cooldown,
|
||||
)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"bot_loop_budget cache set failed on release, skip writeback",
|
||||
budget_key=budget_key,
|
||||
error=str(exc),
|
||||
@ -202,7 +202,7 @@ class BotLoopBudgetGuard:
|
||||
try:
|
||||
await self.cache_port.set(budget_key, max_budget, cooldown)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"bot_loop_budget cache set failed, skip writeback",
|
||||
budget_key=budget_key,
|
||||
error=str(exc),
|
||||
@ -212,7 +212,7 @@ class BotLoopBudgetGuard:
|
||||
try:
|
||||
cached = await self.cache_port.get(budget_key)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"bot_loop_budget cache get failed, degrade to cache-miss",
|
||||
budget_key=budget_key,
|
||||
error=str(exc),
|
||||
|
||||
@ -91,7 +91,7 @@ class CapabilityVerifier:
|
||||
try:
|
||||
cached = await self.cache_port.get(cache_key)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"capability cache get failed, skip cache",
|
||||
plugin_id=plugin_id,
|
||||
capability=capability,
|
||||
@ -105,7 +105,7 @@ class CapabilityVerifier:
|
||||
prover = self.registry.findProver(plugin_id)
|
||||
if prover is None:
|
||||
if self._logger is not None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"capability proof: no prover registered, trusting declaration by default",
|
||||
plugin_id=plugin_id,
|
||||
capability=capability,
|
||||
@ -116,7 +116,7 @@ class CapabilityVerifier:
|
||||
try:
|
||||
await self.cache_port.set(cache_key, proof.proven, 300)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"capability cache set failed, skip writeback",
|
||||
plugin_id=plugin_id,
|
||||
capability=capability,
|
||||
|
||||
@ -258,7 +258,7 @@ class CommonCommandAdapter:
|
||||
sub = params[0] if params else ""
|
||||
|
||||
if self._whitelist_command_port is None:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"whitelist command port not injected, fallback to usage text",
|
||||
command="allowlist",
|
||||
action=sub,
|
||||
|
||||
@ -326,7 +326,7 @@ class CredentialService:
|
||||
try:
|
||||
cached = await self._cache_port.get(cache_key)
|
||||
except DependencyError as exc:
|
||||
await self._logger_port.warn(
|
||||
await self._logger_port.warning(
|
||||
"credential cache get failed, fallback to ConfigPort",
|
||||
account_id=account_id,
|
||||
error=str(exc),
|
||||
@ -365,7 +365,7 @@ class CredentialService:
|
||||
ttl_seconds=_CREDENTIAL_CACHE_TTL,
|
||||
)
|
||||
except DependencyError as exc:
|
||||
await self._logger_port.warn(
|
||||
await self._logger_port.warning(
|
||||
"credential cache backfill failed",
|
||||
account_id=account_id,
|
||||
error=str(exc),
|
||||
@ -482,7 +482,7 @@ class CredentialService:
|
||||
ttl_seconds=_CREDENTIAL_CACHE_TTL,
|
||||
)
|
||||
except DependencyError as exc:
|
||||
await self._logger_port.warn(
|
||||
await self._logger_port.warning(
|
||||
"credential cache refresh failed",
|
||||
account_id=account_id,
|
||||
error=str(exc),
|
||||
|
||||
@ -96,7 +96,7 @@ class DegradationManager:
|
||||
try:
|
||||
retry_count = await self.cache_port.incr(count_key)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"degradation cache incr failed, degrade to retry_count=1",
|
||||
plugin_id=plugin_id,
|
||||
error=str(exc),
|
||||
@ -108,7 +108,7 @@ class DegradationManager:
|
||||
try:
|
||||
await self.cache_port.expire(count_key, _DEGRADATION_RECORD_TTL)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"degradation cache expire failed, count key may persist beyond window",
|
||||
plugin_id=plugin_id,
|
||||
error=str(exc),
|
||||
@ -128,7 +128,7 @@ class DegradationManager:
|
||||
try:
|
||||
await self.cache_port.set(cache_key, payload, _DEGRADATION_RECORD_TTL)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"degradation cache set failed, skip writeback",
|
||||
plugin_id=plugin_id,
|
||||
error=str(exc),
|
||||
@ -177,7 +177,7 @@ class DegradationManager:
|
||||
try:
|
||||
cached = await self.cache_port.get(cache_key)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"degradation cache get failed, skip plugin",
|
||||
plugin_id=plugin_id,
|
||||
error=str(exc),
|
||||
@ -194,7 +194,7 @@ class DegradationManager:
|
||||
try:
|
||||
next_retry_at = datetime.fromisoformat(next_retry_str)
|
||||
except ValueError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"degradation cache next_retry_at parse failed, skip plugin",
|
||||
plugin_id=plugin_id,
|
||||
next_retry_at=next_retry_str,
|
||||
@ -231,7 +231,7 @@ class DegradationManager:
|
||||
try:
|
||||
cached = await self.cache_port.get(cache_key)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"degradation cache get failed, treat as not failed",
|
||||
plugin_id=plugin.manifest.id,
|
||||
error=str(exc),
|
||||
|
||||
@ -314,7 +314,7 @@ class DoctorService:
|
||||
try:
|
||||
repair_result = await adapter.autoFix(account_id, check_id)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"doctor autoFix failed",
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
@ -362,7 +362,7 @@ class DoctorService:
|
||||
except DependencyError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"doctor runItem failed",
|
||||
check_id=item.check_id,
|
||||
error=str(exc),
|
||||
@ -432,7 +432,7 @@ class DoctorService:
|
||||
try:
|
||||
result = await adapter.checkConnectivity(account_id)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"doctor checkConnectivity failed",
|
||||
check_id="credential_validity",
|
||||
error=str(exc),
|
||||
@ -485,7 +485,7 @@ class DoctorService:
|
||||
try:
|
||||
result = await adapter.checkPermissions(account_id)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"doctor checkPermissions failed",
|
||||
check_id="required_permissions",
|
||||
error=str(exc),
|
||||
@ -693,7 +693,7 @@ class DoctorService:
|
||||
try:
|
||||
value = await self._config_port.get(key, scope=scope, target=target)
|
||||
except (ConfigValidationError, NotFoundError) as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"config read failed, returning None",
|
||||
key=key,
|
||||
scope=scope.value,
|
||||
@ -732,7 +732,7 @@ class DoctorService:
|
||||
target=f"{channel_type}:{account_id}",
|
||||
)
|
||||
except (ConfigValidationError, NotFoundError) as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"applied migrations read failed, returning empty",
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
|
||||
@ -143,7 +143,7 @@ class FenceGuard:
|
||||
current_generation=0,
|
||||
stale_generation=generation,
|
||||
)
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"fence TTL 过期,栅栏保护失效",
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
@ -209,7 +209,7 @@ class FenceGuard:
|
||||
if fence.revertGeneration(generation):
|
||||
await self._saveFenceLocked(cache_key, fence, conversation_id)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"fence rollback skipped due to cache dependency error",
|
||||
conversation_id=conversation_id,
|
||||
generation=generation,
|
||||
@ -286,7 +286,7 @@ class FenceGuard:
|
||||
error=str(exc),
|
||||
)
|
||||
raise
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"fence cache get failed, fallback to local",
|
||||
cache_key=cache_key,
|
||||
error=str(exc),
|
||||
@ -300,7 +300,7 @@ class FenceGuard:
|
||||
if not fence.isExpired():
|
||||
return fence
|
||||
del self._local_fallback[cache_key]
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"fence TTL expired, auto-released",
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
@ -345,7 +345,7 @@ class FenceGuard:
|
||||
error=str(exc),
|
||||
)
|
||||
raise
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"fence cache set failed, fallback to local",
|
||||
cache_key=cache_key,
|
||||
error=str(exc),
|
||||
@ -373,7 +373,7 @@ class FenceGuard:
|
||||
仅在 fail-open 模式下调用并记 WARN 日志(标注多实例保护弱化)。
|
||||
fail-closed 模式在降级前已抛 ``DependencyError``,ERROR 日志由
|
||||
``_loadFenceLocked`` / ``_saveFenceLocked`` 调用方在抛出前记录。
|
||||
``logger.warn`` 在去重检查与标志置位之后调用,保持与原有结构一致。
|
||||
``logger.warning`` 在去重检查与标志置位之后调用,保持与原有结构一致。
|
||||
|
||||
.. note:: 调用方必须已持有 ``self._lock``(事务级锁语义),本方法
|
||||
内部不再加锁,直接访问 ``_degrade_warned``。
|
||||
@ -384,7 +384,7 @@ class FenceGuard:
|
||||
if self._degrade_warned:
|
||||
return
|
||||
self._degrade_warned = True
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"fence cache unavailable, degrading to in-process memory counter; "
|
||||
"多实例部署下栅栏保护弱化,建议恢复 Redis 后重启所有实例",
|
||||
conversation_id=conversation_id,
|
||||
|
||||
@ -179,7 +179,7 @@ class IdentityResolver:
|
||||
try:
|
||||
result = await self._cache_port.get(key)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"identity cache get failed, fallback to no-cache",
|
||||
cache_key=key,
|
||||
error=str(exc),
|
||||
@ -204,7 +204,7 @@ class IdentityResolver:
|
||||
try:
|
||||
await self._cache_port.set(key, self._toCacheValue(value), self._cache_ttl)
|
||||
except DependencyError as exc:
|
||||
await self._logger.warn(
|
||||
await self._logger.warning(
|
||||
"identity cache set failed",
|
||||
cache_key=key,
|
||||
error=str(exc),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user