Skip to main content

drasi_lib/indexes/
config.rs

1// Copyright 2025 The Drasi Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use serde::{Deserialize, Serialize};
16use std::path::Path;
17
18/// Configuration for a named storage backend that can be referenced by queries
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct StorageBackendConfig {
21    /// Unique identifier for this storage backend
22    pub id: String,
23    /// Storage backend specification
24    #[serde(flatten)]
25    pub spec: StorageBackendSpec,
26}
27
28/// Storage backend specification defining the type and parameters
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(tag = "backend_type")]
31pub enum StorageBackendSpec {
32    /// In-memory storage backend (volatile, fast, no persistence)
33    ///
34    /// # Example
35    /// ```yaml
36    /// backend_type: memory
37    /// enable_archive: true
38    /// ```
39    #[serde(rename = "memory")]
40    Memory {
41        /// Enable archive index for past() function support
42        #[serde(default)]
43        enable_archive: bool,
44    },
45
46    /// RocksDB storage backend (persistent, local, production-ready)
47    ///
48    /// # Example
49    /// ```yaml
50    /// backend_type: rocksdb
51    /// path: /data/drasi
52    /// enable_archive: true
53    /// direct_io: false
54    /// ```
55    #[serde(rename = "rocksdb")]
56    RocksDb {
57        /// Base directory path for RocksDB files (must be absolute)
58        path: String,
59        /// Enable archive index for past() function support
60        #[serde(default)]
61        enable_archive: bool,
62        /// Use direct I/O (bypasses OS cache, may improve performance in some cases)
63        #[serde(default)]
64        direct_io: bool,
65    },
66
67    /// Redis/Garnet storage backend (persistent, distributed, scalable)
68    ///
69    /// # Example
70    /// ```yaml
71    /// backend_type: redis
72    /// connection_string: "redis://localhost:6379"
73    /// cache_size: 10000
74    /// ```
75    #[serde(rename = "redis")]
76    Redis {
77        /// Redis connection URL (e.g., "redis://localhost:6379")
78        connection_string: String,
79        /// Optional local cache size (number of elements to cache)
80        #[serde(skip_serializing_if = "Option::is_none")]
81        cache_size: Option<usize>,
82    },
83}
84
85/// Reference to a storage backend, either by name or inline specification
86#[derive(Debug, Clone, Serialize, Deserialize)]
87#[serde(untagged)]
88pub enum StorageBackendRef {
89    /// Reference to a named storage backend defined in storage_backends
90    Named(String),
91    /// Inline storage backend specification
92    Inline(StorageBackendSpec),
93}
94
95impl StorageBackendSpec {
96    /// Validate the storage backend configuration
97    pub fn validate(&self) -> Result<(), String> {
98        match self {
99            StorageBackendSpec::Memory { .. } => Ok(()),
100            StorageBackendSpec::RocksDb { path, .. } => {
101                // Validate path is absolute
102                let path_obj = Path::new(path);
103                if !path_obj.is_absolute() {
104                    return Err(format!("RocksDB path must be absolute, got: {path}"));
105                }
106                Ok(())
107            }
108            StorageBackendSpec::Redis {
109                connection_string,
110                cache_size,
111            } => {
112                // Validate connection string format
113                if !connection_string.starts_with("redis://")
114                    && !connection_string.starts_with("rediss://")
115                {
116                    return Err(format!(
117                        "Redis connection string must start with 'redis://' or 'rediss://', got: {connection_string}"
118                    ));
119                }
120
121                // Warn if cache size seems unreasonably large
122                if let Some(size) = cache_size {
123                    if *size > 10_000_000 {
124                        log::warn!(
125                            "Redis cache_size is very large ({size}), this may consume significant memory"
126                        );
127                    }
128                }
129                Ok(())
130            }
131        }
132    }
133
134    /// Check if this storage backend is volatile (requires re-bootstrap after restart)
135    pub fn is_volatile(&self) -> bool {
136        matches!(self, StorageBackendSpec::Memory { .. })
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn test_memory_serde() {
146        let yaml = r#"
147backend_type: memory
148enable_archive: true
149"#;
150        let spec: StorageBackendSpec = serde_yaml::from_str(yaml).unwrap();
151        match spec {
152            StorageBackendSpec::Memory { enable_archive } => {
153                assert!(enable_archive);
154            }
155            _ => panic!("Expected Memory variant"),
156        }
157
158        // Test serialization round-trip
159        let serialized = serde_yaml::to_string(&spec).unwrap();
160        let deserialized: StorageBackendSpec = serde_yaml::from_str(&serialized).unwrap();
161        match deserialized {
162            StorageBackendSpec::Memory { enable_archive } => {
163                assert!(enable_archive);
164            }
165            _ => panic!("Expected Memory variant after round-trip"),
166        }
167    }
168
169    #[test]
170    fn test_rocksdb_serde() {
171        let yaml = r#"
172backend_type: rocksdb
173path: /data/drasi
174enable_archive: true
175direct_io: false
176"#;
177        let spec: StorageBackendSpec = serde_yaml::from_str(yaml).unwrap();
178        match spec {
179            StorageBackendSpec::RocksDb {
180                path,
181                enable_archive,
182                direct_io,
183            } => {
184                assert_eq!(path, "/data/drasi");
185                assert!(enable_archive);
186                assert!(!direct_io);
187            }
188            _ => panic!("Expected RocksDb variant"),
189        }
190    }
191
192    #[test]
193    fn test_redis_serde() {
194        let yaml = r#"
195backend_type: redis
196connection_string: "redis://localhost:6379"
197cache_size: 10000
198"#;
199        let spec: StorageBackendSpec = serde_yaml::from_str(yaml).unwrap();
200        match spec {
201            StorageBackendSpec::Redis {
202                connection_string,
203                cache_size,
204            } => {
205                assert_eq!(connection_string, "redis://localhost:6379");
206                assert_eq!(cache_size, Some(10000));
207            }
208            _ => panic!("Expected Redis variant"),
209        }
210    }
211
212    #[test]
213    fn test_storage_backend_config_serde() {
214        let yaml = r#"
215id: rocks_persistent
216backend_type: rocksdb
217path: /data/drasi
218enable_archive: true
219"#;
220        let config: StorageBackendConfig = serde_yaml::from_str(yaml).unwrap();
221        assert_eq!(config.id, "rocks_persistent");
222        match config.spec {
223            StorageBackendSpec::RocksDb {
224                path,
225                enable_archive,
226                ..
227            } => {
228                assert_eq!(path, "/data/drasi");
229                assert!(enable_archive);
230            }
231            _ => panic!("Expected RocksDb variant"),
232        }
233    }
234
235    #[test]
236    fn test_storage_backend_ref_named() {
237        let yaml = r#""rocks_persistent""#;
238        let ref_val: StorageBackendRef = serde_yaml::from_str(yaml).unwrap();
239        match ref_val {
240            StorageBackendRef::Named(name) => {
241                assert_eq!(name, "rocks_persistent");
242            }
243            _ => panic!("Expected Named variant"),
244        }
245    }
246
247    #[test]
248    fn test_storage_backend_ref_inline() {
249        let yaml = r#"
250backend_type: memory
251enable_archive: false
252"#;
253        let ref_val: StorageBackendRef = serde_yaml::from_str(yaml).unwrap();
254        match ref_val {
255            StorageBackendRef::Inline(spec) => match spec {
256                StorageBackendSpec::Memory { enable_archive } => {
257                    assert!(!enable_archive);
258                }
259                _ => panic!("Expected Memory variant"),
260            },
261            _ => panic!("Expected Inline variant"),
262        }
263    }
264
265    #[test]
266    fn test_validate_memory() {
267        let spec = StorageBackendSpec::Memory {
268            enable_archive: true,
269        };
270        assert!(spec.validate().is_ok());
271    }
272
273    #[test]
274    fn test_validate_rocksdb_absolute_path() {
275        let spec = StorageBackendSpec::RocksDb {
276            path: "/data/drasi".to_string(),
277            enable_archive: true,
278            direct_io: false,
279        };
280        assert!(spec.validate().is_ok());
281    }
282
283    #[test]
284    fn test_validate_rocksdb_relative_path() {
285        let spec = StorageBackendSpec::RocksDb {
286            path: "data/drasi".to_string(),
287            enable_archive: true,
288            direct_io: false,
289        };
290        assert!(spec.validate().is_err());
291        let err = spec.validate().unwrap_err();
292        assert!(err.contains("must be absolute"));
293    }
294
295    #[test]
296    fn test_validate_redis_valid_url() {
297        let spec = StorageBackendSpec::Redis {
298            connection_string: "redis://localhost:6379".to_string(),
299            cache_size: Some(1000),
300        };
301        assert!(spec.validate().is_ok());
302    }
303
304    #[test]
305    fn test_validate_redis_invalid_url() {
306        let spec = StorageBackendSpec::Redis {
307            connection_string: "localhost:6379".to_string(),
308            cache_size: Some(1000),
309        };
310        assert!(spec.validate().is_err());
311        let err = spec.validate().unwrap_err();
312        assert!(err.contains("must start with"));
313    }
314
315    #[test]
316    fn test_is_volatile() {
317        let memory_spec = StorageBackendSpec::Memory {
318            enable_archive: false,
319        };
320        assert!(memory_spec.is_volatile());
321
322        let rocks_spec = StorageBackendSpec::RocksDb {
323            path: "/data/drasi".to_string(),
324            enable_archive: false,
325            direct_io: false,
326        };
327        assert!(!rocks_spec.is_volatile());
328
329        let redis_spec = StorageBackendSpec::Redis {
330            connection_string: "redis://localhost:6379".to_string(),
331            cache_size: None,
332        };
333        assert!(!redis_spec.is_volatile());
334    }
335}