Skip to main content

khive_storage/
error.rs

1//! Storage error types shared across all backend implementations.
2
3use std::borrow::Cow;
4use std::error::Error as StdError;
5
6use thiserror::Error;
7
8use crate::capability::StorageCapability;
9
10/// Unified error type for all storage operations.
11#[derive(Debug, Error)]
12pub enum StorageError {
13    #[error("{capability:?} resource not found: {resource} ({key})")]
14    NotFound {
15        capability: StorageCapability,
16        resource: &'static str,
17        key: String,
18    },
19
20    #[error("{capability:?} resource already exists: {resource} ({key})")]
21    AlreadyExists {
22        capability: StorageCapability,
23        resource: &'static str,
24        key: String,
25    },
26
27    #[error("conflict in {capability:?} during {operation}: {message}")]
28    Conflict {
29        capability: StorageCapability,
30        operation: Cow<'static, str>,
31        message: String,
32    },
33
34    #[error("invalid input for {capability:?} during {operation}: {message}")]
35    InvalidInput {
36        capability: StorageCapability,
37        operation: Cow<'static, str>,
38        message: String,
39    },
40
41    #[error("unsupported operation for {capability:?}: {operation} ({message})")]
42    Unsupported {
43        capability: StorageCapability,
44        operation: Cow<'static, str>,
45        message: String,
46    },
47
48    #[error("pool failure during {operation}: {message}")]
49    Pool {
50        operation: Cow<'static, str>,
51        message: String,
52    },
53
54    #[error("timeout during {operation}")]
55    Timeout { operation: Cow<'static, str> },
56
57    #[error("sql transaction failure during {operation}: {message}")]
58    Transaction {
59        operation: Cow<'static, str>,
60        message: String,
61    },
62
63    #[error("serialization failure in {capability:?}: {message}")]
64    Serialization {
65        capability: StorageCapability,
66        message: String,
67    },
68
69    #[error("index maintenance failure in {capability:?}: {message}")]
70    IndexMaintenance {
71        capability: StorageCapability,
72        message: String,
73    },
74
75    #[error("backend driver error in {capability:?} during {operation}: {source}")]
76    Driver {
77        capability: StorageCapability,
78        operation: Cow<'static, str>,
79        #[source]
80        source: Box<dyn StdError + Send + Sync>,
81    },
82
83    /// The bounded write-queue channel (ADR-067 Component A) did not free
84    /// capacity within the caller-supplied deadline. Returned only when a
85    /// caller wraps `WriterTaskHandle::send`'s `channel.send().await` in a
86    /// `tokio::time::timeout`; there is no immediate-error `try_send` path.
87    #[error("write queue full: timed out after {timeout_ms}ms waiting for writer task capacity")]
88    WriteQueueFull { timeout_ms: u64 },
89
90    /// An internal write-queue plumbing failure not attributable to a
91    /// specific storage capability: the writer task's channel closed (the
92    /// task panicked or was dropped) or its oneshot reply was dropped before
93    /// sending a result.
94    #[error("internal storage error: {0}")]
95    Internal(String),
96
97    /// `KHIVE_WRITE_QUEUE=1` is set but the calling thread has no Tokio
98    /// runtime context, so the writer task cannot be spawned (ADR-067
99    /// Component A). Returned instead of panicking.
100    /// See `crates/khive-storage/docs/api/error-taxonomy.md#writertasknoruntime`.
101    #[error(
102        "KHIVE_WRITE_QUEUE=1 but no Tokio runtime context is available to spawn the writer task"
103    )]
104    WriterTaskNoRuntime,
105
106    /// A filesystem-backed capability (e.g. `BlobStore`) refused a write
107    /// because `volume`'s available space, after accounting for the pending
108    /// write, would drop below the configured free-space floor (khive#292).
109    #[error(
110        "refusing write on {capability:?} at {volume}: {available_bytes} bytes available, \
111         below the {floor_bytes}-byte floor"
112    )]
113    CapacityFloor {
114        capability: StorageCapability,
115        volume: String,
116        available_bytes: u64,
117        floor_bytes: u64,
118    },
119}
120
121impl StorageError {
122    /// Construct a `Driver` error wrapping a backend-specific error source.
123    pub fn driver(
124        capability: StorageCapability,
125        operation: impl Into<Cow<'static, str>>,
126        source: impl StdError + Send + Sync + 'static,
127    ) -> Self {
128        Self::Driver {
129            capability,
130            operation: operation.into(),
131            source: Box::new(source),
132        }
133    }
134
135    /// Return the storage capability surface that produced this error, if any.
136    pub fn capability(&self) -> Option<StorageCapability> {
137        match self {
138            Self::NotFound { capability, .. }
139            | Self::AlreadyExists { capability, .. }
140            | Self::Conflict { capability, .. }
141            | Self::InvalidInput { capability, .. }
142            | Self::Unsupported { capability, .. }
143            | Self::Serialization { capability, .. }
144            | Self::IndexMaintenance { capability, .. }
145            | Self::Driver { capability, .. }
146            | Self::CapacityFloor { capability, .. } => Some(*capability),
147            Self::Pool { .. }
148            | Self::Timeout { .. }
149            | Self::Transaction { .. }
150            | Self::WriteQueueFull { .. }
151            | Self::Internal(..)
152            | Self::WriterTaskNoRuntime => None,
153        }
154    }
155
156    /// Whether this error is transient and the operation may succeed on retry.
157    pub fn is_retryable(&self) -> bool {
158        matches!(
159            self,
160            Self::Pool { .. }
161                | Self::Timeout { .. }
162                | Self::Transaction { .. }
163                | Self::WriteQueueFull { .. }
164        )
165    }
166
167    /// Whether this error is an FTS5 query-parser rejection of the MATCH
168    /// expression itself, as opposed to a connection/pool/driver-level
169    /// failure of the text-search backend.
170    ///
171    /// True only for `Driver` errors from the `Text` capability at the
172    /// `fts_search` operation whose message names one of SQLite's FTS5
173    /// parser failure modes (syntax error, stack overflow, unsupported
174    /// column/phrase/NEAR query); all other errors return `false`.
175    ///
176    /// Callers that fail-open the FTS leg of a hybrid search (degrading to
177    /// vector-only results on a bad query string) MUST gate on this
178    /// predicate rather than on `StorageError` broadly โ€” treating every
179    /// `Err` as degradable turns a real backend outage into a silently-empty
180    /// "successful" search (issue #389).
181    /// See `crates/khive-storage/docs/api/error-taxonomy.md#is_fts5_syntax_error`.
182    pub fn is_fts5_syntax_error(&self) -> bool {
183        let Self::Driver {
184            capability,
185            operation,
186            source,
187        } = self
188        else {
189            return false;
190        };
191        if *capability != StorageCapability::Text || operation.as_ref() != "fts_search" {
192            return false;
193        }
194        let msg = source.to_string();
195        msg.contains("fts5: syntax error")
196            || msg.contains("fts5: parser stack overflow")
197            || msg.contains("fts5: column queries are not supported")
198            || msg.contains("fts5: phrase queries are not supported (detail")
199            || msg.contains("fts5: NEAR queries are not supported (detail")
200    }
201
202    /// Whether this error is a UNIQUE constraint violation from a raw SQL
203    /// `execute` (e.g. an `INSERT` racing an existing row under a natural
204    /// key). True only for `Driver` errors from the `Sql` capability whose
205    /// `operation` is one of `execute`, `pool_writer.execute`, or
206    /// `tx.execute`, and whose message contains `UNIQUE constraint failed`.
207    /// Batch/script operations are intentionally excluded.
208    ///
209    /// Callers that treat exact-key duplicates as a tolerated no-op
210    /// (ADR-081 ยง4 serve-ledger idempotency) MUST gate on this predicate
211    /// rather than swallowing every `Driver` error at `execute` โ€” that would
212    /// also hide genuine write failures (disk full, corruption).
213    /// See `crates/khive-storage/docs/api/error-taxonomy.md#is_unique_constraint_violation`.
214    pub fn is_unique_constraint_violation(&self) -> bool {
215        let Self::Driver {
216            capability,
217            operation,
218            source,
219        } = self
220        else {
221            return false;
222        };
223        if *capability != StorageCapability::Sql {
224            return false;
225        }
226        if !matches!(
227            operation.as_ref(),
228            "execute" | "pool_writer.execute" | "tx.execute"
229        ) {
230            return false;
231        }
232        source.to_string().contains("UNIQUE constraint failed")
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use std::fmt;
240
241    #[derive(Debug)]
242    struct FakeSource(String);
243
244    impl fmt::Display for FakeSource {
245        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246            write!(f, "{}", self.0)
247        }
248    }
249
250    impl StdError for FakeSource {}
251
252    fn driver_err(operation: &'static str, message: &str) -> StorageError {
253        StorageError::driver(
254            StorageCapability::Text,
255            operation,
256            FakeSource(message.into()),
257        )
258    }
259
260    #[test]
261    fn fts5_syntax_error_at_fts_search_is_classified_as_syntax_error() {
262        let e = driver_err("fts_search", "fts5: syntax error near \"@\"");
263        assert!(e.is_fts5_syntax_error());
264    }
265
266    #[test]
267    fn fts5_parser_stack_overflow_is_classified_as_syntax_error() {
268        let e = driver_err("fts_search", "fts5: parser stack overflow");
269        assert!(e.is_fts5_syntax_error());
270    }
271
272    #[test]
273    fn fts5_unsupported_column_query_is_classified_as_syntax_error() {
274        let e = driver_err(
275            "fts_search",
276            "fts5: column queries are not supported (detail=none)",
277        );
278        assert!(e.is_fts5_syntax_error());
279    }
280
281    #[test]
282    fn timeout_is_not_classified_as_syntax_error() {
283        let e = StorageError::Timeout {
284            operation: "fts_search".into(),
285        };
286        assert!(!e.is_fts5_syntax_error());
287    }
288
289    #[test]
290    fn pool_failure_is_not_classified_as_syntax_error() {
291        let e = StorageError::Pool {
292            operation: "fts_search".into(),
293            message: "pool exhausted".into(),
294        };
295        assert!(!e.is_fts5_syntax_error());
296    }
297
298    #[test]
299    fn driver_error_at_non_search_operation_is_not_classified_as_syntax_error() {
300        let e = driver_err("open_fts_reader", "fts5: syntax error near \"@\"");
301        assert!(!e.is_fts5_syntax_error());
302    }
303
304    #[test]
305    fn driver_error_with_unrelated_message_is_not_classified_as_syntax_error() {
306        let e = driver_err("fts_search", "disk I/O error");
307        assert!(!e.is_fts5_syntax_error());
308    }
309
310    #[test]
311    fn fts5_phrase_detail_query_is_classified_as_syntax_error() {
312        let e = driver_err(
313            "fts_search",
314            "fts5: phrase queries are not supported (detail!=full)",
315        );
316        assert!(e.is_fts5_syntax_error());
317    }
318
319    #[test]
320    fn fts5_near_detail_query_is_classified_as_syntax_error() {
321        let e = driver_err(
322            "fts_search",
323            "fts5: NEAR queries are not supported (detail!=full)",
324        );
325        assert!(e.is_fts5_syntax_error());
326    }
327
328    #[test]
329    fn unprefixed_detail_message_is_not_classified_as_syntax_error() {
330        let e = driver_err(
331            "fts_search",
332            "phrase queries are not supported (detail!=full)",
333        );
334        assert!(!e.is_fts5_syntax_error());
335    }
336
337    #[test]
338    fn fts5_shadow_table_corruption_is_not_classified_as_syntax_error() {
339        let e = driver_err(
340            "fts_search",
341            "fts5: error creating shadow table notes_content: no such table",
342        );
343        assert!(!e.is_fts5_syntax_error());
344    }
345
346    #[test]
347    fn non_text_capability_is_not_classified_as_syntax_error() {
348        let e = StorageError::Driver {
349            capability: StorageCapability::Vectors,
350            operation: "fts_search".into(),
351            source: Box::new(FakeSource("fts5: syntax error near \"@\"".into())),
352        };
353        assert!(!e.is_fts5_syntax_error());
354    }
355
356    fn driver_err_sql(operation: &'static str, message: &str) -> StorageError {
357        StorageError::driver(
358            StorageCapability::Sql,
359            operation,
360            FakeSource(message.into()),
361        )
362    }
363
364    #[test]
365    fn unique_constraint_failure_at_execute_sql_capability_is_classified() {
366        let e = driver_err_sql(
367            "execute",
368            "UNIQUE constraint failed: brain_serve_ledger.namespace, \
369             brain_serve_ledger.target_id, brain_serve_ledger.query_class, \
370             brain_serve_ledger.served_at",
371        );
372        assert!(e.is_unique_constraint_violation());
373    }
374
375    #[test]
376    fn unique_constraint_failure_at_pool_writer_execute_is_classified() {
377        let e = driver_err_sql("pool_writer.execute", "UNIQUE constraint failed: t.id");
378        assert!(e.is_unique_constraint_violation());
379    }
380
381    #[test]
382    fn unique_constraint_message_at_non_execute_operation_is_not_classified() {
383        let e = driver_err_sql("query_row", "UNIQUE constraint failed: t.id");
384        assert!(!e.is_unique_constraint_violation());
385    }
386
387    #[test]
388    fn non_unique_driver_error_at_execute_is_not_classified() {
389        let e = driver_err_sql("execute", "disk I/O error");
390        assert!(!e.is_unique_constraint_violation());
391    }
392
393    #[test]
394    fn non_sql_capability_is_not_classified_as_unique_violation() {
395        let e = driver_err("execute", "UNIQUE constraint failed: t.id");
396        assert!(!e.is_unique_constraint_violation());
397    }
398
399    #[test]
400    fn timeout_is_not_classified_as_unique_violation() {
401        let e = StorageError::Timeout {
402            operation: "execute".into(),
403        };
404        assert!(!e.is_unique_constraint_violation());
405    }
406}