1#[cfg(feature = "redis")]
19mod implementation {
20 use crate::models::{Memory, MemoryId, MemoryStatus, Namespace, SearchFilter};
21 use crate::storage::resilience::{StorageResilienceConfig, retry_connection};
22 use crate::storage::traits::IndexBackend;
23 use crate::{Error, Result};
24 use redis::{Client, Commands, Connection};
25 use std::sync::Mutex;
26 use std::time::Duration;
27
28 pub struct RedisBackend {
42 client: Client,
44 index_name: String,
46 connection: Mutex<Option<Connection>>,
48 timeout: Duration,
50 }
51
52 fn default_redis_timeout() -> Duration {
55 crate::config::OperationTimeoutConfig::from_env().get(crate::config::OperationType::Redis)
56 }
57
58 impl RedisBackend {
59 pub fn new(connection_url: &str, index_name: impl Into<String>) -> Result<Self> {
68 let client = Client::open(connection_url).map_err(|e| Error::OperationFailed {
69 operation: "redis_connect".to_string(),
70 cause: e.to_string(),
71 })?;
72
73 let backend = Self {
74 client,
75 index_name: index_name.into(),
76 connection: Mutex::new(None),
77 timeout: default_redis_timeout(),
78 };
79
80 backend.ensure_index()?;
82
83 Ok(backend)
84 }
85
86 pub fn with_defaults() -> Result<Self> {
92 Self::new("redis://localhost:6379", "subcog_memories")
93 }
94
95 pub fn health_check(&self) -> Result<bool> {
119 let mut conn = match self.get_connection() {
120 Ok(c) => c,
121 Err(_) => return Ok(false),
122 };
123
124 let result: redis::RedisResult<String> = redis::cmd("PING").query(&mut conn);
125
126 let healthy = result.is_ok_and(|response| response == "PONG");
127
128 self.return_connection(conn);
129 Ok(healthy)
130 }
131
132 fn get_connection(&self) -> Result<Connection> {
147 let mut guard = self.connection.lock().map_err(|e| Error::OperationFailed {
149 operation: "redis_lock_connection".to_string(),
150 cause: e.to_string(),
151 })?;
152
153 if let Some(conn) = guard.take() {
155 return Ok(conn);
158 }
159 drop(guard); let resilience_config = StorageResilienceConfig::from_env();
163 let timeout = self.timeout;
164
165 retry_connection(&resilience_config, "redis_index", "get_connection", || {
166 let conn = self
167 .client
168 .get_connection()
169 .map_err(|e| Error::OperationFailed {
170 operation: "redis_get_connection".to_string(),
171 cause: e.to_string(),
172 })?;
173
174 conn.set_read_timeout(Some(timeout))
176 .map_err(|e| Error::OperationFailed {
177 operation: "redis_set_read_timeout".to_string(),
178 cause: e.to_string(),
179 })?;
180 conn.set_write_timeout(Some(timeout))
181 .map_err(|e| Error::OperationFailed {
182 operation: "redis_set_write_timeout".to_string(),
183 cause: e.to_string(),
184 })?;
185
186 Ok(conn)
187 })
188 }
189
190 fn return_connection(&self, conn: Connection) {
192 if let Ok(mut guard) = self.connection.lock() {
193 *guard = Some(conn);
194 }
195 }
197
198 fn ensure_index(&self) -> Result<()> {
200 let mut conn = self.get_connection()?;
201
202 let exists = self.index_exists(&mut conn);
204 if exists {
205 self.return_connection(conn);
206 return Ok(());
207 }
208
209 let result = self.create_index(&mut conn);
211 self.return_connection(conn);
212 result
213 }
214
215 fn index_exists(&self, conn: &mut Connection) -> bool {
217 let result: redis::RedisResult<Vec<String>> = redis::cmd("FT._LIST").query(conn);
218
219 result
220 .map(|indices| indices.iter().any(|i| i == &self.index_name))
221 .unwrap_or(false)
222 }
223
224 fn create_index(&self, conn: &mut Connection) -> Result<()> {
226 let result: redis::RedisResult<String> = redis::cmd("FT.CREATE")
227 .arg(&self.index_name)
228 .arg("ON")
229 .arg("HASH")
230 .arg("PREFIX")
231 .arg(1)
232 .arg("mem:")
233 .arg("SCHEMA")
234 .arg("content")
235 .arg("TEXT")
236 .arg("WEIGHT")
237 .arg("1.0")
238 .arg("namespace")
239 .arg("TAG")
240 .arg("domain")
241 .arg("TAG")
242 .arg("status")
243 .arg("TAG")
244 .arg("tags")
245 .arg("TAG")
246 .arg("project_id")
247 .arg("TAG")
248 .arg("branch")
249 .arg("TAG")
250 .arg("file_path")
251 .arg("TAG")
252 .arg("created_at")
253 .arg("NUMERIC")
254 .arg("SORTABLE")
255 .arg("updated_at")
256 .arg("NUMERIC")
257 .arg("SORTABLE")
258 .query(conn);
259
260 match result {
261 Ok(_) => Ok(()),
262 Err(e) if e.to_string().contains("Index already exists") => Ok(()),
263 Err(e) => Err(Error::OperationFailed {
264 operation: "redis_create_index".to_string(),
265 cause: e.to_string(),
266 }),
267 }
268 }
269
270 fn build_filter_clause(filter: &SearchFilter) -> String {
272 let mut clauses = Vec::new();
273
274 if !filter.namespaces.is_empty() {
275 let ns_tags: Vec<&str> = filter
276 .namespaces
277 .iter()
278 .copied()
279 .map(namespace_to_tag)
280 .collect();
281 clauses.push(format!("@namespace:{{{}}}", ns_tags.join("|")));
282 }
283
284 if !filter.domains.is_empty() {
285 let domain_strs: Vec<String> =
286 filter.domains.iter().map(ToString::to_string).collect();
287 clauses.push(format!("@domain:{{{}}}", domain_strs.join("|")));
288 }
289
290 if !filter.statuses.is_empty() {
291 let status_strs: Vec<&str> = filter
292 .statuses
293 .iter()
294 .map(crate::models::MemoryStatus::as_str)
295 .collect();
296 clauses.push(format!("@status:{{{}}}", status_strs.join("|")));
297 }
298
299 if let Some(ref project_id) = filter.project_id {
300 clauses.push(format!("@project_id:{{{project_id}}}"));
301 }
302
303 if let Some(ref branch) = filter.branch {
304 clauses.push(format!("@branch:{{{branch}}}"));
305 }
306
307 if let Some(ref file_path) = filter.file_path {
308 clauses.push(format!("@file_path:{{{file_path}}}"));
309 }
310
311 if clauses.is_empty() {
312 String::new()
313 } else {
314 clauses.join(" ")
315 }
316 }
317 }
318
319 const fn namespace_to_tag(ns: Namespace) -> &'static str {
321 ns.as_str()
322 }
323
324 impl IndexBackend for RedisBackend {
325 fn index(&self, memory: &Memory) -> Result<()> {
326 let mut conn = self.get_connection()?;
327 let key = format!("mem:{}", memory.id.as_str());
328
329 let tags_str = memory.tags.join(",");
331 let domain_str = memory.domain.to_string();
332 let status_str = memory.status.as_str();
333 let namespace_str = memory.namespace.as_str();
334 let project_id = memory.project_id.as_deref().unwrap_or("");
335 let branch = memory.branch.as_deref().unwrap_or("");
336 let file_path = memory.file_path.as_deref().unwrap_or("");
337
338 let result: redis::RedisResult<()> = conn.hset_multiple(
339 &key,
340 &[
341 ("content", memory.content.as_str()),
342 ("namespace", namespace_str),
343 ("domain", &domain_str),
344 ("status", status_str),
345 ("tags", &tags_str),
346 ("project_id", project_id),
347 ("branch", branch),
348 ("file_path", file_path),
349 ],
350 );
351
352 if let Err(e) = result {
353 self.return_connection(conn);
354 return Err(Error::OperationFailed {
355 operation: "redis_index".to_string(),
356 cause: e.to_string(),
357 });
358 }
359
360 let result: redis::RedisResult<()> = conn.hset(&key, "created_at", memory.created_at);
362 if let Err(e) = result {
363 self.return_connection(conn);
364 return Err(Error::OperationFailed {
365 operation: "redis_index_created".to_string(),
366 cause: e.to_string(),
367 });
368 }
369
370 let result: redis::RedisResult<()> = conn.hset(&key, "updated_at", memory.updated_at);
371 if let Err(e) = result {
372 self.return_connection(conn);
373 return Err(Error::OperationFailed {
374 operation: "redis_index_updated".to_string(),
375 cause: e.to_string(),
376 });
377 }
378
379 self.return_connection(conn);
380 Ok(())
381 }
382
383 fn remove(&self, id: &MemoryId) -> Result<bool> {
384 let mut conn = self.get_connection()?;
385 let key = format!("mem:{}", id.as_str());
386
387 let result: redis::RedisResult<i32> = conn.del(&key);
388 match result {
389 Ok(deleted) => {
390 self.return_connection(conn);
391 Ok(deleted > 0)
392 },
393 Err(e) => {
394 self.return_connection(conn);
395 Err(Error::OperationFailed {
396 operation: "redis_remove".to_string(),
397 cause: e.to_string(),
398 })
399 },
400 }
401 }
402
403 fn search(
404 &self,
405 query: &str,
406 filter: &SearchFilter,
407 limit: usize,
408 ) -> Result<Vec<(MemoryId, f32)>> {
409 let mut conn = self.get_connection()?;
410
411 let filter_clause = Self::build_filter_clause(filter);
413 let full_query = if filter_clause.is_empty() {
414 query.to_string()
415 } else {
416 format!("{query} {filter_clause}")
417 };
418
419 let result: redis::RedisResult<Vec<redis::Value>> = redis::cmd("FT.SEARCH")
421 .arg(&self.index_name)
422 .arg(&full_query)
423 .arg("LIMIT")
424 .arg(0)
425 .arg(limit)
426 .arg("WITHSCORES")
427 .query(&mut conn);
428
429 let output = match result {
430 Ok(values) => Ok(parse_search_results(&values)),
431 Err(e) => Err(Error::OperationFailed {
432 operation: "redis_search".to_string(),
433 cause: e.to_string(),
434 }),
435 };
436 self.return_connection(conn);
437 output
438 }
439
440 fn list_all(&self, filter: &SearchFilter, limit: usize) -> Result<Vec<(MemoryId, f32)>> {
441 let mut conn = self.get_connection()?;
442
443 let filter_clause = Self::build_filter_clause(filter);
445 let query = if filter_clause.is_empty() {
446 "*".to_string()
447 } else {
448 filter_clause
449 };
450
451 let result: redis::RedisResult<Vec<redis::Value>> = redis::cmd("FT.SEARCH")
453 .arg(&self.index_name)
454 .arg(&query)
455 .arg("LIMIT")
456 .arg(0)
457 .arg(limit)
458 .arg("SORTBY")
459 .arg("updated_at")
460 .arg("DESC")
461 .query(&mut conn);
462
463 let output = match result {
464 Ok(values) => Ok(parse_list_results(&values)),
465 Err(e) => Err(Error::OperationFailed {
466 operation: "redis_list_all".to_string(),
467 cause: e.to_string(),
468 }),
469 };
470 self.return_connection(conn);
471 output
472 }
473
474 fn get_memory(&self, id: &MemoryId) -> Result<Option<Memory>> {
475 use crate::models::{Domain, Namespace};
476
477 let mut conn = self.get_connection()?;
478 let key = format!("mem:{}", id.as_str());
479
480 let result: redis::RedisResult<std::collections::HashMap<String, String>> =
482 conn.hgetall(&key);
483
484 let output = match result {
485 Ok(fields) if fields.is_empty() => Ok(None),
486 Ok(fields) => {
487 let content = fields.get("content").cloned().unwrap_or_default();
488 let namespace_str = fields.get("namespace").cloned().unwrap_or_default();
489 let domain_str = fields.get("domain").cloned();
490 let status_str = fields.get("status").cloned().unwrap_or_default();
491 let tags_str = fields.get("tags").cloned();
492 let project_id = fields
493 .get("project_id")
494 .cloned()
495 .filter(|value| !value.is_empty());
496 let branch = fields
497 .get("branch")
498 .cloned()
499 .filter(|value| !value.is_empty());
500 let file_path = fields
501 .get("file_path")
502 .cloned()
503 .filter(|value| !value.is_empty());
504 let created_at: u64 = fields
505 .get("created_at")
506 .and_then(|s| s.parse().ok())
507 .unwrap_or(0);
508 let updated_at: u64 = fields
509 .get("updated_at")
510 .and_then(|s| s.parse().ok())
511 .unwrap_or(created_at);
512
513 let namespace = Namespace::parse(&namespace_str).unwrap_or_default();
514 let domain = domain_str.map_or_else(Domain::new, |_| Domain::new());
515 let status = parse_memory_status(&status_str);
516 let tags = parse_tags_string(tags_str);
517
518 Ok(Some(Memory {
519 id: id.clone(),
520 content,
521 namespace,
522 domain,
523 project_id,
524 branch,
525 file_path,
526 status,
527 created_at,
528 updated_at,
529 tombstoned_at: None,
530 expires_at: None,
531 embedding: None,
532 tags,
533 #[cfg(feature = "group-scope")]
534 group_id: None,
535 source: None,
536 is_summary: false,
537 source_memory_ids: None,
538 consolidation_timestamp: None,
539 }))
540 },
541 Err(e) => Err(Error::OperationFailed {
542 operation: "redis_get_memory".to_string(),
543 cause: e.to_string(),
544 }),
545 };
546 self.return_connection(conn);
547 output
548 }
549
550 fn clear(&self) -> Result<()> {
551 let mut conn = self.get_connection()?;
552
553 let _: redis::RedisResult<String> = redis::cmd("FT.DROPINDEX")
555 .arg(&self.index_name)
556 .arg("DD")
557 .query(&mut conn);
558
559 self.return_connection(conn);
561
562 self.ensure_index()
564 }
565 }
566
567 fn parse_search_results(values: &[redis::Value]) -> Vec<(MemoryId, f32)> {
569 let mut results = Vec::new();
570
571 if values.is_empty() {
572 return results;
573 }
574
575 let mut i = 1; while i + 1 < values.len() {
577 if let redis::Value::BulkString(key_bytes) = &values[i] {
578 let key = String::from_utf8_lossy(key_bytes);
579 let id = key.strip_prefix("mem:").unwrap_or(&key);
580
581 i += 1;
582 let score = parse_score(&values[i]);
583 results.push((MemoryId::new(id), score));
584 }
585
586 i += 1;
587 if i < values.len() && matches!(&values[i], redis::Value::Array(_)) {
589 i += 1;
590 }
591 }
592
593 results
594 }
595
596 fn parse_score(value: &redis::Value) -> f32 {
598 match value {
599 redis::Value::BulkString(s) => String::from_utf8_lossy(s).parse::<f32>().unwrap_or(1.0),
600 _ => 1.0,
601 }
602 }
603
604 fn parse_list_results(values: &[redis::Value]) -> Vec<(MemoryId, f32)> {
606 let mut results = Vec::new();
607
608 if values.is_empty() {
609 return results;
610 }
611
612 let mut i = 1; while i < values.len() {
614 if let redis::Value::BulkString(key_bytes) = &values[i] {
615 let key = String::from_utf8_lossy(key_bytes);
616 let id = key.strip_prefix("mem:").unwrap_or(&key);
617 results.push((MemoryId::new(id), 1.0));
618 }
619
620 i += 1;
621 if i < values.len() && matches!(&values[i], redis::Value::Array(_)) {
623 i += 1;
624 }
625 }
626
627 results
628 }
629
630 fn parse_memory_status(s: &str) -> MemoryStatus {
632 match s.to_lowercase().as_str() {
633 "active" => MemoryStatus::Active,
634 "archived" => MemoryStatus::Archived,
635 "superseded" => MemoryStatus::Superseded,
636 "pending" => MemoryStatus::Pending,
637 "deleted" => MemoryStatus::Deleted,
638 _ => MemoryStatus::Active,
639 }
640 }
641
642 fn parse_tags_string(tags_str: Option<String>) -> Vec<String> {
644 tags_str.map_or_else(Vec::new, |t| {
645 t.split(',')
646 .map(|s| s.trim().to_string())
647 .filter(|s| !s.is_empty())
648 .collect()
649 })
650 }
651}
652
653#[cfg(feature = "redis")]
654pub use implementation::RedisBackend;
655
656#[cfg(not(feature = "redis"))]
657mod stub {
658 use crate::models::{Memory, MemoryId, SearchFilter};
659 use crate::storage::traits::IndexBackend;
660 use crate::{Error, Result};
661
662 pub struct RedisBackend;
664
665 impl RedisBackend {
666 pub fn new(_connection_url: &str, _index_name: impl Into<String>) -> Result<Self> {
672 Err(Error::FeatureNotEnabled("redis".to_string()))
673 }
674
675 pub fn with_defaults() -> Result<Self> {
681 Err(Error::FeatureNotEnabled("redis".to_string()))
682 }
683
684 pub fn health_check(&self) -> Result<bool> {
690 Err(Error::FeatureNotEnabled("redis".to_string()))
691 }
692 }
693
694 impl IndexBackend for RedisBackend {
695 fn index(&self, _memory: &Memory) -> Result<()> {
696 Err(Error::FeatureNotEnabled("redis".to_string()))
697 }
698
699 fn remove(&self, _id: &MemoryId) -> Result<bool> {
700 Err(Error::FeatureNotEnabled("redis".to_string()))
701 }
702
703 fn search(
704 &self,
705 _query: &str,
706 _filter: &SearchFilter,
707 _limit: usize,
708 ) -> Result<Vec<(MemoryId, f32)>> {
709 Err(Error::FeatureNotEnabled("redis".to_string()))
710 }
711
712 fn list_all(&self, _filter: &SearchFilter, _limit: usize) -> Result<Vec<(MemoryId, f32)>> {
713 Err(Error::FeatureNotEnabled("redis".to_string()))
714 }
715
716 fn get_memory(&self, _id: &MemoryId) -> Result<Option<Memory>> {
717 Err(Error::FeatureNotEnabled("redis".to_string()))
718 }
719
720 fn clear(&self) -> Result<()> {
721 Err(Error::FeatureNotEnabled("redis".to_string()))
722 }
723 }
724}
725
726#[cfg(not(feature = "redis"))]
727pub use stub::RedisBackend;