Skip to main content

reddb_server/storage/unified/store/
builder.rs

1use super::*;
2
3impl Default for UnifiedStore {
4    fn default() -> Self {
5        Self::new()
6    }
7}
8
9#[cfg(test)]
10mod tests {
11    use super::*;
12    use reddb_types::Value;
13    use std::collections::HashMap;
14    use std::path::PathBuf;
15
16    #[test]
17    fn test_store_basic() {
18        let store = UnifiedStore::new();
19        store.create_collection("hosts").unwrap();
20
21        let entity = UnifiedEntity::table_row(
22            store.next_entity_id(),
23            "hosts",
24            1,
25            vec![Value::text("192.168.1.1".to_string())],
26        );
27
28        let id = store.insert("hosts", entity).unwrap();
29        assert!(store.get("hosts", id).is_some());
30    }
31
32    #[test]
33    fn test_store_auto_create() {
34        let store = UnifiedStore::new();
35
36        let entity =
37            UnifiedEntity::vector(store.next_entity_id(), "embeddings", vec![0.1, 0.2, 0.3]);
38
39        let id = store.insert_auto("new_collection", entity).unwrap();
40        assert!(store.get("new_collection", id).is_some());
41    }
42
43    #[test]
44    fn test_cross_references() {
45        let store = UnifiedStore::new();
46
47        // Create hosts collection
48        let host_entity = UnifiedEntity::table_row(
49            store.next_entity_id(),
50            "hosts",
51            1,
52            vec![Value::text("192.168.1.1".to_string())],
53        );
54        let host_id = store.insert_auto("hosts", host_entity).unwrap();
55
56        // Create vulns collection
57        let vuln_entity = UnifiedEntity::table_row(
58            store.next_entity_id(),
59            "vulns",
60            1,
61            vec![Value::text("CVE-2024-1234".to_string())],
62        );
63        let vuln_id = store.insert_auto("vulns", vuln_entity).unwrap();
64
65        // Add cross-reference
66        store
67            .add_cross_ref("hosts", host_id, "vulns", vuln_id, RefType::RelatedTo, 1.0)
68            .unwrap();
69
70        // Verify forward reference
71        let refs = store.get_refs_from(host_id);
72        assert_eq!(refs.len(), 1);
73        assert_eq!(refs[0].0, vuln_id);
74
75        // Verify reverse reference
76        let back_refs = store.get_refs_to(vuln_id);
77        assert_eq!(back_refs.len(), 1);
78        assert_eq!(back_refs[0].0, host_id);
79    }
80
81    /// Pins issue #113: when a delete batch covers entities with no
82    /// inbound *or* outbound cross-refs, `unindex_cross_refs_batch` must
83    /// take the read-only fast path and skip the `reverse_refs` write
84    /// lock entirely. Observable via `unindex_cross_refs_fast_path_hits`.
85    #[test]
86    fn unindex_cross_refs_batch_takes_fast_path_when_no_inbound_refs() {
87        let store = UnifiedStore::new();
88
89        // Insert plain rows with zero cross-refs.
90        let mut ids = Vec::new();
91        for i in 0..16 {
92            let row = UnifiedEntity::table_row(
93                store.next_entity_id(),
94                "rows",
95                (i + 1) as u64,
96                vec![Value::text(format!("v{i}"))],
97            );
98            ids.push(store.insert_auto("rows", row).unwrap());
99        }
100
101        let before = store.unindex_cross_refs_fast_path_hits();
102        store.delete_batch("rows", &ids).unwrap();
103        let after = store.unindex_cross_refs_fast_path_hits();
104
105        // Exactly one fast-path hit recorded for the single batch call.
106        assert_eq!(
107            after - before,
108            1,
109            "expected unindex_cross_refs_batch to take the read-only fast path"
110        );
111
112        // And of course, the rows are gone.
113        for id in &ids {
114            assert!(store.get("rows", *id).is_none());
115        }
116    }
117
118    /// Inverse of the above: when at least one deleted entity *does*
119    /// have an inbound cross-ref, the slow path must run and the
120    /// fast-path counter must NOT advance.
121    #[test]
122    fn unindex_cross_refs_batch_uses_slow_path_when_inbound_refs_exist() {
123        let store = UnifiedStore::new();
124
125        let host = UnifiedEntity::table_row(
126            store.next_entity_id(),
127            "hosts",
128            1,
129            vec![Value::text("h".to_string())],
130        );
131        let host_id = store.insert_auto("hosts", host).unwrap();
132
133        let vuln = UnifiedEntity::table_row(
134            store.next_entity_id(),
135            "vulns",
136            1,
137            vec![Value::text("v".to_string())],
138        );
139        let vuln_id = store.insert_auto("vulns", vuln).unwrap();
140
141        store
142            .add_cross_ref("hosts", host_id, "vulns", vuln_id, RefType::RelatedTo, 1.0)
143            .unwrap();
144
145        let before = store.unindex_cross_refs_fast_path_hits();
146        // Deleting the *target* (vuln_id) — it has an inbound ref from host_id,
147        // so reverse_refs has it as a key and the slow path is mandatory.
148        store.delete_batch("vulns", &[vuln_id]).unwrap();
149        let after = store.unindex_cross_refs_fast_path_hits();
150
151        assert_eq!(
152            after, before,
153            "fast-path counter must not advance when inbound refs exist"
154        );
155
156        // Reverse ref is gone.
157        assert!(store.get_refs_to(vuln_id).is_empty());
158    }
159
160    /// Manual perf check for the #113 fast path. Pollutes `reverse_refs`
161    /// with a large dictionary of unrelated entries, then deletes a fresh
162    /// batch of cross-ref-free rows. The pre-fix code paid O(|reverse_refs|)
163    /// in `values_mut()` per call; the fast path makes it O(|batch|).
164    /// `cargo test --release -p reddb-server -- --ignored unindex_cross_refs_batch_fast_path_perf --nocapture`.
165    #[test]
166    #[ignore]
167    fn unindex_cross_refs_batch_fast_path_perf() {
168        let store = UnifiedStore::new();
169
170        // Seed a fat reverse_refs dictionary by creating cross-refs in
171        // a *separate* set of entities. These entries will be untouched
172        // by the delete batch below.
173        const SEED: usize = 50_000;
174        let mut sources = Vec::with_capacity(SEED);
175        let mut targets = Vec::with_capacity(SEED);
176        for i in 0..SEED {
177            let s = UnifiedEntity::table_row(store.next_entity_id(), "src", (i + 1) as u64, vec![]);
178            sources.push(store.insert_auto("src", s).unwrap());
179            let t = UnifiedEntity::table_row(store.next_entity_id(), "tgt", (i + 1) as u64, vec![]);
180            targets.push(store.insert_auto("tgt", t).unwrap());
181        }
182        for (s, t) in sources.iter().zip(targets.iter()) {
183            store
184                .add_cross_ref("src", *s, "tgt", *t, RefType::RelatedTo, 1.0)
185                .unwrap();
186        }
187
188        // Now insert a fresh batch of plain rows (no cross-refs at all).
189        const BATCH: usize = 100;
190        let mut victims = Vec::with_capacity(BATCH);
191        for i in 0..BATCH {
192            let row =
193                UnifiedEntity::table_row(store.next_entity_id(), "rows", (i + 1) as u64, vec![]);
194            victims.push(store.insert_auto("rows", row).unwrap());
195        }
196
197        let before_hits = store.unindex_cross_refs_fast_path_hits();
198        let start = std::time::Instant::now();
199        store.delete_batch("rows", &victims).unwrap();
200        let elapsed = start.elapsed();
201        let after_hits = store.unindex_cross_refs_fast_path_hits();
202
203        eprintln!(
204            "delete_batch({BATCH}) over {SEED} unrelated reverse-refs: \
205             {elapsed:?}, fast_path_hits +{}",
206            after_hits - before_hits,
207        );
208        assert_eq!(after_hits - before_hits, 1);
209    }
210
211    #[test]
212    fn test_expand_refs() {
213        let store = UnifiedStore::new();
214
215        // Create a chain: A → B → C
216        let _ = store.get_or_create_collection("test");
217
218        let a = UnifiedEntity::vector(store.next_entity_id(), "v", vec![0.1]);
219        let a_id = store.insert_auto("test", a).unwrap();
220
221        let b = UnifiedEntity::vector(store.next_entity_id(), "v", vec![0.2]);
222        let b_id = store.insert_auto("test", b).unwrap();
223
224        let c = UnifiedEntity::vector(store.next_entity_id(), "v", vec![0.3]);
225        let c_id = store.insert_auto("test", c).unwrap();
226
227        store
228            .add_cross_ref("test", a_id, "test", b_id, RefType::SimilarTo, 0.9)
229            .unwrap();
230        store
231            .add_cross_ref("test", b_id, "test", c_id, RefType::SimilarTo, 0.8)
232            .unwrap();
233
234        // Expand from A with depth 2
235        let expanded = store.expand_refs(a_id, 2, None);
236        assert_eq!(expanded.len(), 2); // Should find B and C
237    }
238
239    #[test]
240    fn test_query_all_collections() {
241        let store = UnifiedStore::new();
242
243        // Insert into multiple collections
244        store
245            .insert_auto(
246                "hosts",
247                UnifiedEntity::table_row(store.next_entity_id(), "hosts", 1, vec![]),
248            )
249            .unwrap();
250
251        store
252            .insert_auto(
253                "vulns",
254                UnifiedEntity::table_row(store.next_entity_id(), "vulns", 1, vec![]),
255            )
256            .unwrap();
257
258        // Query all
259        let results = store.query_all(|_| true);
260        assert_eq!(results.len(), 2);
261    }
262
263    #[test]
264    fn test_stats() {
265        let store = UnifiedStore::new();
266
267        let _ = store.get_or_create_collection("test");
268        for i in 0..5 {
269            store
270                .insert_auto(
271                    "test",
272                    UnifiedEntity::vector(store.next_entity_id(), "v", vec![i as f32]),
273                )
274                .unwrap();
275        }
276
277        let stats = store.stats();
278        assert_eq!(stats.collection_count, 1);
279        assert_eq!(stats.total_entities, 5);
280    }
281
282    struct FileGuard {
283        path: PathBuf,
284    }
285
286    impl Drop for FileGuard {
287        fn drop(&mut self) {
288            let _ = std::fs::remove_file(&self.path);
289        }
290    }
291
292    fn temp_path(name: &str) -> (FileGuard, PathBuf) {
293        let path =
294            std::env::temp_dir().join(format!("rb_store_{}_{}.rdb", name, std::process::id()));
295        let guard = FileGuard { path: path.clone() };
296        let _ = std::fs::remove_file(&path);
297        (guard, path)
298    }
299
300    #[test]
301    fn test_cross_refs_persist_file_mode() {
302        let (_guard, path) = temp_path("file");
303        let store = UnifiedStore::new();
304
305        let row = UnifiedEntity::table_row(
306            store.next_entity_id(),
307            "hosts",
308            1,
309            vec![Value::text("10.0.0.1".to_string())],
310        );
311        let row_id = store.insert_auto("hosts", row).unwrap();
312
313        let node =
314            UnifiedEntity::graph_node(store.next_entity_id(), "host", "asset", HashMap::new());
315        let node_id = store.insert_auto("graph", node).unwrap();
316
317        let vector =
318            UnifiedEntity::vector(store.next_entity_id(), "embeddings", vec![0.1, 0.2, 0.3]);
319        let vector_id = store.insert_auto("embeddings", vector).unwrap();
320
321        store
322            .add_cross_ref("hosts", row_id, "graph", node_id, RefType::RowToNode, 1.0)
323            .unwrap();
324        store
325            .add_cross_ref(
326                "graph",
327                node_id,
328                "embeddings",
329                vector_id,
330                RefType::NodeToVector,
331                1.0,
332            )
333            .unwrap();
334
335        store.save_to_file(&path).unwrap();
336
337        let loaded = UnifiedStore::load_from_file(&path).unwrap();
338        let refs = loaded.get_refs_from(row_id);
339        assert!(refs.iter().any(|(id, kind, coll)| {
340            *id == node_id && *kind == RefType::RowToNode && coll == "graph"
341        }));
342
343        let graph_refs = loaded.get_refs_from(node_id);
344        assert!(graph_refs.iter().any(|(id, kind, coll)| {
345            *id == vector_id && *kind == RefType::NodeToVector && coll == "embeddings"
346        }));
347
348        let expanded = loaded.expand_refs(row_id, 2, None);
349        assert!(expanded
350            .iter()
351            .any(|(entity, depth, _)| { entity.id == node_id && *depth == 1 }));
352        assert!(expanded
353            .iter()
354            .any(|(entity, depth, _)| { entity.id == vector_id && *depth == 2 }));
355    }
356
357    #[test]
358    fn test_cross_refs_persist_paged_mode() {
359        let (_guard, path) = temp_path("paged");
360        let store = UnifiedStore::open(&path).unwrap();
361
362        let row = UnifiedEntity::table_row(store.next_entity_id(), "hosts", 1, vec![]);
363        let row_id = store.insert_auto("hosts", row).unwrap();
364
365        let node =
366            UnifiedEntity::graph_node(store.next_entity_id(), "host", "asset", HashMap::new());
367        let node_id = store.insert_auto("graph", node).unwrap();
368
369        store
370            .add_cross_ref("hosts", row_id, "graph", node_id, RefType::RowToNode, 1.0)
371            .unwrap();
372
373        store.persist().unwrap();
374
375        drop(store);
376
377        let loaded = UnifiedStore::open(&path).unwrap();
378        let refs = loaded.get_refs_from(row_id);
379        assert!(refs.iter().any(|(id, kind, coll)| {
380            *id == node_id && *kind == RefType::RowToNode && coll == "graph"
381        }));
382    }
383
384    /// #2376: every DDL ends in `persist()`, which deletes and re-inserts
385    /// every key of every collection B-tree. Concurrent `insert_auto` calls
386    /// into the same tree (control events, index registry) raced that
387    /// rebuild: duplicate-key errors on both sides and torn trees that
388    /// broke every later DDL and the reopen.
389    #[test]
390    fn concurrent_insert_auto_and_persist_keep_paged_btree_consistent() {
391        const WRITER_COUNT: usize = 4;
392        const ROW_COUNT_PER_WRITER: usize = 150;
393        const COLLECTION: &str = "red.control_events";
394
395        let (_guard, path) = temp_path("concurrent_persist_2376");
396        let store = Arc::new(UnifiedStore::open(&path).unwrap());
397        let writers_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
398
399        let persister = {
400            let store = Arc::clone(&store);
401            let writers_done = Arc::clone(&writers_done);
402            std::thread::spawn(move || {
403                let mut errors = Vec::new();
404                while writers_done.load(std::sync::atomic::Ordering::Acquire) < WRITER_COUNT {
405                    if let Err(err) = store.persist() {
406                        errors.push(format!("persist: {err}"));
407                    }
408                }
409                errors
410            })
411        };
412        let writers: Vec<_> = (0..WRITER_COUNT)
413            .map(|writer| {
414                let store = Arc::clone(&store);
415                let writers_done = Arc::clone(&writers_done);
416                std::thread::spawn(move || {
417                    let mut errors = Vec::new();
418                    for i in 0..ROW_COUNT_PER_WRITER {
419                        let row = UnifiedEntity::table_row(
420                            EntityId::new(0),
421                            COLLECTION,
422                            0,
423                            vec![Value::text(format!("writer{writer}-row{i}"))],
424                        );
425                        if let Err(err) = store.insert_auto(COLLECTION, row) {
426                            errors.push(format!("insert_auto: {err}"));
427                        }
428                    }
429                    writers_done.fetch_add(1, std::sync::atomic::Ordering::Release);
430                    errors
431                })
432            })
433            .collect();
434        let mut errors: Vec<String> = writers
435            .into_iter()
436            .flat_map(|writer| writer.join().expect("writer thread panicked"))
437            .collect();
438        errors.extend(persister.join().expect("persist thread panicked"));
439        assert!(
440            errors.is_empty(),
441            "race produced errors:\n{}",
442            errors.join("\n")
443        );
444
445        store.persist().expect("final persist");
446        let row_count = WRITER_COUNT * ROW_COUNT_PER_WRITER;
447        let btree = store
448            .btree_indices
449            .read()
450            .get(COLLECTION)
451            .cloned()
452            .expect("collection B-tree exists");
453        assert_eq!(btree.count().expect("B-tree count"), row_count);
454        drop(btree);
455        drop(Arc::try_unwrap(store).ok().expect("all threads joined"));
456
457        let reopened = UnifiedStore::open(&path).expect("reopen after concurrent persist");
458        let reopened_count = reopened
459            .get_collection(COLLECTION)
460            .map(|manager| manager.query_all(|_| true).len())
461            .unwrap_or(0);
462        assert_eq!(reopened_count, row_count);
463    }
464
465    #[test]
466    fn test_paged_mode_survives_multiple_reopens() {
467        let (_guard, path) = temp_path("paged_multi_reopen");
468
469        let store = UnifiedStore::open(&path).unwrap();
470        store.set_config_tree(
471            "red.system",
472            &crate::json!({
473                "hostname": "test-host",
474                "arch": "x86_64",
475                "started_at": 123_u64
476            }),
477        );
478        let initial = store
479            .get_collection("red_config")
480            .map(|m| m.query_all(|_| true).len())
481            .unwrap_or(0);
482        assert!(initial >= 3);
483        drop(store);
484
485        let reopened = UnifiedStore::open(&path).unwrap();
486        let first_reopen = reopened
487            .get_collection("red_config")
488            .map(|m| m.query_all(|_| true).len())
489            .unwrap_or(0);
490        assert_eq!(first_reopen, initial);
491        drop(reopened);
492
493        let reopened_again = UnifiedStore::open(&path).unwrap();
494        let second_reopen = reopened_again
495            .get_collection("red_config")
496            .map(|m| m.query_all(|_| true).len())
497            .unwrap_or(0);
498        assert_eq!(second_reopen, initial);
499    }
500
501    #[test]
502    fn set_config_tree_replaces_earlier_rows_for_the_same_key() {
503        let store = UnifiedStore::new();
504        let config_rows = |store: &UnifiedStore| {
505            store
506                .get_collection("red_config")
507                .map(|m| m.query_all(|_| true).len())
508                .unwrap_or(0)
509        };
510
511        store.set_config_tree("red.system", &crate::json!({ "pid": 1_u64, "os": "linux" }));
512        let after_first = config_rows(&store);
513        for pid in 2..=5_u64 {
514            store.set_config_tree("red.system", &crate::json!({ "pid": pid, "os": "linux" }));
515        }
516
517        assert_eq!(config_rows(&store), after_first);
518        assert_eq!(
519            store.get_config("red.system.pid"),
520            Some(reddb_types::Value::UnsignedInteger(5))
521        );
522    }
523
524    #[test]
525    fn set_config_tree_skips_keys_that_already_hold_the_value() {
526        let store = UnifiedStore::new();
527        store.set_config_tree("red.system", &crate::json!({ "os": "linux", "pid": 1_u64 }));
528        let ids_of = |store: &UnifiedStore| {
529            let mut ids: Vec<u64> = store
530                .get_collection("red_config")
531                .map(|m| m.query_all(|_| true).iter().map(|e| e.id.raw()).collect())
532                .unwrap_or_default();
533            ids.sort_unstable();
534            ids
535        };
536        let before = ids_of(&store);
537
538        store.set_config_tree("red.system", &crate::json!({ "os": "linux", "pid": 1_u64 }));
539        assert_eq!(
540            ids_of(&store),
541            before,
542            "identical values must not rewrite rows"
543        );
544
545        store.set_config_tree("red.system", &crate::json!({ "os": "linux", "pid": 2_u64 }));
546        let after = ids_of(&store);
547        assert_eq!(after.len(), before.len());
548        assert_ne!(after, before, "a changed value is rewritten");
549        assert_eq!(
550            store.get_config("red.system.pid"),
551            Some(reddb_types::Value::UnsignedInteger(2))
552        );
553        assert_eq!(
554            store.get_config("red.system.os"),
555            Some(reddb_types::Value::text("linux"))
556        );
557    }
558
559    #[test]
560    fn test_global_ids_unique_across_collections() {
561        let store = UnifiedStore::new();
562
563        let entity_a = UnifiedEntity::table_row(EntityId::new(0), "alpha", 1, vec![]);
564        let entity_b = UnifiedEntity::table_row(EntityId::new(0), "beta", 1, vec![]);
565
566        let id_a = store.insert_auto("alpha", entity_a).unwrap();
567        let id_b = store.insert_auto("beta", entity_b).unwrap();
568
569        assert_ne!(id_a, id_b);
570
571        store
572            .add_cross_ref("alpha", id_a, "beta", id_b, RefType::RelatedTo, 1.0)
573            .unwrap();
574
575        let expanded = store.expand_refs(id_a, 1, None);
576        assert!(expanded.iter().any(|(entity, _, _)| entity.id == id_b));
577    }
578}