Skip to main content

zeph_memory/
error.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4/// Top-level error type for all `zeph-memory` operations.
5///
6/// Wraps database, vector-store, LLM, and serialization errors into a single type
7/// consumed by callers in `zeph-core`.
8///
9/// # Examples
10///
11/// ```rust
12/// use zeph_memory::MemoryError;
13///
14/// fn demo(e: MemoryError) -> String {
15///     e.to_string()
16/// }
17/// ```
18#[derive(Debug, thiserror::Error)]
19#[non_exhaustive]
20pub enum MemoryError {
21    #[error("sqlx error: {0}")]
22    Sqlx(#[from] zeph_db::SqlxError),
23
24    #[error("db error: {0}")]
25    Db(#[from] zeph_db::DbError),
26
27    #[error("Qdrant error: {0}")]
28    Qdrant(#[from] Box<qdrant_client::QdrantError>),
29
30    #[error("vector store error: {0}")]
31    VectorStore(#[from] crate::vector_store::VectorStoreError),
32
33    #[error("LLM error: {0}")]
34    Llm(#[from] zeph_llm::LlmError),
35
36    #[error("JSON error: {0}")]
37    Json(#[from] serde_json::Error),
38
39    #[error("integer conversion: {0}")]
40    IntConversion(#[from] std::num::TryFromIntError),
41
42    #[error("snapshot error: {0}")]
43    Snapshot(String),
44
45    #[error("I/O error: {0}")]
46    Io(#[from] std::io::Error),
47
48    #[error("graph store error: {0}")]
49    GraphStore(String),
50
51    #[error("invalid input: {0}")]
52    InvalidInput(String),
53
54    /// A mutex or `RwLock` was poisoned by a panicking thread.
55    ///
56    /// This indicates a programming error (a thread panicked while holding the lock).
57    /// The inner string describes which lock was poisoned.
58    #[error("lock poisoned: {0}")]
59    LockPoisoned(String),
60
61    /// Catch-all for errors that do not yet have a specific typed variant.
62    ///
63    /// # Deprecation
64    ///
65    /// Prefer adding a typed variant over using `Other`. This variant exists for
66    /// backward compatibility and will be removed once all callsites are migrated.
67    #[error("{0}")]
68    Other(String),
69
70    #[error("operation timed out: {0}")]
71    Timeout(String),
72
73    /// Returned when inserting a supersede pointer would form a cycle in the chain.
74    #[error("supersede cycle detected at edge id={0}")]
75    SupersedeCycle(i64),
76
77    /// Returned when the supersede chain depth would exceed [`crate::graph::conflict::SUPERSEDE_DEPTH_CAP`].
78    #[error("supersede chain depth cap exceeded at edge id={0}")]
79    SupersedeDepthExceeded(i64),
80
81    /// A promotion-scan or promote error (Feature A, #3305).
82    ///
83    /// Wraps errors from clustering, skill generation, evaluator calls, or disk writes.
84    #[error("promotion scan failed: {0}")]
85    Promotion(String),
86
87    /// An error during `zeph knowledge ingest` (spec-067).
88    ///
89    /// Covers path-validation failures, unsupported source kinds, and per-file ingest errors
90    /// reported by the notes-sink pipeline.
91    #[error("ingest error: {0}")]
92    Ingest(String),
93
94    /// The post-extract validator rejected this extraction result.
95    ///
96    /// Returned by `extract_and_store` when the `post_extract_validator` callback returns
97    /// `Err`. The caller (`ingest_documents`) converts this into `DocOutcome::Rejected`
98    /// so the document is counted separately from hard failures.
99    #[error("validation rejected: {0}")]
100    ValidationRejected(String),
101}
102
103impl MemoryError {
104    /// Returns `true` when this error is a database foreign-key constraint violation.
105    ///
106    /// Used to distinguish silent-drop-worthy edge/entity write failures (e.g. a stale
107    /// cross-database entity id) from ordinary transient DB errors, so callers can log the
108    /// former at `WARN` instead of `DEBUG` (#5801).
109    #[must_use]
110    pub fn is_foreign_key_violation(&self) -> bool {
111        use sqlx::error::DatabaseError;
112
113        matches!(self, Self::Sqlx(err) if err
114            .as_database_error()
115            .is_some_and(DatabaseError::is_foreign_key_violation))
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::MemoryError;
122
123    #[test]
124    fn sqlx_variant_display() {
125        let inner = zeph_db::SqlxError::RowNotFound;
126        let err = MemoryError::Sqlx(inner);
127        assert!(
128            err.to_string().starts_with("sqlx error:"),
129            "unexpected display: {err}"
130        );
131    }
132
133    #[test]
134    fn db_variant_display() {
135        let inner = zeph_db::DbError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
136        let err = MemoryError::Db(inner);
137        assert!(
138            err.to_string().starts_with("db error:"),
139            "unexpected display: {err}"
140        );
141    }
142
143    #[test]
144    fn is_foreign_key_violation_false_for_non_constraint_error() {
145        let err = MemoryError::Sqlx(zeph_db::SqlxError::RowNotFound);
146        assert!(!err.is_foreign_key_violation());
147    }
148
149    #[test]
150    fn is_foreign_key_violation_false_for_non_sqlx_variant() {
151        let err = MemoryError::GraphStore("unrelated failure".into());
152        assert!(!err.is_foreign_key_violation());
153    }
154
155    /// Regression test for #5801: `is_foreign_key_violation()` must recognize a genuine
156    /// FK constraint violation (not just a manufactured/mocked one), since it is the
157    /// linchpin of the WARN-vs-DEBUG observability fix.
158    #[tokio::test]
159    async fn is_foreign_key_violation_true_for_real_fk_violation() {
160        use crate::graph::GraphStore;
161        use crate::store::SqliteStore;
162
163        let sqlite = SqliteStore::new(":memory:").await.unwrap();
164        let store = GraphStore::new(sqlite.pool().clone());
165
166        // Neither entity id exists in this fresh database — a genuine FK violation.
167        let err = store
168            .insert_edge(999_999, 888_888, "related_to", "fact", 0.9, None, None)
169            .await
170            .expect_err("inserting an edge between nonexistent entities must fail");
171
172        assert!(
173            err.is_foreign_key_violation(),
174            "expected a foreign-key violation, got: {err:#}"
175        );
176    }
177}