enhance: expose retryable storage errors through extend status and C FFI - #574
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #574 +/- ##
==========================================
+ Coverage 74.82% 75.38% +0.56%
==========================================
Files 162 164 +2
Lines 15600 15574 -26
Branches 2376 2336 -40
==========================================
+ Hits 11672 11741 +69
+ Misses 3928 3833 -95
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:
|
cbc0380 to
94d653a
Compare
4ef93fe to
f5b41c1
Compare
0b05459 to
a169cde
Compare
6c83a3e to
2405f59
Compare
Storage failures used to mostly come back through the C API as generic Arrow errors, so callers could not cleanly tell whether restarting the whole read or write was worth trying. This change adds explicit transient storage extend statuses and carries a retryable flag in the status detail, while keeping existing non-retryable S3 cases like conflicts and precondition failures separate. S3 error translation now classifies the cases we actually want outer callers to retry after the AWS SDK has already returned its final error. NoSuchUpload is treated as a retryable upload-state failure, and transient network, timeout, throttling, and service-side failures are mapped into dedicated storage transient statuses. Other S3 failures still fall back to plain Arrow errors or existing non-retryable extend statuses, so the retry signal stays intentional instead of being inferred from every storage error. The C FFI now exposes dedicated storage transient errcodes and adds loon_ffi_is_retryable_errcode, so callers can ask the API directly instead of duplicating the mapping. The FFI return helpers also route Arrow statuses with extend status details through the same conversion path across filesystem, reader, writer, manifest, and external table APIs, while preserving the normal fallback code for plain Arrow failures. Signed-off-by: jiaqizho <jiaqi.zhou@zilliz.com>
2405f59 to
6ae8cfc
Compare
|
Overall the direction is right — an explicit transient taxonomy plus a retryable signal through the C FFI is exactly what upper layers need, and replacing P0:
Callers that recognize code 101 can still decide to rebuild the upload and rewrite at their own level. P0: transient HTTP fallback is missing 429 (TOO_MANY_REQUESTS)
One-line fix: add Beyond 429, it's worth auditing the throttling signal of each object store we support, since every vendor throttles differently and the current recognizers only cover the AWS/MinIO shapes ( Also, when the backend does throttle us, the response usually carries an explicit P1: two not-found paths still bypass the structured codes (pre-existing, but worth closing in this PR since it establishes the taxonomy)
P1: public C header lost its stable error-code constants The |
|
18854df to
a31384f
Compare
Signed-off-by: jiaqizho <jiaqi.zhou@zilliz.com>
a31384f to
e58b107
Compare
|
Thanks for the quick turnaround on the round-1 comments — verified the fixes in e58b107: 429 → A second pass over the new head found one blocking issue and two that I'd strongly prefer to see in this PR. Ranked: P0: the streaming read path silently drops the retryable classification (and leaks the marker string)
Why this is blocking: a mid-scan S3 throttle or network blip is the single most common transient failure (large scans spend ~all their time in stream iteration, and throttling correlates with heavy chunk reads). Today the milvus side masks this by retrying every loon error, but the whole point of this PR is to let Suggested fix — translate at the layer where bridge errors first enter C++, so both consumers are covered for free: class VortexErrorTranslatingReader : public arrow::RecordBatchReader {
public:
explicit VortexErrorTranslatingReader(std::shared_ptr<arrow::RecordBatchReader> inner)
: inner_(std::move(inner)) {}
std::shared_ptr<arrow::Schema> schema() const override { return inner_->schema(); }
arrow::Status ReadNext(std::shared_ptr<arrow::RecordBatch>* batch) override {
return MakeVortexErrorStatus("Failed to read vortex record batch", inner_->ReadNext(batch));
}
arrow::Status Close() override {
return MakeVortexErrorStatus("Failed to close vortex record batch reader", inner_->Close());
}
private:
std::shared_ptr<arrow::RecordBatchReader> inner_;
};and return P1: The new ENOENT signal rides an errno side-channel that only survives while nobody wraps the status. Suggested fix — promote it into the taxonomy instead of patching each boundary:
P1: 503 throttle classification — the name allowlist is currently dead code; a coarse split inside the 503 case does the job Three facts (verified against aws-sdk-cpp 1.11.692, the version this repo pins, plus vendor docs/source):
Suggested shape — no standalone name branch, just split the 503 case coarsely: case Aws::Http::HttpResponseCode::SERVICE_UNAVAILABLE: {
// 503 is ambiguous: vendors carry both throttling (MinIO SlowDownRead/Write,
// OSS *Exceeded, COS SlowDown) and genuine unavailability on it. Coarse split
// by name; a miss only costs label precision (Service vs Throttling), never
// retryability, since everything here is transient.
const auto& name = error.GetExceptionName();
if (name.starts_with("SlowDown") || name.ends_with("Exceeded")) {
return MakeExtendError(ExtendStatusCode::StorageTransientThrottling, message, message);
}
return MakeExtendError(ExtendStatusCode::StorageTransientService, message, message);
}
(Also noting for a future PR, not this one: the azure filesystem ( |
|
/approve |
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: jiaqizho, xiaofan-luan 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 |
Signed-off-by: jiaqizho <jiaqi.zhou@zilliz.com>
|
/lgtm |
…e-destroying paths in packed (milvus-io#598) ## Problem Three related defects in the packed (V2 API) library-mode error paths — all instances of the "stringify-and-rethrow destroys the classification" / "abort instead of status" classes: 1. **`PackedRecordBatchReader`'s constructor throws.** On a failed open it does `throw std::runtime_error(status.ToString())` — the classification the lower layers carefully attached (`PackedInvalidArgs` / `PackedStorageIO` / `PackedMetadataCorrupted` details, the filesystem's ENOENT) is flattened into a string at the very last step. Direct-link consumers are forced to `try`-wrap construction and can only parse text. 2. **`ColumnGroup::Table()` does the same** (`throw std::runtime_error(result.status().message())` — even drops `ToString()`'s code prefix). 3. **A fifth unguarded `ValueOrDie` abort path** (missed by the milvus-io#575 sweep, which fixed four): `file_reader.cpp` `FileRecordBatchReader::init`'s `schema==nullptr` branch calls `FieldIDList::Make(schema_).ValueOrDie()`. A parquet file whose stored schema lacks `PARQUET:field_id` metadata **aborts the whole process** — a data-dependent abort. The sibling `schema!=nullptr` branch already used `ARROW_ASSIGN_OR_RAISE`. ## Change - **New `PackedRecordBatchReader::Make(...) → arrow::Result<std::unique_ptr<...>>`** — same parameters, reports open failures as a status with the classification intact. The throwing constructor is **kept** (documented as deprecated, "do not add new call sites") so direct-link consumers can migrate without a lockstep bump; it can be deleted once they have. - milvus side: the only constructor consumer is `segcore/packed_reader_c.cpp` (3 try-wrapped sites). Migrating those to `Make()` hands them a classified status — which is exactly the input the planned "wire the packed C-API through `ToSegcoreError`" follow-up needs (it currently hardcodes FileReadFailed/FileWriteFailed). Tracked for the milvus-side PR; not part of this change. - **`ColumnGroup::Table()` returns `arrow::Result`**, wrapping the cause without destroying it. `ColumnGroup::Schema()` now reads the schema from the first batch (all batches in a group share one schema) instead of materializing the merged table. In-tree callers updated; there are no milvus-side callers of either. - **`file_reader.cpp` abort path** → `ARROW_ASSIGN_OR_RAISE`, matching the sibling branch. ## Verification - Full `make build` clean. - New tests in `packed_error_status_test.cpp`: - `Make()` on a missing file → the wrap preserves the filesystem's not-found detail → `ToSegcoreError` = `ObjectNotExist` (the throwing constructor destroyed exactly this); - `Make()` with empty paths → `PackedInvalidArgs`; - `Make()` success path reads a batch. - Packed/column-group suites: 27/29 passed — the single failure is the pre-existing upstream `ColumnGroupTest.MemoryUsageCalculation` (fails 3/3 in isolation on unmodified main; not touched here), one skipped. Not covered (honest scope): the deprecated throwing constructor still exists (one `throw` site remains by design until milvus migrates); no fault-injection e2e. Refs: milvus-io/milvus#50903. Follows the classification taxonomy of milvus-io#574/milvus-io#575. 🤖 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>
…nomy Azure tagged exactly one condition (PreconditionFailed, plus Conflict + BlobAlreadyExists) and let everything else fall through to a plain Status::IOError. An untagged status reaches segcore as StorageError/2044, permanent and non-retriable -- so on Azure a throttle or a 503 was reported as a permanent storage error and never retried, and a missing blob was indistinguishable from a generic storage failure. internal::ClassifyAzureError maps the failure onto the existing taxonomy: transport failure (no response, StatusCode None) -> 107; 408 -> 108; 429 and 503+ServerBusy -> 109; other 5xx -> 110; 404 -> 104; 401/403 -> 105; 412 and 409+BlobAlreadyExists -> 103. Anything not positively identified stays untagged and keeps today's conservative non-retriable behaviour -- retriability is never invented. It takes the raw HTTP status rather than the SDK exception type so the mapping is unit-testable without an Azure account; every credential-bearing Azure test is skipped in CI, so a mapping tested only through the filesystem would be untested in practice. Also fixes the one real GCS gap: the producer stringified an init failure into Status::Invalid, turning a credential/network problem into a data-class error (DataFormatBroken/2024) and destroying the cause's own classification. It now preserves the status code and detail. Two claims in milvus-io#595 do not hold and are deliberately NOT acted on: GCS is not unclassified (GcpFileSystemProducer builds milvus-storage's own S3FileSystem, so the data path already inherits ErrorToStatus), and azurefs's PathNotFound already carries an ENOENT detail, so it already reaches ObjectNotExist/2017. Closes milvus-io#595 Refs: milvus-io#574, milvus-io#575, milvus-io/milvus#50903 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>
…nomy (milvus-io#604) ## Problem The ExtendStatusCode taxonomy (milvus-io#574 transients 107–110, milvus-io#575 permanent S3 tags 104–106) only covers the S3 path. On Azure, `ExceptionToStatus` tagged exactly one condition — `PreconditionFailed`, plus `Conflict`+`BlobAlreadyExists` — and everything else fell through to a plain `Status::IOError`: https://github.com/milvus-io/milvus-storage/blob/d3eebb1ff653f023bb02c885e139d69daca0f1d2/cpp/src/filesystem/azure/azurefs.cc#L360-L375 An untagged status reaches segcore as `StorageError`/2044 — permanent and non-retriable. So on Azure a **throttle or a 503 was reported as a permanent storage error and never retried**, which is the availability-relevant half of the gap. Closes milvus-io#595. ## Change `internal::ClassifyAzureError(http_status, error_code)` maps the failure onto the existing taxonomy; `ExceptionToStatus` tags the status when it returns a code and keeps today's plain `IOError` when it does not. | Azure | ExtendStatusCode | segcore | retry | |---|---|---|---| | no response (transport: conn refused/reset, DNS, TLS) | `StorageTransientNetwork` 107 | 2045 | yes | | 408 Request Timeout | `StorageTransientTimeout` 108 | 2045 | yes | | 429 Too Many Requests | `StorageTransientThrottling` 109 | 2045 | yes | | 503 + `ServerBusy` | `StorageTransientThrottling` 109 | 2045 | yes | | 503 (other), 500, 502, 504 | `StorageTransientService` 110 | 2045 | yes | | 404 | `AwsErrorNotFound` 104 | 2017 `ObjectNotExist` | no | | 401, 403 | `AwsErrorAccessDenied` 105 | 2044 | no | | 412, 409 + `BlobAlreadyExists` | `AwsErrorPreConditionFailed` 103 | 2044 | no | | anything else (incl. 409 non-`BlobAlreadyExists`) | untagged | 2044 | no | Notes on the two judgement calls: - **Transport failures are the `StatusCode == None` (0) case.** `Azure::Core::Http::TransportException` derives from `RequestFailedException` and `ExceptionToStatus` is already called with it (the `catch` in the HNS check), so it arrives here with `StatusCode` left at its `None` default. That is the discriminator, and it is the one class where "no response at all" makes retry unambiguously right. - **503 is overloaded on Azure Storage**: `ServerBusy` is throttling, everything else is an availability blip. Both retriable, but keeping them apart lets a consumer apply throttle-aware backoff only where it belongs. Same split milvus-io#574 settled on for S3. Codes are cloud-neutral despite the `AwsError*` prefix (per the naming discussion in milvus-io#574) — no new values needed, as milvus-io#595 predicted. The classifier takes the raw HTTP status rather than the SDK exception type specifically so it can be unit-tested **without an Azure account**. Every credential-bearing Azure test is skipped in CI, so a mapping tested only through the filesystem would be untested in practice. ## Two corrections to milvus-io#595 Both were load-bearing for its proposed scope, and both turn out to be wrong. Verified against `d3eebb1`: 1. **"GCS: zero `MakeExtendError` call sites in the whole subtree. All GCS errors surface as untagged status."** — Not so. `GcpFileSystemProducer` builds **milvus-storage's own** `S3FileSystem`, the one carrying `ErrorToStatus`: https://github.com/milvus-io/milvus-storage/blob/d3eebb1ff653f023bb02c885e139d69daca0f1d2/cpp/src/filesystem/gcp/gcp_filesystem_producer.cpp#L255-L259 (`#include "milvus-storage/filesystem/s3/s3_filesystem.h"` at L41, inside `namespace milvus_storage` at L45.) So the GCS **data path already inherits the full S3 classification**, including the HTTP-status channel that is vendor-neutral. What is genuinely unclassified is only the init path, which this PR fixes as a one-liner: it was stringifying the cause into `Status::Invalid`, turning a credential/network init failure into a data-class error (`DataFormatBroken`/2024) and destroying whatever classification the cause carried. The remaining GCS gap is the exception-*name* channel (`SlowDown`, `XMinioServerNotInitialized` are AWS/MinIO dialect), which is a long tail, not the whole taxonomy. 2. **"`PathNotFound()` returns arrow's generic `PathNotFound` IOError with no `ExtendStatusDetail` and no errno detail, so it cannot reach `ObjectNotExist`/2017."** — It does carry an errno detail. `arrow::fs::internal::PathNotFound` is `Status::IOError(...).WithDetail(StatusDetailFromErrno(ENOENT))`, and `ToSegcoreError` checks ENOENT before anything else, so that path already reaches 2017. No change needed, and none made. Net: milvus-io#595's Azure analysis holds and is what this PR implements; its GCS half shrinks to the init-path one-liner. ## Verification - `make build` clean; clang-format-18 clean. - New `cpp/test/filesystem/azure_error_classification_test.cpp`, which runs in CI without credentials: every transient status is retriable and maps to 2045; every permanent one is non-retriable, maps to its expected segcore code, and is asserted **not** to be 2045; unidentified statuses stay untagged and land on 2044; and the two conditions milvus-io#595 named are stated as the end-to-end verdict a consumer sees. - Full suite: **1071 ran / 915 passed / 154 skipped (cloud credentials) / 2 failed.** Both failures — `MetadataTest.TestFieldIDList` and `FileReaderTest.SchemaEvolutionWithInvalidFieldID` — are pre-existing: I reverted the change in place, rebuilt, and reproduced them identically on unmodified `origin/main`. They are in the `FieldIDList` area milvus-io#598 is separately fixing. ## Not covered - The Azure **name** channel: this classifies on HTTP status plus `ErrorCode` only where the status is ambiguous (409, 503). Azure error codes such as `OperationTimedOut` (which arrives as 500) are covered by their status, but a name-only signal with no distinguishing status is not. - The Go/FFI consumer still discards the classification: milvus's `HandleLoonFFIResult` wraps every loon failure in one `ErrLoonTransient` sentinel and never reads `err_code`. The C++/segcore half is wired as of milvus-io/milvus#51530. So this makes Azure's errors *classifiable*, and they now reach segcore correctly, but the Go retry path still cannot tell them apart. Tracked in milvus-io/milvus#50903. - No fault-injection e2e against a live Azure account. Refs: milvus-io#595, milvus-io#574, milvus-io#575, milvus-io/milvus#50903. 🤖 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>
…ability Errors returned to upper layers could say "retryable" but never "whose problem is this", and the retry bit was stored independently of the meaning, in three hand-synced tables. This adds the missing axis and makes the tables generated instead of hand-maintained. - One category per code: User / Transient / Permanent, crossing the C ABI as loon_ffi_error_category(). Retriability is now DERIVED (retryable == category == Transient), not a second stored bool. - Both code tables (LOON_* internal, ExtendStatusCode) move into X-macro lists in ffi_error_code.h. The exported loon_errcode_* constants, error_to_string, the category/retryable lookups, the ExtendStatusCode enum and its metadata are all generated from them, so a code cannot be classified two different ways in two different places. - The 11 internal LOON_* codes are classified for the first time: they were absent from the metadata table, so loon_ffi_is_retryable_errcode answered false for all of them by omission rather than by decision. - Codes are aligned with the AWS S3 / Aliyun OSS vocabulary, with the five deliberate divergences pinned by tests and documented. - New LOON_SOURCE_NOT_FOUND / LOON_SOURCE_ACCESS_DENIED: the same object-store condition is a system failure on an internally generated path and a user error on a path the user typed, and only the entry point knows which. Wired at loon_exttable_explore / loon_exttable_get_file_info, the two entry points that take a user-supplied location. - docs/error-codes.md enumerates every code with its category, retry verdict, S3/OSS equivalent and segcore mapping, plus an honest per-producer coverage table. Behaviour delta: loon_ffi_is_retryable_errcode(LOON_MEMORY_ERROR) now returns true (OOM is retriable, matching segcore 2034). Every other code keeps its previous verdict. No consumer reads these values yet, so the delta is inert today. Refs: milvus-io/milvus#50903 (the deferred "LOON_* enum-ization + category" item), milvus-io#574, milvus-io#575. Supersedes the classification half of milvus-io#568, whose IOError -> retriable mapping contradicts the conservative default milvus-io#574 settled on. 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>
Storage failures used to mostly come back through the C API as generic Arrow errors, so callers could not cleanly tell whether restarting the whole read or write was worth trying. This change adds explicit transient storage extend statuses and carries a retryable flag in the status detail, while keeping existing non-retryable S3 cases like conflicts and precondition failures separate.
S3 error translation now classifies the cases we actually want outer callers to retry after the AWS SDK has already returned its final error. NoSuchUpload is treated as a retryable upload-state failure, and transient network, timeout, throttling, and service-side failures are mapped into dedicated storage transient statuses. Other S3 failures still fall back to plain Arrow errors or existing non-retryable extend statuses, so the retry signal stays intentional instead of being inferred from every storage error.
The C FFI now exposes dedicated storage transient errcodes and adds loon_ffi_is_retryable_errcode, so callers can ask the API directly instead of duplicating the mapping. The FFI return helpers also route Arrow statuses with extend status details through the same conversion path across filesystem, reader, writer, manifest, and external table APIs, while preserving the normal fallback code for plain Arrow failures.