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;