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]
161 fn test_expand_refs() {
162 let store = UnifiedStore::new();
163
164 let _ = store.get_or_create_collection("test");
166
167 let a = UnifiedEntity::vector(store.next_entity_id(), "v", vec![0.1]);
168 let a_id = store.insert_auto("test", a).unwrap();
169
170 let b = UnifiedEntity::vector(store.next_entity_id(), "v", vec![0.2]);
171 let b_id = store.insert_auto("test", b).unwrap();
172
173 let c = UnifiedEntity::vector(store.next_entity_id(), "v", vec![0.3]);
174 let c_id = store.insert_auto("test", c).unwrap();
175
176 store
177 .add_cross_ref("test", a_id, "test", b_id, RefType::SimilarTo, 0.9)
178 .unwrap();
179 store
180 .add_cross_ref("test", b_id, "test", c_id, RefType::SimilarTo, 0.8)
181 .unwrap();
182
183 let expanded = store.expand_refs(a_id, 2, None);
185 assert_eq!(expanded.len(), 2); }
187
188 #[test]
189 fn test_query_all_collections() {
190 let store = UnifiedStore::new();
191
192 store
194 .insert_auto(
195 "hosts",
196 UnifiedEntity::table_row(store.next_entity_id(), "hosts", 1, vec![]),
197 )
198 .unwrap();
199
200 store
201 .insert_auto(
202 "vulns",
203 UnifiedEntity::table_row(store.next_entity_id(), "vulns", 1, vec![]),
204 )
205 .unwrap();
206
207 let results = store.query_all(|_| true);
209 assert_eq!(results.len(), 2);
210 }
211
212 #[test]
213 fn test_stats() {
214 let store = UnifiedStore::new();
215
216 let _ = store.get_or_create_collection("test");
217 for i in 0..5 {
218 store
219 .insert_auto(
220 "test",
221 UnifiedEntity::vector(store.next_entity_id(), "v", vec![i as f32]),
222 )
223 .unwrap();
224 }
225
226 let stats = store.stats();
227 assert_eq!(stats.collection_count, 1);
228 assert_eq!(stats.total_entities, 5);
229 }
230
231 struct FileGuard {
232 path: PathBuf,
233 }
234
235 impl Drop for FileGuard {
236 fn drop(&mut self) {
237 let _ = std::fs::remove_file(&self.path);
238 }
239 }
240
241 fn temp_path(name: &str) -> (FileGuard, PathBuf) {
242 let path =
243 std::env::temp_dir().join(format!("rb_store_{}_{}.rdb", name, std::process::id()));
244 let guard = FileGuard { path: path.clone() };
245 let _ = std::fs::remove_file(&path);
246 (guard, path)
247 }
248
249 #[test]
250 fn test_cross_refs_persist_file_mode() {
251 let (_guard, path) = temp_path("file");
252 let store = UnifiedStore::new();
253
254 let row = UnifiedEntity::table_row(
255 store.next_entity_id(),
256 "hosts",
257 1,
258 vec![Value::text("10.0.0.1".to_string())],
259 );
260 let row_id = store.insert_auto("hosts", row).unwrap();
261
262 let node =
263 UnifiedEntity::graph_node(store.next_entity_id(), "host", "asset", HashMap::new());
264 let node_id = store.insert_auto("graph", node).unwrap();
265
266 let vector =
267 UnifiedEntity::vector(store.next_entity_id(), "embeddings", vec![0.1, 0.2, 0.3]);
268 let vector_id = store.insert_auto("embeddings", vector).unwrap();
269
270 store
271 .add_cross_ref("hosts", row_id, "graph", node_id, RefType::RowToNode, 1.0)
272 .unwrap();
273 store
274 .add_cross_ref(
275 "graph",
276 node_id,
277 "embeddings",
278 vector_id,
279 RefType::NodeToVector,
280 1.0,
281 )
282 .unwrap();
283
284 store.save_to_file(&path).unwrap();
285
286 let loaded = UnifiedStore::load_from_file(&path).unwrap();
287 let refs = loaded.get_refs_from(row_id);
288 assert!(refs.iter().any(|(id, kind, coll)| {
289 *id == node_id && *kind == RefType::RowToNode && coll == "graph"
290 }));
291
292 let graph_refs = loaded.get_refs_from(node_id);
293 assert!(graph_refs.iter().any(|(id, kind, coll)| {
294 *id == vector_id && *kind == RefType::NodeToVector && coll == "embeddings"
295 }));
296
297 let expanded = loaded.expand_refs(row_id, 2, None);
298 assert!(expanded
299 .iter()
300 .any(|(entity, depth, _)| { entity.id == node_id && *depth == 1 }));
301 assert!(expanded
302 .iter()
303 .any(|(entity, depth, _)| { entity.id == vector_id && *depth == 2 }));
304 }
305
306 #[test]
307 fn test_cross_refs_persist_paged_mode() {
308 let (_guard, path) = temp_path("paged");
309 let store = UnifiedStore::open(&path).unwrap();
310
311 let row = UnifiedEntity::table_row(store.next_entity_id(), "hosts", 1, vec![]);
312 let row_id = store.insert_auto("hosts", row).unwrap();
313
314 let node =
315 UnifiedEntity::graph_node(store.next_entity_id(), "host", "asset", HashMap::new());
316 let node_id = store.insert_auto("graph", node).unwrap();
317
318 store
319 .add_cross_ref("hosts", row_id, "graph", node_id, RefType::RowToNode, 1.0)
320 .unwrap();
321
322 store.persist().unwrap();
323
324 drop(store);
325
326 let loaded = UnifiedStore::open(&path).unwrap();
327 let refs = loaded.get_refs_from(row_id);
328 assert!(refs.iter().any(|(id, kind, coll)| {
329 *id == node_id && *kind == RefType::RowToNode && coll == "graph"
330 }));
331 }
332
333 #[test]
339 fn concurrent_insert_auto_and_persist_keep_paged_btree_consistent() {
340 const WRITER_COUNT: usize = 4;
341 const ROW_COUNT_PER_WRITER: usize = 150;
342 const COLLECTION: &str = "red.control_events";
343
344 let (_guard, path) = temp_path("concurrent_persist_2376");
345 let store = Arc::new(UnifiedStore::open(&path).unwrap());
346 let writers_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
347
348 let persister = {
349 let store = Arc::clone(&store);
350 let writers_done = Arc::clone(&writers_done);
351 std::thread::spawn(move || {
352 let mut errors = Vec::new();
353 while writers_done.load(std::sync::atomic::Ordering::Acquire) < WRITER_COUNT {
354 if let Err(err) = store.persist() {
355 errors.push(format!("persist: {err}"));
356 }
357 }
358 errors
359 })
360 };
361 let writers: Vec<_> = (0..WRITER_COUNT)
362 .map(|writer| {
363 let store = Arc::clone(&store);
364 let writers_done = Arc::clone(&writers_done);
365 std::thread::spawn(move || {
366 let mut errors = Vec::new();
367 for i in 0..ROW_COUNT_PER_WRITER {
368 let row = UnifiedEntity::table_row(
369 EntityId::new(0),
370 COLLECTION,
371 0,
372 vec![Value::text(format!("writer{writer}-row{i}"))],
373 );
374 if let Err(err) = store.insert_auto(COLLECTION, row) {
375 errors.push(format!("insert_auto: {err}"));
376 }
377 }
378 writers_done.fetch_add(1, std::sync::atomic::Ordering::Release);
379 errors
380 })
381 })
382 .collect();
383 let mut errors: Vec<String> = writers
384 .into_iter()
385 .flat_map(|writer| writer.join().expect("writer thread panicked"))
386 .collect();
387 errors.extend(persister.join().expect("persist thread panicked"));
388 assert!(
389 errors.is_empty(),
390 "race produced errors:\n{}",
391 errors.join("\n")
392 );
393
394 store.persist().expect("final persist");
395 let row_count = WRITER_COUNT * ROW_COUNT_PER_WRITER;
396 let btree = store
397 .btree_indices
398 .read()
399 .get(COLLECTION)
400 .cloned()
401 .expect("collection B-tree exists");
402 assert_eq!(btree.count().expect("B-tree count"), row_count);
403 drop(btree);
404 drop(Arc::try_unwrap(store).ok().expect("all threads joined"));
405
406 let reopened = UnifiedStore::open(&path).expect("reopen after concurrent persist");
407 let reopened_count = reopened
408 .get_collection(COLLECTION)
409 .map(|manager| manager.query_all(|_| true).len())
410 .unwrap_or(0);
411 assert_eq!(reopened_count, row_count);
412 }
413
414 #[test]
415 fn test_paged_mode_survives_multiple_reopens() {
416 let (_guard, path) = temp_path("paged_multi_reopen");
417
418 let store = UnifiedStore::open(&path).unwrap();
419 store.set_config_tree(
420 "system",
421 &crate::json!({
422 "hostname": "test-host",
423 "arch": "x86_64",
424 "started_at": 123_u64
425 }),
426 );
427 let initial = store
428 .get_collection("red.config")
429 .map(|m| m.query_all(|_| true).len())
430 .unwrap_or(0);
431 assert!(initial >= 3);
432 drop(store);
433
434 let reopened = UnifiedStore::open(&path).unwrap();
435 let first_reopen = reopened
436 .get_collection("red.config")
437 .map(|m| m.query_all(|_| true).len())
438 .unwrap_or(0);
439 assert_eq!(first_reopen, initial);
440 drop(reopened);
441
442 let reopened_again = UnifiedStore::open(&path).unwrap();
443 let second_reopen = reopened_again
444 .get_collection("red.config")
445 .map(|m| m.query_all(|_| true).len())
446 .unwrap_or(0);
447 assert_eq!(second_reopen, initial);
448 }
449
450 #[test]
451 fn set_config_tree_replaces_earlier_rows_for_the_same_key() {
452 let store = UnifiedStore::new();
453 let config_rows = |store: &UnifiedStore| {
454 store
455 .get_collection("red.config")
456 .map(|m| m.query_all(|_| true).len())
457 .unwrap_or(0)
458 };
459
460 store.set_config_tree("system", &crate::json!({ "pid": 1_u64, "os": "linux" }));
461 let after_first = config_rows(&store);
462 for pid in 2..=5_u64 {
463 store.set_config_tree("system", &crate::json!({ "pid": pid, "os": "linux" }));
464 }
465
466 assert_eq!(config_rows(&store), after_first);
467 assert_eq!(
468 store.get_config("system.pid"),
469 Some(reddb_types::Value::UnsignedInteger(5))
470 );
471 }
472
473 #[test]
474 fn set_config_tree_skips_keys_that_already_hold_the_value() {
475 let store = UnifiedStore::new();
476 store.set_config_tree("system", &crate::json!({ "os": "linux", "pid": 1_u64 }));
477 let ids_of = |store: &UnifiedStore| {
478 let mut ids: Vec<u64> = store
479 .get_collection("red.config")
480 .map(|m| m.query_all(|_| true).iter().map(|e| e.id.raw()).collect())
481 .unwrap_or_default();
482 ids.sort_unstable();
483 ids
484 };
485 let before = ids_of(&store);
486
487 store.set_config_tree("system", &crate::json!({ "os": "linux", "pid": 1_u64 }));
488 assert_eq!(
489 ids_of(&store),
490 before,
491 "identical values must not rewrite rows"
492 );
493
494 store.set_config_tree("system", &crate::json!({ "os": "linux", "pid": 2_u64 }));
495 let after = ids_of(&store);
496 assert_eq!(after.len(), before.len());
497 assert_ne!(after, before, "a changed value is rewritten");
498 assert_eq!(
499 store.get_config("system.pid"),
500 Some(reddb_types::Value::UnsignedInteger(2))
501 );
502 assert_eq!(
503 store.get_config("system.os"),
504 Some(reddb_types::Value::text("linux"))
505 );
506 }
507
508 fn add_duplicate_system_value(store: &UnifiedStore) {
509 let mut row = UnifiedEntity::table_row(EntityId::new(0), "red.config", 0, vec![]);
510 row.data = EntityData::Row(RowData::with_names(
511 vec![Value::text("system.value"), Value::text("duplicate")],
512 vec!["key".into(), "value".into()],
513 ));
514 store.insert_auto("red.config", row).unwrap();
515 }
516
517 #[test]
518 fn embedded_config_replacement_is_one_wal_publication_and_replays() {
519 for mode in [DurabilityMode::Strict, DurabilityMode::WalDurableGrouped] {
520 let directory = tempfile::tempdir().unwrap();
521 let path = directory.path().join("config.rdb");
522 let mut store = UnifiedStore::new();
523 store
524 .try_set_config_tree("system", &crate::json!({"value": "old", "other": 1}))
525 .unwrap();
526 add_duplicate_system_value(&store);
527 let snapshot = store.to_binary_dump_bytes();
528 let initial =
529 crate::storage::EmbeddedRdbArtifact::create_with_snapshot(&path, &snapshot)
530 .unwrap();
531 store.config.embedded_wal_path = Some(path.clone());
532 store.config.durability_mode = mode;
533 store
534 .try_set_config_tree("system", &crate::json!({"value": "new", "other": 2}))
535 .unwrap();
536 let open = crate::storage::EmbeddedRdbArtifact::open(&path).unwrap();
537 assert_eq!(
538 open.selected_superblock.generation,
539 initial.selected_superblock.generation + 1
540 );
541 let replay = UnifiedStore::load_from_bytes(&snapshot).unwrap();
542 for payload in crate::storage::EmbeddedRdbArtifact::read_wal_payloads(&open).unwrap() {
543 replay.apply_encoded_store_wal_action(&payload).unwrap();
544 }
545 assert_eq!(
546 replay.get_config("system.value"),
547 Some(reddb_types::Value::text("new"))
548 );
549 assert_eq!(
550 replay
551 .get_collection("red.config")
552 .unwrap()
553 .query_all(|_| true)
554 .len(),
555 2
556 );
557 assert_eq!(
558 replay.get_config("system.other"),
559 Some(reddb_types::Value::UnsignedInteger(2))
560 );
561 }
562 }
563
564 #[test]
565 fn embedded_config_replacement_restores_old_value_when_wal_fails() {
566 for mode in [DurabilityMode::Strict, DurabilityMode::WalDurableGrouped] {
567 let directory = tempfile::tempdir().unwrap();
568 let mut store = UnifiedStore::new();
569 store
570 .try_set_config_tree("system", &crate::json!({"value": "old", "other": 1}))
571 .unwrap();
572 add_duplicate_system_value(&store);
573 store.config.embedded_wal_path = Some(directory.path().to_path_buf());
575 store.config.durability_mode = mode;
576 assert!(store
577 .try_set_config_tree("system", &crate::json!({"value": "new", "other": 2}))
578 .is_err());
579 assert_eq!(
580 store.get_config("system.value"),
581 Some(reddb_types::Value::text("duplicate"))
582 );
583 assert_eq!(
584 store
585 .get_collection("red.config")
586 .unwrap()
587 .query_all(|_| true)
588 .len(),
589 3
590 );
591 assert_eq!(
592 store.get_config("system.other"),
593 Some(reddb_types::Value::UnsignedInteger(1))
594 );
595 assert!(!UnifiedStore::deferred_store_wal_capture_active());
596 }
597 }
598
599 #[test]
600 fn embedded_system_config_batch_survives_interrupted_wal_publication() {
601 const CHILD: &str = "REDDB_TEST_SYSTEM_CONFIG_CRASH_PATH";
602 if let Some(path) = std::env::var_os(CHILD) {
603 let path = PathBuf::from(path);
604 let (_, snapshot) =
605 crate::storage::EmbeddedRdbArtifact::open_with_snapshot(&path).unwrap();
606 let mut store = UnifiedStore::load_from_bytes(&snapshot.unwrap()).unwrap();
607 store.config.embedded_wal_path = Some(path);
608 store.config.durability_mode =
609 if std::env::var("REDDB_TEST_SYSTEM_CONFIG_GROUPED").is_ok() {
610 DurabilityMode::WalDurableGrouped
611 } else {
612 DurabilityMode::Strict
613 };
614 store
615 .try_set_config_tree("system", &crate::json!({"value": "new", "other": 2}))
616 .unwrap();
617 panic!("crash point was not reached");
618 }
619 let directory = tempfile::tempdir().unwrap();
620 for mode in [DurabilityMode::Strict, DurabilityMode::WalDurableGrouped] {
621 for point in [
622 "wal_after_frame_write",
623 "wal_after_frame_sync",
624 "wal_after_superblock_write",
625 ] {
626 let path = directory.path().join(format!("{mode:?}-{point}.rdb"));
627 let store = UnifiedStore::new();
628 store
629 .try_set_config_tree("system", &crate::json!({"value": "old", "other": 1}))
630 .unwrap();
631 let snapshot = store.to_binary_dump_bytes();
632 crate::storage::EmbeddedRdbArtifact::create_with_snapshot(&path, &snapshot)
633 .unwrap();
634 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
635 command.args(["--exact", "storage::unified::store::builder::tests::embedded_system_config_batch_survives_interrupted_wal_publication", "--nocapture"])
636 .env(CHILD, &path).env("REDDB_EMBEDDED_RDB_CRASH_AT", point);
637 if mode == DurabilityMode::WalDurableGrouped {
638 command.env("REDDB_TEST_SYSTEM_CONFIG_GROUPED", "1");
639 }
640 let output = command.output().unwrap();
641 assert_eq!(
642 output.status.code(),
643 Some(173),
644 "child did not reach {point}: {}",
645 String::from_utf8_lossy(&output.stderr)
646 );
647 let (open, snapshot) =
648 crate::storage::EmbeddedRdbArtifact::open_with_snapshot(&path).unwrap();
649 let recovered = UnifiedStore::load_from_bytes(&snapshot.unwrap()).unwrap();
650 for payload in
651 crate::storage::EmbeddedRdbArtifact::read_wal_payloads(&open).unwrap()
652 {
653 recovered.apply_encoded_store_wal_action(&payload).unwrap();
654 }
655 let values = (
656 recovered.get_config("system.value"),
657 recovered.get_config("system.other"),
658 );
659 assert!(
660 values
661 == (
662 Some(reddb_types::Value::text("old")),
663 Some(reddb_types::Value::UnsignedInteger(1))
664 )
665 || values
666 == (
667 Some(reddb_types::Value::text("new")),
668 Some(reddb_types::Value::UnsignedInteger(2))
669 ),
670 "partial config after {mode:?} {point}: {values:?}"
671 );
672 assert_eq!(
673 recovered
674 .get_collection("red.config")
675 .unwrap()
676 .query_all(|_| true)
677 .len(),
678 2
679 );
680 }
681 }
682 }
683
684 #[test]
685 fn test_global_ids_unique_across_collections() {
686 let store = UnifiedStore::new();
687
688 let entity_a = UnifiedEntity::table_row(EntityId::new(0), "alpha", 1, vec![]);
689 let entity_b = UnifiedEntity::table_row(EntityId::new(0), "beta", 1, vec![]);
690
691 let id_a = store.insert_auto("alpha", entity_a).unwrap();
692 let id_b = store.insert_auto("beta", entity_b).unwrap();
693
694 assert_ne!(id_a, id_b);
695
696 store
697 .add_cross_ref("alpha", id_a, "beta", id_b, RefType::RelatedTo, 1.0)
698 .unwrap();
699
700 let expanded = store.expand_refs(id_a, 1, None);
701 assert!(expanded.iter().any(|(entity, _, _)| entity.id == id_b));
702 }
703}