Worker 失败原因不再只留类名:区分"可落库的固定文案"与"异常消息"

起因是查 Worker 运行状态时发现库里 373 条死信的 last_error 全是裸的 "ValueError"
(工具 tools/probe_worker_state.py,证据 docs/evidence/worker-state.json)。
dispatch(run not found)、dispatch_run_completed、dispatch_memory_extraction、
dispatch_profile_rebuild 抛的都是 ValueError,只记类名等于把"哪一处失败"也一起丢了。

但"直接存 str(exc)"是错的:tests/unit/worker/test_outbox_worker.py 那条
RuntimeError("credential=do-not-log") 断言异常消息不得落库 —— 它可能含凭据、SQL 或
客户标识。第一版改动就是这么写的,被这个测试当场拦下(这测试写得值)。

折中:

- 新增 OutboxHandlerError(继承 ValueError,这些失败本就是 ValueError 语义,保持
  继承关系才不会改动既有的 except ValueError 行为与断言)。它的 reason 由代码写死、
  不含任何请求数据,因此可以落库;
- safe_error_text:OutboxHandlerError → "类名: 固定文案"(截断 500 字符),
  其余异常 → 仍只记类名;
- runtime.py 的 5 处 handler 失败改抛 OutboxHandlerError。

测试:新增"固定文案落库"用例;并把既有用例的断言收紧为 last_error == "RuntimeError"
(原先只断言"不含 do-not-log",太松,漏掉的情况测不出来)。

顺带产出 tools/probe_worker_state.py(只读):outbox / agent_run 各状态计数、按事件
类型分组、死信原因聚合。当前环境实测 pending 347、dead 373、published 410、
agent_run 无 queued/running。

门禁:ruff 干净 / mypy 138 文件 / 697 unit+contract / 33 integration。
This commit is contained in:
2026-09-11 16:28:19 +08:00
parent 8d79bd9767
commit 8b8883ccca
5 changed files with 315 additions and 8 deletions
+37 -1
View File
@@ -8,6 +8,41 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.model.platform import DomainEventOutbox, OutboxDelivery
class OutboxHandlerError(ValueError):
"""Handler 失败,且失败原因是**可以安全落库**的固定文案。
继承 `ValueError` 而不是 `Exception`:这些失败(run not found、payload 不完整、
mode 非法)本来就是 ValueError 语义,保持继承关系才不会改动既有的 `except
ValueError` 行为与断言。
为什么需要这个类型:`last_error` 默认只记异常类名,因为异常消息可能含凭据、SQL
语句或客户标识(`tests/unit/worker/test_outbox_worker.py` 里那条
`RuntimeError("credential=do-not-log")` 就是守这条的)。
但只记类名又不够 —— `dispatch`(run not found)、`dispatch_run_completed`、
`dispatch_memory_extraction`、`dispatch_profile_rebuild` 抛的全是 ValueError,实测
库里 373 条死信的 `last_error` 都是裸的 `"ValueError"`,分不清是哪一处失败的。
折中办法:handler 想让人看见原因时,抛这个类型,`reason` 由**代码写死**、不含任何
请求数据,于是可以落库;其余异常仍然只记类名。
"""
def __init__(self, reason: str) -> None:
super().__init__(reason)
self.reason = reason
def safe_error_text(exc: BaseException) -> str:
"""把异常转成可落库的失败原因。
- `OutboxHandlerError` → `类名: 固定文案`(文案由代码写死,安全);
- 其他异常 → **只记类名**(消息可能含凭据/请求数据,不落库)。
"""
if isinstance(exc, OutboxHandlerError):
return f"{type(exc).__name__}: {exc.reason}"[:500]
return type(exc).__name__
class OutboxWorker:
"""One dispatcher per event type, not a fan-out transport.
@@ -71,7 +106,8 @@ class OutboxWorker:
await self.session.flush()
except Exception as exc:
event.retry_count += 1
event.last_error = type(exc).__name__
# 只让"代码写死的固定文案"落库;异常消息可能含凭据,见 safe_error_text。
event.last_error = safe_error_text(exc)
event.status = "dead" if event.retry_count >= 5 else "failed"
event.next_retry_at = now + timedelta(seconds=min(300, 2**event.retry_count))
else:
+6 -6
View File
@@ -41,7 +41,7 @@ from app.worker.episode_worker import (
EpisodeWorker,
)
from app.worker.memory_extraction_worker import MemoryExtractionWorker
from app.worker.outbox_worker import OutboxWorker
from app.worker.outbox_worker import OutboxHandlerError, OutboxWorker
logger = logging.getLogger(__name__)
@@ -119,11 +119,11 @@ class WorkerRuntime:
async def dispatch(payload: dict[str, Any]) -> None:
run = await AgentRunRepository(session).get(str(payload["run_id"]))
if run is None:
raise ValueError("run not found")
raise OutboxHandlerError("run not found")
async def dispatch_memory_extraction(payload: dict[str, Any]) -> None:
if "message_id" not in payload or "customer_id" not in payload:
raise ValueError("memory extraction payload is incomplete")
raise OutboxHandlerError("memory extraction payload is incomplete")
# 幂等键只认事件 id,由 worker 自己按 payload 回查,避免调用方漏传。
# 注入召回缓存适配器:写入生效后立即失效该客户的热缓存。
await MemoryExtractionWorker(
@@ -135,7 +135,7 @@ class WorkerRuntime:
# "运行已完成"的对外通知职责。当前没有独立外部消费者,
# 这里显式消费以免事件永久滞留;接入推送链路时在此处扩展。
if not str(payload.get("run_id", "")):
raise ValueError("agent.run_completed payload is incomplete")
raise OutboxHandlerError("agent.run_completed payload is incomplete")
async def dispatch_cache_invalidate(payload: dict[str, Any]) -> None:
await self._invalidate_config_cache(payload)
@@ -149,7 +149,7 @@ class WorkerRuntime:
"""
customer_id = payload.get("customer_id")
if not customer_id:
raise ValueError("profile.rebuild_requested payload is incomplete")
raise OutboxHandlerError("profile.rebuild_requested payload is incomplete")
# 延迟导入:bootstrap 会间接导入本模块,模块级导入会形成循环依赖
from app.service.profile_assembly_service import ProfileAssemblyService
from app.service.profile_graph_projection_service import (
@@ -181,7 +181,7 @@ class WorkerRuntime:
raise ValueError("memory.deletion_requested payload is incomplete")
mode = str(payload.get("mode", "invalidate"))
if mode not in {"invalidate", "delete"}:
raise ValueError("memory.deletion_requested mode is invalid")
raise OutboxHandlerError("memory.deletion_requested mode is invalid")
await MemoryLifecycleService(session).run(
int(customer_id),
mode=cast("Mode", mode),