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    /// an un-timed-out send simply keeps applying backpressure.
88    #[error("write queue full: timed out after {timeout_ms}ms waiting for writer task capacity")]
89    WriteQueueFull { timeout_ms: u64 },
90
91    /// An internal write-queue plumbing failure not attributable to a
92    /// specific storage capability: the writer task's channel closed (the
93    /// task panicked or was dropped) or its oneshot reply was dropped before
94    /// sending a result.
95    #[error("internal storage error: {0}")]
96    Internal(String),
97
98    /// `KHIVE_WRITE_QUEUE=1` is set but the calling thread has no Tokio
99    /// runtime context, so the writer task (which spawns via `tokio::spawn`)
100    /// cannot be started (ADR-067 Component A).
101    ///
102    /// Returned instead of panicking: a caller that constructs a store from
103    /// a plain, non-async context with the flag on gets a clean, typed
104    /// failure at first write rather than a `tokio::spawn`-outside-runtime
105    /// panic. Flag-off callers never see this variant — `writer_task_handle`
106    /// only attempts to spawn when `PoolConfig::write_queue_enabled` is set.
107    #[error(
108        "KHIVE_WRITE_QUEUE=1 but no Tokio runtime context is available to spawn the writer task"
109    )]
110    WriterTaskNoRuntime,
111}
112
113impl StorageError {
114    /// Construct a `Driver` error wrapping a backend-specific error source.
115    pub fn driver(
116        capability: StorageCapability,
117        operation: impl Into<Cow<'static, str>>,
118        source: impl StdError + Send + Sync + 'static,
119    ) -> Self {
120        Self::Driver {
121            capability,
122            operation: operation.into(),
123            source: Box::new(source),
124        }
125    }
126
127    /// Return the storage capability surface that produced this error, if any.
128    pub fn capability(&self) -> Option<StorageCapability> {
129        match self {
130            Self::NotFound { capability, .. }
131            | Self::AlreadyExists { capability, .. }
132            | Self::Conflict { capability, .. }
133            | Self::InvalidInput { capability, .. }
134            | Self::Unsupported { capability, .. }
135            | Self::Serialization { capability, .. }
136            | Self::IndexMaintenance { capability, .. }
137            | Self::Driver { capability, .. } => Some(*capability),
138            Self::Pool { .. }
139            | Self::Timeout { .. }
140            | Self::Transaction { .. }
141            | Self::WriteQueueFull { .. }
142            | Self::Internal(..)
143            | Self::WriterTaskNoRuntime => None,
144        }
145    }
146
147    /// Whether this error is transient and the operation may succeed on retry.
148    pub fn is_retryable(&self) -> bool {
149        matches!(
150            self,
151            Self::Pool { .. }
152                | Self::Timeout { .. }
153                | Self::Transaction { .. }
154                | Self::WriteQueueFull { .. }
155        )
156    }
157
158    /// Whether this error is an FTS5 query-parser rejection of the MATCH
159    /// expression itself, as opposed to a connection/pool/driver-level
160    /// failure of the text-search backend.
161    ///
162    /// Callers that fail-open the FTS leg of a hybrid search (degrading to
163    /// vector-only results on a bad query string) MUST gate on this predicate
164    /// rather than on `StorageError` broadly. `TextSearch::search` returns the
165    /// same `Driver` variant for a malformed MATCH expression *and* for a
166    /// genuine backend outage (pool exhaustion, connection failure, reader
167    /// open failure) — treating every `Err` as degradable turns a real outage
168    /// into a silently-empty "successful" search (issue #389 round-2 High).
169    ///
170    /// SQLite's FTS5 query parser (`sqlite3Fts5ParseError`, fts5_expr.c)
171    /// prefixes every message it emits with the literal `"fts5: "` token —
172    /// e.g. `fts5: syntax error near "@"`, `fts5: parser stack overflow`,
173    /// `fts5: column queries are not supported (detail=none)`. This is a
174    /// stable SQLite-internal convention, not a substring picked to match one
175    /// observed message. It excludes non-parser FTS5 subsystem failures such
176    /// as `fts5: error creating shadow table ...` (schema/storage corruption)
177    /// by requiring the message to name one of the parser's own failure
178    /// modes, not just the `fts5:` namespace prefix.
179    ///
180    /// Only applies to `Driver` errors from the `Text` capability at the
181    /// `fts_search` operation — the exact seam `Fts5TextSearch::search` uses
182    /// (`crates/khive-db/src/stores/text.rs`). Pool, Timeout, Transaction,
183    /// and any other `operation` value (e.g. `fts_count`, `open_fts_reader`)
184    /// always propagate.
185    pub fn is_fts5_syntax_error(&self) -> bool {
186        let Self::Driver {
187            capability,
188            operation,
189            source,
190        } = self
191        else {
192            return false;
193        };
194        if *capability != StorageCapability::Text || operation.as_ref() != "fts_search" {
195            return false;
196        }
197        let msg = source.to_string();
198        msg.contains("fts5: syntax error")
199            || msg.contains("fts5: parser stack overflow")
200            || msg.contains("fts5: column queries are not supported")
201            || msg.contains("fts5: phrase queries are not supported (detail")
202            || msg.contains("fts5: NEAR queries are not supported (detail")
203    }
204
205    /// Whether this error is a UNIQUE constraint violation from a raw SQL
206    /// `execute` (e.g. an `INSERT` racing an existing row under the ledger's
207    /// natural key). Callers that treat exact-key duplicates as a tolerated
208    /// no-op (ADR-081 §4 serve-ledger idempotency) MUST gate on this
209    /// predicate rather than swallowing every `Driver` error at `execute` —
210    /// that would also hide genuine write failures (disk full, corruption).
211    ///
212    /// Only applies to `Driver` errors from the `Sql` capability at a single-
213    /// statement execute operation. `khive-db`'s `sql_bridge` labels this
214    /// operation differently depending on which `SqlAccess` seam produced the
215    /// writer — a bare transaction's `execute` vs. a pooled `writer()`'s
216    /// `pool_writer.execute` vs. an explicit `tx.execute` — so all three are
217    /// accepted; batch/script variants are intentionally excluded since a
218    /// UNIQUE violation partway through a multi-statement batch is not the
219    /// same single-row-duplicate case this predicate exists to tolerate.
220    pub fn is_unique_constraint_violation(&self) -> bool {
221        let Self::Driver {
222            capability,
223            operation,
224            source,
225        } = self
226        else {
227            return false;
228        };
229        if *capability != StorageCapability::Sql {
230            return false;
231        }
232        if !matches!(
233            operation.as_ref(),
234            "execute" | "pool_writer.execute" | "tx.execute"
235        ) {
236            return false;
237        }
238        source.to_string().contains("UNIQUE constraint failed")
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use std::fmt;
246
247    #[derive(Debug)]
248    struct FakeSource(String);
249
250    impl fmt::Display for FakeSource {
251        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252            write!(f, "{}", self.0)
253        }
254    }
255
256    impl StdError for FakeSource {}
257
258    fn driver_err(operation: &'static str, message: &str) -> StorageError {
259        StorageError::driver(
260            StorageCapability::Text,
261            operation,
262            FakeSource(message.into()),
263        )
264    }
265
266    #[test]
267    fn fts5_syntax_error_at_fts_search_is_classified_as_syntax_error() {
268        let e = driver_err("fts_search", "fts5: syntax error near \"@\"");
269        assert!(e.is_fts5_syntax_error());
270    }
271
272    #[test]
273    fn fts5_parser_stack_overflow_is_classified_as_syntax_error() {
274        let e = driver_err("fts_search", "fts5: parser stack overflow");
275        assert!(e.is_fts5_syntax_error());
276    }
277
278    #[test]
279    fn fts5_unsupported_column_query_is_classified_as_syntax_error() {
280        let e = driver_err(
281            "fts_search",
282            "fts5: column queries are not supported (detail=none)",
283        );
284        assert!(e.is_fts5_syntax_error());
285    }
286
287    #[test]
288    fn timeout_is_not_classified_as_syntax_error() {
289        let e = StorageError::Timeout {
290            operation: "fts_search".into(),
291        };
292        assert!(!e.is_fts5_syntax_error());
293    }
294
295    #[test]
296    fn pool_failure_is_not_classified_as_syntax_error() {
297        let e = StorageError::Pool {
298            operation: "fts_search".into(),
299            message: "pool exhausted".into(),
300        };
301        assert!(!e.is_fts5_syntax_error());
302    }
303
304    #[test]
305    fn driver_error_at_non_search_operation_is_not_classified_as_syntax_error() {
306        // Same message text, but at a different operation (e.g. reader open
307        // failure) — must not be classified as a syntax error even if the
308        // underlying message happened to mention "fts5:".
309        let e = driver_err("open_fts_reader", "fts5: syntax error near \"@\"");
310        assert!(!e.is_fts5_syntax_error());
311    }
312
313    #[test]
314    fn driver_error_with_unrelated_message_is_not_classified_as_syntax_error() {
315        // A genuine connection/driver outage at the fts_search operation, but
316        // whose message does not name a parser failure mode — must propagate.
317        let e = driver_err("fts_search", "disk I/O error");
318        assert!(!e.is_fts5_syntax_error());
319    }
320
321    #[test]
322    fn fts5_phrase_detail_query_is_classified_as_syntax_error() {
323        let e = driver_err(
324            "fts_search",
325            "fts5: phrase queries are not supported (detail!=full)",
326        );
327        assert!(e.is_fts5_syntax_error());
328    }
329
330    #[test]
331    fn fts5_near_detail_query_is_classified_as_syntax_error() {
332        let e = driver_err(
333            "fts_search",
334            "fts5: NEAR queries are not supported (detail!=full)",
335        );
336        assert!(e.is_fts5_syntax_error());
337    }
338
339    #[test]
340    fn unprefixed_detail_message_is_not_classified_as_syntax_error() {
341        // Round-3 High: a driver message containing the detail-mode substring
342        // WITHOUT the `fts5: ` parser prefix is not from the FTS5 query
343        // parser — must propagate, not degrade.
344        let e = driver_err(
345            "fts_search",
346            "phrase queries are not supported (detail!=full)",
347        );
348        assert!(!e.is_fts5_syntax_error());
349    }
350
351    #[test]
352    fn fts5_shadow_table_corruption_is_not_classified_as_syntax_error() {
353        // A real FTS5-subsystem failure (schema/storage corruption), not a
354        // MATCH-expression parser rejection — must propagate, not degrade.
355        let e = driver_err(
356            "fts_search",
357            "fts5: error creating shadow table notes_content: no such table",
358        );
359        assert!(!e.is_fts5_syntax_error());
360    }
361
362    #[test]
363    fn non_text_capability_is_not_classified_as_syntax_error() {
364        let e = StorageError::Driver {
365            capability: StorageCapability::Vectors,
366            operation: "fts_search".into(),
367            source: Box::new(FakeSource("fts5: syntax error near \"@\"".into())),
368        };
369        assert!(!e.is_fts5_syntax_error());
370    }
371
372    fn driver_err_sql(operation: &'static str, message: &str) -> StorageError {
373        StorageError::driver(
374            StorageCapability::Sql,
375            operation,
376            FakeSource(message.into()),
377        )
378    }
379
380    #[test]
381    fn unique_constraint_failure_at_execute_sql_capability_is_classified() {
382        let e = driver_err_sql(
383            "execute",
384            "UNIQUE constraint failed: brain_serve_ledger.namespace, \
385             brain_serve_ledger.target_id, brain_serve_ledger.query_class, \
386             brain_serve_ledger.served_at",
387        );
388        assert!(e.is_unique_constraint_violation());
389    }
390
391    #[test]
392    fn unique_constraint_failure_at_pool_writer_execute_is_classified() {
393        // khive-db's pooled writer seam (`SqlAccess::writer()`) labels its
394        // single-statement execute operation "pool_writer.execute", not bare
395        // "execute" — this is the exact seam brain.record_serve writes through.
396        let e = driver_err_sql("pool_writer.execute", "UNIQUE constraint failed: t.id");
397        assert!(e.is_unique_constraint_violation());
398    }
399
400    #[test]
401    fn unique_constraint_message_at_non_execute_operation_is_not_classified() {
402        let e = driver_err_sql("query_row", "UNIQUE constraint failed: t.id");
403        assert!(!e.is_unique_constraint_violation());
404    }
405
406    #[test]
407    fn non_unique_driver_error_at_execute_is_not_classified() {
408        let e = driver_err_sql("execute", "disk I/O error");
409        assert!(!e.is_unique_constraint_violation());
410    }
411
412    #[test]
413    fn non_sql_capability_is_not_classified_as_unique_violation() {
414        let e = driver_err("execute", "UNIQUE constraint failed: t.id");
415        assert!(!e.is_unique_constraint_violation());
416    }
417
418    #[test]
419    fn timeout_is_not_classified_as_unique_violation() {
420        let e = StorageError::Timeout {
421            operation: "execute".into(),
422        };
423        assert!(!e.is_unique_constraint_violation());
424    }
425}