Skip to main content

storage/
segmented.rs

1//! Segmented (LSM-style) storage backend built on the compaction engine.
2//!
3//! This adapts [`CompactionManager`] — a complete in-memory log-structured store
4//! (append to an active segment, seal, tombstone-on-delete, merge garbage-heavy
5//! segments) — to the [`VectorStorage`] trait so it can be selected as a real
6//! backend. Deletes are tombstones reclaimed by background compaction rather than
7//! in-place removals, which keeps writes append-only and bounds read amplification
8//! by periodically merging segments.
9//!
10//! Before DAK-7428 the compaction engine existed but was never wired to a storage
11//! interface — it was reachable only from unit tests. This module makes it a
12//! functional backend (`DAKERA_STORAGE=segmented`) with self-contained background
13//! auto-compaction, not a simulated one.
14
15use std::sync::Arc;
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17
18use async_trait::async_trait;
19use common::{NamespaceId, Result, Vector, VectorId};
20
21use crate::compaction::{CompactionConfig, CompactionManager};
22use crate::traits::VectorStorage;
23
24/// Current Unix time in seconds — captured once per call to avoid a syscall per
25/// vector when filtering expired entries.
26fn now_secs() -> u64 {
27    SystemTime::now()
28        .duration_since(UNIX_EPOCH)
29        .map(|d| d.as_secs())
30        .unwrap_or(0)
31}
32
33/// Log-structured, compaction-backed vector storage.
34pub struct SegmentedStorage {
35    manager: Arc<CompactionManager>,
36}
37
38impl SegmentedStorage {
39    /// Build a segmented store with the given compaction policy. When
40    /// `config.auto_compact` is set and a Tokio runtime is active, a background
41    /// task is spawned that periodically merges garbage-heavy/small segments so
42    /// tombstones are reclaimed and read amplification stays bounded.
43    pub fn new(config: CompactionConfig) -> Self {
44        let manager = Arc::new(CompactionManager::new(config.clone()));
45        if config.auto_compact {
46            if let Ok(handle) = tokio::runtime::Handle::try_current() {
47                let mgr = manager.clone();
48                let interval = config.compaction_interval_secs.max(1);
49                handle.spawn(async move {
50                    let mut ticker = tokio::time::interval(Duration::from_secs(interval));
51                    ticker.tick().await; // consume the immediate first tick
52                    loop {
53                        ticker.tick().await;
54                        let results = mgr.compact_all();
55                        if !results.is_empty() {
56                            let merged: usize = results.values().map(|v| v.len()).sum();
57                            tracing::debug!(
58                                namespaces = results.len(),
59                                compactions = merged,
60                                "segmented storage auto-compaction merged segments"
61                            );
62                        }
63                    }
64                });
65                tracing::info!(
66                    interval_secs = interval,
67                    "Segmented storage background compaction started"
68                );
69            }
70        }
71        Self { manager }
72    }
73
74    /// Build with the default compaction policy.
75    pub fn with_defaults() -> Self {
76        Self::new(CompactionConfig::default())
77    }
78
79    /// Access the underlying compaction manager (stats, manual compaction).
80    pub fn manager(&self) -> &Arc<CompactionManager> {
81        &self.manager
82    }
83
84    /// Manually run compaction across every namespace; returns the number of
85    /// segment-merge operations performed.
86    pub fn compact_all(&self) -> usize {
87        self.manager.compact_all().values().map(|v| v.len()).sum()
88    }
89}
90
91impl Default for SegmentedStorage {
92    fn default() -> Self {
93        Self::with_defaults()
94    }
95}
96
97#[async_trait]
98impl VectorStorage for SegmentedStorage {
99    async fn upsert(&self, namespace: &NamespaceId, vectors: Vec<Vector>) -> Result<usize> {
100        let count = vectors.len();
101        for vector in vectors {
102            self.manager.add(namespace, vector);
103        }
104        Ok(count)
105    }
106
107    async fn get(&self, namespace: &NamespaceId, ids: &[VectorId]) -> Result<Vec<Vector>> {
108        let now = now_secs();
109        Ok(ids
110            .iter()
111            .filter_map(|id| self.manager.get(namespace, id))
112            .filter(|v| !v.is_expired_at(now))
113            .collect())
114    }
115
116    async fn get_all(&self, namespace: &NamespaceId) -> Result<Vec<Vector>> {
117        let now = now_secs();
118        Ok(self
119            .manager
120            .get_all(namespace)
121            .into_iter()
122            .filter(|v| !v.is_expired_at(now))
123            .collect())
124    }
125
126    async fn delete(&self, namespace: &NamespaceId, ids: &[VectorId]) -> Result<usize> {
127        let mut removed = 0;
128        for id in ids {
129            if self.manager.delete(namespace, id) {
130                removed += 1;
131            }
132        }
133        Ok(removed)
134    }
135
136    async fn namespace_exists(&self, namespace: &NamespaceId) -> Result<bool> {
137        Ok(self
138            .manager
139            .list_namespaces()
140            .iter()
141            .any(|n| n == namespace))
142    }
143
144    async fn ensure_namespace(&self, namespace: &NamespaceId) -> Result<()> {
145        // `namespace()` is get-or-create.
146        let _ = self.manager.namespace(namespace);
147        Ok(())
148    }
149
150    async fn count(&self, namespace: &NamespaceId) -> Result<usize> {
151        let now = now_secs();
152        Ok(self
153            .manager
154            .get_all(namespace)
155            .into_iter()
156            .filter(|v| !v.is_expired_at(now))
157            .count())
158    }
159
160    async fn dimension(&self, namespace: &NamespaceId) -> Result<Option<usize>> {
161        let now = now_secs();
162        Ok(self
163            .manager
164            .get_all(namespace)
165            .into_iter()
166            .find(|v| !v.is_expired_at(now))
167            .map(|v| v.values.len()))
168    }
169
170    async fn list_namespaces(&self) -> Result<Vec<NamespaceId>> {
171        Ok(self.manager.list_namespaces())
172    }
173
174    async fn delete_namespace(&self, namespace: &NamespaceId) -> Result<bool> {
175        Ok(self.manager.delete_namespace(namespace))
176    }
177
178    async fn cleanup_expired(&self, namespace: &NamespaceId) -> Result<usize> {
179        let now = now_secs();
180        let expired: Vec<VectorId> = self
181            .manager
182            .get_all(namespace)
183            .into_iter()
184            .filter(|v| v.is_expired_at(now))
185            .map(|v| v.id)
186            .collect();
187        let mut removed = 0;
188        for id in &expired {
189            if self.manager.delete(namespace, id) {
190                removed += 1;
191            }
192        }
193        Ok(removed)
194    }
195
196    async fn cleanup_all_expired(&self) -> Result<usize> {
197        let mut total = 0;
198        for namespace in self.manager.list_namespaces() {
199            total += self.cleanup_expired(&namespace).await?;
200        }
201        Ok(total)
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn tv(id: &str) -> Vector {
210        Vector {
211            id: id.to_string(),
212            values: vec![0.1, 0.2, 0.3],
213            metadata: None,
214            ttl_seconds: None,
215            expires_at: None,
216        }
217    }
218
219    // Build without background compaction so the test is deterministic and needs
220    // no live runtime timer.
221    fn store() -> SegmentedStorage {
222        SegmentedStorage::new(CompactionConfig {
223            auto_compact: false,
224            ..Default::default()
225        })
226    }
227
228    #[tokio::test]
229    async fn upsert_get_delete_roundtrip() {
230        let s = store();
231        let ns = "n".to_string();
232        s.ensure_namespace(&ns).await.unwrap();
233        assert_eq!(s.upsert(&ns, vec![tv("a"), tv("b")]).await.unwrap(), 2);
234        assert_eq!(s.count(&ns).await.unwrap(), 2);
235        assert_eq!(s.dimension(&ns).await.unwrap(), Some(3));
236
237        let got = s.get(&ns, &["a".to_string()]).await.unwrap();
238        assert_eq!(got.len(), 1);
239        assert_eq!(got[0].id, "a");
240
241        assert_eq!(s.delete(&ns, &["a".to_string()]).await.unwrap(), 1);
242        assert!(s.get(&ns, &["a".to_string()]).await.unwrap().is_empty());
243        assert_eq!(s.count(&ns).await.unwrap(), 1);
244
245        let all: Vec<String> = s
246            .get_all(&ns)
247            .await
248            .unwrap()
249            .into_iter()
250            .map(|v| v.id)
251            .collect();
252        assert_eq!(all, vec!["b".to_string()]);
253    }
254
255    #[tokio::test]
256    async fn expired_vectors_are_filtered_and_reclaimed() {
257        let s = store();
258        let ns = "n".to_string();
259        let mut expired = tv("old");
260        expired.expires_at = Some(1); // far in the past
261        s.upsert(&ns, vec![expired, tv("fresh")]).await.unwrap();
262        // Read path hides the expired vector.
263        assert_eq!(s.count(&ns).await.unwrap(), 1);
264        let ids: Vec<String> = s
265            .get_all(&ns)
266            .await
267            .unwrap()
268            .into_iter()
269            .map(|v| v.id)
270            .collect();
271        assert_eq!(ids, vec!["fresh".to_string()]);
272        // Sweep physically tombstones it.
273        assert_eq!(s.cleanup_expired(&ns).await.unwrap(), 1);
274    }
275
276    #[tokio::test]
277    async fn namespace_lifecycle() {
278        let s = store();
279        let ns = "n".to_string();
280        s.upsert(&ns, vec![tv("a")]).await.unwrap();
281        assert!(s.namespace_exists(&ns).await.unwrap());
282        assert_eq!(s.list_namespaces().await.unwrap(), vec![ns.clone()]);
283        assert!(s.delete_namespace(&ns).await.unwrap());
284        assert!(!s.namespace_exists(&ns).await.unwrap());
285    }
286}