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::blob::ContentRef;
10use crate::capability::StorageCapability;
11
12/// What is known about one request when its single-writer execution seam
13/// terminates or is retired.
14///
15/// The state is deliberately about the request, not about why the task
16/// stopped. Callers need this distinction to decide whether a write is known
17/// not to have started, known to have had its SQLite transaction rolled back,
18/// or may already have produced side effects.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum WriterTaskRequestState {
21    /// The request's operation closure was never invoked.
22    NotStarted,
23    /// The request panicked inside `BEGIN IMMEDIATE`, and that transaction was
24    /// successfully rolled back on the connection that owned it.
25    TransactionRolledBack,
26    /// The request was accepted, but its exact outcome cannot be established;
27    /// it may already have produced side effects and must not be blindly
28    /// retried.
29    SideEffectsUnknown,
30}
31
32impl fmt::Display for WriterTaskRequestState {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        f.write_str(match self {
35            Self::NotStarted => "not_started",
36            Self::TransactionRolledBack => "transaction_rolled_back",
37            Self::SideEffectsUnknown => "side_effects_unknown",
38        })
39    }
40}
41
42/// Unified error type for all storage operations.
43#[derive(Debug, Error)]
44pub enum StorageError {
45    #[error("{capability:?} resource not found: {resource} ({key})")]
46    NotFound {
47        capability: StorageCapability,
48        resource: &'static str,
49        key: String,
50    },
51
52    #[error("{capability:?} resource already exists: {resource} ({key})")]
53    AlreadyExists {
54        capability: StorageCapability,
55        resource: &'static str,
56        key: String,
57    },
58
59    #[error("conflict in {capability:?} during {operation}: {message}")]
60    Conflict {
61        capability: StorageCapability,
62        operation: Cow<'static, str>,
63        message: String,
64    },
65
66    #[error("invalid input for {capability:?} during {operation}: {message}")]
67    InvalidInput {
68        capability: StorageCapability,
69        operation: Cow<'static, str>,
70        message: String,
71    },
72
73    #[error("unsupported operation for {capability:?}: {operation} ({message})")]
74    Unsupported {
75        capability: StorageCapability,
76        operation: Cow<'static, str>,
77        message: String,
78    },
79
80    /// The authoritative object is larger than the caller's declared
81    /// whole-buffer limit. `observed_at_least` is either opened-object
82    /// metadata used for early refusal or the running byte count that first
83    /// crossed the limit; metadata is not treated as the actual-byte bound.
84    #[error(
85        "blob {content_ref} exceeds the {max_bytes}-byte read limit (observed at least {observed_at_least} bytes)"
86    )]
87    BlobTooLarge {
88        content_ref: ContentRef,
89        max_bytes: u64,
90        observed_at_least: u64,
91    },
92
93    /// The complete bounded body disagreed with metadata obtained from the
94    /// same opened object / GET response.
95    #[error(
96        "blob {content_ref} metadata reports {metadata_bytes} bytes but the complete body contains {actual_bytes} bytes"
97    )]
98    BlobSizeMismatch {
99        content_ref: ContentRef,
100        metadata_bytes: u64,
101        actual_bytes: u64,
102    },
103
104    /// The complete, size-consistent bounded body did not hash to the
105    /// requested content-addressed reference.
106    #[error("blob digest mismatch: expected {expected}, computed {actual}")]
107    BlobDigestMismatch {
108        expected: ContentRef,
109        actual: ContentRef,
110    },
111
112    #[error("pool failure during {operation}: {message}")]
113    Pool {
114        operation: Cow<'static, str>,
115        message: String,
116    },
117
118    #[error("timeout during {operation}")]
119    Timeout { operation: Cow<'static, str> },
120
121    /// A bounded wait for storage admission (a reader/writer handle slot or a
122    /// pooled reader checkout) elapsed before anything was acquired. The
123    /// operation never started, so retrying cannot duplicate a side effect —
124    /// distinct from [`StorageError::Timeout`], which makes no claim about
125    /// whether work was in flight when the deadline expired.
126    #[error("admission timeout during {operation} after {timeout_ms}ms")]
127    AdmissionTimeout {
128        operation: Cow<'static, str>,
129        /// The configured admission deadline that elapsed, in milliseconds.
130        timeout_ms: u64,
131    },
132
133    #[error("sql transaction failure during {operation}: {message}")]
134    Transaction {
135        operation: Cow<'static, str>,
136        message: String,
137    },
138
139    /// A cached read-only handle's admitted transaction pinned a WAL
140    /// snapshot past the configured `read_tx_max_age` bound and was
141    /// proactively rolled back so the next call can open a fresh snapshot
142    /// (#1846). Distinct from the generic [`StorageError::Transaction`]
143    /// variant — which also covers failed-cleanup and write-side ambiguity
144    /// cases that are not uniformly safe to retry — so callers (and MCP
145    /// dispatch) can recognize this specific, always-safe-to-retry
146    /// condition by variant rather than by parsing rendered text.
147    #[error(
148        "cached read-only transaction exceeded the maximum read-transaction age \
149         ({max_age_secs}s) during {operation} and was rolled back; retry to open a fresh \
150         read snapshot"
151    )]
152    ReadTransactionAgeEvicted {
153        operation: Cow<'static, str>,
154        max_age_secs: u64,
155    },
156
157    /// A cached read-only handle's admitted transaction pinned a WAL
158    /// snapshot past `read_tx_max_age`, and the proactive rollback used to
159    /// end the eviction (#1846) did not restore autocommit, or the rollback
160    /// itself failed. The connection is discarded either way rather than
161    /// returned to the pool. The age check that triggered this still ran
162    /// before any read on the connection, so — exactly like
163    /// [`StorageError::ReadTransactionAgeEvicted`] — retrying the caller's
164    /// operation on a fresh connection is always safe; this variant exists
165    /// only to keep that guarantee distinguishable from a clean eviction in
166    /// the rendered message and to keep [`StorageError::Transaction`] (whose
167    /// other cases are not uniformly safe to retry) out of this path.
168    #[error(
169        "cached read-only transaction exceeded the maximum read-transaction age \
170         ({max_age_secs}s) during {operation} but could not be cleanly rolled back \
171         ({message}); the connection was discarded, retry to open a fresh read snapshot"
172    )]
173    ReadTransactionAgeEvictionCleanupFailed {
174        operation: Cow<'static, str>,
175        max_age_secs: u64,
176        message: String,
177    },
178
179    #[error("serialization failure in {capability:?}: {message}")]
180    Serialization {
181        capability: StorageCapability,
182        message: String,
183    },
184
185    #[error("index maintenance failure in {capability:?}: {message}")]
186    IndexMaintenance {
187        capability: StorageCapability,
188        message: String,
189    },
190
191    #[error("backend driver error in {capability:?} during {operation}: {source}")]
192    Driver {
193        capability: StorageCapability,
194        operation: Cow<'static, str>,
195        #[source]
196        source: Box<dyn StdError + Send + Sync>,
197    },
198
199    /// The bounded write-queue channel (ADR-067 Component A) did not free
200    /// capacity within the caller-supplied deadline. Returned only when a
201    /// caller wraps `WriterTaskHandle::send`'s `channel.send().await` in a
202    /// `tokio::time::timeout`; there is no immediate-error `try_send` path.
203    #[error("write queue full: timed out after {timeout_ms}ms waiting for writer task capacity")]
204    WriteQueueFull { timeout_ms: u64 },
205
206    /// SQLite refused the writer task's `BEGIN IMMEDIATE` with
207    /// `SQLITE_BUSY`/`SQLITE_LOCKED` until the configured busy timeout. The
208    /// queue accepted the request, but its operation closure was never
209    /// invoked, so retrying that one failed operation is safe.
210    #[error(
211        "writer task could not begin within {timeout_ms}ms because SQLite remained busy; request was not executed"
212    )]
213    WriterTaskBusy { timeout_ms: u64 },
214
215    /// A single-writer execution seam has terminated permanently. This is the
216    /// historical writer-task variant and display name; the fail-closed
217    /// pool-mutex fallback also uses it when transaction finalization becomes
218    /// terminal. The state identifies what is known about this request at
219    /// that boundary. Retrying on the same pool cannot recover the retired
220    /// writer seam.
221    #[error("writer task terminated (request_state={request_state})")]
222    WriterTaskTerminated {
223        request_state: WriterTaskRequestState,
224    },
225
226    /// An internal storage failure not attributable to a specific storage
227    /// capability.
228    #[error("internal storage error: {0}")]
229    Internal(String),
230
231    /// `KHIVE_WRITE_QUEUE=1` is set but the calling thread has no Tokio
232    /// runtime context, so the writer task cannot be spawned (ADR-067
233    /// Component A). Returned instead of panicking.
234    /// See `crates/khive-storage/docs/api/error-taxonomy.md#writertasknoruntime`.
235    #[error(
236        "KHIVE_WRITE_QUEUE=1 but no Tokio runtime context is available to spawn the writer task"
237    )]
238    WriterTaskNoRuntime,
239
240    /// A filesystem-backed capability (e.g. `BlobStore`) refused a write
241    /// because `volume`'s available space, after accounting for the pending
242    /// write, would drop below the configured free-space floor (khive#292).
243    #[error(
244        "refusing write on {capability:?} at {volume}: {available_bytes} bytes available, \
245         below the {floor_bytes}-byte floor"
246    )]
247    CapacityFloor {
248        capability: StorageCapability,
249        volume: String,
250        available_bytes: u64,
251        floor_bytes: u64,
252    },
253}
254
255impl StorageError {
256    /// Construct a `Driver` error wrapping a backend-specific error source.
257    pub fn driver(
258        capability: StorageCapability,
259        operation: impl Into<Cow<'static, str>>,
260        source: impl StdError + Send + Sync + 'static,
261    ) -> Self {
262        Self::Driver {
263            capability,
264            operation: operation.into(),
265            source: Box::new(source),
266        }
267    }
268
269    /// Return the storage capability surface that produced this error, if any.
270    pub fn capability(&self) -> Option<StorageCapability> {
271        match self {
272            Self::NotFound { capability, .. }
273            | Self::AlreadyExists { capability, .. }
274            | Self::Conflict { capability, .. }
275            | Self::InvalidInput { capability, .. }
276            | Self::Unsupported { capability, .. }
277            | Self::Serialization { capability, .. }
278            | Self::IndexMaintenance { capability, .. }
279            | Self::Driver { capability, .. }
280            | Self::CapacityFloor { capability, .. } => Some(*capability),
281            Self::BlobTooLarge { .. }
282            | Self::BlobSizeMismatch { .. }
283            | Self::BlobDigestMismatch { .. } => Some(StorageCapability::Blob),
284            Self::Pool { .. }
285            | Self::Timeout { .. }
286            | Self::AdmissionTimeout { .. }
287            | Self::Transaction { .. }
288            | Self::ReadTransactionAgeEvicted { .. }
289            | Self::ReadTransactionAgeEvictionCleanupFailed { .. }
290            | Self::WriteQueueFull { .. }
291            | Self::WriterTaskBusy { .. }
292            | Self::WriterTaskTerminated { .. }
293            | Self::Internal(..)
294            | Self::WriterTaskNoRuntime => None,
295        }
296    }
297
298    /// Whether this error is transient and the operation may succeed on retry.
299    pub fn is_retryable(&self) -> bool {
300        matches!(
301            self,
302            Self::Pool { .. }
303                | Self::Timeout { .. }
304                | Self::AdmissionTimeout { .. }
305                | Self::Transaction { .. }
306                | Self::ReadTransactionAgeEvicted { .. }
307                | Self::ReadTransactionAgeEvictionCleanupFailed { .. }
308                | Self::WriteQueueFull { .. }
309                | Self::WriterTaskBusy { .. }
310        )
311    }
312
313    /// Whether this error is an FTS5 query-parser rejection of the MATCH
314    /// expression itself, as opposed to a connection/pool/driver-level
315    /// failure of the text-search backend.
316    ///
317    /// True only for `Driver` errors from the `Text` capability at the
318    /// `fts_search` operation whose message names one of SQLite's FTS5
319    /// parser failure modes (syntax error, stack overflow, unsupported
320    /// column/phrase/NEAR query); all other errors return `false`.
321    ///
322    /// Callers that fail-open the FTS leg of a hybrid search (degrading to
323    /// vector-only results on a bad query string) MUST gate on this
324    /// predicate rather than on `StorageError` broadly — treating every
325    /// `Err` as degradable turns a real backend outage into a silently-empty
326    /// "successful" search (issue #389).
327    /// See `crates/khive-storage/docs/api/error-taxonomy.md#is_fts5_syntax_error`.
328    pub fn is_fts5_syntax_error(&self) -> bool {
329        let Self::Driver {
330            capability,
331            operation,
332            source,
333        } = self
334        else {
335            return false;
336        };
337        if *capability != StorageCapability::Text || operation.as_ref() != "fts_search" {
338            return false;
339        }
340        let msg = source.to_string();
341        msg.contains("fts5: syntax error")
342            || msg.contains("fts5: parser stack overflow")
343            || msg.contains("fts5: column queries are not supported")
344            || msg.contains("fts5: phrase queries are not supported (detail")
345            || msg.contains("fts5: NEAR queries are not supported (detail")
346    }
347
348    /// Whether this error is a UNIQUE constraint violation from a raw SQL
349    /// `execute` (e.g. an `INSERT` racing an existing row under a natural
350    /// key). True only for `Driver` errors from the `Sql` capability whose
351    /// `operation` is one of `execute`, `pool_writer.execute`, or
352    /// `tx.execute`, and whose message contains `UNIQUE constraint failed`.
353    /// Batch/script operations are intentionally excluded.
354    ///
355    /// Callers that treat exact-key duplicates as a tolerated no-op
356    /// (ADR-081 §4 serve-ledger idempotency) MUST gate on this predicate
357    /// rather than swallowing every `Driver` error at `execute` — that would
358    /// also hide genuine write failures (disk full, corruption).
359    /// See `crates/khive-storage/docs/api/error-taxonomy.md#is_unique_constraint_violation`.
360    pub fn is_unique_constraint_violation(&self) -> bool {
361        let Self::Driver {
362            capability,
363            operation,
364            source,
365        } = self
366        else {
367            return false;
368        };
369        if *capability != StorageCapability::Sql {
370            return false;
371        }
372        if !matches!(
373            operation.as_ref(),
374            "execute" | "pool_writer.execute" | "tx.execute"
375        ) {
376            return false;
377        }
378        source.to_string().contains("UNIQUE constraint failed")
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use std::fmt;
386
387    #[derive(Debug)]
388    struct FakeSource(String);
389
390    impl fmt::Display for FakeSource {
391        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
392            write!(f, "{}", self.0)
393        }
394    }
395
396    impl StdError for FakeSource {}
397
398    fn driver_err(operation: &'static str, message: &str) -> StorageError {
399        StorageError::driver(
400            StorageCapability::Text,
401            operation,
402            FakeSource(message.into()),
403        )
404    }
405
406    #[test]
407    fn writer_task_request_state_display_is_stable() {
408        assert_eq!(
409            WriterTaskRequestState::NotStarted.to_string(),
410            "not_started"
411        );
412        assert_eq!(
413            WriterTaskRequestState::TransactionRolledBack.to_string(),
414            "transaction_rolled_back"
415        );
416        assert_eq!(
417            WriterTaskRequestState::SideEffectsUnknown.to_string(),
418            "side_effects_unknown"
419        );
420    }
421
422    #[test]
423    fn writer_task_busy_is_retryable_without_claiming_queue_rejection() {
424        let error = StorageError::WriterTaskBusy { timeout_ms: 175 };
425        assert!(error.is_retryable());
426        assert_eq!(
427            error.to_string(),
428            "writer task could not begin within 175ms because SQLite remained busy; request was not executed"
429        );
430        assert_eq!(error.capability(), None);
431    }
432
433    #[test]
434    fn writer_task_terminated_is_uncapability_scoped_and_not_retryable() {
435        for request_state in [
436            WriterTaskRequestState::NotStarted,
437            WriterTaskRequestState::TransactionRolledBack,
438            WriterTaskRequestState::SideEffectsUnknown,
439        ] {
440            let error = StorageError::WriterTaskTerminated { request_state };
441            assert_eq!(error.capability(), None);
442            assert!(!error.is_retryable());
443            assert_eq!(
444                error.to_string(),
445                format!("writer task terminated (request_state={request_state})")
446            );
447        }
448    }
449
450    #[test]
451    fn blob_integrity_errors_are_blob_scoped_and_not_retryable() {
452        let requested = crate::blob::ContentRef::from_hex("a".repeat(64)).unwrap();
453        let actual = crate::blob::ContentRef::from_hex("b".repeat(64)).unwrap();
454        let errors = [
455            StorageError::BlobTooLarge {
456                content_ref: requested.clone(),
457                max_bytes: 8,
458                observed_at_least: 9,
459            },
460            StorageError::BlobSizeMismatch {
461                content_ref: requested.clone(),
462                metadata_bytes: 7,
463                actual_bytes: 8,
464            },
465            StorageError::BlobDigestMismatch {
466                expected: requested,
467                actual,
468            },
469        ];
470
471        for error in errors {
472            assert_eq!(error.capability(), Some(StorageCapability::Blob));
473            assert!(!error.is_retryable());
474        }
475    }
476
477    #[test]
478    fn fts5_syntax_error_at_fts_search_is_classified_as_syntax_error() {
479        let e = driver_err("fts_search", "fts5: syntax error near \"@\"");
480        assert!(e.is_fts5_syntax_error());
481    }
482
483    #[test]
484    fn fts5_parser_stack_overflow_is_classified_as_syntax_error() {
485        let e = driver_err("fts_search", "fts5: parser stack overflow");
486        assert!(e.is_fts5_syntax_error());
487    }
488
489    #[test]
490    fn fts5_unsupported_column_query_is_classified_as_syntax_error() {
491        let e = driver_err(
492            "fts_search",
493            "fts5: column queries are not supported (detail=none)",
494        );
495        assert!(e.is_fts5_syntax_error());
496    }
497
498    #[test]
499    fn timeout_is_not_classified_as_syntax_error() {
500        let e = StorageError::Timeout {
501            operation: "fts_search".into(),
502        };
503        assert!(!e.is_fts5_syntax_error());
504    }
505
506    #[test]
507    fn pool_failure_is_not_classified_as_syntax_error() {
508        let e = StorageError::Pool {
509            operation: "fts_search".into(),
510            message: "pool exhausted".into(),
511        };
512        assert!(!e.is_fts5_syntax_error());
513    }
514
515    #[test]
516    fn driver_error_at_non_search_operation_is_not_classified_as_syntax_error() {
517        let e = driver_err("open_fts_reader", "fts5: syntax error near \"@\"");
518        assert!(!e.is_fts5_syntax_error());
519    }
520
521    #[test]
522    fn driver_error_with_unrelated_message_is_not_classified_as_syntax_error() {
523        let e = driver_err("fts_search", "disk I/O error");
524        assert!(!e.is_fts5_syntax_error());
525    }
526
527    #[test]
528    fn fts5_phrase_detail_query_is_classified_as_syntax_error() {
529        let e = driver_err(
530            "fts_search",
531            "fts5: phrase queries are not supported (detail!=full)",
532        );
533        assert!(e.is_fts5_syntax_error());
534    }
535
536    #[test]
537    fn fts5_near_detail_query_is_classified_as_syntax_error() {
538        let e = driver_err(
539            "fts_search",
540            "fts5: NEAR queries are not supported (detail!=full)",
541        );
542        assert!(e.is_fts5_syntax_error());
543    }
544
545    #[test]
546    fn unprefixed_detail_message_is_not_classified_as_syntax_error() {
547        let e = driver_err(
548            "fts_search",
549            "phrase queries are not supported (detail!=full)",
550        );
551        assert!(!e.is_fts5_syntax_error());
552    }
553
554    #[test]
555    fn fts5_shadow_table_corruption_is_not_classified_as_syntax_error() {
556        let e = driver_err(
557            "fts_search",
558            "fts5: error creating shadow table notes_content: no such table",
559        );
560        assert!(!e.is_fts5_syntax_error());
561    }
562
563    #[test]
564    fn non_text_capability_is_not_classified_as_syntax_error() {
565        let e = StorageError::Driver {
566            capability: StorageCapability::Vectors,
567            operation: "fts_search".into(),
568            source: Box::new(FakeSource("fts5: syntax error near \"@\"".into())),
569        };
570        assert!(!e.is_fts5_syntax_error());
571    }
572
573    fn driver_err_sql(operation: &'static str, message: &str) -> StorageError {
574        StorageError::driver(
575            StorageCapability::Sql,
576            operation,
577            FakeSource(message.into()),
578        )
579    }
580
581    #[test]
582    fn unique_constraint_failure_at_execute_sql_capability_is_classified() {
583        let e = driver_err_sql(
584            "execute",
585            "UNIQUE constraint failed: brain_serve_ledger.namespace, \
586             brain_serve_ledger.target_id, brain_serve_ledger.query_class, \
587             brain_serve_ledger.served_at",
588        );
589        assert!(e.is_unique_constraint_violation());
590    }
591
592    #[test]
593    fn unique_constraint_failure_at_pool_writer_execute_is_classified() {
594        let e = driver_err_sql("pool_writer.execute", "UNIQUE constraint failed: t.id");
595        assert!(e.is_unique_constraint_violation());
596    }
597
598    #[test]
599    fn unique_constraint_message_at_non_execute_operation_is_not_classified() {
600        let e = driver_err_sql("query_row", "UNIQUE constraint failed: t.id");
601        assert!(!e.is_unique_constraint_violation());
602    }
603
604    #[test]
605    fn non_unique_driver_error_at_execute_is_not_classified() {
606        let e = driver_err_sql("execute", "disk I/O error");
607        assert!(!e.is_unique_constraint_violation());
608    }
609
610    #[test]
611    fn non_sql_capability_is_not_classified_as_unique_violation() {
612        let e = driver_err("execute", "UNIQUE constraint failed: t.id");
613        assert!(!e.is_unique_constraint_violation());
614    }
615
616    #[test]
617    fn timeout_is_not_classified_as_unique_violation() {
618        let e = StorageError::Timeout {
619            operation: "execute".into(),
620        };
621        assert!(!e.is_unique_constraint_violation());
622    }
623}