Milvus 错误哨兵约定(Error Sentinel Convention):typed merr 与内部哨兵的两层体系与 gRPC 边界硬性不变量
【免费下载链接】milvusMilvus is a high-performance, cloud-native vector database built for scalable vector ANN search项目地址: https://gitcode.com/GitHub_Trending/mi/milvus
本篇文章系统讲解 Milvus 服务端错误处理的两层体系——携带数字错误码、可跨 gRPC 传输的 typed merr(wire-protocol errors),与仅用于单进程内控制流的内部哨兵(internal sentinels),并给出二者之间必须遵守的errors.Is硬性不变量、命名约定、错误码段分区、仓库现状审计结果以及未来的静态检查(linter)规划。读完本文,你将掌握在 Milvus 源码中"何时该创建merr.WrapErrXxx、何时该定义errors.New哨兵、何时必须用返回值携带信号"的完整决策依据,并能在提交代码前自行对照审计。
本文是 Milvus 错误处理规范三部曲的核心:规则与决策树见 docs/dev/error_handling_guide.md(日常 how-to),真实正反例见 docs/dev/error_handling_casebook.md(那些能通过 review 的典型错误)。
一、Milvus 错误处理的两层体系
Milvus 的错误处理存在两个截然不同的层次,本规范的核心目标就是把它们彻底分开:
1. Typed merr:wire-protocol 错误
定义于 pkg/util/merr/errors.go(ErrCollectionNotFound、ErrParameterInvalid等)。这些错误携带一个数字错误码,会被序列化进commonpb.Status{ErrorCode, Reason},并通过 gRPC 传给客户端。它们是客户端(以及任何处于 RPC 接收端的 Milvus 组件)唯一能看到的东西。
创建方式为merr.WrapErrXxxMsg(...)或merr.WrapErrXxxErr(cause, ...)。注意:这些工厂函数只用于错误发源地(origination)——绝不用于给一个已存在的 typed merr 追加上下文(追加上下文请用merr.Wrap,详见下文"两条易混淆的规则")。
2. Internal sentinels:单进程内控制流
使用包作用域的errors.New(...)创建(例如errIgnoredAlterAlias、errReleaseCollectionNotLoaded、errNodeNotEnough)。它们是单个 Go 进程内部的"信号词汇":被调用方通过它们告诉调用方"这是一次幂等空操作"或"队列为空",调用方据此分支、重试或忽略。它们不属于wire 协议。
捕获方总是位于调用栈的某个边界处,通过errors.Is(err, sentinelX)判断,该边界随后二选一:
- 翻译为
merr.Success()(幂等语义:例如 drop 一个不存在的东西 → 视为成功); - 翻译为 typed merr
merr.WrapErrXxxMsg(...)(例如用户已存在 →WrapErrParameterInvalid)。
二、硬性不变量:哨兵绝不允许越过任何 gRPC 边界
任何内部哨兵必须在越过 gRPC handler 边界之前,被
errors.Is捕获并翻译成 typed merr(或Success)——无论是面向客户端的边界,还是组件与组件之间的边界。
为什么是"任何 gRPC 边界"而不只是客户端边界?因为 gRPC 会把错误序列化为commonpb.Status{ErrorCode, Reason}。errors.New哨兵赖以工作的 Go 指针身份(pointer identity)不会穿过网络。对端用merr.Error(status)从数字码重建 typed merr 时,哨兵链已经永远消失。因此,一个从内部 coord RPC 逃逸的哨兵,与一个从用户 RPC 逃逸的哨兵一样是缺陷——只是更隐蔽,因为不会有客户看到由此产生的Code=65535 (unexpected)。从源码看,pkg/util/merr/utils.go 的Error(status)只依据Status.Code重建milvusError,并只恢复Retriable与is_input_error标记,这正是"哨兵链不可复原"的机制根源。
什么会破坏errors.Is链
errors.Is链可以安全地穿过return err、errors.Wrap(err, "...")/merr.Wrap(err, "...")(cockroachdb 薄封装),以及merr.WrapErrServiceInternalErr(err, "...")——因为milvusError.Unwrap()返回内部错误,所以errors.Is(outer, innerSentinel)在这些情况下始终为 true(见 pkg/util/merr/errors.go 中milvusError的inner字段与Unwrap()实现)。
链只会在以下情况被摧毁:
- 把 cause 塞进格式化参数而不是错误链:
merr.WrapErrXxxMsg("...: %s", err),或%w错误用法(WrapErr*Msg用fmt.Sprintf格式化,不认%w,会渲染成%!w(...))。内部错误进入了消息文本,但无法通过Unwrap()触达,于是errors.Is(outer, innerSentinel)返回 false。配套文档 docs/dev/error_handling_casebook.md 的 Pattern 4 专门记录了此类缺陷:Status.Reason里的审计痕迹看起来一切正常,这正是它能屡屡通过 review 的原因。 - 任何未实现
Unwrap()的自定义包装器。
不要与"码/可重试性"规则混淆
merr.Wrap(err, ...)与merr.WrapErrXxxErr(err, ...)都能保住errors.Is,但它们在边界上报的内容不同:
merr.Wrap(err, ...)保留内层错误的Code()与IsRetryable——链最终解析到内层的*milvusError;merr.WrapErrXxxErr(err, ...)上报外层哨兵的码与可重试性:As()解析到ErrServiceInternal(Code 5,不可重试),掩盖了内层的分类。
所以存在两条经常被混为一谈的规则:
- 要让
errors.Is持续有效:绝不要把 cause 塞进格式字符串,而要作为 error 参数传入。 - 要加上下文且不改分类(保留内层码 + 可重试性):用
merr.Wrap,而不是merr.WrapErr*Err。只有当你有意断言一个新的分类(例如这确实就是 service-internal 错误)时才用merr.WrapErrXxxErr。
从 pkg/util/merr/errors.go 的wrapInner实现可以看到机制:它把外层哨兵的msg/detail替换为上下文消息并挂上inner,因此Code()与IsRetryableErr均报告外层哨兵,而Unwrap()保留内层——这正是"掩盖分类但保留链"的底层原因。
三、命名约定:两层错误,两套命名
Wire 层(typed merr)——大写Err*,只能存在于pkg/util/merr
所有可能越过任何 gRPC 边界(客户端或组件间)的错误,都必须是定义在 pkg/util/merr/errors.go 中的*merr.milvusError。它们具备:
- 传入
newMilvusError(...)的数字码;唯一性由 init 期代码注册表强制(在同一码上重复定义第二个哨兵会在包初始化时 panic,因为milvusError.Is仅凭码匹配,见下文); - 在 pkg/util/merr/errors.go 中的
var ErrXxx = newMilvusError(...)声明; - 导出的
WrapErrXxxMsg/WrapErrXxxErr工厂函数。
如果某个错误需要出现在 wire 上,它就必须住在这里。没有例外。
每个哨兵还带有一个 Input-vs-System 分类(责任在谁),它驱动Status.Retriable、cause指标标签、proxy 的 lb_policy 故障转移与retry.Do。分类的判定规则、误分类的代价与边界标记机制(WrapErrAsInputErrorWhen),详见 docs/dev/error_handling_guide.md 的 "Input vs System: who is to blame?" 一节。
码段分区
错误码按家族分配。新增哨兵前,先扫描注册表(grep -nE "= newMilvusError\(" pkg/util/merr/errors.go),把新码放进所属家族区间内——init 期注册表只会在重复时 panic,它无法告诉你 1305 属于 MQ 家族。不要为一个一次性错误开新区间;多数"新"错误都能装进现有家族或现有哨兵(标准化工作中ErrSegcore和ErrMqInternal都险些被重复发明)。
| 区间 | 家族 | 区间 | 家族 |
|---|---|---|---|
| 1–99 | 服务级(NotReady1、Unavailable2、Internal5、…) | 1300–1399 | MQ |
| 100–199 | collection | 1400–1499 | privilege / RBAC |
| 200–299 | partition | 1600–1699 | alias |
| 300–399 | resource group | 1700–1799 | field |
| 400–499 | replica | 1800–1899 | HTTP / REST gateway |
| 500–599 | channel | 1900–1999 | replicate / CDC |
| 600–699 | segment | 2000–2099 | segcore + knowhere(cgo;表驱动,见下) |
| 700–799 | index | 2100–2199 | import |
| 800–899 | database | 2200–2299 | query / requery plan |
| 901–999 | node | 2300–2399 | compaction |
| 1000–1099 | io / storage / serialization / data integrity | 2400–2499 | function pipeline(ErrFunctionFailed2400)——但ErrDataNodeSlotExhausted是 2401,添加前先查占用 |
| 1100–1199 | request parameter | 2500–2599 | KMS |
| 1200–1299 | metrics | 2600–2699 | snapshot |
| 3000+ | misc(ErrOperationNotSupported3000、ErrOldSessionExists3001) |
65535((1<<16)-1)是errUnexpected——没有 merr 码的错误落网时的 wire 回退码。它是保留值,绝不允许故意起源(源码中它被注释为 "Do NOT export this, never allow programmer using this",见 pkg/util/merr/errors.go)。
2000–2099 的 segcore 区间由 cgo 转换表拥有:必须走merr.SegcoreError(pkg/util/merr/utils.go),它查询 pkg/util/merr/segcore.go 中的码/可重试性表——不要手工挑选区间内的数字(见 casebook 的 Pattern 7)。该表同时定义了 C++ 码到 Go 哨兵的映射、InputError 标记(2020/2023/2025/2026/2028/2031/2032/2042/2007/2021/2022 等)与可重试系统错误(2012/2014/2015/2018/2027/2034/2036/2043/2045 等),未注册码回退到非可重试的ErrSegcore。
milvusError.Is按码匹配——两个后果
milvusError.Is的实现仅比较errCode(见 pkg/util/merr/errors.go),由此产生两个推论:
- 一个码只能有一个哨兵。共享一个码的两个哨兵将
errors.Is相等;init 期注册表 panic 就是为了让这种状态不可表达(newMilvusError中的registeredCodes查重)。 - 把内部
errors.New哨兵提升为 merr 会放宽所有 guard。裸哨兵按指针身份匹配;merr 按码匹配。转换后,errors.Is(err, thatSentinel)会静默匹配任何携带同码的错误。转换前必须执行grep -rn "errors.Is(.*<sentinelName>"并审计每一个命中(casebook 的 Pattern 6 记录了这个规则来源的>// errFull / errNoSuchElement are INTERNAL sentinels: caught by errors.Is // inside the compaction inspector / scheduler loop and never serialized // across any gRPC boundary. var ( errFull = errors.New("compaction queue is full") errNoSuchElement = errors.New("compaction queue has no element") )跨包幂等性:用返回值标志,而不是导出哨兵
旧代码导出了
meta.ErrResourceGroupOperationIgnored,让父包querycoordv2能通过errors.Is捕获并翻译为merr.Success()。这是一个位于internal/...中的导出Err*——与 typed wire 错误merr.ErrXxx视觉上无法区分,极易误用。当前代码改为在返回值中编码该信号(见 internal/querycoordv2/meta/resource_manager.go 与 internal/querycoordv2/ddl_callbacks_alter_resource_group.go):
// meta/resource_manager.go func (rm *ResourceManager) CheckIfResourceGroupAddable(...) (ignored bool, err error) { if proto.Equal(rm.groups[rgName].GetConfig(), cfg) { return true, nil // idempotent no-op } ... } // querycoordv2/ddl_callbacks_alter_resource_group.go (broadcaster) func (s *Server) broadcastCreateResourceGroup(...) (ignored bool, err error) { if ignored, err := s.meta.CheckIfResourceGroupAddable(...); err != nil || ignored { return ignored, err } ... } // querycoordv2/services.go (RPC handler) ignored, err := s.broadcastCreateResourceGroup(ctx, req) if err != nil { return merr.Status(err), nil } if ignored { return merr.Success(), nil }没有任何哨兵跨过包边界;信号通过结构化返回值传递。这是任何新的跨包幂等性场景的首选模式。
四、现状审计:
internal/{datacoord,rootcoord,querycoordv2}中的 28 个哨兵审计基于 err-std-04-coord 分支(2026-05-19)。28 个
errors.New(...)哨兵的命运:种类 数量 示例 状态 幂等:被捕获 → merr.Success()13 个捕获点,约 12 个不同哨兵 errIgnoredAlterAlias、errIgnoredCreateCollection、errReleaseCollectionNotLoaded、errUserNotFound、…✅ 合规 被捕获 → 翻译为 typed merr 3 个捕获点( errUserAlreadyExists、errRoleAlreadyExists、errRoleNotExists)客户端收到 WrapErrParameterInvalidMsg(...)或WrapErrServiceInternalMsg(...)✅ 合规(1100 / 5) 仅后台使用(从不进入 RPC handler) 5 个( errFull、errNoSuchElement、errNodeNotEnough、errDisposed、errTypeNotFound)compaction 队列 / resource observer / session 生命周期 / checker 注册表 ✅ 合规 通过 (ignored bool, err error)签名实现跨包幂等1 个(resource group 创建/删除) meta 层返回 ignored=true;querycoordv2 RPC handler 翻译为merr.Success()——没有哨兵跨包✅ 合规(本分支从导出的 ErrResourceGroupOperationIgnored重构而来)死代码(0 个调用者的函数) 3 个( errNilResponse、errNilStatusResponse、errUnknownResponseType——仅被datacoord/util.go的VerifyResponse使用)可安全删除 🪦 清理候选 违规:未捕获就逃逸出 RPC handler(已解决)3 个( errEmptyUsername、errEmptyRoleName、errEmptyPrivilegeGroupName)meta_table.go中的起源点现在直接发出WrapErrParameterInvalidMsg;裸哨兵已消失✅ 已解决 语义误分类:用错误的 typed merr 码捕获并包裹(已解决)1 个( ops_services.go:87,101中的errTypeNotFound)客户端传入的非法 CheckerID现在包装为WrapErrParameterInvalidMsg(码 1100),原来是WrapErrServiceInternal(码 5)✅ 已解决 从当前仓库源码可以印证表中"已解决"项:
internal/rootcoord/meta_table.go中的errIgnoredAlterAlias、errIgnoredCreateCollection、errIgnoredDropPartition仍为裸哨兵,并分别被 internal/rootcoord/root_coord.go 的errors.Isguard(第 1010/1644/1936/1997 行附近)捕获翻译为成功;internal/rootcoord/meta_rbac.go定义errUserAlreadyExists/errRoleAlreadyExists/errRoleNotExists,由 internal/rootcoord/root_coord.go 翻译为 typed merr;而 internal/querycoordv2/ops_services.go 的ActivateChecker/DeactivateChecker已改为merr.WrapErrParameterInvalidMsg("invalid checker type %d: %v", req.CheckerID, err)(码 1100)。清理状态
- ✅ 已完成——
errEmptyUsername/errEmptyRoleName/errEmptyPrivilegeGroupName在 internal/rootcoord/meta_table.go 的起源点现在直接发出merr.WrapErrParameterInvalidMsg("username is empty")等;裸哨兵已不存在。 - ✅ 已完成——internal/querycoordv2/ops_services.go 第 87、101 行的
errTypeNotFound捕获点现包装为merr.WrapErrParameterInvalidMsg("invalid checker type %d: %v", req.CheckerID, err)。 - 未完成——删除
VerifyResponse及其三个死代码哨兵(errNilResponse、errNilStatusResponse、errUnknownResponseType)。
五、未来 linter 规划
三个候选方案,按实现成本与强制难度排序。Tier 1.5 的 return 形态现已实现(见下);Tier 1 与 Tier 2 仍在设计队列中。
Tier 1——导出哨兵禁令(约 1 小时编写)
最简单的规则:
internal/...包不得声明导出的var Err\w+ = errors.New(...)。扫描所有internal/...的*.go文件,命中即 CI 失败。修复违规有两条路径:- 改成小写(
var errXxx = errors.New(...))——仅同包可调用。如果 lint 失败是因为跨包调用者需要该信号,见修复 2。 - 重构 API,让信号通过返回值传递(例如在返回元组中加
ignored bool)并删除哨兵。
这使 Go 可见性本身成为强制机制:任何需要看起来像
merr.ErrXxx的东西只能存在于pkg/util/merr。内部哨兵在其所属包中安静地保持小写。internal/...内的小写哨兵(var errXxx = errors.New(...))仍建议带上INTERNAL: ...文档注释以提供 reviewer 上下文,但这不是强制的——可见性规则已经防止了最坏情况(与merr.ErrXxx的Err*冲突)。Tier 1.5——裸使用禁令(1 小时 grep;AST 需半天达到 100% 精度)
状态——return 形态已实现。该禁令的return形态现已由
rules.go中的gocritic/ruleguard规则(rawmerrerror)强制执行,随make verifiers运行:它拒绝在函数体里return errors.New / fmt.Errorf / errors.Errorf(包级哨兵、cmd/、tests/、codegen 与 walimpls 测试框架豁免)。日常指南见 docs/dev/error_handling_guide.md。下述无例外形态(局部:=、panic(...)、函数实参)未被覆盖:ruleguard 的 DSL 无法表达"函数体内任意位置的调用但不在ValueSpec中",所以完整禁令仍需基于 AST 的 Tier 2 linter。最严格的强制,无任何例外。
internal/...包不得在函数体内内联使用errors.New(...)或errors.Errorf(...)。这些调用唯一合法的位置是包级var <Name> = errors.New(...)(哨兵声明)。允许/禁止矩阵:
形态 位置 判定 var errInvalid = errors.New("invalid")包级(文件顶部 / var块)✅ 允许 var ( errA = ...; errB = ... )包级 var块✅ 允许 return errors.New(...)函数体 ❌ 禁止 x := errors.New(...)函数体局部 ❌ 禁止 panic(errors.New(...))函数体 ❌ 禁止 foo(errors.New(...))函数体实参 ❌ 禁止 为什么不允许例外(即使是今天看起来无害的"局部 break 信号"/"仅日志"/"panic 绑定"场景):
- 今天的局部变量可能被明天的重构提升为包级,然后静默开始跨越边界。
- 带例外的 linter 需要 AST 级的 wire 可达性分析(昂贵);无例外 linter 只是一次 grep。
- 迫使作者使用正确的原语,而不是把
errors.New当作万能的逃生舱:- 回调中的 break 信号→ 定义一个实现了
error的type doneSignal struct{}并使用errors.As。意图体现在类型中,而不是字符串键控的哨兵中。 - "不可达"断言→ 直接
panic(...)。如果调用方已经写了if err != nil { panic(err) },就把它折叠进被调用方。 - 输入校验 / 配置校验→ 按层次使用
merr.WrapErrParameterInvalidMsg(...)或status.NewInvalidArgument(...)。
- 回调中的 break 信号→ 定义一个实现了
实现:grep 版本约 1 小时覆盖 ~95% 的真实违规。AST 版本(go/analysis)覆盖边缘情况(例如
init()体内给包变量赋值),但需约半天。先用 grep,若误报率超过 5% 再升级。# grep 骨架 grep -rnE 'errors\.(New|Errorf)\(' internal/ --include='*.go' \ | grep -v _test.go \ | grep -vE ':[0-9]+:\s*(var\s+)?[A-Za-z_]+\s*=\s*errors\.(New|Errorf)' \ | grep -vE ':[0-9]+:\s*[A-Za-z_]+\s+(\w+\s+)?=\s*errors\.(New|Errorf)' # 任何剩余行 = 违规一个
//nolint:err-bare逃生阀(带必需的理由注释)用来处理真正的例外(少数init()模式、嵌入式errors.Mark用法等)。Tier 2——逃逸路径 linter(约 1 天,基于 go/analysis)
对每个 gRPC handler 方法(匹配
internal/{rootcoord,datacoord,querycoordv2}/services.go,root_coord.go,*_handler.go模式,返回类型为(*proto.XxxResponse, error)或(*commonpb.Status, error)),追踪 err-return 数据流。任何错误:- 传递性地起源于
INTERNAL:标记的哨兵,且 - 在没有经过
errors.Is(err, internalSentinelX) { ... }分支的情况下到达return Status{Code: merr.Status(err)}或return err,
即为违规。报告泄漏处的 file:line。
这能捕获真正的不变量违规(那 3 个 RBAC 空值哨兵本应被标记出来),而不只是命名卫生。需要 AST 分析;如果再一次向客户端静默输出
Code=1的代价很高,就值得做。Tier 3(若 Tier 2 已就位则不再必要)——wrap 规则 linter
扫描
merr\.WrapErr[A-Za-z]+Err\(err,(注意:第一个实参是err,不是新的纯字符串起源),并要求 causeerr本身不是 typed merr。这就是feedback_merr_wrap_rule(见下文)按约定强制的内容;Tier 2 会作为副作用捕获其症状(哨兵逃逸)。六、相关规则
feedback_merr_wrap_rule(本仓库的协作记忆):"给已有 err 加上下文用merr.Wrap/merr.Wrapf,绝不用merr.WrapErr*Err——后者会掩盖内层 typed code 与可重试性(errors.Is链本身通过Unwrap()保留)。"该规则已沉淀为 rules.go 中的merrsentinel检查:禁止把 merr 类型错误存进形似哨兵的变量——因为errors.Is对 merr 是按码比较而非按身份比较,任何同码错误都会命中(真实事故:stale-meta / already-loaded guard 曾把 etcd 写失败当作成功吞掉)。project_errstd_autogen_defects:本分支系列中自动生成的errors.Wrap → merr转换存在三个系统性缺陷;其中缺陷 #2 与 #3 正是违反本文规则造成的直接后果。
七、与其他文档的分工
- docs/dev/error_handling_guide.md——日常 how-to:三种错误类型的心智模型、"绝不返回裸错误"的决策树、Input vs System 分类、三种正确写法(起源 / 加上下文 / 哨兵)与边界回退(
Code=65535安全网)。 - docs/dev/error_handling_casebook.md——真实正反例:七个反复出现的错误模式("看起来像校验"并非用户输入、
WrapErrXxxErr是重贴标签而非加上下文、cause 进 error 实参而非格式串、InputError 会中止retry.Do、转换哨兵前先 greperrors.Isguard、边界转换是契约等),以及常用易混码速查表。 - docs/dev/error_sentinel_convention.md(本文主题)——两层体系的分界规则、命名约定、码段分区与 lint 规划,是前两者的规则根基。
- 权威数字码清单——pkg/util/merr/errors.go 中的哨兵定义(init 期注册表对重复码直接 panic)。注意 docs/archive/milvus-2.0/developer_guides/appendix_d_error_code.md 附录早于 merr,列出的是已废弃的
commonpb.ErrorCode枚举,而非 merr 码。
对开发者而言,动手前只需记住三件事:任何要越过边界的错误必须是携带数字码的 typed merr;任何单进程内控制流信号必须是同包可见的小写哨兵并在边界前被
errors.Is翻译;任何跨包信号都优先走返回值标志而非导出哨兵。这三条贯穿 Milvus 全部服务端组件,也是本规范全部细节的浓缩。【免费下载链接】milvusMilvus is a high-performance, cloud-native vector database built for scalable vector ANN search
项目地址: https://gitcode.com/GitHub_Trending/mi/milvus
- ✅ 已完成——
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考