Skip to content

Carry a typed classification on storage failures - #491

Open
ragnorc wants to merge 2 commits into
mainfrom
rfc-031-typed-storage-failures
Open

Carry a typed classification on storage failures#491
ragnorc wants to merge 2 commits into
mainfrom
rfc-031-typed-storage-failures

Conversation

@ragnorc

@ragnorc ragnorc commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Every Lance and object-store failure was flattened into OmniError::Lance(String), so nothing above the substrate boundary could distinguish a transient transport condition from a permanent one without parsing error text.

That is not hypothetical. object_store routes every retried-and-exhausted HTTP condition — 5xx, connection resets, DNS, TLS, retry-budget exhaustion — into Error::Generic, and Lance keeps that value reachable through IO { source }. All of it arrived upstream as an opaque string, indistinguishable from a corrupt file.

This adds StorageFailureKind (Transient / Configuration / NotFound / Permanent) and derives it once, at each substrate boundary:

  • From<lance::Error> classifies by variant and, for IO, downcasts through the source chain to the originating object_store::Error. No text parsing anywhere.
  • The storage adapter's storage_backend_error takes the typed object_store::Error instead of impl Display, so the classification survives the boundary it previously died at. LocalFileSystem reports plain OS errors through Generic too, so that path inspects the underlying io::Error — a wrong path is not something retrying fixes.
  • From<ArrowError> maps to a manifest-internal failure: an Arrow shape violation is an engine bug and must never be classifiable as storage.
  • From<DataFusionError> classifies by source chain, since the same type carries both in-memory execution failures and failures raised during a Lance scan.

OmniError becomes #[non_exhaustive] (one catch-all arm added in the server). StorageFailureKind stays closed on purpose: adding a class should be a compile error at every consumer that switches on it.

The first commit is separable and lands first: ~50 call sites raised OmniError::Lance for failures that never touched storage — missing columns, unexpected Arrow array types, blob-descriptor shape mismatches. Harmless while the variant was an untyped string bucket; actively wrong once storage failures carry a retryability signal.

Behavior

Display is byte-identical (storage: <message>), so logs, operator runbooks, and the HTTP mapping are unchanged. This PR adds the signal; no caller consumes it yet.

One deliberate omission: the publisher's namespace-validation conflicts keep the storage variant as Permanent rather than becoming manifest conflicts. That retargeting would change their HTTP status, which is a behavior change that does not belong in a classification refactor.

Verification

  • 8 new classification tests construct the exact upstream lance::Error and object_store::Error shapes (not approximations) and pin: transient survives the Lance boundary, permission/absence classify correctly, commit conflicts keep their dedicated variant, Arrow failures are never storage, DataFusion classifies by source not call site, Display is unchanged, and context preserves classification.
  • New storage-crate tests pin that a remote Generic stays transient while a local OS error through the same variant does not.
  • cargo test --workspace --locked --features omnigraph-engine/failpoints,omnigraph-cluster/failpoints — 75 suites, 2019 tests, zero failures.
  • cargo clippy --workspace --all-targets --locked -- -D warnings -W clippy::dbg_macro — clean.
  • cargo fmt --all --check.

No storage-format migration.


Note

Medium Risk
Wide mechanical refactor across manifest, recovery, exec, and HTTP error translation; wrong classification could mislead future retry logic, though current HTTP/logs behavior is intended to stay the same.

Overview
Replaces the flat OmniError::Lance(String) bucket with OmniError::Storage(StorageFailure), carrying StorageFailureKind (Transient, Configuration, NotFound, Permanent) derived once at substrate boundaries—no error-text parsing above storage.

omnigraph-storage adds classify_object_store_error (including Generic → transient for remote S3 vs local io::Error inspection), wraps failures in StorageError::Backend, and changes storage_backend_error to accept typed object_store::Error.

