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