Skip to main content

cqlite_core/observability/
error_schema.rs

1//! Low-cardinality error taxonomy for observability (issue #1038).
2//!
3//! This taxonomy is intentionally distinct from [`crate::error::ErrorCategory`]:
4//! that one is a 14-variant developer-facing grouping consumed by the CLI and
5//! existing tests, and changing it would break public API. The taxonomy here is
6//! the *telemetry* taxonomy — a small, stable, bounded set of `&'static str`
7//! labels safe to use as a metric/span attribute value (see
8//! [`crate::observability::catalog::attr::ERROR_CATEGORY`]).
9//!
10//! # Taxonomy
11//!
12//! | Variant        | `as_str()`       | Maps from `cqlite_core::Error` …                                  |
13//! |----------------|------------------|-------------------------------------------------------------------|
14//! | `Io`           | `io`             | `Io`, `InvalidPath`, `Timeout`                                     |
15//! | `Serialization`| `serialization`  | `Serialization`, `TypeConversion`                                 |
16//! | `Corruption`   | `corruption`     | `Corruption`, `CorruptCommitLogFrame`, `ColumnDecode`             |
17//! | `Schema`       | `schema`         | `Schema`, `Table`                                                  |
18//! | `Parsing`      | `parsing`        | `Parse`, `CqlParse`, `InvalidFormat`, `UnsupportedFormat`,        |
19//! |                |                  | `UnsupportedVersion`, `UnsupportedCommitLogVersion`               |
20//! | `Storage`      | `storage`        | `Storage`, `Memory`, `Index`, `Compaction`, `WriteDirLocked`     |
21//! | `Concurrency`  | `concurrency`    | `Concurrency`, `Transaction`                                       |
22//! | `Constraints`  | `constraints`    | `ConstraintViolation`, `AlreadyExists`                            |
23//! | `Query`        | `query`          | `QueryExecution`, `UnsupportedQuery`, `InvalidInput`,             |
24//! |                |                  | `ResultTooLarge`, `ForcedReadPathUnavailable`, `InvalidReadPath`  |
25//! | `Cancelled`    | `cancelled`      | `Cancelled` (issue #2264 — a cooperative abort, never `Io`)       |
26//! | `Timeout`      | `timeout`        | `QueryTimeout` (issue #1695 — an operator budget, never data)     |
27//! | `Other`        | `other`          | `Configuration`, `InvalidState`, `InvalidOperation`, `NotFound`,  |
28//! |                |                  | `Internal`, `Wasm` (`wasm32` builds only)                         |
29//!
30//! The table is EXACT, not illustrative: every row's `Maps from` column lists the
31//! COMPLETE set of `Error` variants [`classify`] routes to that category, `Other`
32//! included. There is **no catch-all**. [`classify`] matches on `&Error` with every
33//! arm an explicit `Error::<Variant>` pattern (pinned by
34//! `error_schema_tests::classify_has_no_catch_all_arm`), so a newly-added `Error`
35//! variant is a COMPILE ERROR until it is categorised by hand — it is never
36//! silently absorbed into `Other`. `Wasm` is `#[cfg(target_arch = "wasm32")]`-gated
37//! and therefore exists only in `wasm32` builds; it is listed because the table
38//! describes the enum, not one target.
39//!
40//! `error_schema_tests::every_error_variant_classify_routes_is_documented_in_the_taxonomy_table`
41//! enforces variant→category set equality against [`classify`]'s match arms in
42//! both directions (issue #1705, AI5 of epic #1686): a variant routed but
43//! undocumented, a documented variant that is never routed, and a variant listed
44//! under the wrong category all fail. The `Maps from` column is parsed
45//! fail-closed — a non-parenthetical item that is not a backticked variant name,
46//! or a parenthetical that claims catch-all behaviour, reds the guard rather than
47//! being silently dropped as prose (which is how the stale "any future variant
48//! (catch-all)" claim survived here).
49//!
50//! **Scope: telemetry only.** The language bindings do NOT derive from
51//! [`classify`]: `cqlite_ffi_common::error_contract` (issue #1451) mirrors the distinct
52//! [`Error::category`](crate::error::Error::category) enum, and nothing pins the
53//! two together — `QueryTimeout` is `Timeout` here and `Query` there.
54//!
55//! # Relation to spans and CLI exit codes
56//!
57//! - **Spans**: [`crate::observability::record_error`] attaches the
58//!   `as_str()` value as the `cqlite.error.category` attribute on the
59//!   `cqlite.errors.total` counter and as a span event field, and marks the
60//!   current span `otel.status_code = ERROR`.
61//! - **CLI exit codes** (`cqlite-cli/src/error.rs`): the CLI maps the *raw*
62//!   `Error` variants to numeric exit codes (2/3/4/5/6). This taxonomy is a
63//!   coarser, monitoring-oriented view; the rough correspondence is:
64//!   `Schema → exit 3`, `Io`/`Storage` → exit 4, `Query`/`Parsing` → exit 5.
65//!   The two are deliberately decoupled: exit codes are an operator contract,
66//!   the telemetry taxonomy is tuned for dashboards/alerts.
67
68use crate::error::Error;
69
70/// Bounded, telemetry-safe error categories. The total count is small and
71/// fixed, making `as_str()` values safe as metric/span attribute values.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
73pub enum ObsErrorCategory {
74    /// Filesystem / OS I/O, paths, timeouts.
75    Io,
76    /// (De)serialization and type-conversion failures.
77    Serialization,
78    /// On-disk data corruption / checksum failures.
79    Corruption,
80    /// Schema / catalog problems.
81    Schema,
82    /// Binary-format / CQL text parsing failures.
83    Parsing,
84    /// Storage-engine, memory, index, compaction, locking.
85    Storage,
86    /// Concurrency / transaction failures.
87    Concurrency,
88    /// Constraint violations and conflicts.
89    Constraints,
90    /// Query execution / unsupported queries / bad input.
91    Query,
92    /// A cooperative cancellation / abort (issue #2264). Kept distinct from
93    /// `Other` (and never `Io`) so dashboards can see cancellation rate
94    /// separately from genuine errors — a cancelled `do_get` is an expected
95    /// outcome, not a fault.
96    Cancelled,
97    /// A query exceeded its configured execution budget
98    /// (`query.max_execution_time`, issue #1695). Its OWN bucket, never
99    /// `Corruption` (an operator-imposed deadline is not damaged data) and never
100    /// the generic `Other` bucket (a rising timeout rate is the signal that the budget
101    /// is too tight or a scan has regressed — the one thing dashboards must see).
102    Timeout,
103    /// Everything else: configuration, invalid state/operation, not-found,
104    /// internal, platform. NOT a catch-all — [`classify`] names every `Error`
105    /// variant explicitly, so a new variant lands here only when a human puts it
106    /// here (see the module-doc taxonomy table).
107    Other,
108}
109
110impl ObsErrorCategory {
111    /// Stable, low-cardinality label. Safe to use as a metric/span attribute
112    /// value — these strings never change for a given variant.
113    pub fn as_str(self) -> &'static str {
114        match self {
115            ObsErrorCategory::Io => "io",
116            ObsErrorCategory::Serialization => "serialization",
117            ObsErrorCategory::Corruption => "corruption",
118            ObsErrorCategory::Schema => "schema",
119            ObsErrorCategory::Parsing => "parsing",
120            ObsErrorCategory::Storage => "storage",
121            ObsErrorCategory::Concurrency => "concurrency",
122            ObsErrorCategory::Constraints => "constraints",
123            ObsErrorCategory::Query => "query",
124            ObsErrorCategory::Cancelled => "cancelled",
125            ObsErrorCategory::Timeout => "timeout",
126            ObsErrorCategory::Other => "other",
127        }
128    }
129
130    /// All variants, for tests and exhaustiveness checks.
131    pub const ALL: &'static [ObsErrorCategory] = &[
132        ObsErrorCategory::Io,
133        ObsErrorCategory::Serialization,
134        ObsErrorCategory::Corruption,
135        ObsErrorCategory::Schema,
136        ObsErrorCategory::Parsing,
137        ObsErrorCategory::Storage,
138        ObsErrorCategory::Concurrency,
139        ObsErrorCategory::Constraints,
140        ObsErrorCategory::Query,
141        ObsErrorCategory::Cancelled,
142        ObsErrorCategory::Timeout,
143        ObsErrorCategory::Other,
144    ];
145}
146
147impl std::fmt::Display for ObsErrorCategory {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        f.write_str(self.as_str())
150    }
151}
152
153/// Map a [`cqlite_core::Error`](crate::error::Error) to its telemetry
154/// [`ObsErrorCategory`]. This is the single classification point; both
155/// `Error::obs_category` and `record_error` route through it.
156pub(crate) fn classify(err: &Error) -> ObsErrorCategory {
157    match err {
158        Error::Io(_) | Error::InvalidPath(_) | Error::Timeout(_) => ObsErrorCategory::Io,
159
160        Error::Serialization { .. } | Error::TypeConversion(_) => ObsErrorCategory::Serialization,
161
162        // Issue #3721: a per-column decode failure IS damaged/undecodable data at
163        // the cell level — the same operator signal as `Corruption`, and never the
164        // `Other` bucket, so a dashboard shows a read that failed on bad bytes.
165        Error::Corruption(_)
166        | Error::CorruptCommitLogFrame(_)
167        | Error::ColumnDecode { .. } => ObsErrorCategory::Corruption,
168
169        Error::Schema(_) | Error::Table(_) => ObsErrorCategory::Schema,
170
171        Error::Parse(_)
172        | Error::CqlParse(_)
173        | Error::InvalidFormat(_)
174        | Error::UnsupportedFormat(_)
175        | Error::UnsupportedVersion { .. }
176        | Error::UnsupportedCommitLogVersion { .. } => ObsErrorCategory::Parsing,
177
178        Error::Storage(_)
179        | Error::Memory(_)
180        | Error::Index(_)
181        | Error::Compaction(_)
182        | Error::WriteDirLocked { .. } => ObsErrorCategory::Storage,
183
184        Error::Concurrency(_) | Error::Transaction(_) => ObsErrorCategory::Concurrency,
185
186        Error::ConstraintViolation(_) | Error::AlreadyExists(_) => ObsErrorCategory::Constraints,
187
188        Error::QueryExecution(_)
189        | Error::ResultTooLarge { .. }
190        | Error::UnsupportedQuery(_)
191        // Issue #1918: the read-path forcing knob failing closed is a query-time
192        // outcome (`point` unavailable / invalid knob value).
193        | Error::ForcedReadPathUnavailable { .. }
194        | Error::InvalidReadPath { .. }
195        | Error::InvalidInput(_) => ObsErrorCategory::Query,
196
197        // Issue #2264: a cooperative cancellation is an expected outcome, not a
198        // fault — kept out of both `Io` and the generic `Other` bucket.
199        Error::Cancelled => ObsErrorCategory::Cancelled,
200
201        // Issue #1695: an elapsed `query.max_execution_time` budget. Its own
202        // bucket so it is never indistinguishable from `Corruption` on a
203        // dashboard, and never buried in `Other`.
204        Error::QueryTimeout { .. } => ObsErrorCategory::Timeout,
205
206        // The remaining variants, each named EXPLICITLY. This is not a catch-all
207        // and there is no wildcard arm anywhere in this match, so a newly-added
208        // `Error` variant fails to compile until it is categorised here by hand
209        // (pinned by `error_schema_tests::classify_has_no_catch_all_arm`).
210        Error::Configuration(_)
211        | Error::InvalidState(_)
212        | Error::InvalidOperation(_)
213        | Error::NotFound(_)
214        | Error::Internal(_) => ObsErrorCategory::Other,
215
216        #[cfg(target_arch = "wasm32")]
217        Error::Wasm(_) => ObsErrorCategory::Other,
218    }
219}
220
221impl Error {
222    /// Telemetry error category for this error (issue #1038).
223    ///
224    /// Distinct from [`Error::category`](crate::error::Error::category), which
225    /// returns the developer-facing [`crate::error::ErrorCategory`]. This one
226    /// returns the bounded, monitoring-oriented
227    /// [`crate::observability::ObsErrorCategory`].
228    pub fn obs_category(&self) -> ObsErrorCategory {
229        classify(self)
230    }
231}
232
233/// Invariant + code↔doc completeness tests live in a sibling file so this file
234/// stays pure source inside the campsite-rule target (#1116); they are logically
235/// the `tests` submodule of this module.
236#[cfg(test)]
237#[path = "error_schema_tests.rs"]
238mod tests;