omnigraph centralizes Lance mapping in From<lance::Error> (commit conflicts stay RetryableCommitConflict), walks source chains for DataFusionError, maps ArrowError to manifest-internal, and adds storage_context / storage_permanent / with_context. Hundreds of call sites switch from OmniError::Lance(...) to OmniError::from or manifest_internal for engine/shape bugs that were mislabeled as storage.

omnigraph-server maps OmniError::Storage to HTTP internal and adds a catch-all for #[non_exhaustive] OmniError. Operator-facing storage: … display is unchanged; classification is additive (not yet consumed for retry/HTTP branching beyond mapping).

Reviewed by Cursor Bugbot for commit 496bee9. Bugbot is set up for automated code reviews on this repo. Configure here.

Greptile Summary

The PR replaces string-flattened storage errors with typed failure classifications while preserving existing operator-facing messages.

  • Adds transient, configuration, not-found, and permanent storage failure classes at the object-store and Lance boundaries.
  • Retargets non-storage Arrow, DataFusion, and engine validation failures to appropriate error variants.
  • Makes OmniError non-exhaustive and updates the server fallback mapping.
  • Adds focused classification and message-compatibility tests and documents the new public error taxonomy.

Confidence Score: 5/5

The PR appears safe to merge; no actionable changed-code defects were identified.

Storage errors are classified at typed substrate boundaries, non-storage failures remain separately represented, existing display behavior is preserved, and the dependency advisories inspected are unchanged from the base branch.

Important Files Changed

Filename Overview
crates/omnigraph-storage/src/lib.rs Introduces the shared storage-failure model and classifies typed object-store and local I/O failures at the adapter boundary.
crates/omnigraph/src/error.rs Centralizes Lance and DataFusion source-chain classification, adds typed storage accessors, and keeps non-storage failures outside the storage taxonomy.
crates/omnigraph-server/src/lib.rs Updates the HTTP translation for typed storage failures and adds a forward-compatible fallback for the non-exhaustive engine error.
crates/omnigraph/src/db/manifest/recovery.rs Propagates typed Lance failures through recovery paths while preserving manifest and recovery-specific error semantics.
crates/omnigraph/src/exec/query.rs Replaces string flattening with typed conversions so scan-originated storage failures retain their classification.
docs/user/operations/errors.md Documents the new storage classification API without claiming an HTTP behavior change.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  OS[object_store::Error] --> OC[classify_object_store_error]
  OC --> SF[StorageFailure]
  LE[lance::Error] --> LC[classify_lance_error]
  LC --> SF
  DF[DataFusionError] --> SC{Storage source chain?}
  SC -->|yes| SF
  SC -->|no| DE[DataFusion execution error]
  AE[ArrowError] --> ME[Manifest internal error]
  SF --> OE[OmniError::Storage]
  OE --> API[Existing HTTP 500 storage message]
Loading

Reviews (1): Last reviewed commit: "feat(error): carry a typed classificatio..." | Re-trigger Greptile

Context used (5)

…rage

Fifty call sites raised `OmniError::Lance` for failures that never touched
storage: missing columns, unexpected Arrow array types, blob-descriptor shape
mismatches, and row-alignment violations. These are engine invariant
violations wearing a storage costume.

They are harmless today because `OmniError::Lance` is an untyped string bucket
that everything above treats as opaque. They stop being harmless the moment
storage failures carry a typed retryability signal: a supervisor deciding
"should I retry this graph?" would read an Arrow-shape bug as a storage
condition and reason about it as transient.

Retarget them to `OmniError::manifest_internal`, which is what they always
were. No behavior change beyond the error's own classification: both variants
surface as an opaque internal failure to every current consumer, and no test
asserts on their text.

The sites that legitimately wrap a `lance::Error` with context keep the
storage variant.
Every Lance and object-store failure was flattened to a string
(`OmniError::Lance(String)`), so nothing above the substrate boundary could
tell a transient transport condition from a permanent one without parsing
error text — which no code was willing to do, correctly.

