enhance: classify lance/iceberg bridge errors and stop leaking exceptions - #597
enhance: classify lance/iceberg bridge errors and stop leaking exceptions#597czs007 wants to merge 8 commits into
Conversation
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: czs007 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 |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #597 +/- ##
==========================================
+ Coverage 75.95% 76.02% +0.06%
==========================================
Files 168 168
Lines 16672 16681 +9
Branches 2509 2517 +8
==========================================
+ Hits 12664 12681 +17
+ Misses 4008 4000 -8
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Adversarial review of the error-handling refactor confirmed 1 high, 4 medium, and 1 low issue, centered on error classification fidelity in the bridge layer and error-path resource cleanup in the Lance writer. High
Medium
Low
|
Adopts the adversarial-review findings on milvus-io#597: - High (object_store downcast coupling + untagged 429/503): the typed carrier of the post-retry HTTP status (client::retry::RetryError) is pub(crate) in object_store and cannot be downcast, so the status is recovered from RequestError::Status's stable Display pattern ("non-2xx status code: NNN"): 408 -> transient-timeout, 429 -> throttling, 500/502/503/504 -> service. Fail-safe by construction: a reworded message degrades to untagged/non-retriable, never the reverse; unknown 4xx stay untagged (test-pinned). The version coupling is now a compile-time pin: a unit test constructs LanceError::from(object_store::Error), which stops compiling if the bridge's object_store ever diverges from lance's. - Medium (writer stream leak): LanceTableWriter::Close now guards the exported stream with RAII; the Rust write entry points take ownership immediately (ptr::replace with an empty stream, making the guard a no-op on those paths), so the guard only fires on the error returns before the stream reaches Rust -- exactly the paths this PR added. - Medium (FieldNotFound classified as ENOENT): FieldNotFound no longer maps to file-not-found -- ENOENT drives create-if-missing in the writer, so a projection typo could have triggered dataset creation. It stays untagged (conservative), with a rust test pinning that. - Medium (SchemaMismatch != corruption): Schema/SchemaMismatch moved out of the data-corrupt bucket to untagged; producer sites are mixed (library-assembled schemas vs user projections), so no input-blame either. CorruptFile alone remains data-corrupt. - Medium (missing writer tests): two tests anchor both directions of the Close decision: a classified not-found creates the dataset; an EACCES open failure propagates and creates nothing. - Low (TranslateBridgeStatus downgraded non-IOError statuses): bridge errors only travel as IOError strings, so non-IOError statuses (Invalid / OutOfMemory / NotImplemented from arrow itself) now pass through with their StatusCode intact instead of being rewritten to IOError -- an OutOfMemory would have become non-retriable. Test-pinned. Verified: cargo test (release) 4/4 classification tests; full make build clean; lance/bridge suites 66 ran / 56 passed / 10 skipped (cloud credentials); the three new tests pass in isolation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: zhenshan.cao <zhenshan.cao@zilliz.com>
|
All six findings verified and addressed in af9fb35 — thanks, several of these were real catches:
Verification: |
|
Adversarial review confirmed 1 high and 2 medium severity issues, all in error classification paths; the high one is a blocking regression on the mid-scan read path. High
Medium
|
|
Round-2 findings addressed (commit just pushed):
Verification: |
| return message; | ||
| } | ||
|
|
||
| ParsedBridgeError ParseBridgeError(std::string_view error) { |
There was a problem hiding this comment.
This logical is vortex only. Because vortex using the filesystem_c as the obejct storage accessor.
But lance/iceberg won't use the filesystem_c. the error from obejct-store won't got any extend_status from the error message. So bring the vortex error convertor out the bridge file is messlesss.
There was a problem hiding this comment.
You're describing the pre-PR world accurately: the marker used to have exactly one producer — vortex's filesystem_c.rs (LOON codes carried back from the C++ filesystem layer), and lance/iceberg, which use their own Rust-native object_store, never produced it. If that were still true, sharing the decoder would indeed be pointless.
The core of this PR is that it adds a second, lance-native producer that doesn't involve filesystem_c at all — bridge_error.rs:
classify_lance_error()classifies the typedlance::Error(NotFound family → 12,CorruptFile→ data-corrupt, lance's ownRetryableCommitConflict/TooMuchWriteContention→ transient, and theIOvariant downcast toobject_store::Error→ NotFound/PermissionDenied/Precondition, plus the post-retry HTTP status recovered fromobject_store's message pattern);BridgeError'sDisplayembeds the same marker; every lance bridge fn now returnsResult<T, BridgeError>, andBatchFutStreamReader::next()wraps mid-scan stream errors the same way before arrow FFI stringifies them.
So the decoder now has two producers feeding one wire format: vortex (LOON codes via filesystem_c) and lance (classified lance::Error via bridge_error.rs) — that's what motivated hoisting it. It's verified end-to-end on the lance path: LanceBridgeErrorTest.OpenNonexistentDatasetClassifiesNotFound opens a nonexistent dataset through real lance, and the real DatasetNotFound arrives in C++ as ENOENT-detail → ObjectNotExist; BridgeErrorTest.TranslateDecodesMarkerRegardlessOfStatusCode covers the mid-scan form.
Where you're fully right today: iceberg. Its bridge errors carry no classification yet (stated in the PR's honest-scope section) — for iceberg this PR only removes the exception leak; classifying iceberg-rust error kinds is follow-up. If the sticking point is naming/placement rather than the mechanism, happy to rename or move things (e.g. keep bridge_error.{h,cpp} but note vortex/lance as the two current producers in the header comment).
There was a problem hiding this comment.
Then u should not use the __LOON_VORTEX_FFI_ERRCODE__ as marker. maybe use __LOON_RUST_BRIDGE_ERRCODE__ to replace it.
There was a problem hiding this comment.
Done — renamed to __LOON_RUST_BRIDGE_ERRCODE__= across all producers and the decoder in lockstep (vortex's filesystem_c.rs, lance's bridge_error.rs, the C++ bridge_error.cpp, and both test suites). The marker never persists to storage nor crosses a process boundary (encoded and decoded within one call stack), so the rename is compatibility-free. Verified: cargo 4/4, bridge/lance/vortex error suites 13/13.
The abort category (ValueOrDie/ValueUnsafe) is dropped: a text-level gate cannot tell the guarded FFI idiom (.ok() check + macro return + ValueOrDie, the house style in cpp/src/ffi/) from an unguarded abort path, so counting them nagged every legitimate addition without distinguishing dangerous ones. Unguarded aborts remain a review concern; a clang-query-based check could reintroduce the category with real semantic discrimination. The throw category stays: Ring-1 forbids the library from leaking exceptions, so in library code ANY throw is a violation -- textual counting IS the semantic judgment there, and it is what prevents the 23 sites burned down in milvus-io#597 from creeping back. Baseline regenerated: throw=59 only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: zhenshan.cao <zhenshan.cao@zilliz.com>
| /// Classify a `lance::Error` into a marker code. `None` = not positively | ||
| /// identified -> stays untagged -> conservative non-retriable fallback on the | ||
| /// consumer side. | ||
| pub fn classify_lance_error(e: &LanceError) -> Option<i32> { |
There was a problem hiding this comment.
Does it really make sense to distinguish between internal errors within the Lance object store?
From the perspective of the interface, both Lance and Iceberg are accessed via table-level APIs rather than file-level APIs. This implies that internal errors within Lance or Iceberg might be unrecoverable for the milvus-storage or milvus.
There was a problem hiding this comment.
Good question — it goes to the heart of why this classification exists. The key point: the consumer of retriability is not milvus-storage retrying a file operation; it's the milvus querynode deciding whether to re-route the whole request to another replica. That decision point exists at exactly the table-API granularity you describe:
- A table-level
take/scan fails mid-stream because S3 returned 429/503 → the error IS unrecoverable for this call, agreed — but re-issuing the whole request (against another replica / a moment later) very likely succeeds. That'sStorageTransientError/2045, and the retry loop that consumes it lives in milvus's lb_policy, not inside this library. - The same table-level call fails because a fragment file is corrupt → re-routing hits the same shared object store and fails identically. Never retry:
DataFormatBroken/2024. DatasetNotFound→ObjectNotExist/2017 matters operationally regardless of retriability: milvus can tell "the data is gone (stale loadinfo / GC'd)" apart from "storage is misbehaving" — different alarms, different remediation.
Same failure surface at the interface, opposite correct reactions — one bit ("error happened") cannot carry that. This is also the empirically hot case: the #574 review flagged mid-scan throttling as the most common transient in production, and it is precisely a table-level scan that hits it.
Where your instinct is fully encoded in the table already: everything not positively identified defaults to non-retriable 2044 — Lance-internal errors with no clear signal (the Internal/Generic variants) land exactly where you'd put them, unrecoverable. Only positive signals (HTTP 429/503, lance's own RetryableCommitConflict, typed NotFound) get distinguished. And iceberg today matches your description completely: no classification, everything non-retriable — follow-up only if a consumer shows up.
|
I don't think the error-code contract is actually unified here. For example:
This does not cause an immediate parsing failure because the decoder accepts the union of known codes and conservatively falls back for unknown ones. However, it can produce different structured statuses for equivalent failures and overload the same retry/metrics category with different semantics. More importantly, broad Lance not-found variants are collapsed into code Could we define one canonical |
…ions
The lance and iceberg cxx bridges reported every failure by throwing a
string-only exception (LanceException/IcebergException) out of the
library, which (a) violated the no-exceptions-across-the-boundary
contract -- four LanceTableReader read methods and api::Reader had no
catch at all, so bridge errors escaped into consumers as foreign
exceptions and collapsed to a generic internal error -- and (b) erased
the error class: a corrupt lance file, a missing dataset, and an S3
throttle all surfaced as one opaque IOError, so permanent failures were
indistinguishable from retriable ones.
Rust side (producer owns classification):
- new bridge_error.rs: BridgeError embeds a classification code into the
error message with the same marker/parser the vortex bridge already
uses; classify_lance_error maps lance::Error variants -- the NotFound
family to file-not-found, CorruptFile/Schema{,Mismatch} to a
data-corrupt code, NotSupported to not-supported, and the
lance-declared-retryable RetryableCommitConflict/TooMuchWriteContention
to the transient-throttling tag; the IO variant downcasts its
object_store source (NotFound / PermissionDenied+Unauthenticated /
Precondition / NotSupported). Anything not positively identified stays
untagged and lands in the conservative non-retriable bucket; no
retriability is invented. InvalidInput is deliberately NOT tagged as
caller input pending a producer-site audit.
- BatchFutStreamReader::next() wraps stream errors in BridgeError so the
classification survives arrow FFI stringification -- this is the only
choke point mid-scan read errors (the hot transient case) pass through.
C++ side:
- shared bridge_error.{h,cpp}: decodes the marker back into a structured
arrow::Status (file-not-found -> IOError+ENOENT detail -> ObjectNotExist;
extend codes -> ExtendStatusDetail; bridge-private data-corrupt ->
Status::Invalid; not-supported -> Status::NotImplemented; no marker ->
plain IOError) plus a translating RecordBatchReader wrapper for live
streams. The vortex bridge delegates to it; vortex public API and
behavior are unchanged.
- lance_bridge/iceberg_bridge: all fallible APIs now return
arrow::Result/arrow::Status; the 23 throw sites and both exception
types are gone. Estimate/IOStats keep their best-effort degrade
semantics.
- consumers (lance_table_reader/writer, lance_format, iceberg_format,
loon tool, tests, benchmarks) converted to status propagation;
read_with_range wraps its live stream in the translating reader, and
the drained-stream paths (get_chunk/get_chunks/take) decode stream
errors on failure.
- behavior fix in LanceTableWriter::Close: the create-new-dataset
fallback now triggers only on a classified not-found; previously any
open failure (auth error, corruption, transient IO) was treated as
"dataset does not exist" and silently created a fresh dataset.
Tests: new lance_bridge_error_test pins the decoder table (not-found ->
2017 ObjectNotExist, transient tag -> 2045 retryable, corrupt -> 2024,
untagged/unknown -> 2044, marker never leaks into messages) and an
end-to-end open of a nonexistent dataset classifying as not-found.
Existing vortex error tests unchanged and passing; lance/iceberg suites
pass (78 ran / 69 passed / 9 skipped for missing cloud credentials).
issue: milvus-io#595, milvus-io/milvus#50903
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: zhenshan.cao <zhenshan.cao@zilliz.com>
Adopts the adversarial-review findings on milvus-io#597: - High (object_store downcast coupling + untagged 429/503): the typed carrier of the post-retry HTTP status (client::retry::RetryError) is pub(crate) in object_store and cannot be downcast, so the status is recovered from RequestError::Status's stable Display pattern ("non-2xx status code: NNN"): 408 -> transient-timeout, 429 -> throttling, 500/502/503/504 -> service. Fail-safe by construction: a reworded message degrades to untagged/non-retriable, never the reverse; unknown 4xx stay untagged (test-pinned). The version coupling is now a compile-time pin: a unit test constructs LanceError::from(object_store::Error), which stops compiling if the bridge's object_store ever diverges from lance's. - Medium (writer stream leak): LanceTableWriter::Close now guards the exported stream with RAII; the Rust write entry points take ownership immediately (ptr::replace with an empty stream, making the guard a no-op on those paths), so the guard only fires on the error returns before the stream reaches Rust -- exactly the paths this PR added. - Medium (FieldNotFound classified as ENOENT): FieldNotFound no longer maps to file-not-found -- ENOENT drives create-if-missing in the writer, so a projection typo could have triggered dataset creation. It stays untagged (conservative), with a rust test pinning that. - Medium (SchemaMismatch != corruption): Schema/SchemaMismatch moved out of the data-corrupt bucket to untagged; producer sites are mixed (library-assembled schemas vs user projections), so no input-blame either. CorruptFile alone remains data-corrupt. - Medium (missing writer tests): two tests anchor both directions of the Close decision: a classified not-found creates the dataset; an EACCES open failure propagates and creates nothing. - Low (TranslateBridgeStatus downgraded non-IOError statuses): bridge errors only travel as IOError strings, so non-IOError statuses (Invalid / OutOfMemory / NotImplemented from arrow itself) now pass through with their StatusCode intact instead of being rewritten to IOError -- an OutOfMemory would have become non-retriable. Test-pinned. Verified: cargo test (release) 4/4 classification tests; full make build clean; lance/bridge suites 66 ran / 56 passed / 10 skipped (cloud credentials); the three new tests pass in isolation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: zhenshan.cao <zhenshan.cao@zilliz.com>
…l-path HTTP failures - High (mid-scan decode regression): the round-1 fix guarded TranslateBridgeStatus with !IsIOError(), but arrow-rs's C-stream exporter maps Rust stream errors to EINVAL, so mid-scan bridge errors arrive as Status::Invalid still carrying the marker -- the guard passed them through undecoded, leaking the marker and collapsing transients into DataFormatBroken. Discrimination is now on MARKER PRESENCE: any status whose message carries the marker is decoded regardless of StatusCode; marker-less statuses pass through untouched (the original no-downgrade property, still test-pinned). New test drives Invalid(marker+109) -> retryable transient detail and Invalid(marker+12) -> ENOENT. - Medium x2 (credential paths stringify the typed status): the GCP impersonation token requests and the Aliyun STS/OIDC fetches now prefix the canonical "non-2xx status code: NNN:" pattern while the typed StatusCode is still in hand, so the downstream classifier recovers the class; 401/403 now map to access-denied(105) alongside the transient codes (test-pinned via the canonical pattern). Verified: cargo test --release 4/4; full build clean; bridge error suites 9/9; lance suites 27 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: zhenshan.cao <zhenshan.cao@zilliz.com>
The marker is no longer vortex-only (this PR added the lance-native producer), so the name now reflects its role as the shared rust-bridge error channel. All three producers/consumers renamed in lockstep (filesystem_c.rs, bridge_error.rs, bridge_error.cpp) plus tests; the marker never persists nor crosses process boundaries, so the rename has no compatibility impact. Verified: cargo test 4/4; bridge/lance/vortex error suites 13/13. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: zhenshan.cao <zhenshan.cao@zilliz.com>
Signed-off-by: xiaofanluan <xiaofan.luan@zilliz.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: xiaofanluan <xiaofan.luan@zilliz.com>
…own) (milvus-io#596) ## What Adds a CI **ratchet** for error-handling discipline: a checked-in per-file baseline of `abort` sites (`ValueOrDie`/`ValueUnsafe`/`MoveValueUnsafe`) and `throw` sites in production code (`cpp/src` + `cpp/include`, tests excluded), enforced by a lightweight two-layer check: 1. **Exact match** against the committed baseline: any per-file count change fails CI — an increase must be fixed in code, a decrease must be recorded by regenerating the baseline (`make -C cpp update-error-ratchet`) in the same PR, so freed slack cannot silently grow back. 2. **Base-branch totals** (pull requests): per-category **totals must not increase** versus the baseline recorded on the PR's base commit. This is the machine-enforced ratchet direction: regenerating a *raised* baseline inside the PR keeps layer 1 green but fails layer 2. Baseline at introduction: **abort = 143, throw = 59** (git-tracked files only, so vendored/generated trees are excluded automatically). ## Why Ring-1 discipline for this library is that failures are reported as `arrow::Status` with structured classification, not by aborting the process or leaking exceptions to consumers (milvus links this C++ directly). Individual sweeps keep fixing sites — milvus-io#575 removed four unguarded `ValueOrDie` abort paths — but nothing prevents regressions, and the pattern keeps reappearing: - a fifth unguarded `ValueOrDie` (`format/parquet/file_reader.cpp`, reachable when the file schema lacks field-id metadata) predates milvus-io#575's sweep and was never on any list (fixed in milvus-io#598); - `packed/reader.cpp` still throws from a constructor; the lance/iceberg bridges threw across the library boundary until milvus-io#597 — the ratchet makes that burn-down visible and permanent in the baseline diff. ## How to work with it ```bash make -C cpp check-error-ratchet # what CI runs (also: bash cpp/scripts/error_handling_ratchet.sh check) make -C cpp update-error-ratchet # regenerate the baseline after a burn-down ``` The failure output is a unified diff against the baseline plus instructions for both directions; the base-totals failure names the category and both totals. ## Verification - `check` passes on the pristine tree; category totals reported (abort=143, throw=59). - Injecting a `throw std::runtime_error` fails layer 1 with a per-file diff (verified locally, then reverted). - The self-reference attack (add a throw → regenerate the baseline in the same PR → check against the pre-attack base) fails layer 2 with `throw: 59 (base) -> 60 (this PR)`; a genuine burn-down against the same base passes (both verified locally). - The workflow is toolchain-free (checkout + bash); layer 2 anchors to `github.event.pull_request.base.sha` and distinguishes bootstrap (no baseline on base, verified via `git cat-file`) from real failures. Not covered (honest scope) — this is a text-level ratchet, not a semantic linter: - the grep counts comments/strings too, so an equal-count swap (delete a commented `throw`, add a real one in the same file — or, for layer 2, in another file) is not caught; - `grep -c` counts matching **lines**, not call sites: two sites on one line count once, and a site added to an already-matching line does not trip the gate; - the checker runs from the PR checkout (standard in-repo lint-gate trust model — defense target is accidental regression; edits to the gate are visible in the diff); - migrating to clang-query later can reuse the same baseline flow. issue: milvus-io/milvus#50903 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: zhenshan.cao <zhenshan.cao@zilliz.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
cfee57f to
cd7a578
Compare
|
Thanks — agreed that the previous version unified the marker/wire format but still overloaded the taxonomy. Fixed in
Also regenerated the #596 error-handling ratchet baseline after the rebase: |
Code reviewMechanism and direction look right to me — one marker, one shared decoder, classification stays with the producer. I reviewed Two CI blockers, one of which is not obvious from the summary line: 1. The gate requires an exact match, so a burn-down has to be recorded in the same PR. The reason there was no local signal: this PR branches from 2. The failing step is the one this PR adds, and the log tail is: milvus-storage/.github/workflows/cpp-ci.yml Lines 69 to 77 in cfee57f
3.
milvus-storage/cpp/src/format/bridge/rust/src/bridge_error.rs Lines 113 to 121 in cfee57f The retriability itself is defensible — 4. Two nits
milvus-storage/cpp/src/format/bridge/rust/src/lance_bridge.cpp Lines 125 to 130 in cfee57f And lance guarantees a marker by construction ( For what it is worth, several things that looked alarming did not survive checking, so nobody needs to re-raise them: the Happy to push the two mechanical fixes (1) and (2) myself if that is easier — 🤖 Generated with Claude Code |
|
Follow-up — my review above crossed with What is still red is only the third one, and it is unchanged on Worth noting from the same log: conan does provide it in this job — Still open from the review, both minor: One heads-up for whoever merges second: this now overlaps #603, which converts |
The new "Test Rust bridge error classifier" step fails with lance-encoding build-script-build (exit status: 1) Error: Could not find `protoc`. `cargo test` runs under the dev profile, so it does not reuse the artifacts corrosion produced during the Release build and instead rebuilds the dependency tree, which re-runs lance-encoding's build script (prost-build needs protoc). The Build step gets protoc from conan -- the same job logs `Conan: Component target declared 'protobuf::libprotoc'` -- but the standalone cargo step does not inherit that environment. Installing protobuf-compiler alongside libaio-dev is the smallest fix that does not depend on the conan layout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011HDgMok3nR8ZNugKWWQQK2 Signed-off-by: xiaofanluan <xiaofan.luan@zilliz.com>
|
Pushed Revert it if you would rather fix it differently; the alternative I considered was adding Worth flagging while CI re-runs: |
classify_lance_error handled LanceError::IO but let Wrapped and External
fall through to `_ => None`, which discards a classification that is
already present one box deeper.
This is not a coarseness gap, it is a live loss on the most common
retriable path. lance-io's batch read scheduler stashes the failing
task's error and re-wraps it when the batch drops:
// lance-io/src/scheduler.rs
Err(err) => { self.err.get_or_insert(Box::new(err)); }
...
impl Drop for MutableBatch { ... Err(Error::wrapped(self.err.take().unwrap())) }
and `impl From<object_store::Error> for lance::Error` produces
`IO { source }`. So on any batched read an S3 throttle arrives as
Wrapped(IO(object_store::Generic)) rather than IO(..), and was reported
as a permanent StorageError instead of a retriable one. The encoding
decoder wraps the same way (lance-encoding/src/decoder.rs).
Both wrappers now downcast their box: object_store::Error first, then
LanceError recursively (each step strips one layer, so it terminates).
The object_store arm is extracted into classify_object_store_error so
the two paths share it.
Cloned { message: String } stays unclassifiable by construction -- lance
stringifies errors when cloning them across task boundaries, so the type
is gone before the bridge ever sees it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HDgMok3nR8ZNugKWWQQK2
Signed-off-by: xiaofanluan <xiaofan.luan@zilliz.com>
|
Pushed
// lance-io-7.0.0/src/scheduler.rs
Err(err) => { self.err.get_or_insert(Box::new(err)); }
...
impl Drop for MutableBatch<F> {
fn drop(&mut self) {
let result = if self.err.is_some() { Err(Error::wrapped(self.err.take().unwrap())) }and Both wrappers now downcast:
Verified locally: Remaining from my earlier review, both still open and both minor: |
…us-io#606) ## Problem `main` currently fails its own error-handling ratchet, and every open PR inherits the failure once CI merges it with main. milvus-io#598 (`352d545`) removed the last `throw` from `cpp/src/packed/column_group.cpp` — `ColumnGroup::Table()` now returns `arrow::Result` — but did not regenerate `cpp/scripts/error_handling_baseline.tsv`. The committed baseline still claims one throw there, and the ratchet's first layer is an exact match: ``` -throw cpp/src/packed/column_group.cpp 1 ``` ## Why it got through milvus-io#598 branched from before milvus-io#596 added the gate, so there was no baseline at its base commit and the workflow took its documented bootstrap path: ``` ::notice::error-handling-ratchet: no baseline at base commit (bootstrap); base-totals layer skipped ``` The exact-match layer had nothing to compare against on that PR, so the divergence only became visible after the merge. milvus-io#597 is in the same position (branched pre-milvus-io#596) — it regenerated its baseline on rebase, so it is fine, but the bootstrap hole is worth knowing about: a PR that predates the gate can merge a stale baseline. ## Change Deletes the one stale line. Content taken from the diff CI printed on milvus-io#604, not from a local `update` run. **Warning for whoever touches this next on macOS:** do not run `cpp/scripts/error_handling_ratchet.sh update` there. The scan uses `gcc -fpreprocessed -dD -E -P`, which Apple clang rejects with `unknown argument`, so every file silently counts zero — `check` reports the entire baseline as removed, and `update` would erase the file. Worth a guard in the script (probe the preprocessor once and fail loudly, and refuse to write an empty baseline); I can follow up with that if wanted. Unblocks: milvus-io#597, milvus-io#603, milvus-io#604, and anything else merged with main after `352d545`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_011HDgMok3nR8ZNugKWWQQK2 Signed-off-by: xiaofanluan <xiaofan.luan@zilliz.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…l the doc drift Review found that the taxonomy this PR defines did not hold up against its own standard. Six concrete defects, all real: 1. StorageConfigInvalid (115) and SourceUriInvalid (116) had NO producer. Only the ToSegcoreErrorCode switch mentioned them, so config and URI failures still reached the FFI boundary as LOON_ARROW_ERROR. This is exactly the criticism this branch levelled at milvus-io#597's TableNotFound. Fixed by converting the 14 unclassified arrow::Status::Invalid sites in fs.cpp: extfs.* property and cloud-provider/storage-type failures to 115, URI parse failures to 116. FFIErrorCodeFromExtendStatus only falls back when untagged, so exttable_c.cpp now reports both without further change. 2. LOON_INVALID_PROPERTIES (7) was blanket-classified Config. Its actual producers are loon_properties_create's duplicate-key and bad-index checks -- the caller's construction bug, not a deployment problem. Back to User; unusable deployment config is what 115 is for. 3. AwsErrorAccessDenied (105) was Permanent while its own comment said the credentials are operator configuration. Config, and 2006 ConfigInvalid at the segcore boundary rather than a generic 2044. 4. Six loon_errcode_packed_* symbols were exported in both linker maps and consumed by the Python binding with no declaration in ffi_c.h. The declarations were hand-transcribed from the X-macro table; they are now generated from it, which is the only fix that also prevents the next one. 5. docs/error-codes.md restated the old three-category model in 20 table cells, and extend_status.h and python/_ffi.py still documented "retryable iff Transient". The doc contradicted the code it documents. 6. The invariant tests could not have caught (1): they compare the tables to each other, and a code with no producer is self-consistent. Added cpp/scripts/check_error_table.py, wired into the existing ratchet workflow, which requires every code to have a real producer (a `case` label does not count) and re-derives the doc's four transcribed columns from source. Added three functional tests that drive the real entry points with bad input and read the classification back off the Status -- the coverage no table-comparison test can provide. Also: arrow's StatusCode is not derived from the category. They are orthogonal axes -- category says who owns the failure, the arrow code says what failed. An S3 403 is owned by whoever configured the credentials but is still an IO error to every caller branching on IsIOError(). Invalid is reserved for conditions detected before any IO is attempted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011HDgMok3nR8ZNugKWWQQK2
…l the doc drift Review found that the taxonomy this PR defines did not hold up against its own standard. Six concrete defects, all real: 1. StorageConfigInvalid (115) and SourceUriInvalid (116) had NO producer. Only the ToSegcoreErrorCode switch mentioned them, so config and URI failures still reached the FFI boundary as LOON_ARROW_ERROR. This is exactly the criticism this branch levelled at milvus-io#597's TableNotFound. Fixed by converting the 14 unclassified arrow::Status::Invalid sites in fs.cpp: extfs.* property and cloud-provider/storage-type failures to 115, URI parse failures to 116. FFIErrorCodeFromExtendStatus only falls back when untagged, so exttable_c.cpp now reports both without further change. 2. LOON_INVALID_PROPERTIES (7) was blanket-classified Config. Its actual producers are loon_properties_create's duplicate-key and bad-index checks -- the caller's construction bug, not a deployment problem. Back to User; unusable deployment config is what 115 is for. 3. AwsErrorAccessDenied (105) was Permanent while its own comment said the credentials are operator configuration. Config, and 2006 ConfigInvalid at the segcore boundary rather than a generic 2044. 4. Six loon_errcode_packed_* symbols were exported in both linker maps and consumed by the Python binding with no declaration in ffi_c.h. The declarations were hand-transcribed from the X-macro table; they are now generated from it, which is the only fix that also prevents the next one. 5. docs/error-codes.md restated the old three-category model in 20 table cells, and extend_status.h and python/_ffi.py still documented "retryable iff Transient". The doc contradicted the code it documents. 6. The invariant tests could not have caught (1): they compare the tables to each other, and a code with no producer is self-consistent. Added cpp/scripts/check_error_table.py, wired into the existing ratchet workflow, which requires every code to have a real producer (a `case` label does not count) and re-derives the doc's four transcribed columns from source. Added three functional tests that drive the real entry points with bad input and read the classification back off the Status -- the coverage no table-comparison test can provide. Also: arrow's StatusCode is not derived from the category. They are orthogonal axes -- category says who owns the failure, the arrow code says what failed. An S3 403 is owned by whoever configured the credentials but is still an IO error to every caller branching on IsIOError(). Invalid is reserved for conditions detected before any IO is attempted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011HDgMok3nR8ZNugKWWQQK2
Adversarial review found no issues requiring changes. Verified:
The previously reported high-severity finding is confirmed fixed, and no new issues were introduced by the incremental commits. |
Problem
The lance/iceberg cxx bridges reported every failure by throwing a string-only exception (
LanceException/IcebergException) out of the library:LanceTableReader::get_chunk/get_chunks/take/read_with_rangeand the wholeapi::Readerchain had no catch — a bridge error during a lance read left milvus-storage as a foreign exception and could only collapse to a generic internal error (2001) at the consumer's outermost boundary.lance::Errordistinguishes not-found / corruption / lance-declared-retryable contention, and itsIOvariant wraps a typedobject_store::Error— but the cxx boundary flattened all of it into one opaque string, so every failure surfaced as one bucket. A corrupt lance file was indistinguishable from an S3 throttle: permanent errors could be retried forever, transients could never be classified retriable. (Both this and the root cause — the bridge discards the typed error — were called out in the enhance: route arrow Status to finer LOON FFI error codes #568 review audit.)Approach
Same mechanism the vortex bridge already established (#574): the Rust side embeds a classification code into the error string with a marker; the C++ side parses it back into a structured
arrow::Status. One marker, one parser, now shared.Rust (producer owns classification) —
bridge_error.rs:classify_lance_error:DatasetNotFoundalone → dataset-missing/ENOENT (12); internal resource-not-found variants (NotFound/IndexNotFound/RefNotFound/VersionNotFoundandIO↳object_store::NotFound) → LanceResourceNotFound (114, non-retryable, no ENOENT);CorruptFile→ data-corrupt;NotSupported→ not-supported; lance-declared-retryableRetryableCommitConflict/TooMuchWriteContention→ LanceWriteContention (113). Object-store throttling keeps its existing 109 category;IO{source}still maps PermissionDenied/Unauthenticated → 105 and Precondition → 103.InvalidInputis deliberately not tagged as caller input pending a producer-site audit (avoiding the mixed-semantics misclassification class).BatchFutStreamReader::next()wraps stream errors inBridgeError— the single choke point mid-scan read errors (the hot transient case) pass through before arrow FFI stringifies them.C++ — shared
bridge_error.{h,cpp}:IOError+ENOENT detail (→ObjectNotExist/2017); extend codes →ExtendStatusDetail(transients → 2045 retriable); bridge-private 1001 data-corrupt →Status::Invalid(→ 2024); 1002 →NotImplemented; no/unknown marker → plainIOError(→ 2044, conservative). Bridge-private codes (≥1000) never cross the C ABI.WrapBridgeRecordBatchReadertranslates live streams (read_with_range); drained-stream paths (get_chunk/get_chunks/take) decode on failure.lance_bridge/iceberg_bridgeAPIs are nowarrow::Result/arrow::Status; all 23 throw sites and both exception types are gone.EstimateFragmentColumnMemory/EstimateFragmentMemory/IOStatsIncrementalkeep their best-effort degrade semantics (enhance: add per-column memory size estimates for chunks #586).Behavior fix (intentional, please review):
LanceTableWriter::Closefell back to creating a fresh dataset on any open failure (catch (std::exception)= "dataset does not exist"). The fallback now triggers only on classifiedDatasetNotFound/ ENOENT; internal resource-not-found, auth failures, corruption, and transient IO propagate instead of silently creating a new dataset.Classification table
Status::InvalidStatus::NotImplementedVerification
cargo checkclean; fullmake build(lib + tests + benchmarks + tools) clean.lance_bridge_error_testpins the decoder table (not-found → 2017, transient → 2045+retryable detail, corrupt → 2024, untagged/unknown-code → 2044, marker never leaks into messages, context preserved through translation) and an end-to-end open of a nonexistent dataset classifying as not-found through real lance.LanceBasicTest11/11 — real local write/read exercises the new writer fallback (not-found → create).milvus_testregression: 1074 ran / 919 passed / 154 skipped (cloud-credential) / 1 failed — the single failure isColumnGroupTest.MemoryUsageCalculation, a pre-existing upstream test bug (dangling-pointer dedup inBuffer::Wrap), reproduced 3/3 in isolation and unrelated to this change (no packed/column_group code is touched here).cd7a578): rebased onto enhance: add error-handling ratchet gate (abort/throw baseline burn-down) #596, regenerated the error-handling ratchet baseline (lance bridge 21→0, iceberg bridge 2→0), reserved 109 for storage throttling, and added 113/114 with end-to-end Rust/C++/FFI coverage. Local Release build passed; focused GTest 24/24 and FFI 77/77.Not covered (honest scope): iceberg-rust error kinds are not yet classified on the Rust side — iceberg bridge errors surface as plain non-retriable IOError (no worse than before, minus the exception); fault-injection e2e for mid-scan transient classification not added (the decoder path is unit-tested via synthetic markers). The rustfmt of
lance_bridgeimpl.rsadds some formatting-only churn in that one file.Refs: #595 (azure/GCS follow-up), milvus-io/milvus#50903 (error-handling tracking). Builds on the taxonomy from #574/#575.
🤖 Generated with Claude Code