recall_echo/graph/
store.rs1use std::path::Path;
14use std::time::Duration;
15
16use surrealdb::engine::any::Any;
17use surrealdb::Surreal;
18
19use super::error::GraphError;
20
21pub type Db = Any;
22
23const LOCK_RETRY_ATTEMPTS: u32 = 8;
30const LOCK_RETRY_BASE: Duration = Duration::from_millis(15);
31
32#[derive(Clone)]
34pub struct ServerConfig {
35 pub url: String,
36 pub username: String,
37 pub password: String,
38 pub namespace: String,
39 pub database: String,
40}
41
42impl std::fmt::Debug for ServerConfig {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 f.debug_struct("ServerConfig")
45 .field("url", &self.url)
46 .field("username", &self.username)
47 .field("password", &"[REDACTED]")
48 .field("namespace", &self.namespace)
49 .field("database", &self.database)
50 .finish()
51 }
52}
53
54fn is_lock_error(err: &surrealdb::Error) -> bool {
57 is_lock_message(&err.to_string().to_lowercase())
58}
59
60fn is_lock_message(msg: &str) -> bool {
61 msg.contains("lock") && (msg.contains("already") || msg.contains("held"))
62}
63
64pub async fn open(path: &Path) -> Result<Surreal<Db>, GraphError> {
69 let surreal_path = path.join("surreal");
70 std::fs::create_dir_all(&surreal_path)?;
71
72 let path_str = surreal_path.to_str().ok_or_else(|| {
73 GraphError::Io(std::io::Error::new(
74 std::io::ErrorKind::InvalidData,
75 "graph store path contains non-UTF8 characters",
76 ))
77 })?;
78
79 let endpoint = format!("surrealkv://{path_str}");
80 let mut attempt: u32 = 0;
81 let db: Surreal<Db> = loop {
82 match surrealdb::engine::any::connect(&endpoint).await {
83 Ok(db) => break db,
84 Err(e) if is_lock_error(&e) && attempt < LOCK_RETRY_ATTEMPTS => {
85 attempt += 1;
86 tokio::time::sleep(LOCK_RETRY_BASE * 2u32.pow(attempt - 1)).await;
87 }
88 Err(e) if is_lock_error(&e) => {
89 return Err(GraphError::Locked(format!(
90 "graph store at {} is locked by another process. The embedded \
91 backend allows one process at a time — retried {} times. \
92 Another recall-echo command (or the serve daemon) is using it; \
93 wait for it to finish, or use server mode to share the store.",
94 surreal_path.display(),
95 LOCK_RETRY_ATTEMPTS
96 )));
97 }
98 Err(e) => return Err(e.into()),
99 }
100 };
101 db.use_ns("recall").use_db("graph").await?;
102
103 Ok(db)
104}
105
106pub async fn connect(config: &ServerConfig) -> Result<Surreal<Db>, GraphError> {
108 let db = surrealdb::engine::any::connect(&config.url).await?;
109 db.signin(surrealdb::opt::auth::Database {
110 namespace: config.namespace.clone(),
111 database: config.database.clone(),
112 username: config.username.clone(),
113 password: config.password.clone(),
114 })
115 .await?;
116 db.use_ns(&config.namespace)
117 .use_db(&config.database)
118 .await?;
119
120 Ok(db)
121}
122
123pub async fn init_schema(db: &Surreal<Db>) -> Result<(), GraphError> {
125 db.query(
126 r#"
127 DEFINE TABLE IF NOT EXISTS entity SCHEMAFULL;
128 DEFINE FIELD IF NOT EXISTS name ON entity TYPE string;
129 DEFINE FIELD IF NOT EXISTS entity_type ON entity TYPE string;
130 DEFINE FIELD IF NOT EXISTS abstract ON entity TYPE string;
131 DEFINE FIELD IF NOT EXISTS overview ON entity TYPE string;
132 DEFINE FIELD IF NOT EXISTS content ON entity TYPE option<string>;
133 DEFINE FIELD IF NOT EXISTS attributes ON entity TYPE option<object> FLEXIBLE;
134 DEFINE FIELD IF NOT EXISTS embedding ON entity TYPE option<array<float>>;
135 DEFINE FIELD IF NOT EXISTS mutable ON entity TYPE bool DEFAULT true;
136 DEFINE FIELD IF NOT EXISTS access_count ON entity TYPE int DEFAULT 0;
137 DEFINE FIELD IF NOT EXISTS utility_score ON entity TYPE float DEFAULT 0.5;
138 DEFINE FIELD IF NOT EXISTS utility_updates ON entity TYPE int DEFAULT 0;
139 DEFINE FIELD IF NOT EXISTS created_at ON entity TYPE datetime DEFAULT time::now();
140 DEFINE FIELD IF NOT EXISTS updated_at ON entity TYPE datetime DEFAULT time::now();
141 DEFINE FIELD IF NOT EXISTS source ON entity TYPE option<string>;
142
143 DEFINE INDEX IF NOT EXISTS entity_name ON entity FIELDS name;
144 DEFINE INDEX IF NOT EXISTS entity_type ON entity FIELDS entity_type;
145 DEFINE INDEX IF NOT EXISTS entity_vector ON entity FIELDS embedding HNSW DIMENSION 384 DIST COSINE;
146
147 -- Pipeline attribute indexes
148 DEFINE INDEX IF NOT EXISTS entity_pipeline_stage ON entity FIELDS attributes.pipeline_stage;
149 DEFINE INDEX IF NOT EXISTS entity_pipeline_status ON entity FIELDS attributes.pipeline_status;
150
151 DEFINE TABLE IF NOT EXISTS relates_to SCHEMAFULL TYPE RELATION;
152 DEFINE FIELD IF NOT EXISTS rel_type ON relates_to TYPE string;
153 DEFINE FIELD IF NOT EXISTS description ON relates_to TYPE option<string>;
154 DEFINE FIELD IF NOT EXISTS valid_from ON relates_to TYPE datetime DEFAULT time::now();
155 DEFINE FIELD IF NOT EXISTS valid_until ON relates_to TYPE option<datetime>;
156 DEFINE FIELD IF NOT EXISTS confidence ON relates_to TYPE float DEFAULT 1.0;
157 DEFINE FIELD IF NOT EXISTS last_reinforced ON relates_to TYPE option<datetime>;
158 DEFINE FIELD IF NOT EXISTS source ON relates_to TYPE option<string>;
159
160 DEFINE INDEX IF NOT EXISTS rel_type_idx ON relates_to FIELDS rel_type;
161
162 DEFINE TABLE IF NOT EXISTS episode SCHEMAFULL;
163 DEFINE FIELD IF NOT EXISTS session_id ON episode TYPE string;
164 DEFINE FIELD IF NOT EXISTS timestamp ON episode TYPE datetime DEFAULT time::now();
165 DEFINE FIELD IF NOT EXISTS abstract ON episode TYPE string;
166 DEFINE FIELD IF NOT EXISTS overview ON episode TYPE option<string>;
167 DEFINE FIELD IF NOT EXISTS content ON episode TYPE option<string>;
168 DEFINE FIELD IF NOT EXISTS embedding ON episode TYPE option<array<float>>;
169 DEFINE FIELD IF NOT EXISTS log_number ON episode TYPE option<int>;
170 DEFINE FIELD IF NOT EXISTS extracted ON episode TYPE bool DEFAULT false;
171
172 DEFINE INDEX IF NOT EXISTS episode_session ON episode FIELDS session_id;
173 DEFINE INDEX IF NOT EXISTS episode_time ON episode FIELDS timestamp;
174 DEFINE INDEX IF NOT EXISTS episode_vector ON episode FIELDS embedding HNSW DIMENSION 384 DIST COSINE;
175
176 DEFINE TABLE IF NOT EXISTS contributed_to SCHEMAFULL TYPE RELATION;
177 DEFINE FIELD IF NOT EXISTS outcome_result ON contributed_to TYPE string;
178 DEFINE FIELD IF NOT EXISTS was_used ON contributed_to TYPE bool DEFAULT true;
179 DEFINE FIELD IF NOT EXISTS session_id ON contributed_to TYPE string;
180 DEFINE FIELD IF NOT EXISTS timestamp ON contributed_to TYPE datetime DEFAULT time::now();
181
182 DEFINE INDEX IF NOT EXISTS ct_session ON contributed_to FIELDS session_id;
183 "#,
184 )
185 .await?
186 .check()?;
187
188 Ok(())
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn lock_message_detected() {
197 assert!(is_lock_message(
198 "database: the database at /x/surreal/lock is already locked by another process"
199 ));
200 assert!(is_lock_message("file lock held by another process"));
201 }
202
203 #[test]
204 fn non_lock_messages_pass_through() {
205 assert!(!is_lock_message("connection refused"));
206 assert!(!is_lock_message("lockstep protocol mismatch")); assert!(!is_lock_message("table entity already exists"));
208 }
209}