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 /// Optimistic-concurrency conflict on [`crate::store::cross_thread`]'s `store_put`
103 /// (spec-080, #6363, FR-A-003).
104 ///
105 /// Returned when a caller-supplied `expected_version` does not match the row's current
106 /// version — including when no row exists at all under `(owner_key, namespace, key)`,
107 /// since there is then no row at the expected version either. Callers must surface this
108 /// as a failure rather than silently retrying and clobbering the newer write.
109 #[error(
110 "version conflict: owner_key={owner_key:?} namespace={namespace:?} key={key:?} \
111 expected_version={expected}"
112 )]
113 VersionConflict {
114 owner_key: String,
115 namespace: String,
116 key: String,
117 expected: i64,
118 },
119}
120
121impl MemoryError {
122 /// Returns `true` when this error is a database foreign-key constraint violation.
123 ///
124 /// Used to distinguish silent-drop-worthy edge/entity write failures (e.g. a stale
125 /// cross-database entity id) from ordinary transient DB errors, so callers can log the
126 /// former at `WARN` instead of `DEBUG` (#5801).
127 #[must_use]
128 pub fn is_foreign_key_violation(&self) -> bool {
129 use sqlx::error::DatabaseError;
130
131 matches!(self, Self::Sqlx(err) if err
132 .as_database_error()
133 .is_some_and(DatabaseError::is_foreign_key_violation))
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::MemoryError;
140
141 #[test]
142 fn sqlx_variant_display() {
143 let inner = zeph_db::SqlxError::RowNotFound;
144 let err = MemoryError::Sqlx(inner);
145 assert!(
146 err.to_string().starts_with("sqlx error:"),
147 "unexpected display: {err}"
148 );
149 }
150
151 #[test]
152 fn db_variant_display() {
153 let inner = zeph_db::DbError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
154 let err = MemoryError::Db(inner);
155 assert!(
156 err.to_string().starts_with("db error:"),
157 "unexpected display: {err}"
158 );
159 }
160
161 #[test]
162 fn is_foreign_key_violation_false_for_non_constraint_error() {
163 let err = MemoryError::Sqlx(zeph_db::SqlxError::RowNotFound);
164 assert!(!err.is_foreign_key_violation());
165 }
166
167 #[test]
168 fn is_foreign_key_violation_false_for_non_sqlx_variant() {
169 let err = MemoryError::GraphStore("unrelated failure".into());
170 assert!(!err.is_foreign_key_violation());
171 }
172
173 /// Regression test for #5801: `is_foreign_key_violation()` must recognize a genuine
174 /// FK constraint violation (not just a manufactured/mocked one), since it is the
175 /// linchpin of the WARN-vs-DEBUG observability fix.
176 #[tokio::test]
177 async fn is_foreign_key_violation_true_for_real_fk_violation() {
178 use crate::graph::GraphStore;
179 use crate::store::SqliteStore;
180
181 let sqlite = SqliteStore::new(":memory:").await.unwrap();
182 let store = GraphStore::new(sqlite.pool().clone());
183
184 // Neither entity id exists in this fresh database — a genuine FK violation.
185 let err = store
186 .insert_edge(999_999, 888_888, "related_to", "fact", 0.9, None, None)
187 .await
188 .expect_err("inserting an edge between nonexistent entities must fail");
189
190 assert!(
191 err.is_foreign_key_violation(),
192 "expected a foreign-key violation, got: {err:#}"
193 );
194 }
195}