enhance: classify segcore errors across producers and enforce classification end-to-end - #50768
enhance: classify segcore errors across producers and enforce classification end-to-end#50768czs007 wants to merge 56 commits into
Conversation
Confirmed, and this is expected for the current draft state of this PR. The break is real: this PR consumes On the two suggested fixes:
Holding this PR as a draft until #568 lands. |
Confirmed — it's intended, bounded, and acceptable. One correction to the persistent-failure example.
Worth noting the prior behavior was arguably worse here: these transient failures collapsed to |
eed0a1b to
60abe54
Compare
…milvus-io#575) ## What Add the producer-owned mapping `ToSegcoreErrorCode` / `ToSegcoreError` so milvus-storage classifies its own packed failures once, at the source, and milvus can consume a typed segcore `ErrorCode` instead of a generic Arrow status text. > Note: this **builds on milvus-io#572** (jiaqizho — packed `ExtendStatusCode` + `WrapExtendError`), which is not yet merged, so this branch currently includes those commits. Once milvus-io#572 lands this PR should be rebased onto it so it only carries the mapping delta. Happy to coordinate. ## Mapping (no-`default` switch + `-Werror=switch`) | ExtendStatusCode | segcore ErrorCode | retry | |---|---|---| | PackedInvalidArgs | InvalidParameter (2042) | non-retriable (input) | | PackedStorageIO | **StorageTransientError (2045)** | **retriable** | | PackedMetadataCorrupted / PackedFileCorrupted | DataFormatBroken (2024) | permanent | | PackedArrowError / PackedUnexpected / AWS / Txn | StorageError (2044) | permanent | The retriable verdict is load-bearing: a transient storage IO failure maps to the retriable `StorageTransientError(2045)` and must never collapse into the non-retriable `StorageError(2044)`. The switch has no `default` + a post-switch fallback under `-Werror=switch`, so a new `ExtendStatusCode` breaks the build until classified. The no-detail path keeps a coarse arrow mapping (IO→transient, OOM→mem-allocate, Invalid/Type/Key→corruption). ## Dependencies Depends on `milvus-common` `StorageTransientError(2045)`: zilliztech/milvus-common#102. Bump the `milvus-common` conan pin to the published 2045 revision before merge (the pin here points at a local build for verification). Design + tracking: milvus-io/milvus#50903. Consumed by milvus-io/milvus#50768. --------- Signed-off-by: zhenshan.cao <zhenshan.cao@zilliz.com> Signed-off-by: jiaqizho <jiaqi.zhou@zilliz.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: jiaqizho <jiaqi.zhou@zilliz.com>
…milvus-io#575) Add the producer-owned mapping `ToSegcoreErrorCode` / `ToSegcoreError` so milvus-storage classifies its own packed failures once, at the source, and milvus can consume a typed segcore `ErrorCode` instead of a generic Arrow status text. > Note: this **builds on milvus-io#572** (jiaqizho — packed `ExtendStatusCode` + `WrapExtendError`), which is not yet merged, so this branch currently includes those commits. Once milvus-io#572 lands this PR should be rebased onto it so it only carries the mapping delta. Happy to coordinate. | ExtendStatusCode | segcore ErrorCode | retry | |---|---|---| | PackedInvalidArgs | InvalidParameter (2042) | non-retriable (input) | | PackedStorageIO | **StorageTransientError (2045)** | **retriable** | | PackedMetadataCorrupted / PackedFileCorrupted | DataFormatBroken (2024) | permanent | | PackedArrowError / PackedUnexpected / AWS / Txn | StorageError (2044) | permanent | The retriable verdict is load-bearing: a transient storage IO failure maps to the retriable `StorageTransientError(2045)` and must never collapse into the non-retriable `StorageError(2044)`. The switch has no `default` + a post-switch fallback under `-Werror=switch`, so a new `ExtendStatusCode` breaks the build until classified. The no-detail path keeps a coarse arrow mapping (IO→transient, OOM→mem-allocate, Invalid/Type/Key→corruption). Depends on `milvus-common` `StorageTransientError(2045)`: zilliztech/milvus-common#102. Bump the `milvus-common` conan pin to the published 2045 revision before merge (the pin here points at a local build for verification). Design + tracking: milvus-io/milvus#50903. Consumed by milvus-io/milvus#50768. --------- Signed-off-by: zhenshan.cao <zhenshan.cao@zilliz.com> Signed-off-by: jiaqizho <jiaqi.zhou@zilliz.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: jiaqizho <jiaqi.zhou@zilliz.com> (cherry picked from commit 9c28242)
…milvus-io#575) Add the producer-owned mapping `ToSegcoreErrorCode` / `ToSegcoreError` so milvus-storage classifies its own packed failures once, at the source, and milvus can consume a typed segcore `ErrorCode` instead of a generic Arrow status text. > Note: this **builds on milvus-io#572** (jiaqizho — packed `ExtendStatusCode` + `WrapExtendError`), which is not yet merged, so this branch currently includes those commits. Once milvus-io#572 lands this PR should be rebased onto it so it only carries the mapping delta. Happy to coordinate. | ExtendStatusCode | segcore ErrorCode | retry | |---|---|---| | PackedInvalidArgs | InvalidParameter (2042) | non-retriable (input) | | PackedStorageIO | **StorageTransientError (2045)** | **retriable** | | PackedMetadataCorrupted / PackedFileCorrupted | DataFormatBroken (2024) | permanent | | PackedArrowError / PackedUnexpected / AWS / Txn | StorageError (2044) | permanent | The retriable verdict is load-bearing: a transient storage IO failure maps to the retriable `StorageTransientError(2045)` and must never collapse into the non-retriable `StorageError(2044)`. The switch has no `default` + a post-switch fallback under `-Werror=switch`, so a new `ExtendStatusCode` breaks the build until classified. The no-detail path keeps a coarse arrow mapping (IO→transient, OOM→mem-allocate, Invalid/Type/Key→corruption). Depends on `milvus-common` `StorageTransientError(2045)`: zilliztech/milvus-common#102. Bump the `milvus-common` conan pin to the published 2045 revision before merge (the pin here points at a local build for verification). Design + tracking: milvus-io/milvus#50903. Consumed by milvus-io/milvus#50768. --------- Signed-off-by: zhenshan.cao <zhenshan.cao@zilliz.com> Signed-off-by: jiaqizho <jiaqi.zhou@zilliz.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: jiaqizho <jiaqi.zhou@zilliz.com> (cherry picked from commit 9c28242) Signed-off-by: jiaqizho <jiaqi.zhou@zilliz.com>
xiaofan-luan
left a comment
There was a problem hiding this comment.
Requesting changes. I verified each of the four findings below against the current PR head, plus the merged milvus-storage#575 and milvus-common#102. All four reproduce; file:line references are corrected to the current tree. P1-2 and P1-3 mean the PR cannot reach green CI once the dependency pins are bumped, so I'd hold merge until they're resolved.
P1 — DataTypeInvalid / FieldIDInvalid / FieldAlreadyExist classified as InputError, but they carry mixed semantics
pkg/util/merr/segcore.go classForCode maps CodeDataTypeInvalid(2007), CodeFieldIDInvalid(2020), CodeFieldAlreadyExist(2021) to inputError: true unconditionally. A producer audit shows these are predominantly or exclusively thrown for internal conditions:
FieldIDInvalid(2020): 5 producer sites, all internal (load-order guards inload_field_data_c.cpp,"unsupported system field id"inSegmentInterface.cpp:725) — zero user-input sites.FieldAlreadyExist(2021): 2 sites, both internal —internal/core/src/segcore/load_field_data_c.cpp:65"append same field info multi times",internal/core/src/common/GroupChunk.h:51"Field {} already exists in GroupChunk".DataTypeInvalid(2007): ~90 production sites, ~90% internaldefault:/"logical error"guards (e.g.internal/core/src/segcore/SegmentGrowingImpl.cpp:1732ThrowInfo(DataTypeInvalid, "logical error")); only ~7 are genuine request validation (JSON cast-type params, aggregation-over-type, search/metric mismatch).
Because they're InputError, internal/proxy/shardclient/lb_policy.go:334 aborts the cross-replica sweep:
if merr.GetErrorType(err) == merr.InputError {
return false, err
}So an internal/transient failure on one replica that surfaces as 2007/2020/2021 is never rerouted to a healthy replica — an availability regression.
Recommendation: default 2020 and 2021 to a non-retriable system error (they have no user-input producers at all); for 2007, either default to system error or apply WrapErrAsInputErrorWhen only at the ~7 boundaries where the value provably came from the request. Better still, split the mixed-semantics codes at the C++ source.
P1 — Plain Arrow IOError: test/comment claim retriable StorageTransientError(2045), but the delegated (merged) storage mapper returns permanent StorageError(2044)
internal/core/unittest/test_storage_error_code.cpp:54-55 asserts ArrowStatusToErrorCode(arrow::Status::IOError(...)) == StorageTransientError(2045) (retriable), and internal/core/src/storage/StatusToErrorCode.h comments the same. But ArrowStatusToErrorCode is a pure delegate to milvus_storage::ToSegcoreError, and the merged milvus-storage#575 (9c28242) maps it the other way, in the no-detail path:
// cpp/src/common/extend_status.cpp:203-204
} else if (status.IsIOError()) {
code = milvus::StorageError; // 2044, non-retriable
}This is already an internal contradiction in this PR — the test is green only because CI still pins a pre-#575 storage build; bumping the pin to the merged revision breaks ArrowStatusToErrorCodeMapping. Net effect: the 25 plain-arrow sites stop collapsing to UnexpectedError(2001) (a real win), but plain IO lands on non-retriable 2044, so the T3/T4 retriable benefit for the live plain-arrow read path is over-stated. (#575 deliberately moved the transient/permanent split upstream into ExtendStatusDetail tags, so the no-detail path is always 2044 — there is no retriable-IO subset left here.)
Recommendation: #575's author already settled on plain-IO = permanent (the S3 SDK retry budget is already spent by the time the error propagates). So align this PR to that: change test_storage_error_code.cpp:55 to expect StorageError, fix the StatusToErrorCode.h comment, and adjust the PR description to "classification preserved; plain IO stays permanent (2044)." (If plain IO should be retriable, that's a change to #575, not here.)
P1 — RegisterUntypedCgoExceptionObserver is called but defined nowhere → link error
internal/core/src/segcore/segcore_init_c.cpp (SegcoreInit, hunk @@ -34,6 +36,17 @@) calls milvus::RegisterUntypedCgoExceptionObserver(...), but the symbol is not defined anywhere in this PR (only 3 call sites — 1 prod + 2 in test_storage_error_code.cpp), not in milvus-common master, and not in the merged milvus-common#102 (which only adds StorageTransientError = 2045). The ## Dependencies section doesn't list it and the conan pin isn't bumped in this diff. As it stands this fails to link (undefined reference) when building the segcore lib / all_tests, even after the #102/#575 pin bumps.
Recommendation: land the milvus-common side first (the declaration + definition, and its invocation from FailureCStatus) and add it to ## Dependencies; or provide the definition within this PR.
P1 — T6 drift-proofing is not a closed CI loop
generate-segcore-codes (Makefile) is a manual-only target — it is not wired into verifiers, static-check, or any workflow. The exhaustive lint and TestSegcoreCodeTableCoverage both run against the committed snapshot pkg/util/merr/segcore_codes_gen.go. So if a milvus-common pin bump adds an ErrorCode but the file isn't regenerated, the const set is unchanged, the classForCode switch is still "exhaustive", and lint + tests pass — the .golangci.yml / segcore.go comments promising "a new C++ code cannot ship unclassified" don't hold at the point that actually matters (the pin bump).
Recommendation: this repo already has the exact pattern — .github/workflows/code-checker.yaml:63 runs make check-proto-product, which regenerates and git diff --exit-codes the .pb.go files. Add an analogous check-segcore-codes-product: generate-segcore-codes that runs git diff --exit-code -- pkg/util/merr/segcore_codes_gen.go, and chain it into that same CI step. Until the loop is wired, please soften the two in-code guarantee comments. (Runtime blast radius is contained — an unmapped code degrades to non-retriable ErrSegcore and fires the observer — so this is P1 on the false in-code guarantee, not a P0.)
Overall: solid direction (producer-owns-classification + compile-time exhaustiveness is the right shape), but P1-2/P1-3 block a green build after the pin bumps and P1-1 is a live retry-correctness regression. Happy to pair on any of these.
0c1d686 to
9330aef
Compare
|
@xiaofan-luan Thanks for the thorough pass — all four findings verified and addressed. Point-by-point (note your review raced my rebase push, so two were already fixed in flight): P1-1 (mixed-semantics InputError → lb_policy stops rerouting): fixed in b141748. Re-ran the producer audit and it matches yours: 2020 (4 sites) and 2021 (2 sites) have zero user-input producers; 2007 is overwhelmingly internal P1-2 (plain Arrow IOError 2045 vs merged #575's 2044): already fixed in P1-3 ( P1-4 (T6 not a closed CI loop): fixed in b141748 — added One related note from the rebase: master's |
xiaofan-luan
left a comment
There was a problem hiding this comment.
Two additional P1 classification issues found in the current error buckets.
| 2003: {sentinel: ErrSegcoreUnsupported}, | ||
| 2033: {sentinel: ErrSegcorePretendFinished, signal: true}, | ||
| // Caller-input errors -> InputError (non-retriable by construction). | ||
| case CodeOpTypeInvalid, |
There was a problem hiding this comment.
[P1] Keep mixed-semantics native codes as SystemError
OpTypeInvalid(2022) and DataIsEmpty(2023) are not unambiguous caller-input errors. For example, PlanProto.cpp throws OpTypeInvalid while decoding an internally generated plan, where an unknown enum can indicate an internal protocol or rolling-upgrade mismatch; index builders throw DataIsEmpty when an internally scheduled build task yields zero rows. Globally setting inputError: true makes these failures reach lb_policy as InputError, which immediately aborts the cross-replica sweep. Please classify both codes as non-retriable SystemError by default. A boundary that can prove the value came directly from the user may explicitly mark that specific error as input, but the global table should remain system-classified.
There was a problem hiding this comment.
Fixed in 3fe5bae. Producer audit confirms both: 2022 is thrown from PlanProto/expr op switches decoding internally generated plans, 2023 from index builders on internally scheduled zero/null-row builds. Both moved to the mixed-semantics system group (comment documents the audit) and the mixed_semantics_codes_stay_system regression test now locks all five codes (2007/2020/2021/2022/2023).
| case knowhere::Status::out_of_range_in_json: | ||
| case knowhere::Status::type_conflict_in_json: | ||
| case knowhere::Status::invalid_metric_type: | ||
| case knowhere::Status::empty_index: |
There was a problem hiding this comment.
[P1] Keep index-state Knowhere statuses as SystemError
The pinned Knowhere producers use empty_index, index_not_trained, and index_already_trained for server-side index lifecycle/state failures, while invalid_binary_set is primarily produced when deserializing a persisted index. Mapping them all to InvalidParameter(2042) incorrectly blames the request and causes lb_policy to stop replica failover. Please map the three lifecycle statuses to a non-retriable SystemError bucket (for example KnowhereError) and map invalid_binary_set to DataFormatBroken; both mappings should remain system-classified.
There was a problem hiding this comment.
Fixed in 3fe5bae. Verified against the pinned knowhere producers (empty_index = "DiskANN not loaded" — pure server-side index state): empty_index/index_not_trained/index_already_trained → KnowhereError (system, non-retriable), invalid_binary_set → DataFormatBroken. The mapping test pins all four so a future regrouping is explicit.
xiaofan-luan
left a comment
There was a problem hiding this comment.
One additional P1 was found in the StorageV2 manifest error paths.
| index, | ||
| chunk_reader_result.status().ToString()); | ||
| if (!chunk_reader_result.ok()) { | ||
| ThrowInfo(milvus::storage::ArrowStatusToErrorCode( |
There was a problem hiding this comment.
[P1] Preserve structured StorageV2 errors on every manifest path
This overload correctly uses ArrowStatusToErrorCode, but the sibling LoadColumnGroup overload at lines 8380–8386 still checks get_chunk_reader with AssertInfo. In addition, ManifestGroupTranslator.cpp:133–145 converts failures from get_chunk_size and get_chunk_rows into std::runtime_error. Those paths erase the structured Arrow/storage status before it reaches the C ABI catch, so transient storage errors such as 2045, OOM, and corruption classifications collapse to UnexpectedError(2001). Please route all three failures through ThrowInfo(milvus::storage::ArrowStatusToErrorCode(status), ...), consistent with this overload.
There was a problem hiding this comment.
Fixed in 3fe5bae. Both the sibling LoadColumnGroup overload (AssertInfo → ThrowInfo(ArrowStatusToErrorCode(...))) and the two ManifestGroupTranslator sites (bare std::runtime_error → same). These came in with the rebase onto current master and the earlier sweep missed them — thanks for the catch. error_code suites 10/10 after a full rebuild.
3fe5bae to
ccdeb73
Compare
| auto metadata = field->metadata(); | ||
| if (metadata->Contains(DIM_KEY)) { | ||
| auto dim_str = metadata->Get(DIM_KEY).ValueOrDie(); | ||
| dim_ = std::stoi(dim_str); |
There was a problem hiding this comment.
The new try/catch that wraps PayloadReader::init (lines 57-215) was added to classify malformed-file exceptions as DataFormatBroken instead of collapsing to 2001, but std::stoi on the stored dim/elementType metadata (lines 115, 165, 172) throws std::invalid_argument/std::out_of_range, which is not a parquet::ParquetException — so a corrupt dim string escapes the sole catch (line 215) and still surfaces as UnexpectedError(2001) at the cgo boundary. Widen the catch (or wrap the stoi) so a malformed dim maps to DataFormatBroken like the rest of the block. Impact is limited to error labeling/observability since both 2001 and 2024 are permanent, non-retriable codes; framing it as unrelated/pre-existing understates that the PR now owns this try/catch.
There was a problem hiding this comment.
Fixed in 9be7068 — the three bare std::stoi calls now go through a ParseMetadataInt helper that classifies a malformed dim/elementType as DataFormatBroken; the catch itself stays narrow so unrelated failures in the 160-line block are not mislabeled as data corruption.
Also swept the rest of the PR for the same class (narrow catches whose try block can throw uncovered types, std parser additions, callee-internal parsers on the covered read paths): the eight marisa catches each wrap a single trie call, the two remaining parquet catches wrap only parquet/arrow calls, and no other std parser exists in the diff — this was the only instance. Thanks for the catch.
Rebasing onto master brought three new status-consuming AssertInfo calls (ChunkedSegmentSealedImpl .cpp/.h, JsonKeyStats) that swallow the arrow/storage classification the same way the ones this PR already converted did; they now route through ArrowStatusToErrorCode. Two boundary fixes on top: - CGO_CATCH_AND_RETURN_CSTATUS gains a std::bad_alloc arm ahead of std::exception. master had added that mapping by hand at the sites this PR replaced with the macro, and it is worth keeping everywhere: a bad_alloc is not a SegcoreError, so FailureCStatus labelled an out-of-memory failure UnexpectedError(2001) -- permanent -- when it is retriable. - FMIndex.cpp gets a catch(...) tail. fm-index-lite is a vendored library with its own LICENSE/NOTICE and no dependency on milvus error codes, so its bare throws stay where they are and this Ring-2 consumer boundary is where they get classified; without catch(...) a non-std exception from it would escape untyped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Zack <noreply@zilliz.com>
FMIndex::LoadEntries called into the vendored library with no classifying
catch, while the sibling build path had one. The library's parse path
self-classifies every std::exception into a false return value (which
!fm_.valid() already turned into DataFormatBroken) but deliberately RETHROWS
std::bad_alloc, so an out-of-memory during index load escaped untyped and
FailureCStatus reported it as UnexpectedError(2001). 2001 is non-retriable in
merr's classForCode and MemAllocateFailed(2034) is retriable, so a transient
OOM was reported as a permanent failure and the load was never retried.
The two catch ladders are now one GuardFMIndexLibrary, parameterised by the
phase's fallback code (IndexBuildError vs DataFormatBroken). bad_alloc stays
out of that fallback on purpose. All three entry points -- Build, LoadView,
Deserialize -- go through it.
Also adds the static guard that makes this class of defect visible, wired
into make static-check:
RULE 1 every throw in internal/core/src carries a milvus ErrorCode
(already true: 28 typed, 3 folly control-flow, 2 rethrows, 0
untyped), so it runs zero-tolerance rather than on a ratchet.
RULE 2 a vendored library's symbols stay inside its declared boundary
files. Counting throws would not have caught this bug: the throw
was in thirdparty/, where it belongs -- the library has no
dependency on milvus-common and is synced from a pinned upstream
revision -- and the consumption point did hold a classifying catch,
just not on every path. Confining the library to a small file set
turns a per-branch obligation into a per-file one. fmindex is
confined today (2 files).
RULE 3 the spread-out libraries (knowhere 72 files, arrow 53,
milvus_storage 41, tantivy 24) are ratcheted: the current file set
is frozen in scripts/segcore_error_boundary_baseline.txt, a new
file referencing one of them fails the check, and shrinking is
free (UPDATE_SEGCORE_ERROR_BASELINE=1 regenerates). Growing the
baseline is thereby a reviewed decision that the new consumer owes
a classifying catch or status mapper on every path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Zack <noreply@zilliz.com>
…he node Second full sweep over internal/core after the fm-index fix, this time with fingerprints the first pass missed: - tantivy-wrapper.h, 10 sites (json_term_query / json_terms_query): the raw C RustResult API with AssertInfo(res.success) collapsed every tantivy error to UnexpectedError(2001), and the throw skipped free_rust_result, leaking the error string. The first sweep matched the wrapper fingerprint (res.result_->success) and never saw the raw one. All converted to RustResultWrapper + AssertTantivyOk (RAII free + classification). - 5 sites of arrow::ImportSchema(schema).ValueOrDie() in packed_reader_c / packed_writer_c, and one unguarded batch read in ChunkWriter's read_single_column_batches: an error Result does not throw there -- ValueOrDie aborts the whole process. A malformed C-ABI schema or a corrupt file killed the node instead of returning a classified status. Now guarded with ok() + ArrowStatusToErrorCode. - DefaultValueChunkTranslator: builder->Finish().ValueOrDie(), same abort on allocation failure; now classified (retriable). The rest of the sweep came back clean and is worth recording: all 45 non-test catch(...) blocks are legitimate (first-exception capture for parallel tasks, drain-during-unwind, or logging at void cgo boundaries), the remaining ~65 ValueOrDie sites are ok()-guarded on the value they consume (verified variable-matched, not proximity-matched), and src has zero untyped throws left. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zack <noreply@zilliz.com>
…as 2001
Third sweep, this time over the 1540 remaining AssertInfo sites with a sharper
criterion than message keywords: is the guarded condition externally
triggerable? The strerror/errno fingerprint finds the syscall wrappers, and
every one of them was reporting an environmental failure -- disk full,
permission denied, fd exhaustion, mmap failure -- as UnexpectedError(2001),
non-retriable, indistinguishable from an internal bug. 14 sites, all
converted to typed ThrowInfo with retriable codes:
open/fdopen/create File.h x2, IndexEntryEncryptedLocalWriter,
TextLobSpillover -> FileOpenFailed/FileCreateFailed
read IndexEntryDirectStreamWriter,
IndexEntryEncryptedLocalWriter -> FileReadFailed
write/fsync/ftruncate RemoteInputStream x2, IndexEntryReader,
the V3 magic-number write -> FileWriteFailed
mmap ChunkTarget.cpp/.h, BitmapIndex -> MmapError
One pre-existing bug fell out of the sweep: BitmapIndex::UnmapIndexData held
AssertInfo(true, ...) -- an assert that can never fire -- where it meant to
report a munmap failure. It is a teardown path, so it now logs a warning
instead of throwing.
The ~1516 AssertInfo left after this pass guard null checks, bounds, type
switches and logic invariants; for those, firing means a Milvus bug and 2001
is the correct report. Any misjudged stragglers will surface on the 2001
metric curve post-merge and can be reclassified from their messages.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Zack <noreply@zilliz.com>
… by a full audit
Fourth sweep: a site-by-site audit of the 1517 remaining AssertInfo calls,
under two independent criteria applied to every site -- condition morphology
(null / bounds / state-machine / type-switch forms) and failure-keyword
messages. Every flagged site was read by hand; 48 guard externally
triggerable conditions and are converted to typed ThrowInfo:
file/remote IO (open, create, read, write, short read, upload)
IndexEntryReader x13, IndexEntryEncryptedLocalWriter x5,
IndexFactory x3, ScalarIndex x3, StringIndexMarisa x2,
InvertedIndexTantivy, RemoteInputStream, RemoteOutputStream,
storage/Util, JsonKeyStats upload
-> FileOpenFailed / FileCreateFailed / FileReadFailed /
FileWriteFailed (all retriable)
persisted-format damage (V3 magic/footer/CRC, directory JSON, old binlog
layout, parquet schema, sparse-row payload)
IndexEntryReader x8, storage/Util, DataCodec, FieldData,
HybridScalarIndex, JsonKeyStats, common/Utils.h x5
-> DataFormatBroken
mmap-backed allocation
mmap/ChunkData.h -> MmapError (retriable)
The sparse-row checks classify as DataFormatBroken (System), not
InputError: CopyAndWrapSparseRow is fed by both the insert path and binlog
load, and blaming the request would stop lb_policy's replica sweep on a
corrupt-binlog load.
Kept as 2001 after reading them: the cachinglayer "cache is corrupted"
family (process-internal state, not persisted data), GEOS reader/writer
construction, cgo null-argument contracts, and internal state guards. The
~1400 sites whose condition and message match neither criterion remain
invariant-style asserts; stragglers will surface on the 2001 metric curve
post-merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Zack <noreply@zilliz.com>
…lly-triggerable sites
Final pass over the audit's remaining bucket: the 559 AssertInfo sites whose
condition and message matched neither the invariant morphology nor the
failure-keyword fingerprints. Read one by one; 105 guard externally
triggerable conditions and are converted to typed ThrowInfo, 454 are genuine
invariants and keep 2001.
persisted-format damage -> DataFormatBroken (67)
loaded-parquet type/width mismatches (FieldData, FieldDataInterface,
storage/Util, PayloadReader), V3 package CRC/version/size/entry-not-
found (IndexEntryReader x13), marisa CSR sizes/version x8, index slice
lost/len-inconsistent (index/Utils x7, VectorMemIndex x3), manifest row
mismatches, bson terminator, jsmn token checks on stored json, missing
tantivy index dirs
file IO -> FileOpenFailed / FileReadFailed / FileWriteFailed, retriable (9)
marisa/rtree open, kmeans download short-read x2, AddFile-style upload
failures x4
request content -> InvalidParameter, InputError (4)
range-search radius/range_filter contradiction x2, re2 compile failure
of a user-supplied pattern x2 -- deterministic failures where a
cross-replica retry cannot succeed
deployment config -> ConfigInvalid (25)
s3/oss/cos/obs credential presence checks x20, external_source URL
scheme/host, segcore yaml node checks
This closes the audit: all 1517 AssertInfo sites in internal/core have now
been individually read across the four sweeps; 167 total were reclassified
and every kept site is an internal invariant or cgo contract for which 2001
is the correct report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Zack <noreply@zilliz.com>
The previous sweeps bucketed AssertInfo sites by the shape of the guarded
condition (null / bounds / type / state-machine) and read only the leftovers
by hand. That criterion is wrong on its own: `size == expected` is an
invariant when both sides are computed in-process and a corruption detector
when one side was read off disk. What decides the class is where the value
came from.
Re-filtering the ~970 shape-bucketed sites by whether they sit on a
load/deserialize path leaves 168, read one by one here. 49 are externally
triggerable:
retriable resource failures, previously reported as permanent 2001 --
the highest-value group in this pass:
IndexEntryReader pwrite x2 -> FileWriteFailed
a full disk during index download reported as an internal bug
MmapChunkManager block allocation x4 -> MmapError
FileWriter aligned_alloc, arrow builder
Append() x4 (allocation failures) -> MemAllocateFailed
RemoteInputStream over-read -> FileReadFailed
persisted-format damage (39) -> DataFormatBroken
V3 footer too small / zero-size directory table / encrypted slice
overruns / decrypted-size mismatches x5, parquet dim and metadata
(PayloadReader x3), binlog FieldData casts (Event, DiskFileManagerImpl),
loaded-chunk row-count mismatches (SystemIndexTranslator x2),
downloaded-file-count vs meta (MemFileManagerImpl x2), emb_list offset
layout, arrow element-type and int32-offset overflow checks, empty
remote file lists
deployment config (2) -> ConfigInvalid
cipher plugin absent for an encrypted file / V3 index
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Zack <noreply@zilliz.com>
…ppened
Four sweeps reclassified 234 sites that were reporting external failures as
UnexpectedError(2001). What is left is ~1500 sites judged to be genuine
invariants -- but a judgement is a hypothesis, and re-reading code is a poor
way to find the ones that are wrong. A site that fires in production
falsifies its own judgement, so make it say so.
2001 is the only segcore code that does not name its failure; every other one
does. The actionable part is therefore the source location, which
AssertInfo/ThrowInfo already append to the message as " at <file>:<line>" --
the one carrier that survives the cgo boundary. classifySegcoreError now
extracts it and hands it to an observer, wired (same injection pattern as the
unmapped-code observer, since merr cannot import metrics) to:
milvus_cgo_unexpected_segcore_origin_total{origin="internal/core/src/..."}
plus a rate-limited WARN. Series appear only when a site actually fires, so
cardinality is bounded by real failures rather than by assert count.
A site showing up is either a Milvus bug or a misclassification -- a
condition really driven by external input (corrupt file, full disk, OOM) that
should carry a specific retriable code. Both are worth acting on, which is
why the counter is per-origin instead of a single total.
Absolute build paths collapse to repo-relative so one site is one series
across CI images; a message with no parsable location is bucketed as
"unknown" rather than dropped, since a large unknown share would itself mean
the location plumbing broke.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Zack <noreply@zilliz.com>
… parsing text
The C++ classifier for rust tantivy errors matched prefixes of the Display
text ("InvalidArgument:", "IOError:", ...). Display is a human-facing format,
not a contract: a wording change silently degrades every classification back
to UnexpectedError(2001), and nothing would notice.
The binding already has the type -- TantivyBindingError with six variants --
so the discriminant now crosses the FFI explicitly:
rust: #[repr(i32)] TantivyBindingErrorCode { Ok, InvalidArgument, Io,
DataCorruption, Json, Tantivy, Internal }, a RustResult.error_code
field set by every constructor, and TantivyBindingError::code().
The inner tantivy::TantivyError is discriminated too: IoError/
OpenDirectoryError/OpenReadError -> Io, DataCorruption/
IncompatibleIndex -> DataCorruption -- previously the whole
"TantivyError:" family collapsed to 2001, so an engine-level I/O
failure was reported permanent; now it classifies to FileReadFailed
and stays retriable.
c++: TantivyErrorToErrorCode switches on the cbindgen-generated enum
(compile-time checked); the string parser is gone. All 84
AssertTantivyOk call sites are unchanged -- the macro reads
result_->error_code now -- and the full Display text still flows
into every thrown message, so diagnostics lose nothing.
The 8 from_error(e.to_string()) sites whose error really is a
TantivyBindingError now construct through from_binding_error(&e) to keep
their discriminant; the two Utf8Error sites keep the Internal default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Zack <noreply@zilliz.com>
… as 2001 Two families of AssertInfo report conditions that can never become true by retrying, yet their 2001 lands in the generic ErrSegcore bucket, which the index scheduler and load path treat as retriable -- the task spins forever. Static capability checks -> Unsupported(2003), 7 sites: the index_type x metric_type blacklist (VectorMemIndex x2), the per-element-type metric allowlists (index/Utils.h x3), and the json/geometry index-type gates (IndexFactory x2). getStateFromError maps ErrSegcoreUnsupported to JobStateFailed, so an impossible build now fails once instead of rebuilding forever. These are unreachable through the proxy (its allowlists cover the C++ blacklist); they fire on legacy or corrupt persisted meta and on internal callers. Missing keys in persisted index meta -> DataFormatBroken, 10 sites: index_type/metric_type/min_gram/max_gram lookups in FieldIndexInfo.IndexParams (load_index_c x2, V1SealedIndexTranslator x3, ChunkedSegmentSealedImpl, segcore/Utils x4). These params come from meta written at index-build time and shipped at load -- the Go side of the same path already treats the missing key as a reachable error -- so a hole in them is persisted-format damage, not a cgo contract violation, and retrying the load cannot repair it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zack <noreply@zilliz.com>
…ified
The four AssertInfo sweeps left the explicit ThrowInfo(UnexpectedError)
population unread. All 198 production sites are now read one by one; 171
are correct (switch-defaults over plan-derived enums, internal casts and
state machines, cgo null contracts) and 27 change:
knowhere expected<> bypasses (8): QueryResult.h and CachedSearchIterator
asserted has_value() and threw 2001, discarding the Status knowhere
had already classified. Both merge loops and both batch loops now
route through KnowhereStatusToErrorCode, so an OOM or disk-read
failure during search iteration stays retriable.
mmap failures (9): the five mmap()-with-strerror sites in
ScalarIndexSort/StringIndexMarisa/StringIndexSort and the four
MmapChunkManager::Allocate consumers in mmap/ChunkData.h ->
MmapError, retriable.
file IO (2): a raw ::read loop -> FileReadFailed; the R-Tree meta/file
write path -> FileWriteFailed.
persisted-data damage (10): R-Tree load, emb-list offsets file missing,
LOB reference size, WKB geometry construction, JsonStatsMeta
deserialization x2, user-JSON parse during stats build x3, unknown
file in a loaded segment -> DataFormatBroken.
config (1): external_spec (a user-set collection property) parse failure
-> ConfigInvalid, consistent with the other external_source checks.
cancellation (1): the AcquireUntil cancel-race path threw 2001 with a
"cancelled" message; it now throws FollyCancel like the
ThrowIfCancelled call two lines above it.
preflight rewraps (3+1): segment_c and boost_score caught the preflight
exception, flattened it to a string and re-threw 2001 inside the
async future, destroying the original code. They now preserve the
SegcoreError code and keep the "preflight failed" context the
existing tests pin; the catch-all branch re-throws the original
exception object.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Zack <noreply@zilliz.com>
The 2026-07-29 audit of the milvus <-> milvus-storage integration found
three classification gaps; the nested-FFI collapse (milvus_table_c
flattening every failure to LOON_GOT_EXCEPTION) was fixed earlier on this
branch. This closes the other two on the milvus side.
Go path (gap 3.1): HandleLoonFFIResult -- the single funnel for all 11
cgo-to-loon files under storagev2/packed -- dropped RustResult err_code
entirely and wrapped every failure as ErrLoonTransient, so a 404, an
access-denied and corrupt data all retried as if transient. It now reads
err_code and asks the producer's own loon_ffi_is_retryable_errcode:
transient failures keep ErrLoonTransient, everything else carries the new
ErrLoonPermanent, and the message carries the code. Consumers:
- pack_writer_v3's classifyLoonErr needs no code change: permanent
failures now fall through to retry.Unrecoverable, which is exactly the
"narrow the retryable set once codes survive end-to-end" its comment
was waiting for (comment updated);
- external_collection_refresh_manager treats loon errors as a
non-retriable job failure and matched only ErrLoonTransient; it now
matches ErrLoonPermanent too, so the classification change cannot
invert its behavior.
The exttable test pinned the old behavior (nonexistent directory ->
transient); it now asserts code 12 -> ErrLoonPermanent.
C++ path (gap 3.3): the two ThrowIfFFIError helpers classified the same
code differently -- milvus_table_c through LoonErrCodeToErrorCode's
low-band table, external_utils_c through ExtendStatusCodeFromInt plus the
retryable probe, so LOON_FILE_NOT_FOUND(12) surfaced as ObjectNotExist on
one path and StorageError on the other. LoonErrCodeToErrorCode is now the
single entry (low band -> hand table, extend band -> the producer's
ToSegcoreErrorCode, unknown -> the producer's retryable probe), and
external_utils_c routes through it: both paths now agree, and the
audit's divergence (jiaqizho's "converges at the segcore boundary" which
measurement showed it did not) actually converges.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Zack <noreply@zilliz.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zack <noreply@zilliz.com>
…he rethrow rule, sync the wire docs
Three review findings, all applied:
ExprInvalid(2028) producer split: a full audit of the 29 production sites
shows 9 are internal invariants, not request content -- ElementFilterExpr
reaching ParseExprs (x2), the unset/unknown expr oneof (incl. rolling-
upgrade mismatch), the post-parser boolean type check, the function
arity/type guards (Empty/StartsWith/CheckVarcharOrStringType) and the
aggregate-input guards, all guaranteed by internal builders. They now throw
UnexpectedError, so a translator/executor bug is no longer blamed on the
request and lb_policy keeps failing over. Request-driven sites (regex/LIKE
syntax, div-mod-by-zero, bloom blob and field-type checks, unknown function
name) keep ExprInvalid. Regression test PlanProto.InternalExprGuards-
AreNotInputErrors pins two internal producers at UnexpectedError.
throw e; is no longer whitelisted: the lint rule matched the catch variable
name, letting the exact slicing rethrow this PR fixes elsewhere pass CI.
Only bare `throw;` passes now, and the two remaining production sites
(SegcoreConfig.cpp, futures/Future.h) are converted to bare rethrows.
Negative control verified: a `catch (std::exception&) { throw e; }` probe
fails the check.
Wire-contract docs synced: the casebook section that still documented the
old collapse-to-2000 projection now describes the pass-through mapping
(original in-band code on the wire, unknown in-band codes included,
out-of-band collapses to 2000, cross-family keeps the sentinel's code) and
carries an explicit compatibility note for the 2001->2003 / 2002->2033
sentinel renumbering. A new named_sentinel_wire_transitions test pins the
transitions and asserts the vacated numbers do not resurrect the old
sentinel identities.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Zack <noreply@zilliz.com>
…me it Review round 2: the classification now survives to Go, but four consumer paths still dropped or inverted it. All four fixed: Compaction result carries the typed failure. CompactionPlanResult gains `common.Status fail_status`; the datanode executor fills it from merr.Status(err) instead of reducing a failure to completeTask(nil), and completeTask takes the state from the result so the failure survives to the DataCoord query. All four DataCoord consumers persist the code/retriable/ reason into the task's fail_reason; the clustering task additionally requeues a retryable failure (OOM, transient storage) through its EXISTING retry budget -- back to pipelining on a fresh node with RetryTimes+1 -- instead of terminating. mix/L0 have no retry budget machinery; they gain the diagnosable fail_reason, and retry wiring there is scheduler work tracked separately. A failed clustering submission stays pipelining. doCompact rolled the metadata back but fell through and overwrote it as executing on the node that had just rejected the plan; the next poll for the never-created worker task synthesized a terminal failure, bypassing retryOnError entirely. It now returns the original CreateCompaction error after the rollback, so the retry budget and node reselection apply. The two QueryNode delete paths decide by retriability, in the same direction. DeleteBatchResponse gains per-segment `failed_statuses` (index-aligned with failed_ids); the worker fills them from the typed segment.Delete errors. The delegator's batch path offlines a segment only on a permanent failure and retries the retryable subset (the request shrinks to the failed ids); the legacy path stops burning ten attempts on a permanent error (retry.Handle now returns merr.IsRetryableErr(err)). UpdateSchema retries only transient failures: the retry.Do call gains retry.RetryErr(merr.IsRetryableErr), so a permanent typed schema error no longer consumes the bounded retry budget against the same worker. The executor test now asserts the full chain: a SegcoreError(2034) from Compact() reaches the stored result as a FailStatus that reconstructs to a retryable error via merr.Error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zack <noreply@zilliz.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zack <noreply@zilliz.com>
…boundaries Six findings from review, all confirmed and fixed. Three of them are defects this PR's own previous rounds introduced or failed to verify. Delete no longer goes missing (delta_forward). When every failed segment was classified retryable, the batch path narrowed the request and returned an error to drive another attempt -- but once retry.Attempts(10) is spent, conc.AwaitAll discards that error and applyDeleteBatch returns only offlineSegments. Those segments were neither offlined nor updated, so the delete was silently dropped and the segment kept serving deleted rows; master offlined on the first failure, which forced a reload that reapplied it. The still-failing ids are now offlined before giving up. UpdateSchema retries transport failures again. merr.IsRetryableErr is an allowlist over milvusError, and CheckRPCCall returns the raw grpc error when the transport fails, so the predicate added last round made connection-refused/reset/EOF abort on the first attempt -- and since delete_node panics on an UpdateSchema error, a QueryNode restarting during a schema change would crash the delegator's node instead of being waited out. The predicate now stops only on a typed error explicitly classified non-retriable. The LOON err_code really survives the nested boundary now. milvus_table_c.cpp is itself a C-ABI export layer whose tail funnels every exception through RETURN_EXCEPTION, which is hardcoded to LOON_GOT_EXCEPTION(5) -- so the code ThrowIfFFIError attached was discarded regardless, the comment claiming otherwise was wrong, and combined with the new ErrLoonPermanent every failure of that entry point became permanent. A LoonFFIError (deriving from SegcoreError) now carries the original loon code and the tail returns it unchanged. Regression test test_loon_ffi_error_passthrough.cpp drives the real exported entry point with a missing source manifest and asserts the code is not 5; negative-controlled by restoring the old tail, which makes it fail. Parquet status exceptions keep their arrow code. The catch on parquet::ParquetException also captured ParquetStatusException -- what PARQUET_THROW_NOT_OK raises for any non-ok arrow Status, including OutOfMemory -- and stamped DataFormatBroken on all of them, reporting a retriable allocation failure as permanent corruption. Its status now goes through ArrowStatusToErrorCode, the same mapper the ok() checks in that function already use. Pass-through errors stop repeating themselves. The relabel keeps the family sentinel as inner so errors.Is still matches the umbrella, but both carry the same msg, so every pass-through segcore error rendered as "...: segcore error[segcoreCode=2028]: segcore error" in Status.Reason and Detail. milvusError.Error() now skips exactly that shape. A requeued clustering compaction is dropped on the worker first. Resetting the task to pipelining without DropCompactionPlan left the DataNode's failed entry in place; the scheduler re-pushes a pipelining task without calling DropTaskOnWorker, so re-submitting the same planID to the same node is rejected with ErrDuplicatedCompactionTask and the task never leaves the pending queue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zack <noreply@zilliz.com>
LoonFFIError derives from SegcoreError and additionally carries the producer's raw LOON code so milvus's own C-ABI export tail can return it unchanged; it is a typed throw, but the guard's allowlist did not know the name and flagged it. Negative control re-verified: an untyped throw std::runtime_error still fails the check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zack <noreply@zilliz.com>
6d52ca3 to
31b0330
Compare
Three boundaries were still letting exceptions cross the C ABI or swallowing them into a wrong success: 1. create_token_stream: std::string(text, len)/make_unique can throw on a user-supplied RunAnalyzer placeholder and would rethrow straight through cgo, terminating the QueryNode. Returning null instead is not safe either (Go would call Advance() on it), so the entry point now returns CStatus with a CTokenStream out-param and the analyzer interface propagates the error to the caller. 2. EstimateLoadIndexResource: an estimation exception (including the DataFormatBroken missing index_type) was swallowed into a zero LoadResourceRequest, letting the segment pass load admission with no memory/disk reservation. It now returns CStatus with an out-param and the segment loader aborts the load on failure. 3. segcore_init_c QueryNode entry points: SegcoreSetSimdType and the Knowhere thread-pool setters ThrowInfo(ConfigInvalid) on bad values (common.simdType has no paramtable validator) and had no catch, unlike their IndexBuilder counterparts. They now mirror IndexBuilderInit / IndexBuilderSetSimdType: swallow, log loudly, keep the default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017iV265R68zUfpn34rFoTCM Signed-off-by: xiaofanluan <xf@hjjaq.com>
FailureCStatus strdups error_msg; leaving it unfreed on a failing estimation would let LSan kill all_tests and mask the real assertion (the PR milvus-io#51680 failure mode). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017iV265R68zUfpn34rFoTCM Signed-off-by: xiaofanluan <xf@hjjaq.com>
|
Reviewed the boundary layer once more after the last push. Five findings — the first three are places where this PR's own contract isn't met yet, the last two are pre-existing but in the same "exception crosses the C ABI and kills the QueryNode" class this PR exists to close, so flagging them here rather than in a separate issue. 1. [P1] Analyzer FFI still hardcodes These sites convert failures via 2. [P2] Four extern-C entries still catch only The catch-hardening commit message states every remaining extern-C entry that caught only 3. [P2]
4. [P1, pre-existing] No try/catch, and the impl is 5. [P1, pre-existing]
Storage-side classification comments (PayloadWriter / IndexEntryReader / loon_ffi) intentionally withheld for now — those should follow the storage error-taxonomy rework rather than being settled piecemeal here. |
…analyzer discriminant Five findings from the boundary re-review, all applied. The analyzer FFI carries its discriminant now. tokenizer_c.rs (x3) and index_writer_text_c.rs built their failures with RustResult::from_error(String), which hardcodes error_code = Internal -- so a user-supplied bad analyzer_params reached AssertTantivyOk as Internal and collapsed to UnexpectedError(2001), on the very path where most of this PR's InvalidArgument classification lives, while index_reader_c.rs (using .into()) classified the same error correctly. Added RustResult::from_binding_error_msg(&err, msg), which keeps error.code() while allowing the contextual message, and switched the four sites. Four extern-C entries caught only std::exception, contrary to what the catch-hardening commit claimed: segment_c's two getters and boost_score's synchronous entry now use CGO_CATCH_AND_RETURN_CSTATUS (which also adds the bad_alloc arm), and boost_score's async preflight gets a hand-written catch(...) mirroring its error-future construction. Delete parsed the IDs blob before its try, so a malformed blob left the function as a live exception across the C ABI; the parse moves inside, as Insert and LoadDeletedRecord already do. An empty placeholder group no longer kills the QueryNode. GetNumOfQueries is group->at(0) behind a plain int64_t return, and a valid zero-placeholder protobuf parses into exactly that. The invariant belongs to ParsePlaceholderGroup, which returns through a CStatus, so the rejection (InvalidParameter) happens there and covers every downstream consumer at once. HasRawData absorbs the schema-evolution race. It returns a plain bool with no channel for an error, while ChunkedSegmentSealedImpl::HasRawDataFromState looks the field up in the PUBLISHED schema and AssertInfo-throws when the snapshot has not caught up -- normal timing, no malformed data needed. The entry now logs and returns false, which is the safe answer since the caller just loads the field data. (The growing impl never consults the schema and cannot throw here, so the sealed path is the only one that could.) test_cabi_exception_containment.cpp covers the last two; both were negative-controlled by reverting the fixes, which makes them fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zack <noreply@zilliz.com>
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
|
All five addressed in 0e802b7 — rebased on top of your two commits (ee3e1e0, 028a1f3), which touch a disjoint file set, so nothing of yours was overwritten. 1. Analyzer FFI discriminant — added 2. Four extern-C entries — 3. Delete parses inside the try now — moved as suggested. 4. Empty placeholder group — fixed at the invariant's owner as you proposed: 5. HasRawData — entry now logs and returns false. One correction to the finding: only the sealed impl can throw here. Verification: new Agreed on holding the storage-side classification (PayloadWriter / IndexEntryReader / loon_ffi) for the taxonomy rework. |
✅ CI Loop Results
|
| Stage | Result | Duration | Tests |
|---|---|---|---|
| ✅ Build | SUCCESS | 16.2min | - |
| ✅ Code-Check | SUCCESS | 7.9min | - |
| ✅ UT-Integration | SUCCESS | 26.0min | - |
| ✅ UT-GO | SUCCESS | 23.4min | - |
| ✅ UT-CPP-Cov | SUCCESS | 60.7min | 8655 total, 8655 passed, 0 failed |
Total: 85min | Pipeline | Artifacts
Overall Coverage: 74.0%
Diff Coverage: CPP 41.8% (671 hit, 934 miss, 1605 measurable lines, 2994 unmeasured) | Go 44.5% (101 hit, 126 miss, 227 measurable lines, 4572 unmeasured)
Diff Coverage HTML: view changed lines
Go Patch Warning: WARNING: Go patch coverage is partial; 4572 changed lines were unmeasured.
Total Patch Coverage: 42.1% (772/1832 measurable lines, 7566 unmeasured)
✅ CI Loop Results
|
| Stage | Result | Duration | Tests |
|---|---|---|---|
| ✅ Build | SUCCESS | 16.0min | - |
| ✅ Code-Check | SUCCESS | 7.3min | - |
| ✅ UT-Integration | SUCCESS | 26.1min | - |
| ✅ UT-GO | SUCCESS | 23.7min | - |
| ✅ UT-CPP-Cov | SUCCESS | 61.2min | 8657 total, 8657 passed, 0 failed |
Total: 85min | Pipeline | Artifacts
Overall Coverage: 74.0%
Diff Coverage: CPP 41.9% (684 hit, 947 miss, 1631 measurable lines, 3116 unmeasured) | Go 44.5% (101 hit, 126 miss, 227 measurable lines, 4572 unmeasured)
Diff Coverage HTML: view changed lines
Go Patch Warning: WARNING: Go patch coverage is partial; 4572 changed lines were unmeasured.
Total Patch Coverage: 42.2% (785/1858 measurable lines, 7688 unmeasured)
What
Consume the producer-owned error classification at the segcore boundary and make the whole C++→Go classification drift-proof, so a segcore error is classified as input (caller's fault, non-retriable), transient (retriable) or permanent (non-retriable) instead of flattening to
UnexpectedError(2001)or carrying the wrong retry default.Design + tracking: #50903.
Changes
T1 — register the storage fallback pair in
pkg/util/merr/segcore.go:StorageError(2044)non-retriable,StorageTransientError(2045)retriable.T2 —
KnowhereStatusToErrorCode→ a switch with nodefault+-Werror=switchover the fullknowhere::Status; add build-path variantKnowhereBuildStatusToErrorCodeso a build-time OOM / disk read stays retriable instead of collapsing into a permanentIndexBuildError.T3/T4 —
ArrowStatusToErrorCodedelegates to the producer'smilvus_storage::ToSegcoreError(retires milvus's duplicate mapper); audited and routed 25 storage arrow-status sites that were collapsing to2001through the single mapper (extracted tostorage/StatusToErrorCode.h), always preserving the arrow sub-code in the message.T5 — unmapped-code observability:
UnmappedSegcoreCodeTotal{code}counter + rate-limited WARN via an observer hook (merr is a leaf package); registered on QueryNode and DataNode. Unknown code degrades to non-retriable, never panics.T6 — codegen + compile-time enforcement: a generated
SegcoreCodetype (from milvus-common'sEasyAssert.h) + an exhaustiveclassForCodeswitch marked//exhaustive:enforce, with theexhaustivegolangci-lint enabled opt-in — a new C++ code that is not classified fails lint (the C++→Go analog of-Werror=switch).§3 B-tier — classify
marisaandsimdjsonerrors (build/load/parse) instead of collapsing to2001, sub-code in the message; simdjson optional-access (NO_SUCH_FIELD/INCORRECT_TYPE) stays a benign skip; theloon_ffiFFI boundary is untouched.Boundary hardening (adversarial self-review of this PR's own diff) — closed the escapes that would defeat the mapping above: a
throw e;slicing rethrow inLoadWithStrategythat destroyed the very codes the columnar-read mapping attaches (barethrow;now), the same slice inMinioChunkManager::PreCheck;GetCoreMetrics/EstimateLoadIndexResource/ init-and-config entry points that could let an exception cross the C ABI and terminate the process; and every remaining extern-C entry that caught onlystd::exceptionnow ends incatch(...)via the sharedCGoCatch.hmacros.Pin + semantics — bump
milvus-storage_VERSIONto11f8a36(the enhance: expose retryable storage errors through extend status and C FFI milvus-storage#574 merge, which also contains Add AVX flags when building faiss #575) and align the no-detailIOErrorexpectation with the settled semantics: the producer tags every known-transient failure with a retryableExtendStatusDetail, so a bareIOErrorwith no detail is unclassified and deliberately falls back to permanentStorageError(2044)— a stripped-detail NotFound now degrades to non-retriable (safe) instead of retriable (retry storm on a permanent 404).Wire pass-through (client-visible) — a segcore error now reaches the client with its ORIGINAL code (2009 stays 2009, 2024 stays 2024) instead of collapsing to the
ErrSegcore(2000)umbrella with the real code buried in the message. Family identity forerrors.Isis preserved via inner/Unwrap; input/system/retriable classification unchanged. Guardrails: only in-band (2000-2099) codes pass through (garbage still collapses to 2000); cross-family mappings (2046 → wire 110) keep their sentinel's code.ErrSegcoreUnsupported/ErrSegcorePretendFinishedmove to the C++ values they represent (2001→2003, 2002→2033) — their old numbers squatted on C++ UnexpectedError/NotImplemented and would false-match under code-basederrors.Is. Verified end-to-end on a live standalone (ef<k reaches the client as 2042, unsupported tokenizer as 2001); the three e2e assertions pinning the old 2000 updated.Remaining code-destroying sites — the three classes that still swallowed a producer's classification before the cgo boundary are now gone from
internal/core/srcandinternal/core/thirdparty: status-consumingAssertInfo(104 → 0, incl. ~47 arrow builder paths whose commonest failure is OOM, now retriableMemAllocateFailedinstead of a permanent 2001), barethrow std::runtime_error/logic_error/bad_alloc(68 → 0 — these were notSegcoreError, so they collapsed to 2001 and falsely fired the untyped-exception observer), andthrow fmt::format(...)(12 → 0 — it throws astd::string, whichcatch (std::exception&)cannot see at all). tantivy's 73AssertInfo(res.result_->success, ...)(plus 10 raw-RustResultstragglers found later) now classify the rust error — originally by its Display prefix, since replaced by a proper#[repr(i32)]discriminant carried inRustResult.error_code(see the Aug-10 update below). TypedThrowInfosites: 894 → 1081. The ~1500 genuine invariant asserts are untouched — 2001 is correct for them. The long-standing FIXME abouterr_codenot surviving the nested LOON FFI boundary is also resolved, delegating tomilvus_storage::ToSegcoreErrorCoderather than duplicating its table.Verification
Verified in this PR:
test_knowhere_status_mapping.cpp/test_storage_error_code.cpp/test_exec.cppcover every mapper branch (knowhere Status incl. the build variant, arrow/extend status incl.AwsErrorNotFound→ObjectNotExist(2017), permanent-S3 vs transient), plusFailureCStatuscode preservation and both observer hooks firing.segcore_test.gopinsclassForCodefor every generated code and assertsmerr.Status(err).GetRetriable()for transient codes; the T6 generator is idempotent and theexhaustivelint fails on an unclassified code.GrowingConcurrentReopenTestdeadlocks deterministically on current master with or without this PR — rwlock writer starvation in growing-segment reopen code this PR does not touch; reported separately).ArrowStatusToErrorCode, and every extern-C boundary ends in acatch(...)tail.Explicitly NOT verified here (follow-up):
retriable=true(unit-tested projection), but the downstream consumption —lb_policyreplica reroute onmerr.IsRetryableErr, index/analyze scheduler retry — is pre-existing logic from enhance: standardize error handling on merr + Sys/Input classification #50221 and has not been driven by a real segcore transient error in this PR. This PR preserves classification for observability and correct retry defaults; the retry behavior itself is exercised only by its own pre-existing tests.Dependencies
milvus-commonmerged.StorageTransientError(2045)— enhance: add retriable StorageTransientError(2045) error code zilliztech/milvus-common#102milvus-storagemerged; pin bumped in-tree toToSegcoreError/ packedExtendStatusCode— enhance: classify packed extend status codes into segcore error codes milvus-storage#575 + [skip ci](shards): remove color decorator in log file, fixes #573 #57411f8a36.knowhere three-way classification — enhance: three-way status classification; own the segcore code mapping zilliztech/knowhere#1704merged (the milvus-sideKnowhereStatusToErrorCode→ thin delegate to knowhere's ownToSegcoreErrorCodeis a follow-up, gated on a knowhere version bump).milvus-common untyped-cgo-exception observer — enhance: carry typed error codes on cachinglayer failure paths and observe untyped cgo exceptions zilliztech/milvus-common#112merged and released as1.0.0-1fd1160; the pin now points at the published package. All dependencies are in.Update (Aug 10) — full-population audit, LOON path, runtime observability
The originally deferred FFI/LOON path is now done on the milvus side, and the audit was extended from the three grep-able classes to the entire 2001-producing population:
AssertInfo(four sweeps: errno fingerprint, failure-keyword messages, condition morphology, and finally data provenance — does the guarded value come from disk/network?) and all 198 explicitThrowInfo(UnexpectedError)sites. ~290 were externally-triggerable and now carry typed codes: file/remote IO ->FileOpen/Create/Read/WriteFailed(retriable), mmap/allocation ->MmapError/MemAllocateFailed(retriable), persisted-format damage (CRC/magic/parquet meta/index-meta keys) ->DataFormatBroken, deployment config ->ConfigInvalid, request content ->InvalidParameter, a cancel-race ->FollyCancel. The ~1,400 kept sites are genuine invariants or cgo contracts where 2001 is the correct report.Unsupported, whichgetStateFromErrormaps to a terminalJobStateFailed. Missingindex_type/metric_type/min_gram/max_gramkeys in persisted index meta had the same loop on the load path; they areDataFormatBrokennow.expected<>bypasses closed (8 sites inQueryResult.h/CachedSearchIterator): iterator failures went throughAssertInfoand discarded the Status knowhere had already classified; they now route throughKnowhereStatusToErrorCode, so an OOM/disk failure during search iteration stays retriable. Preflight rewraps insegment_c/boost_scoresimilarly preserved the originalSegcoreErrorcode instead of flattening to 2001+string.RustResultnow carrieserror_code(#[repr(i32)] TantivyBindingErrorCode, cbindgen-exported); the C++ mapper switches on the enum instead of parsing the Display text, and the innertantivy::TantivyErroris discriminated too (IoError/Open*Error-> Io/retriable,DataCorruption/IncompatibleIndex-> DataCorruption). Wording changes on the rust side can no longer silently degrade classification.HandleLoonFFIResultdroppederr_codeentirely and wrapped every failure asErrLoonTransient— a 404/access-denied/corrupt-data retried as transient. It now classifies by the producer's ownloon_ffi_is_retryable_errcode; permanent failures carry the newErrLoonPermanentand terminate retry loops (pack_writer_v3viaretry.Unrecoverable; the external-refresh manager guard extended so behavior does not invert). On the C++ sideLoonErrCodeToErrorCodeis the single classification entry (low band -> hand table, extend band -> producer'sToSegcoreErrorCode, unknown -> producer's retryable probe), unifying the two previously-divergentThrowIfFFIErrorhelpers —LOON_FILE_NOT_FOUND(12)now converges toObjectNotExist(2017)on both integration paths. Remaining LOON items (e.g. promoting FileNotFound intoExtendStatusCode) live in the milvus-storage repo.scripts/check_segcore_error_boundaries.shwired intomake static-check: everythrowininternal/core/srcmust carry a milvus ErrorCode (zero-tolerance; currently 0 violations); vendoredfmindex::is confined to its boundary files; knowhere/arrow/milvus_storage/tantivy are ratcheted by a checked-in file-set baseline (new consumer files fail the check; shrinking is free).milvus_cgo_unexpected_segcore_origin_total{origin="<file>:<line>"}counts every 2001 crossing the cgo boundary by its C++ source location (parsed from theat file:linesuffixAssertInfoalready emits, build paths collapsed to repo-relative). A site that fires in production names itself — reclassification becomes evidence-driven instead of re-reading ~1,400 asserts.Site count for the 2001 family: 1,955 on master -> 1,525 on this branch; the delta is reclassification into actionable codes, not deletion of checks.
Deferred
LOON_FILE_NOT_FOUNDintoExtendStatusCode, category byte (design §4.7) — tracked in the storage repo.KnowhereStatusToErrorCodeto knowhere's ownToSegcoreErrorCode, gated on a knowhere version bump.issue: #50903