:typed merr 与内部哨兵的两层体系与 gRPC 边界硬性不变量)
Milvus 错误哨兵约定Error Sentinel Conventiontyped 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 merrwire-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 merrwire-protocol 错误定义于 pkg/util/merr/errors.goErrCollectionNotFound、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 merrmerr.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 逃逸的哨兵一样是缺陷——只是更隐蔽因为不会有客户看到由此产生的Code65535 (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——链最终解析到内层的*milvusErrormerr.WrapErrXxxErr(err, ...)上报外层哨兵的码与可重试性As()解析到ErrServiceInternalCode 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–1399MQ100–199collection1400–1499privilege / RBAC200–299partition1600–1699alias300–399resource group1700–1799field400–499replica1800–1899HTTP / REST gateway500–599channel1900–1999replicate / CDC600–699segment2000–2099segcore knowherecgo表驱动见下700–799index2100–2199import800–899database2200–2299query / requery plan901–999node2300–2399compaction1000–1099io / storage / serialization / data integrity2400–2499function pipelineErrFunctionFailed2400——但ErrDataNodeSlotExhausted是 2401添加前先查占用1100–1199request parameter2500–2599KMS1200–1299metrics2600–2699snapshot3000miscErrOperationNotSupported3000、ErrOldSessionExists300165535(116)-1是errUnexpected——没有 merr 码的错误落网时的 wire 回退码。它是保留值绝不允许故意起源源码中它被注释为 Do NOT export this, never allow programmer using this见 pkg/util/merr/errors.go。2000–2099 的 segcore 区间由 cgo 转换表拥有必须走merr.SegcoreErrorpkg/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 merr3 个捕获点errUserAlreadyExists、errRoleAlreadyExists、errRoleNotExists客户端收到WrapErrParameterInvalidMsg(...)或WrapErrServiceInternalMsg(...)✅ 合规1100 / 5仅后台使用从不进入 RPC handler5 个errFull、errNoSuchElement、errNodeNotEnough、errDisposed、errTypeNotFoundcompaction 队列 / resource observer / session 生命周期 / checker 注册表✅ 合规通过(ignored bool, err error)签名实现跨包幂等1 个resource group 创建/删除meta 层返回ignoredtruequerycoordv2 RPC handler 翻译为merr.Success()——没有哨兵跨包✅ 合规本分支从导出的ErrResourceGroupOperationIgnored重构而来死代码0 个调用者的函数3 个errNilResponse、errNilStatusResponse、errUnknownResponseType——仅被datacoord/util.go的VerifyResponse使用可安全删除 清理候选违规未捕获就逃逸出 RPC handler已解决3 个errEmptyUsername、errEmptyRoleName、errEmptyPrivilegeGroupNamemeta_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 小时 grepAST 需半天达到 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(...)。实现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 分析如果再一次向客户端静默输出Code1的代价很高就值得做。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 分类、三种正确写法起源 / 加上下文 / 哨兵与边界回退Code65535安全网。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),仅供参考