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 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 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 store
67 .add_cross_ref("hosts", host_id, "vulns", vuln_id, RefType::RelatedTo, 1.0)
68 .unwrap();
69
70 let refs = store.get_refs_from(host_id);
72 assert_eq!(refs.len(), 1);
73 assert_eq!(refs[0].0, vuln_id);
74
75 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 #[test]
86 fn unindex_cross_refs_batch_takes_fast_path_when_no_inbound_refs() {
87 let store = UnifiedStore::new();
88
89 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 assert_eq!(
107 after - before,
108 1,
109 "expected unindex_cross_refs_batch to take the read-only fast path"
110 );
111
112 for id in &ids {
114 assert!(store.get("rows", *id).is_none());
115 }
116 }
117
118 #[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 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 assert!(store.get_refs_to(vuln_id).is_empty());
158 }
159
160 #[test]
166 #[ignore]
167 fn unindex_cross_refs_batch_fast_path_perf() {
168 let store = UnifiedStore::new();
169
170 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 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 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 let expanded = store.expand_refs(a_id, 2, None);
236 assert_eq!(expanded.len(), 2); }
238
239 #[test]
240 fn test_query_all_collections() {
241 let store = UnifiedStore::new();
242
243 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 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 #[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}