The cost is not hypothetical. `object_store` routes every retried-and-exhausted
HTTP condition — 5xx, connection resets, DNS, TLS, budget exhaustion — into
`Error::Generic`, and Lance keeps that value reachable through `IO { source }`.
All of it arrived upstream as an opaque string, indistinguishable from a
corrupt file. A caller wanting to retry an S3 blip had no signal to retry on.

Introduce `StorageFailureKind` — `Transient`, `Configuration`, `NotFound`,
`Permanent` — and derive it once, at each substrate boundary:

- `From<lance::Error>` classifies by variant and, for `IO`, downcasts through
  the source chain to the originating `object_store::Error`. No text parsing.
- The storage adapter's `storage_backend_error` now takes the typed
  `object_store::Error` instead of `impl Display`, so the classification
  survives the boundary it previously died at. `LocalFileSystem` also reports
  plain OS errors through `Generic`, so that path inspects the underlying
  `io::Error` — a wrong path is not something retrying fixes.
- `From<ArrowError>` maps to a manifest-internal failure: an Arrow shape
  violation is an engine bug and must never be classifiable as storage.
- `From<DataFusionError>` classifies by source chain, because the same type
  carries both in-memory execution failures and failures raised during a Lance
  scan.

The enum is closed on purpose: adding a class should be a compile error at
every consumer that switches on it. `OmniError` becomes `#[non_exhaustive]` so
the reverse is not true for the error as a whole; that costs one catch-all arm
in the server's translation.

`with_context` replaces the several sites that formatted an already-classified
error into a new error's message, which threw the classification away. Context
says where a failure happened; it never changes what it is.

Commit conflicts keep their dedicated variant — RFC-023's effect-free key
fence depends on telling them apart from arbitrary storage failures.

Display is byte-identical (`storage: <message>`), so logs, operator runbooks,
and the HTTP mapping are unchanged. This commit adds the signal; no caller
consumes it yet.

One deliberate omission: the publisher's namespace-validation conflicts keep
the storage variant as `Permanent` rather than becoming manifest conflicts.
That retargeting would change their HTTP status, which is a behavior change
that does not belong in a classification refactor.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 496bee9. Configure here.

object_store::Error::JoinError { .. } => StorageFailureKind::Transient,
object_store::Error::NotFound { .. } | object_store::Error::NotModified { .. } => {
StorageFailureKind::NotFound
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NotModified mapped to NotFound

Medium Severity

object_store::Error::NotModified is classified as StorageFailureKind::NotFound. A 304 means the object exists and matched a precondition; treating it as absence can make the first consumer of kind take create-or-recreate paths for a live object.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 496bee9. Configure here.

StorageError::Backend(StorageFailure::new(
kind,
format!("storage {} failed for '{}': {}", action, uri, err),
))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backend errors gain storage prefix

Medium Severity

Object-store failures from storage_backend_error now become OmniError::Storage, whose storage: {message} Display and HTTP mapping add another storage: on top of messages that already begin with storage … failed. Logs and HTTP bodies therefore change from the previous manifest-internal wording.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 496bee9. Configure here.

return Ok(true);
};
let kind = BlobKind::try_from(kind).map_err(|e| OmniError::Lance(e.to_string()))?;
let kind = BlobKind::try_from(kind).map_err(OmniError::from)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blob kind still classified as storage

Medium Severity

Invalid BlobKind values still go through OmniError::from on lance::Error, so a descriptor shape mismatch becomes a classified Storage failure (typically Configuration) instead of a manifest-internal engine error like the other retargeted non-storage sites.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 496bee9. Configure here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 496bee9ec1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +178 to +180
object_store::Error::NotFound { .. } | object_store::Error::NotModified { .. } => {
StorageFailureKind::NotFound
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not classify a 304 response as a missing object

When a conditional object-store read receives NotModified (HTTP 304 because the supplied ETag still matches), the object is known to exist, yet this arm reports StorageFailureKind::NotFound, whose public contract says the object genuinely does not exist. SDK consumers using storage_failure() can therefore take absence/recreation or recovery actions on a cache-freshness response; give NotModified a non-absence disposition instead of grouping it with NotFound.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant